diff --git a/backend/app/app/api/endpoints/abl.py b/backend/app/app/api/endpoints/abl.py index 0cc2b85a4..c78ccecb2 100644 --- a/backend/app/app/api/endpoints/abl.py +++ b/backend/app/app/api/endpoints/abl.py @@ -1,17 +1,18 @@ from typing import List, Optional from fastapi import APIRouter, Depends -from httpx import AsyncClient from sqlalchemy.orm import Session -from app.core.auth import RequiresRole +from app.core.auth import RequiresRole, active_user from app.data_models.models import ( ApprovedBook, - RequestApproveBook, + RequestApproveBooks, Role, + UserSession, ) from app.db.utils import get_db -from app.service.abl import add_new_entries, get_abl_info_database +from app.github.client import github_client +from app.service.abl import add_to_abl, get_abl_info_database router = APIRouter() @@ -28,15 +29,16 @@ async def get_abl_info( @router.post("/", dependencies=[Depends(RequiresRole(Role.ADMIN))]) -async def add_to_abl( +async def add_to_abl_request( *, + user: UserSession = Depends(active_user), db: Session = Depends(get_db), - info: List[RequestApproveBook], + info: RequestApproveBooks, ): # Creates a new ApprovedBook # Fetches rex-web release.json # Removes extraneous ApprovedBook entries for rex-web # keeps any version that appears in rex-web # keeps newest version - async with AsyncClient() as client: - return await add_new_entries(db, info, client) + async with github_client(user) as client: + return await add_to_abl(client, db, info) diff --git a/backend/app/app/api/endpoints/github.py b/backend/app/app/api/endpoints/github.py index 1672cb7bc..7992eb712 100644 --- a/backend/app/app/api/endpoints/github.py +++ b/backend/app/app/api/endpoints/github.py @@ -1,11 +1,13 @@ from typing import List -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.core.auth import active_user from app.data_models.models import RepositorySummary, UserSession from app.db.utils import get_db +from app.github import get_book_repository +from app.github.client import github_client from app.service.user import user_service router = APIRouter() @@ -16,3 +18,14 @@ async def repositories( user: UserSession = Depends(active_user), db: Session = Depends(get_db) ): return user_service.get_user_repositories(db, user) + + +@router.get("/book-repository/{owner}/{name}") +async def get_book_repository_endpoint( + owner: str, + name: str, + version: str = Query("main", description="Commit or ref to use"), + user: UserSession = Depends(active_user), +): + async with github_client(user) as client: + return await get_book_repository(client, name, owner, version) diff --git a/backend/app/app/core/config.py b/backend/app/app/core/config.py index 169452182..e6d1595d8 100644 --- a/backend/app/app/core/config.py +++ b/backend/app/app/core/config.py @@ -1,4 +1,7 @@ +import json import os +import re +from typing import Any # DATABASE SETTINGS POSTGRES_SERVER = os.getenv("POSTGRES_SERVER") @@ -19,7 +22,11 @@ STACK_NAME = os.getenv("STACK_NAME") DEPLOYED_AT = os.getenv("DEPLOYED_AT") -IS_DEV_ENV = STACK_NAME is None or STACK_NAME == "dev" +IS_PROD = ( + isinstance(STACK_NAME, str) + and re.search(r"[\s:_-]prod(uction)?$", STACK_NAME, re.I) is not None +) +IS_DEV_ENV = not IS_PROD if IS_DEV_ENV: from dotenv import load_dotenv @@ -45,3 +52,6 @@ # To encrypt session cookie SESSION_SECRET = os.getenv("SESSION_SECRET") ACCESS_TOKEN_EXPIRE_MINUTES = 480 + +# Optional features +MAKE_REPO_PUBLIC_ON_APPROVAL = IS_PROD diff --git a/backend/app/app/data_models/models.py b/backend/app/app/data_models/models.py index e298e466d..8c2b1a124 100644 --- a/backend/app/app/data_models/models.py +++ b/backend/app/app/data_models/models.py @@ -72,11 +72,22 @@ class BaseApprovedBook(BaseModel): uuid: str -class RequestApproveBook(BaseApprovedBook): +class ApprovedBookWithCodeVersion(BaseApprovedBook): code_version: str -class ApprovedBook(RequestApproveBook): +class RepositoryBase(BaseModel): + name: str + owner: str + + +class RequestApproveBooks(BaseModel): + books_to_approve: List[ApprovedBookWithCodeVersion] + repository: RepositoryBase + make_repo_public: bool = False + + +class ApprovedBook(ApprovedBookWithCodeVersion): created_at: datetime committed_at: datetime repository_name: str @@ -150,11 +161,6 @@ def get(self, key: str, default: Any = None) -> Any: return default -class RepositoryBase(BaseModel): - name: str - owner: str - - class Repository(RepositoryBase): class Config: orm_mode = True diff --git a/backend/app/app/github/api.py b/backend/app/app/github/api.py index 05a7c51f1..b30c519f5 100644 --- a/backend/app/app/github/api.py +++ b/backend/app/app/github/api.py @@ -2,7 +2,10 @@ import json from datetime import datetime from typing import Any, Dict, List, Tuple +from urllib.parse import urlencode +import httpx +import yaml from lxml import etree from app.core.auth import get_user_role @@ -39,8 +42,7 @@ async def get_book_repository( query = f""" query {{ repository(name: "{repo_name}", owner: "{repo_owner}") {{ - databaseId - viewerPermission + {GitHubRepo.graphql_query()} object(expression: "{version}") {{ oid ... on Commit {{ @@ -59,17 +61,12 @@ async def get_book_repository( """ payload = await graphql(client, query) repository = payload["data"]["repository"] - repo = GitHubRepo( - name=repo_name, - database_id=repository["databaseId"], - viewer_permission=repository["viewerPermission"], - ) + repo = GitHubRepo.from_node(repository) commit = repository["object"] if commit is None: # pragma: no cover raise CustomBaseError(f"Could not find commit '{version}'") commit_sha = commit["oid"] commit_timestamp = commit["committedDate"] - fixed_timestamp = f"{commit_timestamp[:-1]}+00:00" books_xml = commit["file"]["object"]["text"] meta = parse_xml_doc(books_xml) @@ -77,7 +74,7 @@ async def get_book_repository( {k: get_attr(el, k) for k in ("slug", "style")} for el in xpath_some(meta, "//*[local-name()='book']") ] - return (repo, commit_sha, datetime.fromisoformat(fixed_timestamp), books) + return (repo, commit_sha, datetime.fromisoformat(commit_timestamp), books) async def get_collections( @@ -120,6 +117,66 @@ async def get_collections( } +def contents_path(owner: str, repo: str, path: str, version: str | None = None): + url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" + query = {} + if version is not None: + query["ref"] = version + if query: + url += f"?{urlencode(query)}" + return url + + +async def get_contents( + client: AuthenticatedClient, owner: str, repo: str, path: str, version: str +): + response = await client.get(contents_path(owner, repo, path, version)) + response.raise_for_status() + return response.json() + + +async def get_text_contents( + client: AuthenticatedClient, owner: str, repo: str, path: str, version: str +): + data = await get_contents(client, owner, repo, path, version) + base64content = data["content"] + return base64.b64decode(base64content) + + +async def make_repo_public(client: AuthenticatedClient, owner: str, repo: str): + branch = "main" + path = ".github/settings.yml" + commit_message = "Change repository visibility to public" + file_exists = True + try: + contents = await get_contents(client, owner, repo, path, branch) + serialized = base64.b64decode(contents["content"]).decode("utf-8") + except httpx.HTTPStatusError as hse: + # TODO: Maybe use settings from template when it is missing? + raise CustomBaseError("Could not get settings file") from hse + settings = yaml.load(serialized, yaml.SafeLoader) + repository = settings.setdefault("repository", {}) + if repository.get("private") in (None, True): + repository["private"] = False + else: + raise CustomBaseError( + f"{owner}/{repo}: repository may already be public or the " + "settings.yml file may contain an invalid value for 'private'" + ) + serialized = yaml.dump(settings, indent=2) + await push_to_github( + client=client, + path=path, + content=serialized, + owner=owner, + repo=repo, + branch=branch, + commit_message=commit_message, + file_exists=file_exists, + sha=contents["sha"], + ) + + async def push_to_github( client: AuthenticatedClient, path: str, @@ -129,9 +186,8 @@ async def push_to_github( branch: str, commit_message: str, file_exists=True, + sha: str = "", ): - url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" - base64content = base64.b64encode(content.encode("utf-8")) message = { "message": commit_message, @@ -140,17 +196,16 @@ async def push_to_github( } if file_exists: - response = await client.get(url + "?ref=" + branch) - response.raise_for_status() - data = response.json() - message["sha"] = data["sha"] - - if base64content.decode("utf-8").strip() == data["content"].strip(): - raise CustomBaseError("No changes to push") + if sha == "": + data = await get_contents(client, owner, repo, path, branch) + sha = data["sha"] + if base64content.decode("utf-8").strip() == data["content"].strip(): + raise CustomBaseError("No changes to push") + message["sha"] = sha response = await client.put( - url, - data=json.dumps(message), + contents_path(owner, repo, path), + content=json.dumps(message), headers={ **client.headers, "Content-Type": "application/json", @@ -179,9 +234,7 @@ async def get_user_repositories( edges {{ node {{ ... on Repository {{ - name - databaseId - viewerPermission + {repo_query} }} }} }} @@ -196,7 +249,10 @@ async def get_user_repositories( "https://api.github.com/graphql", json={ "query": query.format( - query_args=",".join(":".join(i) for i in query_args.items()) + query_args=",".join( + ":".join(i) for i in query_args.items() + ), + repo_query=GitHubRepo.graphql_query(), ) }, ) diff --git a/backend/app/app/github/models.py b/backend/app/app/github/models.py index 079e91fb1..9832fb4ec 100644 --- a/backend/app/app/github/models.py +++ b/backend/app/app/github/models.py @@ -1,8 +1,21 @@ +import re from enum import Enum from pydantic import BaseModel +def snake_to_camel(s: str): + return re.sub(r"_[a-z]", lambda match: match.group(0)[-1].upper(), s) + + +def camel_to_snake(s: str): + return re.sub( + r"[a-z][A-Z]", + lambda match: "_".join(c.lower() for c in match.group(0)), + s, + ) + + class RepositoryPermission(int, Enum): ADMIN = 1 MAINTAIN = 2 @@ -11,21 +24,22 @@ class RepositoryPermission(int, Enum): WRITE = 5 -class GitHubRepo(BaseModel): - name: str - database_id: str - viewer_permission: str +class GraphQLModel(BaseModel): + @classmethod + def graphql_fields(cls): + return [snake_to_camel(k) for k in cls.__fields__] + + @classmethod + def graphql_query(cls): + return "\n".join(cls.graphql_fields()) @classmethod def from_node(cls, node: dict): - def to_snake_case(s: str): - ret = [] - for c in s: - if c.isupper(): - ret.append("_") - ret.append(c.lower()) - else: - ret.append(c) - return "".join(ret) - - return cls(**{to_snake_case(k): v for k, v in node.items()}) + return cls(**{camel_to_snake(k): v for k, v in node.items()}) + + +class GitHubRepo(GraphQLModel): + name: str + database_id: str + viewer_permission: str + visibility: str diff --git a/backend/app/app/service/abl.py b/backend/app/app/service/abl.py index 7c2b55e6d..8c19af0c1 100644 --- a/backend/app/app/service/abl.py +++ b/backend/app/app/service/abl.py @@ -5,9 +5,14 @@ from sqlalchemy import and_, delete, or_, select from sqlalchemy.orm import Session, lazyload +import app.github.api as github_api from app.core import config from app.core.errors import CustomBaseError -from app.data_models.models import BaseApprovedBook, RequestApproveBook +from app.data_models.models import ( + ApprovedBookWithCodeVersion, + BaseApprovedBook, + RequestApproveBooks, +) from app.db.schema import ( ApprovedBook, Book, @@ -16,6 +21,7 @@ Consumer, Repository, ) +from app.github.client import AuthenticatedClient async def get_rex_books(client: AsyncClient): @@ -51,7 +57,7 @@ def get_rex_book_versions(rex_books: Dict[str, Any], book_uuids: List[str]): def remove_old_versions( db: Session, consumer_id: int, - to_add: List[RequestApproveBook], + to_add: List[ApprovedBookWithCodeVersion], to_keep: List[BaseApprovedBook] = [], ): # Default: Remove all previous versions of the books in the set @@ -110,7 +116,7 @@ def update_versions_by_consumer( db: Session, consumer_name: str, db_books_by_uuid: Dict[str, Book], - to_add: List[RequestApproveBook], + to_add: List[ApprovedBookWithCodeVersion], to_keep: List[BaseApprovedBook] = [], ): consumer_id = db.scalars( @@ -142,7 +148,7 @@ def guess_consumer(book_slug: str) -> str: async def add_new_entries( db: Session, - to_add: List[RequestApproveBook], + to_add: List[ApprovedBookWithCodeVersion], client: AsyncClient, ): if not to_add: # pragma: no cover @@ -224,3 +230,16 @@ def get_abl_info_database( ) return db.scalars(query).all() + + +async def add_to_abl( + client: AuthenticatedClient, db: Session, info: RequestApproveBooks +): + if info.make_repo_public: + if not config.MAKE_REPO_PUBLIC_ON_APPROVAL: + raise CustomBaseError( + "ABORT - Cannot make repository public (feature disabled)" + ) + repo = info.repository + await github_api.make_repo_public(client, repo.owner, repo.name) + return await add_new_entries(db, info.books_to_approve, client) diff --git a/backend/app/poetry.lock b/backend/app/poetry.lock index 7bd7ee239..7631ef9f9 100644 --- a/backend/app/poetry.lock +++ b/backend/app/poetry.lock @@ -1900,4 +1900,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "dc9a4a9387394fdabb282f6faaa03236bddc0ec1a70481d6dae0fa4d5c3e7a35" +content-hash = "2294c825e7442149aa980df34a5f1c0b85251daaec923c413800cee3f16873ac" diff --git a/backend/app/pyproject.toml b/backend/app/pyproject.toml index fa07ce7e4..1d81795f4 100644 --- a/backend/app/pyproject.toml +++ b/backend/app/pyproject.toml @@ -20,6 +20,7 @@ Authlib = "^1.1.0" itsdangerous = "^2.1.2" cryptography = "^42.0.4" ruff = "^0.4.2" +pyyaml = "^6.0.1" [tool.poetry.group.dev.dependencies] pytest = "^6.2.5" diff --git a/backend/app/tests/unit/conftest.py b/backend/app/tests/unit/conftest.py index 53bde1ae3..cfb317af7 100644 --- a/backend/app/tests/unit/conftest.py +++ b/backend/app/tests/unit/conftest.py @@ -1,5 +1,7 @@ import os +from typing import Any +import httpx import pytest from fastapi.responses import RedirectResponse from starlette.testclient import TestClient @@ -270,39 +272,48 @@ def first(self): return inner -@pytest.fixture -def mock_http_client(): - def inner(get=None, post=None): - if get is None: - get = {} - if post is None: - post = {} - - class MockResponse: - def __init__(self, expected_result): - self.expected_result = expected_result +class MockAsyncClient(httpx.AsyncClient): + responses: list[httpx.Response] - def json(self): - return self.expected_result - - def raise_for_status(self): - pass - - class MockClient: - def __init__(self): - self.calls = [] + def reset_history(self): + while self.responses: + self.responses.pop() - async def get(self, url, **kwargs): - self.calls.append({"type": "get", "kwargs": kwargs, "url": url}) - return MockResponse(get.get(url, None)) - async def post(self, url, **kwargs): - self.calls.append( - {"type": "post", "kwargs": kwargs, "url": url} +@pytest.fixture +def mock_http_client(): + def inner( + **responses_by_method: dict[httpx.URL, httpx.Response | str | dict], + ) -> MockAsyncClient: + responses: list[httpx.Response] = [] + responses_by_method = { + k.lower(): v for k, v in responses_by_method.items() + } + + def handler(request: httpx.Request): + url = request.url + method = request.method + if isinstance(method, bytearray): + method = method.decode() + planned_response = responses_by_method.get(method.lower(), {}).get( + url + ) + if planned_response is None: + response = httpx.Response(404, request=request) + elif isinstance(planned_response, httpx.Response): + response = planned_response + else: + response = httpx.Response( + 200, json=planned_response, request=request ) - return MockResponse(post.get(url, None)) + responses.append(response) + return response - return MockClient() + client = MockAsyncClient( + mounts={"all://": httpx.MockTransport(handler)} + ) + client.responses = responses + return client return inner diff --git a/backend/app/tests/unit/data/get_book_repository.yaml b/backend/app/tests/unit/data/get_book_repository.yaml index 9bb01bb82..7bface202 100644 --- a/backend/app/tests/unit/data/get_book_repository.yaml +++ b/backend/app/tests/unit/data/get_book_repository.yaml @@ -1,7 +1,7 @@ interactions: - request: body: '{"query": "\n query {\n repository(name: \"tiny-book\", - owner: \"openstax\") {\n databaseId\n viewerPermission\n object(expression: + owner: \"openstax\") {\n name\ndatabaseId\nviewerPermission\nvisibility\n object(expression: \"main\") {\n oid\n ... on Commit {\n committedDate\n file (path: \"META-INF/books.xml\") {\n object {\n ... on Blob {\n text\n }\n }\n }\n }\n }\n }\n }\n "}' @@ -11,7 +11,7 @@ interactions: method: POST uri: https://api.github.com/graphql response: - content: '{"data":{"repository":{"databaseId":359188527,"viewerPermission":"WRITE","object":{"oid":"7bc255e285cc4f53debe59b84281cafa002f90cd","committedDate":"2022-09-15T15:41:52Z","file":{"object":{"text":"\n \n\n"}}}}}}' diff --git a/backend/app/tests/unit/data/get_collections.yaml b/backend/app/tests/unit/data/get_collections.yaml index 0b5826935..99b320483 100644 --- a/backend/app/tests/unit/data/get_collections.yaml +++ b/backend/app/tests/unit/data/get_collections.yaml @@ -17,8 +17,7 @@ interactions: book\n book-slug1\n en\n col123\n Creative Commons Attribution License 4.0\n \n \n \n subcollection\n \n We - still needs titles in the Collxml\n \n \n \n\n"}}]}}}}}}' + document=\"m123\" sys:version-at-this-collection-version=\"999.999\"/>\n \n \n \n\n"}}]}}}}}}' headers: Content-Type: - application/json; charset=utf-8 diff --git a/backend/app/tests/unit/data/get_user_repositories.yaml b/backend/app/tests/unit/data/get_user_repositories.yaml index 9ee716e8f..6c87ce9a6 100644 --- a/backend/app/tests/unit/data/get_user_repositories.yaml +++ b/backend/app/tests/unit/data/get_user_repositories.yaml @@ -3,14 +3,14 @@ interactions: body: '{"query": "\n query {\n search(query:\"org:openstax osbooks in:name is:public\",first:100,type:REPOSITORY) {\n repositoryCount\n pageInfo {\n endCursor\n hasNextPage\n }\n edges - {\n node {\n ... on Repository {\n name\n databaseId\n viewerPermission\n }\n }\n }\n }\n }\n "}' + {\n node {\n ... on Repository {\n name\ndatabaseId\nviewerPermission\nvisibility\n }\n }\n }\n }\n }\n "}' headers: host: - api.github.com method: POST uri: https://api.github.com/graphql response: - content: '{"data":{"search":{"repositoryCount":41,"pageInfo":{"endCursor":"Y3Vyc29yOjQx","hasNextPage":false},"edges":[{"node":{"name":"osbooks-college-physics-bundle","databaseId":424340810,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-writing-guide","databaseId":385349509,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-calculus-bundle","databaseId":420179902,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-sociology","databaseId":447724283,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-fizyka-bundle","databaseId":349214243,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-university-physics-bundle","databaseId":331073080,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-astronomy","databaseId":428806530,"viewerPermission":"TRIAGE"}},{"node":{"name":"template-osbooks-new","databaseId":384528039,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-precalculo","databaseId":419790341,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-entrepreneurship","databaseId":429096449,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-american-government","databaseId":447725992,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-playground","databaseId":381823217,"viewerPermission":"WRITE"}},{"node":{"name":"osbooks-quimica-bundle","databaseId":473020953,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-psychologia","databaseId":428786834,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-psychology","databaseId":447304990,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-statistics","databaseId":349214030,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-us-history","databaseId":424342926,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-business","databaseId":428792524,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-principles-finance","databaseId":406121277,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-political-science","databaseId":402484075,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-microbiology","databaseId":428802781,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-calculo-bundle","databaseId":419763545,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-college-algebra-bundle","databaseId":329400042,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introductory-statistics-bundle","databaseId":414272555,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-business-ethics","databaseId":428793576,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-principles-accounting-bundle","databaseId":428805571,"viewerPermission":"TRIAGE"}},{"node":{"name":"template-osbooks","databaseId":368908485,"viewerPermission":"ADMIN"}},{"node":{"name":"osbooks-physics","databaseId":428807294,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-principles-economics-bundle","databaseId":447297254,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduccion-estadistica-bundle","databaseId":401836524,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-fisica-universitaria-bundle","databaseId":421906193,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-biology-bundle","databaseId":428804196,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-life-liberty-and-pursuit-happiness","databaseId":429093860,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-business-law","databaseId":349213981,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-college-success-bundle","databaseId":428790003,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-principles-of-management-bundle","databaseId":349214008,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-intellectual-property","databaseId":428794418,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-chemistry-bundle","databaseId":439412338,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-prealgebra-bundle","databaseId":447720214,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-anatomy-physiology","databaseId":428812124,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-anthropology","databaseId":387500800,"viewerPermission":"TRIAGE"}}]}}}' + content: '{"data":{"search":{"repositoryCount":49,"pageInfo":{"endCursor":"Y3Vyc29yOjQ5","hasNextPage":false},"edges":[{"node":{"name":"osbooks-university-physics-bundle","databaseId":331073080,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-college-physics-bundle","databaseId":424340810,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-writing-guide","databaseId":385349509,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-sociology","databaseId":447724283,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-fizyka-bundle","databaseId":349214243,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-economics-bundle","databaseId":447297254,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-astronomy","databaseId":428806530,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-anatomy-physiology","databaseId":428812124,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-fisica-universitaria-bundle","databaseId":421906193,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-american-government","databaseId":447725992,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-precalculo","databaseId":419790341,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-mikroekonomia","databaseId":522451624,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-entrepreneurship","databaseId":429096449,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-playground","databaseId":381823217,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-finance","databaseId":406121277,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-world-history","databaseId":402811523,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-calculus-bundle","databaseId":420179902,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-psychologia","databaseId":428786834,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-quimica-bundle","databaseId":473020953,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-makroekonomia","databaseId":685988600,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-microbiology","databaseId":428802781,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-us-history","databaseId":424342926,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"template-osbooks","databaseId":368908485,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-physics","databaseId":428807294,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"template-osbooks-new","databaseId":384528039,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-business","databaseId":428792524,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-biology-bundle","databaseId":428804196,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-political-science","databaseId":402484075,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-business-ethics","databaseId":428793576,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-calculo-bundle","databaseId":419763545,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-marketing","databaseId":499553497,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-philosophy","databaseId":405996480,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-psychology","databaseId":447304990,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-statistics","databaseId":349214030,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-business-law","databaseId":349213981,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-contemporary-mathematics","databaseId":398311847,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-anthropology","databaseId":387500800,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-organic-chemistry","databaseId":501678967,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-prealgebra-bundle","databaseId":447720214,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-chemistry-bundle","databaseId":439412338,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduccion-estadistica-bundle","databaseId":401836524,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-workplace-software-skills","databaseId":387540868,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-accounting-bundle","databaseId":428805571,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-college-algebra-bundle","databaseId":329400042,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introductory-statistics-bundle","databaseId":414272555,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-college-success-bundle","databaseId":428790003,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-intellectual-property","databaseId":428794418,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-life-liberty-and-pursuit-happiness","databaseId":429093860,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-of-management-bundle","databaseId":349214008,"viewerPermission":"WRITE","visibility":"PUBLIC"}}]}}}' headers: Content-Type: - application/json; charset=utf-8 diff --git a/backend/app/tests/unit/data/get_user_teams.yaml b/backend/app/tests/unit/data/get_user_teams.yaml index 10b3f413e..a683100bb 100644 --- a/backend/app/tests/unit/data/get_user_teams.yaml +++ b/backend/app/tests/unit/data/get_user_teams.yaml @@ -10,10 +10,11 @@ interactions: method: POST uri: https://api.github.com/graphql response: - content: '{"data":{"organization":{"teams":{"totalCount":4,"edges":[{"node":{"name":"all","description":"Openstax + content: '{"data":{"organization":{"teams":{"totalCount":6,"edges":[{"node":{"name":"all","description":"Openstax folks"}},{"node":{"name":"ce-tech","description":"The CE Tech team"}},{"node":{"name":"ce-all","description":"Discussion - board for the Content Engineering Team"}},{"node":{"name":"ce-be","description":"CE - Backend developers"}}]}}}}' + board for the Content Engineering Team"}},{"node":{"name":"ce-admins","description":"The + GitHub Admin team for Content Engineering repositories"}},{"node":{"name":"all-devs","description":"All + OpenStax users"}},{"node":{"name":"ce-be","description":"CE Backend developers"}}]}}}}' headers: Content-Type: - application/json; charset=utf-8 diff --git a/backend/app/tests/unit/test_abl.py b/backend/app/tests/unit/test_abl.py index 762eea6d0..6cf19b375 100644 --- a/backend/app/tests/unit/test_abl.py +++ b/backend/app/tests/unit/test_abl.py @@ -1,14 +1,22 @@ import pytest from app.core import config -from app.data_models.models import BaseApprovedBook, RequestApproveBook +from app.core.errors import CustomBaseError +from app.data_models.models import ( + ApprovedBookWithCodeVersion, + BaseApprovedBook, + Repository, + RequestApproveBooks, +) from app.db.schema import ApprovedBook, Book, CodeVersion from app.service.abl import ( add_new_entries, + add_to_abl, get_abl_info_database, get_or_add_code_version, get_rex_book_versions, ) +from tests.unit.conftest import MockAsyncClient @pytest.mark.parametrize( @@ -58,17 +66,29 @@ def test_get_or_add_code_version(mock_session): "to_add,to_keep", [ [ - [RequestApproveBook(commit_sha="a", uuid="b", code_version="42")], + [ + ApprovedBookWithCodeVersion( + commit_sha="a", uuid="b", code_version="42" + ) + ], {}, ], [ - [RequestApproveBook(commit_sha="a", uuid="b", code_version="42")], + [ + ApprovedBookWithCodeVersion( + commit_sha="a", uuid="b", code_version="42" + ) + ], {"b": {"defaultVersion": "something"}}, ], [ [ - RequestApproveBook(commit_sha="a", uuid="b", code_version="42"), - RequestApproveBook(commit_sha="a", uuid="c", code_version="42"), + ApprovedBookWithCodeVersion( + commit_sha="a", uuid="b", code_version="42" + ), + ApprovedBookWithCodeVersion( + commit_sha="a", uuid="c", code_version="42" + ), ], {"b": {"defaultVersion": "b"}, "c": {"defaultVersion": "c"}}, ], @@ -95,11 +115,11 @@ def mock_database_logic(session_obj): return [] db = mock_session(mock_database_logic) - client = mock_http_client( + client: MockAsyncClient = mock_http_client( get={config.REX_WEB_RELEASE_URL: {"books": to_keep}} ) await add_new_entries(db, to_add, client) - assert "headers" in client.calls[-1]["kwargs"] + assert "authorization" not in client.responses[-1].request.headers assert not db.did_rollback assert db.did_commit # Twice as many because code version is added each time in the test @@ -151,3 +171,68 @@ def mock_database_logic(session_obj): def test_get_rex_book_versions(rex_books, book_uuids, expected): rex_book_versions = get_rex_book_versions(rex_books, book_uuids) assert rex_book_versions == expected + + +@pytest.mark.asyncio +async def test_add_to_abl_abort(mocker): + # GIVEN: MAKE_REPO_PUBLIC_ON_APPROVAL disabled + mocker.patch("app.service.abl.config.MAKE_REPO_PUBLIC_ON_APPROVAL", False) + add_new_entries_stub = mocker.async_stub() + make_repo_public_stub = mocker.async_stub() + mocker.patch("app.service.abl.add_new_entries", add_new_entries_stub) + mocker.patch( + "app.service.abl.github_api.make_repo_public", make_repo_public_stub + ) + repo = Repository(name="test", owner="test") + # WHEN: make_repo_public is true (client should not do this) + info = RequestApproveBooks( + books_to_approve=[], repository=repo, make_repo_public=True + ) + # THEN: An error is raised and the process is aborted + with pytest.raises(CustomBaseError) as cbe: + await add_to_abl(mocker.stub(), mocker.stub(), info) + make_repo_public_stub.assert_not_called() + add_new_entries_stub.assert_not_called() + assert cbe.match("ABORT") + + +@pytest.mark.asyncio +async def test_add_to_abl_make_book_public(mocker): + # GIVEN: MAKE_REPO_PUBLIC_ON_APPROVAL enabled + mocker.patch("app.service.abl.config.MAKE_REPO_PUBLIC_ON_APPROVAL", True) + add_new_entries_stub = mocker.async_stub() + make_repo_public_stub = mocker.async_stub() + mocker.patch("app.service.abl.add_new_entries", add_new_entries_stub) + mocker.patch( + "app.service.abl.github_api.make_repo_public", make_repo_public_stub + ) + repo = Repository(name="test", owner="test") + # WHEN: make_repo_public is true + info = RequestApproveBooks( + books_to_approve=[], repository=repo, make_repo_public=True + ) + await add_to_abl(mocker.stub(), mocker.stub(), info) + # THEN: make_repo_public and add_new_entries are called + make_repo_public_stub.assert_called_once() + add_new_entries_stub.assert_called_once() + + +@pytest.mark.asyncio +async def test_add_to_abl_do_not_make_book_public(mocker): + # GIVEN: MAKE_REPO_PUBLIC_ON_APPROVAL enabled + mocker.patch("app.service.abl.config.MAKE_REPO_PUBLIC_ON_APPROVAL", True) + add_new_entries_stub = mocker.async_stub() + make_repo_public_stub = mocker.async_stub() + mocker.patch("app.service.abl.add_new_entries", add_new_entries_stub) + mocker.patch( + "app.service.abl.github_api.make_repo_public", make_repo_public_stub + ) + repo = Repository(name="test", owner="test") + # WHEN: make_repo_public is false + info = RequestApproveBooks( + books_to_approve=[], repository=repo, make_repo_public=False + ) + await add_to_abl(mocker.stub(), mocker.stub(), info) + # THEN: only add_new_entries is called + make_repo_public_stub.assert_not_called() + add_new_entries_stub.assert_called_once() diff --git a/backend/app/tests/unit/test_github_api.py b/backend/app/tests/unit/test_github_api.py index 615d0d930..9be428ede 100644 --- a/backend/app/tests/unit/test_github_api.py +++ b/backend/app/tests/unit/test_github_api.py @@ -1,6 +1,19 @@ +import json +from base64 import b64decode, b64encode +from datetime import datetime +from hashlib import sha256 +from typing import cast + +import httpx import pytest from httpx import AsyncClient +from app.core.errors import CustomBaseError +from app.github.api import make_repo_public, push_to_github +from app.github.client import AuthenticatedClient +from app.github.models import GitHubRepo +from tests.unit.conftest import MockAsyncClient + @pytest.mark.unit @pytest.mark.nondestructive @@ -27,8 +40,6 @@ async def test_get_user_and_teams(monkeypatch, mock_github_api): @pytest.mark.nondestructive @pytest.mark.asyncio async def test_get_book_repository(monkeypatch, mock_github_api): - from datetime import datetime - exc = None repo = None books = None @@ -52,6 +63,11 @@ async def test_get_book_repository(monkeypatch, mock_github_api): assert isinstance(commit_sha, str) assert isinstance(timestamp, datetime) assert isinstance(books, list) + assert isinstance(repo, GitHubRepo) + assert isinstance(repo.visibility, str) + assert datetime.fromisoformat( + "2024-06-14T16:26:24.611Z" + ) == datetime.fromisoformat("2024-06-14T16:26:24.611+00:00") assert len(books) > 0 @@ -94,3 +110,280 @@ async def test_get_user_repositories(monkeypatch, mock_github_api): assert isinstance(repos, list) assert len(repos) > 0 assert isinstance(repos[0], GitHubRepo) + + +@pytest.mark.unit +@pytest.mark.nondestructive +@pytest.mark.asyncio +async def test_push_to_github(mock_http_client): + owner = "test_owner" + repo = "test_repo" + path = "README.md" + content = original_content = "# README" + branch = "main" + sha = sha256(content.encode()).hexdigest() + commit_message = "commit message" + client: MockAsyncClient = mock_http_client( + get={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}?ref={branch}": { + "content": b64encode(content.encode()).decode(), + "sha": sha, + } + }, + put={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}": "OK" + }, + ) + + # GIVEN: No changes to existing file + # WHEN: A push is attempted + with pytest.raises(CustomBaseError) as hse: + await push_to_github( + cast(AuthenticatedClient, client), + path, + content, + owner, + repo, + branch, + commit_message, + True, + ) + + # THEN: An error is raised before a PUT request is made + assert len(client.responses) == 1 + request: httpx.Request = client.responses[-1].request + assert request.method == "GET" + assert hse.match("No changes to push") + + # GIVEN: Changes to existing file + content = f"{original_content} with changes" + new_content = b64encode(content.encode()).decode() + client.reset_history() + + # WHEN: A push is attempted + await push_to_github( + cast(AuthenticatedClient, client), + path, + content, + owner, + repo, + branch, + commit_message, + True, + ) + + # THEN: There is a GET and PUT request; the PUT request has correct data + assert len(client.responses) == 2 + assert [r.request.method for r in client.responses] == ["GET", "PUT"] + put_request: httpx.Request = client.responses[1].request + response_content = put_request.content + data = json.loads(response_content) + # Sends to new content + assert data.get("content") == new_content + # Sends the sha of file being updated + assert data.get("sha") == sha + assert data.get("branch") == branch + assert data.get("message") == commit_message + + client.reset_history() + + # GIVEN: Changes to non-existing file + content = f"{original_content} with changes" + # WHEN: A push is attempted + await push_to_github( + cast(AuthenticatedClient, client), + path, + content, + owner, + repo, + branch, + commit_message, + False, + ) + + # THEN: There is one PUT request; the PUT request has correct data + assert len(client.responses) == 1 + assert client.responses[0].request.method == "PUT" + put_request: httpx.Request = client.responses[0].request + response_content = put_request.content + data = json.loads(response_content) + # Sends to new content + assert data.get("content") == new_content + # No sha because file is new + assert data.get("sha") is None + assert data.get("branch") == branch + assert data.get("message") == commit_message + + client.reset_history() + # WHEN: Changes are pushed with a sha + await push_to_github( + cast(AuthenticatedClient, client), + path, + content, + owner, + repo, + branch, + commit_message, + False, + sha, + ) + # THEN: Only one request is made + assert [r.request.method for r in client.responses] == ["PUT"] + + # GIVEN: Changes to non-existing file; an error from github + client: MockAsyncClient = mock_http_client( + get={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}?ref={branch}": httpx.Response(404) + }, + put={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}": httpx.Response(404) + }, + ) + content = f"{original_content} with changes" + # WHEN: Changes are pushed + with pytest.raises(httpx.HTTPStatusError) as hse: + await push_to_github( + cast(AuthenticatedClient, client), + path, + content, + owner, + repo, + branch, + commit_message, + False, + ) + + # THEN: An error is raised when a PUT request is made + assert len(client.responses) == 1 + request: httpx.Request = client.responses[-1].request + assert request.method == "PUT" + assert hse.match("404") + + client.reset_history() + # GIVEN: Changes to existing file; error from github + with pytest.raises(httpx.HTTPStatusError) as hse: + await push_to_github( + cast(AuthenticatedClient, client), + path, + content, + owner, + repo, + branch, + commit_message, + True, + ) + + # THEN: An error is raised when a GET request is made + assert len(client.responses) == 1 + request: httpx.Request = client.responses[-1].request + assert request.method == "GET" + assert hse.match("404") + + +@pytest.mark.unit +@pytest.mark.nondestructive +@pytest.mark.asyncio +async def test_make_repo_public(mock_http_client): + owner = "test_owner" + repo = "test_repo" + path = ".github/settings.yml" + text = b64encode(b"{}").decode() + sha = sha256(b"{}").hexdigest() + content = {"content": text, "sha": sha} + branch = "main" + # GIVEN: A repository with a settings yaml file + client: MockAsyncClient = mock_http_client( + get={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}?ref={branch}": content + }, + put={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}": "OK" + }, + ) + + # WHEN: We attempt to make it public + await make_repo_public(client, owner, repo) + # THEN: There are 2 requests and the expected data is sent + assert [r.request.method for r in client.responses] == ["GET", "PUT"] + put_request = client.responses[-1].request + data = json.loads(put_request.content) + assert ( + b64decode(data.get("content", "")) == b"repository:\n private: false\n" + ) + assert data.get("sha") == sha + assert data.get("branch") == branch + + # GIVEN: Private is set to true + client: MockAsyncClient = mock_http_client( + get={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}?ref={branch}": content + | {"content": b64encode(b"repository:\n private: true\n").decode()} + }, + put={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}": "OK" + }, + ) + # WHEN: We attempt to make it public + await make_repo_public(client, owner, repo) + # THEN: It works as if private was not set + assert [r.request.method for r in client.responses] == ["GET", "PUT"] + put_request = client.responses[-1].request + data = json.loads(put_request.content) + assert ( + b64decode(data.get("content", "")) == b"repository:\n private: false\n" + ) + + # GIVEN: Missing settings file + client: MockAsyncClient = mock_http_client( + get={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}?ref={branch}": httpx.Response(404) + }, + put={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}": httpx.Response( + 500, text="This should not happen" + ) + }, + ) + # WHEN: We attempt to make it public + # THEN: An error about missing settings file is raised + with pytest.raises(CustomBaseError) as cbe: + await make_repo_public(client, owner, repo) + assert cbe.match("Could not get settings file") + + # GIVEN: settings file that is already public + client: MockAsyncClient = mock_http_client( + get={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}?ref={branch}": ( + content + | { + "content": b64encode( + b"repository:\n private: false\n" + ).decode() + } + ) + }, + put={ + "https://api.github.com/repos/" + f"{owner}/{repo}/contents/{path}": httpx.Response( + 500, text="This should not happen" + ) + }, + ) + # WHEN: We attempt to make it public + # THEN: An error about repo already being public or a possible error in + # settings.yml is raised + with pytest.raises(CustomBaseError) as cbe: + await make_repo_public(client, owner, repo) + assert cbe.match("repository may already be public") + assert cbe.match("settings.yml") diff --git a/backend/app/tests/unit/test_github_models.py b/backend/app/tests/unit/test_github_models.py index 4a50405f2..36b5334d6 100644 --- a/backend/app/tests/unit/test_github_models.py +++ b/backend/app/tests/unit/test_github_models.py @@ -7,8 +7,14 @@ @pytest.mark.nondestructive def test_github_repo_model(): model = GitHubRepo.from_node( - {"name": "test", "databaseId": 1, "viewerPermission": "WRITE"} + { + "name": "test", + "databaseId": 1, + "viewerPermission": "WRITE", + "visibility": "PUBLIC", + } ) assert model.name == "test" assert model.database_id == "1" assert model.viewer_permission == "WRITE" + assert model.visibility == "PUBLIC" diff --git a/backend/app/tests/unit/test_github_utils.py b/backend/app/tests/unit/test_github_utils.py index 6d1542906..790074b6d 100644 --- a/backend/app/tests/unit/test_github_utils.py +++ b/backend/app/tests/unit/test_github_utils.py @@ -9,7 +9,10 @@ id=1, token="fake", role=Role.ADMIN, avatar_url="", name="TestUser" ) FAKE_REPO = GitHubRepo( - name="osbooks-fake-book", database_id="1234", viewer_permission="WRITE" + name="osbooks-fake-book", + database_id="1234", + viewer_permission="WRITE", + visibility="PUBLIC", ) diff --git a/docker-compose.stack.dev.yml b/docker-compose.stack.dev.yml index 7262cc923..0a59a11ed 100644 --- a/docker-compose.stack.dev.yml +++ b/docker-compose.stack.dev.yml @@ -35,6 +35,8 @@ services: - "traefik.enable=true" volumes: - ./frontend:/app + environment: + - STACK_NAME=${STACK_NAME} ports: # This is for the live reloading (maybe add to traefik as a tcp service?) - '35729:35729' diff --git a/docker-compose.stack.release.yml b/docker-compose.stack.release.yml index b22ce844d..9a140b9c4 100644 --- a/docker-compose.stack.release.yml +++ b/docker-compose.stack.release.yml @@ -42,6 +42,8 @@ services: frontend: image: 'openstax/corgi-${DOCKER_IMAGE_FRONTEND:?}:${TAG-latest}' + environment: + - STACK_NAME=${STACK_NAME:?} deploy: labels: - "traefik.http.services.${STACK_NAME:?}_frontend.loadbalancer.server.port=80" @@ -62,7 +64,7 @@ services: networks: - ${TRAEFIK_PUBLIC_NETWORK:?} - default - + networks: default: driver: overlay diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c75d5023..07bd9f735 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "prettier-plugin-svelte": "^3.1.2", "rollup": "^2.79.1", "rollup-plugin-css-only": "^3.1.0", + "rollup-plugin-inject-process-env": "^1.3.1", "rollup-plugin-livereload": "^2.0.5", "rollup-plugin-svelte": "^7.1.0", "svelte": "^3.53.1", @@ -7162,6 +7163,15 @@ "node": ">= 8.0.0" } }, + "node_modules/rollup-plugin-inject-process-env": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject-process-env/-/rollup-plugin-inject-process-env-1.3.1.tgz", + "integrity": "sha512-kKDoL30IZr0wxbNVJjq+OS92RJSKRbKV6B5eNW4q3mZTFqoWDh6lHy+mPDYuuGuERFNKXkG+AKxvYqC9+DRpKQ==", + "dev": true, + "dependencies": { + "magic-string": "^0.25.7" + } + }, "node_modules/rollup-plugin-livereload": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/rollup-plugin-livereload/-/rollup-plugin-livereload-2.0.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index f4a6447be..0b709f028 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "prettier-plugin-svelte": "^3.1.2", "rollup": "^2.79.1", "rollup-plugin-css-only": "^3.1.0", + "rollup-plugin-inject-process-env": "^1.3.1", "rollup-plugin-livereload": "^2.0.5", "rollup-plugin-svelte": "^7.1.0", "svelte": "^3.53.1", diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js index 9e5955035..12797230a 100755 --- a/frontend/rollup.config.js +++ b/frontend/rollup.config.js @@ -6,6 +6,7 @@ import terser from "@rollup/plugin-terser"; import sveltePreprocess from "svelte-preprocess"; import typescript from "@rollup/plugin-typescript"; import css from "rollup-plugin-css-only"; +import injectProcessEnv from "rollup-plugin-inject-process-env"; const production = !process.env.ROLLUP_WATCH; const hotdog = process.env.DEPLOYED_IN === "hotdog"; @@ -70,6 +71,10 @@ export default { sourceMap: !production, inlineSources: !production, }), + injectProcessEnv( + { STACK_NAME: process.env.STACK_NAME }, + { include: "./src/**/*" }, + ), // In dev mode, call `npm run start` once // the bundle has been generated diff --git a/frontend/specs/abl.spec.ts b/frontend/specs/abl.spec.ts index 9a952438e..a5d0c2ad4 100644 --- a/frontend/specs/abl.spec.ts +++ b/frontend/specs/abl.spec.ts @@ -42,7 +42,13 @@ describe("newABLentry", () => { version: "1", }), code_version: "123", - expected: [{ uuid: "fake", code_version: "123", commit_sha: "1" }], + expected: { + books_to_approve: [ + { code_version: "123", commit_sha: "1", uuid: "fake" }, + ], + repository: { name: "name", owner: "owner" }, + make_repo_public: false, + }, }, { job: jobFactory.build({ @@ -53,24 +59,31 @@ describe("newABLentry", () => { version: "1", }), code_version: "1234", - expected: [ - { - uuid: "fake", - code_version: "1234", - commit_sha: "1", - }, - { - uuid: "fake2", - code_version: "1234", - commit_sha: "1", + expected: { + books_to_approve: [ + { + uuid: "fake", + code_version: "1234", + commit_sha: "1", + }, + { + uuid: "fake2", + code_version: "1234", + commit_sha: "1", + }, + ], + repository: { + name: "name", + owner: "owner", }, - ], + make_repo_public: false, + }, }, ]; testCases.forEach((args) => { it(`calls fetch with the correct information -> ${args}`, async () => { const { job, code_version: codeVersion, expected } = args; - await newABLentry(job, codeVersion); + await newABLentry(job, codeVersion, false); const url: string = (mockFetch.mock.lastCall as any[])[0] as string; const options = (mockFetch.mock.lastCall as any[])[1]; const body = JSON.parse(options.body); @@ -87,7 +100,7 @@ describe("newABLentry", () => { window.fetch = jest .fn<() => Promise>() .mockResolvedValue({ status: 403 } as unknown as Response); - await newABLentry(job, codeVersion); + await newABLentry(job, codeVersion, false); expect(errors.length).toBe(1); expect(errors[0]).toMatch(/do not.+permission.+entries/i); }); diff --git a/frontend/specs/config.spec.ts b/frontend/specs/config.spec.ts new file mode 100644 index 000000000..76ba8b93c --- /dev/null +++ b/frontend/specs/config.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "@jest/globals"; +import { ProcessConfig, STACK_NAME_KEY } from "../src/ts/config"; +import { FeatureName } from "../src/ts/types"; + +describe("config", () => { + const getActual = ( + config: ProcessConfig, + ): Record => { + return { + makeRepoPublicOnApproval: config.isFeatureEnabled( + FeatureName.makeRepoPublicOnApproval, + ), + }; + }; + it("sets defaults", () => { + const config = ProcessConfig.fromEnv({}); + expect(config.stackName).toBe(undefined); + const expected: Record = { + makeRepoPublicOnApproval: false, + }; + const actual = getActual(config); + expect(actual).toStrictEqual(expected); + }); + it("has expected values for prod", () => { + const config = ProcessConfig.fromEnv({ [STACK_NAME_KEY]: "test_prod" }); + expect(config.stackName).toBe("test_prod"); + const expected: Record = { + makeRepoPublicOnApproval: true, + }; + const actual = getActual(config); + expect(actual).toStrictEqual(expected); + }); +}); diff --git a/frontend/specs/repository.spec.ts b/frontend/specs/repository.spec.ts new file mode 100644 index 000000000..ed9cae583 --- /dev/null +++ b/frontend/specs/repository.spec.ts @@ -0,0 +1,43 @@ +import { + expect, + describe, + it, + jest, + beforeEach, + beforeAll, + afterAll, +} from "@jest/globals"; +import { Fetch, mockJSONResponse, mockResponseStatus } from "./spec-helpers"; +import { getBookRepo } from "../src/ts/repository"; + +const origFetch = window.fetch; +let fetchSpy: jest.SpiedFunction; +beforeAll(() => { + window.fetch = jest.fn(); + fetchSpy = jest.spyOn(window, "fetch"); +}); +afterAll(() => { + window.fetch = origFetch; + jest.restoreAllMocks(); +}); +beforeEach(() => { + // Reset history + jest.resetAllMocks(); + mockResponseStatus(fetchSpy, 200); +}); + +describe("getBookRepo", () => { + it("gets Book Repo", async () => { + const mockResponse = ["bookRepo", "ref", "committedAt", "books"]; + mockJSONResponse(fetchSpy, mockResponse); + const fakeResponse = await getBookRepo({ name: "test", owner: "ing" }); + const expectedURL = "/api/github/book-repository/ing/test"; + const expectedOptions = undefined; + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith(expectedURL, expectedOptions); + // Should return an object with "bookRepo", "ref", "committedAt", "books" + expect(fakeResponse).toStrictEqual( + Object.fromEntries(mockResponse.map((v) => [v, v])), + ); + }); +}); diff --git a/frontend/specs/spec-helpers.ts b/frontend/specs/spec-helpers.ts index 04ef2a51b..8630d617d 100644 --- a/frontend/specs/spec-helpers.ts +++ b/frontend/specs/spec-helpers.ts @@ -1,5 +1,24 @@ import * as Factory from "factory.ts"; import type { ApprovedBookWithDate, Book, Job, User } from "../src/ts/types"; +import type { jest } from "@jest/globals"; + +export type Fetch = ( + input: RequestInfo | URL, + init?: RequestInit | undefined, +) => Promise; + +export type FetchMock = jest.SpiedFunction | jest.Mock; + +export const mockResponseStatus = (mock: FetchMock, status: number) => { + mock.mockResolvedValue({ status } as unknown as Response); +}; + +export const mockJSONResponse = (mock: FetchMock, json: T, status = 200) => { + mock.mockResolvedValue({ + status, + json: async () => json, + } as unknown as Response); +}; // sfc32 credit: https://pracrand.sourceforge.net/ function sfc32(a: number, b: number, c: number, d: number) { diff --git a/frontend/specs/utils.spec.ts b/frontend/specs/utils.spec.ts index 71c927c8f..ad16c2631 100644 --- a/frontend/specs/utils.spec.ts +++ b/frontend/specs/utils.spec.ts @@ -1,5 +1,5 @@ import { expect, describe, it } from "@jest/globals"; -import { sortBy } from "../src/ts/utils"; +import { buildURL, sortBy } from "../src/ts/utils"; describe("sortBy", () => { it("Does lexicographic sorting", () => { @@ -76,3 +76,13 @@ describe("sortBy", () => { expect(sorted).toStrictEqual(expected); }); }); + +describe("buildURL", () => { + it("builds urls as expected", () => { + expect(buildURL("a")).toBe("/a"); + expect(buildURL("a", {})).toBe("/a"); + expect(buildURL("a", { q: "t" })).toBe("/a?q=t"); + expect(buildURL("a", { q: "test=ing" })).toBe("/a?q=test%3Ding"); + expect(buildURL("a", { q: "1", r: "2" })).toBe("/a?q=1&r=2"); + }); +}); diff --git a/frontend/src/components/ApproveBook.svelte b/frontend/src/components/ApproveBook.svelte index 62fb4fb54..43892b26c 100644 --- a/frontend/src/components/ApproveBook.svelte +++ b/frontend/src/components/ApproveBook.svelte @@ -2,13 +2,15 @@ import SegmentedButton, { Label, Segment } from "@smui/segmented-button"; import CircularProgress from "@smui/circular-progress"; import { ABLStore } from "../ts/stores"; - import type { Job } from "../ts/types"; + import { Config, FeatureName, Job } from "../ts/types"; import { getLatestCodeVersionForJob, newABLentry } from "../ts/abl"; import Button from "@smui/button"; import { repoToString } from "../ts/utils"; + import { isRepoPubic } from "../ts/repository"; export let selectedJob: Job; - export let open; + export let open: boolean; + export let config: Config; let selectedCodeVersion; let loading = false; @@ -56,23 +58,43 @@ if (selectedCodeVersion === undefined) { throw new Error("No code version selected"); } + let makeRepoPublic = false; const message = [ - 'Are you sure you wish to add the following to the ABL?', - '', - 'Code Version:', + "Are you sure you wish to add the following to the ABL?", + "", + "Code Version:", ` ${selectedCodeVersion}`, - 'Repository:', + "Repository:", ` ${repoToString(selectedJob.repository)}`, - 'Commit sha:', + "Commit sha:", ` ${selectedJob.version}`, - 'Books:', + "Books:", ` ${selectedJob.books.map((b) => b.slug).join(", ")}`, - ]; - if (!confirm(message.join("\n"))) { + ].join("\n"); + if (!confirm(message)) { return; } + if ( + config.isFeatureEnabled(FeatureName.makeRepoPublicOnApproval) && + !isRepoPubic(selectedJob.repository) + ) { + const repoFullName = repoToString(selectedJob.repository); + const didConfirm = confirm( + `Would you like to make ${repoFullName} public?`, + ); + if (didConfirm) { + const response = prompt( + `WARNING: You are about to make ${repoFullName} public. ` + + 'Enter "public" (without double quotes) to continue.', + ); + if (response !== "public") { + return; + } + makeRepoPublic = true; + } + } loading = true; - newABLentry(selectedJob, selectedCodeVersion) + newABLentry(selectedJob, selectedCodeVersion, makeRepoPublic) .then(() => { void ABLStore.update(); open = false; diff --git a/frontend/src/components/DetailsDialog.svelte b/frontend/src/components/DetailsDialog.svelte index 9785fdde1..dcb0b680f 100644 --- a/frontend/src/components/DetailsDialog.svelte +++ b/frontend/src/components/DetailsDialog.svelte @@ -4,12 +4,13 @@ import Button from "@smui/button"; import { Label } from "@smui/common"; import { abortJob, repeatJob, getErrorMessage } from "../ts/jobs"; - import type { Job } from "../ts/types"; + import type { Config, Job } from "../ts/types"; import ApproveBook from "./ApproveBook.svelte"; import { escapeHTML, repoToString } from "../ts/utils"; import BuildArtifacts from "./BuildArtifacts.svelte"; export let selectedJob: Job; export let open: boolean; + export let config: Config; let isErrorDialog; $: isErrorDialog = selectedJob?.status.name === "failed"; @@ -55,7 +56,7 @@ {#if selectedJob.status.name === "completed"} {#if selectedJob.job_type.name === "git-web-hosting-preview"} - + {/if} {:else if isErrorDialog} {#await getErrorMessage(selectedJob.id)} diff --git a/frontend/src/components/JobsTable.svelte b/frontend/src/components/JobsTable.svelte index d80552d65..f69eec2fe 100755 --- a/frontend/src/components/JobsTable.svelte +++ b/frontend/src/components/JobsTable.svelte @@ -29,12 +29,15 @@ import Button from "@smui/button"; import { repoSummariesStore, jobsStore, ABLStore } from "../ts/stores"; - import type { Job, JobType } from "../ts/types"; + import type { Config, Job, JobType } from "../ts/types"; import DetailsDialog from "./DetailsDialog.svelte"; import { MINUTES, SECONDS } from "../ts/time"; import { hasABLEntry } from "../ts/abl"; import ApprovedBooksDialog from "./ApprovedBooksDialog.svelte"; import VersionLink from "./VersionLink.svelte"; + import { ProcessConfig } from "../ts/config"; + + const config: Config = ProcessConfig.fromEnv(process.env); let statusStyles = { queued: "filter-yellow", @@ -494,7 +497,7 @@ - + diff --git a/frontend/src/ts/abl.ts b/frontend/src/ts/abl.ts index 231aa6b3d..d5fc33ec2 100644 --- a/frontend/src/ts/abl.ts +++ b/frontend/src/ts/abl.ts @@ -3,7 +3,11 @@ import type { Job, Book, ApprovedBookWithDate } from "./types"; import { handleError } from "./utils"; import { parseDateTime } from "./utils"; -export async function newABLentry(job: Job, codeVersion: string) { +export async function newABLentry( + job: Job, + codeVersion: string, + makeRepoPublic: boolean, +) { const entries = job.books.map((b) => ({ uuid: b.uuid, code_version: codeVersion, @@ -13,7 +17,12 @@ export async function newABLentry(job: Job, codeVersion: string) { throw new Error("You do not have permission to add ABL entries."); }; try { - await RequireAuth.sendJson("/api/abl/", entries, { handleAuthError }); + const payload = { + books_to_approve: entries, + repository: job.repository, + make_repo_public: makeRepoPublic, + }; + await RequireAuth.sendJson("/api/abl/", payload, { handleAuthError }); } catch (error) { handleError(error); } @@ -60,12 +69,14 @@ export function getLatestCodeVersionForJob( } export async function fetchABL(): Promise { - let abl: any; + let abl: ApprovedBookWithDate[] = []; try { - abl = await RequireAuth.fetchJson("/api/abl/"); + const maybeAbl = await RequireAuth.fetchJson("/api/abl/"); + if (Array.isArray(maybeAbl)) { + abl = maybeAbl; + } } catch (error) { handleError(error); - abl = []; } return abl; } diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts new file mode 100644 index 000000000..b813ae7eb --- /dev/null +++ b/frontend/src/ts/config.ts @@ -0,0 +1,27 @@ +import { FeatureName, Config } from "./types"; + +interface Env extends Record {} + +const PROD_REGEX = /[\s:_-]prod(uction)?$/i; + +export const STACK_NAME_KEY = "STACK_NAME"; + +export class ProcessConfig implements Config { + private readonly isProd: boolean; + private readonly featureStates: Record; + + constructor(public readonly stackName: string | undefined) { + this.isProd = stackName !== undefined && PROD_REGEX.test(stackName); + this.featureStates = { + [FeatureName.makeRepoPublicOnApproval]: this.isProd, + }; + } + + isFeatureEnabled(featureName: FeatureName) { + return this.featureStates[featureName]; + } + + static fromEnv(env: Env) { + return new ProcessConfig(env[STACK_NAME_KEY]); + } +} diff --git a/frontend/src/ts/repository.ts b/frontend/src/ts/repository.ts new file mode 100644 index 000000000..9bbaf1f25 --- /dev/null +++ b/frontend/src/ts/repository.ts @@ -0,0 +1,25 @@ +import { RequireAuth } from "./fetch-utils"; +import type { BookRepository, Repository } from "./types"; +import { buildURL } from "./utils"; + +export async function getBookRepo( + repo: Repository, + query?: { version?: string }, +): Promise { + const url = buildURL( + `/api/github/book-repository/${repo.owner}/${repo.name}`, + query, + ); + const [bookRepo, ref, committedAt, books] = await RequireAuth.fetchJson(url); + return { + bookRepo, + ref, + committedAt, + books, + }; +} + +export async function isRepoPubic(repo: Repository) { + const { bookRepo } = await getBookRepo(repo); + return bookRepo.visibility === "PUBLIC"; +} diff --git a/frontend/src/ts/types.ts b/frontend/src/ts/types.ts index 383166433..19b496db9 100755 --- a/frontend/src/ts/types.ts +++ b/frontend/src/ts/types.ts @@ -68,3 +68,24 @@ export interface ApprovedBookWithDate extends ApprovedBook { slug: string; consumer: string; } + +export interface BookRepository { + bookRepo: { + name: string; + database_id: string; + viewer_permission: string; + visibility: string; + }; + ref: string; + committedAt: string; + books: Array<{ slug: string; style: string }>; +} + +export enum FeatureName { + makeRepoPublicOnApproval, +} + +export interface Config { + readonly stackName: string | undefined; + isFeatureEnabled: (featureName: FeatureName) => boolean; +} diff --git a/frontend/src/ts/utils.ts b/frontend/src/ts/utils.ts index 03d0cabaf..12d6a3e7a 100755 --- a/frontend/src/ts/utils.ts +++ b/frontend/src/ts/utils.ts @@ -148,3 +148,14 @@ export function sortBy( return n; }); } + +export function buildURL(path: string, query?: Record): string { + const url = new URL("http://origin-not-used"); + url.pathname = path; + if (query !== undefined) { + Object.entries(query).forEach(([k, v]) => { + url.searchParams.append(k, v); + }); + } + return url.href.slice(url.origin.length); +}