From 2f9d6f89e4079b102aad23bb95d0923411a04dda Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 23 Oct 2026 19:12:56 +0300 Subject: [PATCH 1/3] feat: create support issues with media and configurable GitHub routing --- .env.example | 19 + README.md | 1 + deploy/production.env.example | 18 + docs/support.md | 51 +++ papyrus/api/routes/__init__.py | 3 + papyrus/api/routes/support.py | 57 +++ papyrus/config.py | 21 +- papyrus/schemas/support.py | 20 + papyrus/services/support.py | 289 +++++++++++++++ papyrus/services/support_attachments.py | 70 ++++ tests/api/routes/test_support.py | 468 ++++++++++++++++++++++++ 11 files changed, 1016 insertions(+), 1 deletion(-) create mode 100644 docs/support.md create mode 100644 papyrus/api/routes/support.py create mode 100644 papyrus/schemas/support.py create mode 100644 papyrus/services/support.py create mode 100644 papyrus/services/support_attachments.py create mode 100644 tests/api/routes/test_support.py diff --git a/.env.example b/.env.example index 154c927..c1c4b78 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,22 @@ POSTGRES_PASSWORD=papyrus POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 POSTGRES_DB=papyrus + +# Optional: fine-grained GitHub token with Issues write access to PapyrusReader/client. +# Keep this secret on the server. Empty disables public support submissions. +# Attachment uploads also require the token owner to have repository write access. +SUPPORT_GITHUB_TOKEN= + +# Support routing. Token also needs organization Projects write access. +SUPPORT_GITHUB_REPOSITORY=PapyrusReader/client +SUPPORT_GITHUB_ASSIGNEE=Eoic +SUPPORT_GITHUB_PROJECT_OWNER=PapyrusReader +SUPPORT_GITHUB_PROJECT_NUMBER=1 +SUPPORT_GITHUB_BUG_LABEL=bug +SUPPORT_GITHUB_FEATURE_LABEL=enhancement +SUPPORT_GITHUB_BUG_TYPE=Bug +SUPPORT_GITHUB_FEATURE_TYPE=Feature +SUPPORT_GITHUB_STATUS_FIELD=Status +SUPPORT_GITHUB_STATUS=Backlog +SUPPORT_GITHUB_KIND_FIELD=Kind +SUPPORT_GITHUB_KIND=Task diff --git a/README.md b/README.md index 385d29d..94d48fe 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ endpoints, jobs and rules. Provider adapters, job lifecycle and release tokens live in [the acquisition service](papyrus/services/acquisition/). The monitor imports selected media and handles retry/cancellation. [OPDS relay setup](docs/opds-relay.md) describes catalog transport and origin restrictions. +See [in-app support setup](docs/support.md) for GitHub issue submission configuration. ## Verification diff --git a/deploy/production.env.example b/deploy/production.env.example index 4cdc9d7..4d98a73 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -41,3 +41,21 @@ SMTP_FROM_EMAIL= SMTP_FROM_NAME=Papyrus ACQUISITION_ENABLED=false OPDS_RELAY_ENABLED=true + +# Optional server-held token with Issues write permission for PapyrusReader/client. +# Attachment uploads also require the token owner to have repository write access. +SUPPORT_GITHUB_TOKEN= + +# Support routing. Token also needs organization Projects write access. +SUPPORT_GITHUB_REPOSITORY=PapyrusReader/client +SUPPORT_GITHUB_ASSIGNEE=Eoic +SUPPORT_GITHUB_PROJECT_OWNER=PapyrusReader +SUPPORT_GITHUB_PROJECT_NUMBER=1 +SUPPORT_GITHUB_BUG_LABEL=bug +SUPPORT_GITHUB_FEATURE_LABEL=enhancement +SUPPORT_GITHUB_BUG_TYPE=Bug +SUPPORT_GITHUB_FEATURE_TYPE=Feature +SUPPORT_GITHUB_STATUS_FIELD=Status +SUPPORT_GITHUB_STATUS=Backlog +SUPPORT_GITHUB_KIND_FIELD=Kind +SUPPORT_GITHUB_KIND=Task diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..d879f88 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,51 @@ +# In-app support + +`POST /v1/support/issues` accepts bug reports and feature requests from guests and +signed-in readers. It creates a public issue in the configured repository (default +`PapyrusReader/client`). The request +is multipart: `payload` contains the JSON support fields and optional repeated +`attachments` parts contain images or videos. + +Set `SUPPORT_GITHUB_TOKEN` in the server environment (`production.env` for Compose) +to a fine-grained PAT restricted to that repository with **Issues: read and write**. +Also grant organization **Projects: read and write** for the Papyrus project. +The token owner must also have repository write access for media uploads. GitHub +App installation tokens cannot upload attachments. Keep the token out of client +builds. Without it, the endpoint returns 503. + +Routing is controlled by the `SUPPORT_GITHUB_*` environment variables shown in +`.env.example` and `deploy/production.env.example`: repository, assignee, project +owner/number, labels, issue types, and project field names/values. Set overrides in +the server's ignored `.env` or deployment `production.env`. These routing values +are ordinary configuration. Only `SUPPORT_GITHUB_TOKEN` is a secret. None of these +settings can be overridden by a support request. + +By default, bug reports receive the `bug` label and native `Bug` issue type. Feature requests +receive `enhancement` and `Feature`. Both are assigned to `Eoic` and added to the +Papyrus project (#1) with Status `Backlog` and Kind `Task`. Priority, milestone, +and requirement links are left for triage. Project access and field options are +checked before uploads or issue creation. If project updates fail after the issue +is created, submission still succeeds and the server logs its issue number for +manual reconciliation, avoiding duplicate reports from a retry. + +The form accepts up to five PNG, JPEG, GIF, WebP, MP4, MOV, or WebM files, each up to +10 MiB. The server checks sizes and media signatures before contacting GitHub. +The multipart request is bounded to 50 MiB plus 64 KiB of form overhead, including +chunked requests. Media is uploaded through the same native GitHub attachment +endpoint used by [GitHub CLI](https://github.com/cli/cli/blob/v2.102.0/internal/attachments/client.go), +then embedded in the issue. Original filenames are replaced with generic names. +Nothing is stored in the reader's library or exposed through library media routes. + +Submissions are limited to three per hour and ten per day per client IP using the +existing process-local limiter. Configure trusted proxy forwarding so requests +retain their client IP. Multiple API workers need a shared limiter or equivalent +edge limits before increasing deployment scale. + +Each GitHub request has a 30-second socket timeout. The client allows four minutes +for submission and never retries automatically. A failed submission preserves the +form and selected files. An attachment failure prevents issue creation, but files +already accepted by GitHub may remain public even if a later step fails. GitHub +does not provide an attachment deletion API. An issue-creation timeout may mean +GitHub created the issue without the client receiving confirmation. + +Tests mock GitHub and never create public issues or upload real attachments. diff --git a/papyrus/api/routes/__init__.py b/papyrus/api/routes/__init__.py index b1b24af..30398bc 100644 --- a/papyrus/api/routes/__init__.py +++ b/papyrus/api/routes/__init__.py @@ -21,6 +21,7 @@ series, shelves, storage, + support, sync, tags, users, @@ -28,6 +29,8 @@ api_router = APIRouter() +api_router.include_router(support.router, prefix="/support", tags=["Support"]) + api_router.include_router(opds.router, prefix="/opds", tags=["OPDS"]) api_router.include_router(acquisition.router, prefix="/acquisition", tags=["Acquisition"]) diff --git a/papyrus/api/routes/support.py b/papyrus/api/routes/support.py new file mode 100644 index 0000000..8081f8c --- /dev/null +++ b/papyrus/api/routes/support.py @@ -0,0 +1,57 @@ +"""Support is available to guests as well as signed-in readers.""" + +from collections.abc import Callable, Coroutine +from typing import Annotated, Any + +from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile +from fastapi.routing import APIRoute +from pydantic import Json +from starlette.types import Message + +from papyrus.core.rate_limit import limiter +from papyrus.schemas.support import SupportRequest, SupportResponse +from papyrus.services.support import submit_support +from papyrus.services.support_attachments import MAX_REQUEST_BYTES, read_attachments + +RequestHandler = Callable[[Request], Coroutine[Any, Any, Response]] + + +@limiter.limit("3/hour;10/day") +async def handle_support_request(request: Request, handler: RequestHandler) -> Response: + """Rate-limit and bound uploads before the multipart parser spools their bodies.""" + received = 0 + + async def receive() -> Message: + nonlocal received + message = await request.receive() + received += len(message.get("body", b"")) + + if received > MAX_REQUEST_BYTES: + raise HTTPException(status_code=413, detail="Submission is too large.") + + return message + + return await handler(Request(request.scope, receive=receive)) + + +class SupportRoute(APIRoute): + def get_route_handler(self) -> RequestHandler: + handler = super().get_route_handler() + + async def limited_request(request: Request) -> Response: + response: Response = await handle_support_request(request, handler) + return response + + return limited_request + + +router = APIRouter(route_class=SupportRoute) + + +@router.post("/issues", response_model=SupportResponse, status_code=201) +def create_support_issue( + request: Request, + payload: Annotated[Json[SupportRequest], Form()], + attachments: Annotated[list[UploadFile] | None, File()] = None, +) -> SupportResponse: + return submit_support(payload, read_attachments(attachments or [])) diff --git a/papyrus/config.py b/papyrus/config.py index 7558a32..3d2954b 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -1,7 +1,7 @@ from functools import lru_cache from pathlib import Path -from pydantic import computed_field, field_validator +from pydantic import Field, SecretStr, computed_field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -30,6 +30,24 @@ class Settings(BaseSettings): rate_limit_general: int rate_limit_upload: int rate_limit_batch: int + support_github_token: SecretStr | None = None + + support_github_repository: str = Field( + default="PapyrusReader/client", + pattern=r"^[A-Za-z0-9-]+/[A-Za-z0-9][A-Za-z0-9_.-]*$", + ) + + support_github_assignee: str = Field(default="Eoic", pattern=r"^[A-Za-z0-9-]+$") + support_github_project_owner: str = Field(default="PapyrusReader", pattern=r"^[A-Za-z0-9-]+$") + support_github_project_number: int = Field(default=1, gt=0) + support_github_bug_label: str = Field(default="bug", min_length=1) + support_github_feature_label: str = Field(default="enhancement", min_length=1) + support_github_bug_type: str = Field(default="Bug", min_length=1) + support_github_feature_type: str = Field(default="Feature", min_length=1) + support_github_status_field: str = Field(default="Status", min_length=1) + support_github_status: str = Field(default="Backlog", min_length=1) + support_github_kind_field: str = Field(default="Kind", min_length=1) + support_github_kind: str = Field(default="Task", min_length=1) public_base_url: str | None = None app_public_base_url: str | None = None google_oauth_client_id: str | None = None @@ -117,6 +135,7 @@ def normalize_dev_pages_vite_url(cls, value: str) -> str: return value.strip().rstrip("/") @field_validator( + "support_github_token", "public_base_url", "app_public_base_url", "google_oauth_client_id", diff --git a/papyrus/schemas/support.py b/papyrus/schemas/support.py new file mode 100644 index 0000000..9658fbb --- /dev/null +++ b/papyrus/schemas/support.py @@ -0,0 +1,20 @@ +"""Public support submissions contain only text explicitly supplied by the user.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class SupportRequest(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") + + kind: Literal["bug", "feature"] + title: str = Field(min_length=1, max_length=150) + description: str = Field(min_length=10, max_length=8000) + app_version: str = Field(min_length=1, max_length=80) + platform: str = Field(min_length=1, max_length=80) + + +class SupportResponse(BaseModel): + number: int = Field(gt=0) + url: str diff --git a/papyrus/services/support.py b/papyrus/services/support.py new file mode 100644 index 0000000..cb23014 --- /dev/null +++ b/papyrus/services/support.py @@ -0,0 +1,289 @@ +"""Submit user-reviewed reports using server-controlled GitHub routing and credentials.""" + +import json +import logging +import re +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from fastapi import HTTPException + +from papyrus.config import get_settings +from papyrus.schemas.support import SupportRequest, SupportResponse +from papyrus.services.support_attachments import SupportAttachment + +logger = logging.getLogger(__name__) + +PROJECT_QUERY = """ +query SupportProject($owner: String!, $number: Int!, $statusField: String!, $kindField: String!) { + organization(login: $owner) { + projectV2(number: $number) { + id + viewerCanUpdate + status: field(name: $statusField) { + ... on ProjectV2SingleSelectField { id options { id name } } + } + kind: field(name: $kindField) { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } +} +""" + + +@dataclass(frozen=True) +class SupportProject: + id: str + status_field: str + status_option: str + kind_field: str + kind_option: str + + +def submit_support(payload: SupportRequest, attachments: list[SupportAttachment]) -> SupportResponse: + settings = get_settings() + token = settings.support_github_token + repository = settings.support_github_repository + + if token is None: + raise HTTPException(status_code=503, detail="Support submissions are not configured on this server.") + + if payload.kind == "bug": + category = "Bug report" + prefix = "BUG" + label = settings.support_github_bug_label + issue_type = settings.support_github_bug_type + else: + category = "Feature request" + prefix = "FEATURE" + label = settings.support_github_feature_label + issue_type = settings.support_github_feature_type + + body = ( + f"## {category}\n\n{payload.description}\n\n" + f"## App details\n\nVersion: {payload.app_version}\nPlatform: {payload.platform}" + ) + + headers = { + "Authorization": f"Bearer {token.get_secret_value()}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "User-Agent": "Papyrus-Support", + "X-GitHub-Api-Version": "2022-11-28", + } + + project = load_support_project(headers) + + if attachments: + repository_details = github_request(Request(f"https://api.github.com/repos/{repository}", headers=headers)) + repository_id = repository_details.get("id") + + if type(repository_id) is not int or repository_id <= 0: + raise HTTPException(status_code=502, detail="Submission failed.") + + body += "\n\n## Attachments" + + for attachment in attachments: + query = urlencode( + { + "name": attachment.name, + "content_type": attachment.content_type, + "repository_id": repository_id, + } + ) + + asset = github_request( + Request( + f"https://uploads.github.com/user-attachments/assets?{query}", + data=attachment.data, + headers={**headers, "Content-Type": "application/octet-stream"}, + method="POST", + ) + ) + + url = asset.get("url") + + if not isinstance(url, str) or not re.fullmatch( + r"https://github\.com/user-attachments/assets/[a-zA-Z0-9-]+", url + ): + raise HTTPException(status_code=502, detail="Submission failed.") + + if attachment.content_type.startswith("image/"): + body += f"\n\n![{attachment.name}]({url})" + else: + body += f"\n\n{url}" + + result = github_request( + Request( + f"https://api.github.com/repos/{repository}/issues", + data=json.dumps( + { + "title": f"[{prefix}] {payload.title}", + "body": body, + "labels": [label], + "type": issue_type, + "assignees": [settings.support_github_assignee], + } + ).encode(), + headers=headers, + method="POST", + ) + ) + + number = result.get("number") + + if not isinstance(number, int) or isinstance(number, bool) or number <= 0: + raise HTTPException(status_code=504, detail="Submission failed.") + + try: + add_support_to_project(headers, project, required_id(result.get("node_id"))) + except HTTPException: + logger.error( + "Support issue %s#%s was created but project routing failed; reconcile its project fields", + repository, + number, + ) + + return SupportResponse(number=number, url=f"https://github.com/{repository}/issues/{number}") + + +def required_object(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise HTTPException(status_code=502, detail="Submission failed.") + + return value + + +def required_id(value: object) -> str: + if not isinstance(value, str) or not value: + raise HTTPException(status_code=502, detail="Submission failed.") + + return value + + +def project_selection(value: object, option_name: str) -> tuple[str, str]: + field = required_object(value) + options = field.get("options") + + if isinstance(options, list): + for option in options: + if isinstance(option, dict) and option.get("name") == option_name: + return required_id(field.get("id")), required_id(option.get("id")) + + raise HTTPException(status_code=502, detail="Submission failed.") + + +def load_support_project(headers: dict[str, str]) -> SupportProject: + settings = get_settings() + + data = github_graphql( + headers, + PROJECT_QUERY, + { + "owner": settings.support_github_project_owner, + "number": settings.support_github_project_number, + "statusField": settings.support_github_status_field, + "kindField": settings.support_github_kind_field, + }, + ) + organization = required_object(data.get("organization")) + project = required_object(organization.get("projectV2")) + + if project.get("viewerCanUpdate") is not True: + raise HTTPException(status_code=503, detail="Submission failed.") + + status_field, status_option = project_selection(project.get("status"), settings.support_github_status) + kind_field, kind_option = project_selection(project.get("kind"), settings.support_github_kind) + + return SupportProject( + id=required_id(project.get("id")), + status_field=status_field, + status_option=status_option, + kind_field=kind_field, + kind_option=kind_option, + ) + + +def add_support_to_project(headers: dict[str, str], project: SupportProject, issue_id: str) -> None: + """Only mutate project metadata after creation, so a routing failure cannot invite duplicate reports.""" + data = github_graphql( + headers, + """ + mutation AddSupportToProject($project: ID!, $issue: ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) { item { id } } + } + """, + {"project": project.id, "issue": issue_id}, + ) + + added = required_object(data.get("addProjectV2ItemById")) + item = required_object(added.get("item")) + + github_graphql( + headers, + """ + mutation RouteSupport( + $project: ID!, $item: ID!, $status: ID!, $statusOption: String!, $kind: ID!, $kindOption: String! + ) { + status: updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $status, value: {singleSelectOptionId: $statusOption} + }) { projectV2Item { id } } + kind: updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $kind, value: {singleSelectOptionId: $kindOption} + }) { projectV2Item { id } } + } + """, + { + "project": project.id, + "item": required_id(item.get("id")), + "status": project.status_field, + "statusOption": project.status_option, + "kind": project.kind_field, + "kindOption": project.kind_option, + }, + ) + + +def github_graphql(headers: dict[str, str], query: str, variables: dict[str, object]) -> dict[str, object]: + result = github_request( + Request( + "https://api.github.com/graphql", + data=json.dumps({"query": query, "variables": variables}).encode(), + headers=headers, + method="POST", + ) + ) + + if result.get("errors"): + raise HTTPException(status_code=502, detail="Submission failed.") + + return required_object(result.get("data")) + + +def github_request(request: Request) -> dict[str, object]: + try: + with urlopen(request, timeout=30) as response: + result = json.load(response) + except HTTPError as error: + if error.code >= 500: + raise HTTPException( + status_code=504, + detail="Submission could not be confirmed. Check the repository before submitting again.", + ) from error + + raise HTTPException( + status_code=502, detail="GitHub could not accept the submission. Please try later." + ) from error + except (URLError, TimeoutError, OSError, ValueError) as error: + raise HTTPException( + status_code=504, + detail="Submission could not be confirmed. Check the repository before submitting again.", + ) from error + + if not isinstance(result, dict): + raise HTTPException(status_code=504, detail="Submission failed.") + + return result diff --git a/papyrus/services/support_attachments.py b/papyrus/services/support_attachments.py new file mode 100644 index 0000000..57cee02 --- /dev/null +++ b/papyrus/services/support_attachments.py @@ -0,0 +1,70 @@ +"""Validate support media before any bytes are sent to GitHub.""" + +from dataclasses import dataclass + +from fastapi import HTTPException, UploadFile + +MAX_ATTACHMENTS = 5 +MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 +MAX_REQUEST_BYTES = MAX_ATTACHMENTS * MAX_ATTACHMENT_BYTES + 64 * 1024 +MEDIA_TYPES = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "mp4": "video/mp4", + "mov": "video/quicktime", + "webm": "video/webm", +} + + +@dataclass(frozen=True) +class SupportAttachment: + name: str + content_type: str + data: bytes + + +def read_attachments(files: list[UploadFile]) -> list[SupportAttachment]: + if len(files) > MAX_ATTACHMENTS: + raise HTTPException(status_code=422, detail="Attach up to five files.") + + attachments = [] + + for index, file in enumerate(files, start=1): + extension = (file.filename or "").rsplit(".", 1)[-1].lower() + content_type = MEDIA_TYPES.get(extension) + + if content_type is None: + raise HTTPException(status_code=422, detail="Unsupported attachment type.") + + data = file.file.read(MAX_ATTACHMENT_BYTES + 1) + + if not data or len(data) > MAX_ATTACHMENT_BYTES: + raise HTTPException(status_code=413, detail="Attachments must be between 1 byte and 10 MB.") + + if not matches_media(data, extension): + raise HTTPException(status_code=422, detail="Invalid attachment content.") + + attachments.append(SupportAttachment(f"attachment-{index}.{extension}", content_type, data)) + + return attachments + + +def matches_media(data: bytes, extension: str) -> bool: + match extension: + case "png": + return data.startswith(b"\x89PNG\r\n\x1a\n") + case "jpg" | "jpeg": + return data.startswith(b"\xff\xd8\xff") + case "gif": + return data.startswith((b"GIF87a", b"GIF89a")) + case "webp": + return data.startswith(b"RIFF") and data[8:12] == b"WEBP" + case "mp4" | "mov": + return len(data) >= 12 and data[4:8] == b"ftyp" + case "webm": + return data.startswith(b"\x1a\x45\xdf\xa3") and b"webm" in data[:4096] + case _: + return False diff --git a/tests/api/routes/test_support.py b/tests/api/routes/test_support.py new file mode 100644 index 0000000..4ea89a2 --- /dev/null +++ b/tests/api/routes/test_support.py @@ -0,0 +1,468 @@ +"""Public support contracts use a mocked GitHub connection and no database.""" + +import io +import json +from copy import deepcopy +from unittest.mock import Mock +from urllib.error import HTTPError + +import pytest +from httpx import ASGITransport, AsyncClient +from pydantic import SecretStr, ValidationError + +from papyrus.config import Settings, get_settings +from papyrus.main import create_app +from papyrus.services import support + +PAYLOAD = { + "kind": "bug", + "title": "Book does not open", + "description": "Opening an EPUB leaves an empty reader.", + "app_version": "0.0.4 (4)", + "platform": "android", +} + +PROJECT = { + "data": { + "organization": { + "projectV2": { + "id": "project-id", + "viewerCanUpdate": True, + "status": {"id": "status-field", "options": [{"id": "backlog-option", "name": "Backlog"}]}, + "kind": {"id": "kind-field", "options": [{"id": "task-option", "name": "Task"}]}, + } + } + } +} + +ISSUE = {"number": 42, "node_id": "issue-id"} +PROJECT_ITEM = {"data": {"addProjectV2ItemById": {"item": {"id": "item-id"}}}} +PROJECT_FIELDS = { + "data": {"status": {"projectV2Item": {"id": "item-id"}}, "kind": {"projectV2Item": {"id": "item-id"}}} +} + + +def github_response(value): + return io.BytesIO(json.dumps(value).encode()) + + +def github_success(request, **kwargs): + if request.full_url.endswith("/issues"): + return github_response(ISSUE) + + query = json.loads(request.data)["query"] + + if "query SupportProject" in query: + return github_response(PROJECT) + + if "mutation AddSupportToProject" in query: + return github_response(PROJECT_ITEM) + + if "mutation RouteSupport" in query: + return github_response(PROJECT_FIELDS) + + raise AssertionError(f"Unexpected GitHub request: {request.full_url}") + + +@pytest.fixture +def github(monkeypatch): + monkeypatch.setattr(get_settings(), "support_github_token", SecretStr("test-server-secret")) + connection = Mock(side_effect=github_success) + monkeypatch.setattr(support, "urlopen", connection) + return connection + + +@pytest.mark.parametrize("kind", ["bug", "feature"]) +async def test_guest_submission_creates_client_issue_without_forwarding_identity(github, kind): + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps({**PAYLOAD, "kind": kind})}, + headers={"Cookie": "private-session", "Authorization": "Bearer private-user-token"}, + ) + + assert response.status_code == 201 + assert response.json() == {"number": 42, "url": "https://github.com/PapyrusReader/client/issues/42"} + request = github.call_args_list[1].args[0] + assert request.full_url == "https://api.github.com/repos/PapyrusReader/client/issues" + assert request.get_header("Authorization") == "Bearer test-server-secret" + assert request.get_header("Cookie") is None + posted = json.loads(request.data) + assert PAYLOAD["description"] in posted["body"] + assert PAYLOAD["app_version"] in posted["body"] + prefix = "BUG" if kind == "bug" else "FEATURE" + assert posted["title"] == f"[{prefix}] {PAYLOAD['title']}" + assert posted["labels"] == ["bug" if kind == "bug" else "enhancement"] + assert posted["type"] == ("Bug" if kind == "bug" else "Feature") + assert posted["assignees"] == ["Eoic"] + assert "milestone" not in posted + project_request = json.loads(github.call_args_list[2].args[0].data) + assert project_request["variables"] == {"project": "project-id", "issue": "issue-id"} + field_request = json.loads(github.call_args_list[3].args[0].data) + + assert field_request["variables"] == { + "project": "project-id", + "item": "item-id", + "status": "status-field", + "statusOption": "backlog-option", + "kind": "kind-field", + "kindOption": "task-option", + } + + assert "Submitted from Papyrus." not in posted["body"] + assert "test-server-secret" not in response.text + + +async def test_disabled_support_does_not_contact_github(monkeypatch): + monkeypatch.setattr(get_settings(), "support_github_token", None) + connection = Mock() + monkeypatch.setattr(support, "urlopen", connection) + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == 503 + connection.assert_not_called() + + +@pytest.mark.parametrize( + "changes", [{"kind": "other"}, {"title": " "}, {"description": "x" * 8001}, {"repository": "other/repo"}] +) +async def test_invalid_submission_is_rejected_before_github(github, changes): + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps({**PAYLOAD, **changes})} + ) + + assert response.status_code == 422 + github.assert_not_called() + + +async def test_support_rate_limits_guest_submissions(github): + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + responses = [ + await client.post(f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)}) + for _ in range(4) + ] + + assert [response.status_code for response in responses] == [201, 201, 201, 429] + assert sum(call.args[0].full_url.endswith("/issues") for call in github.call_args_list) == 3 + + +@pytest.mark.parametrize( + "error,status", + [(TimeoutError(), 504), (HTTPError("https://api.github.com", 403, "secret upstream error", {}, None), 502)], +) +async def test_provider_failure_is_sanitized_and_never_retried(github, error, status): + github.side_effect = error + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == status + assert "secret upstream error" not in response.text + github.assert_called_once() + + +async def test_malformed_github_success_is_an_uncertain_submission(github): + github.side_effect = [github_response(PROJECT), io.BytesIO(b'{"number": false}')] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == 504 + assert response.json()["detail"] == "Submission failed." + assert github.call_count == 2 + + +PNG = b"\x89PNG\r\n\x1a\n" + b"image-bytes" +MP4 = b"\x00\x00\x00\x18ftypmp42" + b"video-bytes" +ASSET_URL = "https://github.com/user-attachments/assets/11111111-2222-3333-4444-555555555555" + + +async def test_media_is_uploaded_to_github_and_embedded_in_issue(github): + github.side_effect = [ + github_response(PROJECT), + io.BytesIO(b'{"id": 123}'), + io.BytesIO(json.dumps({"url": ASSET_URL}).encode()), + io.BytesIO(json.dumps({"url": ASSET_URL}).encode()), + github_response(ISSUE), + github_response(PROJECT_ITEM), + github_response(PROJECT_FIELDS), + ] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("private-screenshot.png", PNG)), ("attachments", ("demo.mp4", MP4))], + ) + + assert response.status_code == 201 + requests = [call.args[0] for call in github.call_args_list[1:5]] + assert requests[0].full_url == "https://api.github.com/repos/PapyrusReader/client" + assert requests[1].full_url.startswith("https://uploads.github.com/user-attachments/assets?") + assert "repository_id=123" in requests[1].full_url + assert "name=attachment-1.png" in requests[1].full_url + assert requests[1].data == PNG + assert requests[2].data == MP4 + assert requests[1].get_header("Content-type") == "application/octet-stream" + body = json.loads(requests[-1].data)["body"] + assert f"![attachment-1.png]({ASSET_URL})" in body + assert f"\n\n{ASSET_URL}" in body + assert "private-screenshot" not in body + + +@pytest.mark.parametrize( + "files,status", + [ + ([("attachments", ("screen.png", PNG))] * 6, 422), + ([("attachments", ("screen.svg", b""))], 422), + ([("attachments", ("screen.png", b"not an image"))], 422), + ([("attachments", ("empty.mp4", b""))], 413), + ([("attachments", ("large.png", PNG + b"x" * (10 * 1024 * 1024)))], 413), + ], +) +async def test_invalid_media_is_rejected_before_any_github_upload(github, files, status): + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=files, + ) + + assert response.status_code == status + github.assert_not_called() + + +async def test_failed_attachment_upload_never_creates_text_only_issue(github): + github.side_effect = [github_response(PROJECT), io.BytesIO(b'{"id": 123}'), TimeoutError()] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("screen.png", PNG))], + ) + + assert response.status_code == 504 + assert github.call_count == 3 + + +async def test_attachment_response_cannot_inject_markdown_or_external_urls(github): + github.side_effect = [ + github_response(PROJECT), + io.BytesIO(b'{"id": 123}'), + io.BytesIO(b'{"url": "https://evil.example/image"}'), + ] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("screen.png", PNG))], + ) + + assert response.status_code == 502 + assert github.call_count == 3 + + +async def test_body_is_bounded_even_without_content_length(github, monkeypatch): + from papyrus.api.routes import support as support_routes + + monkeypatch.setattr(support_routes, "MAX_REQUEST_BYTES", 32) + + async def body(): + yield b"--boundary\r\nContent-Disposition: form-data; name=payload\r\n\r\n" + yield b"x" * 64 + yield b"\r\n--boundary--\r\n" + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + content=body(), + headers={"Content-Type": "multipart/form-data; boundary=boundary"}, + ) + + assert response.status_code == 413 + github.assert_not_called() + + +async def test_rate_limit_rejects_upload_without_reading_body(github): + async def unread_body(): + pytest.fail("A rate-limited request must not consume attachment bytes") + yield b"" + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + for _ in range(3): + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + assert response.status_code == 201 + + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + content=unread_body(), + headers={"Content-Type": "multipart/form-data; boundary=boundary"}, + ) + + assert response.status_code == 429 + assert sum(call.args[0].full_url.endswith("/issues") for call in github.call_args_list) == 3 + + +@pytest.mark.parametrize("failure", ["permissions", "missing-option", "graphql"]) +async def test_project_configuration_is_checked_before_creating_an_issue_or_uploading(github, failure): + project = deepcopy(PROJECT) + + if failure == "permissions": + project["data"]["organization"]["projectV2"]["viewerCanUpdate"] = False + elif failure == "missing-option": + project["data"]["organization"]["projectV2"]["status"]["options"] = [] + else: + project = {"errors": [{"message": "private upstream details"}]} + + github.side_effect = [github_response(project)] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("screen.png", PNG))], + ) + + assert response.status_code in (502, 503) + assert response.json()["detail"] == "Submission failed." + github.assert_called_once() + + +@pytest.mark.parametrize("stage", ["add", "fields"]) +@pytest.mark.parametrize("failure", ["timeout", "graphql"]) +async def test_project_failure_after_creation_keeps_success_and_logs_issue_for_reconciliation( + github, caplog, stage, failure +): + responses = [github_response(PROJECT), github_response(ISSUE)] + + if stage == "fields": + responses.append(github_response(PROJECT_ITEM)) + + if failure == "timeout": + responses.append(TimeoutError()) + else: + responses.append(github_response({"errors": [{"message": "private upstream details"}]})) + + github.side_effect = responses + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == 201 + assert response.json()["number"] == 42 + assert sum(call.args[0].full_url.endswith("/issues") for call in github.call_args_list) == 1 + assert github.call_count == len(responses) + assert "PapyrusReader/client#42" in caplog.text + assert "private upstream details" not in caplog.text + assert "test-server-secret" not in caplog.text + + +async def test_issue_creation_failure_is_not_retried_or_added_to_project(github): + github.side_effect = [github_response(PROJECT), TimeoutError()] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == 504 + assert github.call_count == 2 + + +@pytest.mark.parametrize("kind", ["bug", "feature"]) +async def test_support_routing_uses_server_environment(github, monkeypatch, kind): + overrides = { + "support_github_repository": "ExampleOrg/reader-support", + "support_github_assignee": "maintainer", + "support_github_project_owner": "ExampleOrg", + "support_github_project_number": "7", + "support_github_bug_label": "defect", + "support_github_feature_label": "suggestion", + "support_github_bug_type": "Problem", + "support_github_feature_type": "Idea", + "support_github_status_field": "Workflow", + "support_github_status": "Triage", + "support_github_kind_field": "Category", + "support_github_kind": "Feedback", + } + + for name, value in overrides.items(): + monkeypatch.setenv(name.upper(), value) + + settings = Settings(**get_settings().model_dump(include=set(Settings.model_fields) - set(overrides))) + monkeypatch.setattr(support, "get_settings", lambda: settings) + project = deepcopy(PROJECT) + project["data"]["organization"]["projectV2"]["status"]["options"][0]["name"] = "Triage" + project["data"]["organization"]["projectV2"]["kind"]["options"][0]["name"] = "Feedback" + + github.side_effect = [ + github_response(project), + github_response({"id": 123}), + github_response({"url": ASSET_URL}), + github_response(ISSUE), + github_response(PROJECT_ITEM), + github_response(PROJECT_FIELDS), + ] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{settings.api_prefix}/support/issues", + data={"payload": json.dumps({**PAYLOAD, "kind": kind})}, + files=[("attachments", ("screen.png", PNG))], + ) + + assert response.status_code == 201 + assert response.json()["url"] == "https://github.com/ExampleOrg/reader-support/issues/42" + requests = [call.args[0] for call in github.call_args_list] + + assert json.loads(requests[0].data)["variables"] == { + "owner": "ExampleOrg", + "number": 7, + "statusField": "Workflow", + "kindField": "Category", + } + + assert requests[1].full_url == "https://api.github.com/repos/ExampleOrg/reader-support" + assert requests[3].full_url == "https://api.github.com/repos/ExampleOrg/reader-support/issues" + posted = json.loads(requests[3].data) + assert posted["assignees"] == ["maintainer"] + assert posted["labels"] == ["defect" if kind == "bug" else "suggestion"] + assert posted["type"] == ("Problem" if kind == "bug" else "Idea") + assert json.loads(requests[5].data)["variables"]["statusOption"] == "backlog-option" + assert json.loads(requests[5].data)["variables"]["kindOption"] == "task-option" + + +@pytest.mark.parametrize( + "name,value", + [ + ("support_github_repository", "https://example.com/repo"), + ("support_github_repository", "ExampleOrg/../repo"), + ("support_github_project_number", 0), + ("support_github_assignee", ""), + ("support_github_project_owner", "org/other"), + ("support_github_status", ""), + ], +) +def test_invalid_support_routing_configuration_is_rejected(name, value): + values = get_settings().model_dump(include=set(Settings.model_fields)) + values[name] = value + + with pytest.raises(ValidationError) as error: + Settings(**values) + + assert [entry["loc"] for entry in error.value.errors()] == [(name,)] From 652a6e33412801c04808cb871d626be252e126d6 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 23 Oct 2026 21:14:01 +0300 Subject: [PATCH 2/3] fix: explain denied GitHub project routing in server logs --- docs/support.md | 6 ++++++ papyrus/services/support.py | 17 ++++++++++++++--- tests/api/routes/test_support.py | 14 +++++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/support.md b/docs/support.md index d879f88..eca8042 100644 --- a/docs/support.md +++ b/docs/support.md @@ -28,6 +28,12 @@ checked before uploads or issue creation. If project updates fail after the issu is created, submission still succeeds and the server logs its issue number for manual reconciliation, avoiding duplicate reports from a retry. +Reading a public project and its `viewerCanUpdate` value only confirms visibility +and the token owner's role. It does not prove the token has Projects write access. +A `FORBIDDEN` project mutation is logged with the issue number and a permission +hint. Grant organization Projects read/write to the configured token, then add +any already-created reports to the project without resubmitting them. + The form accepts up to five PNG, JPEG, GIF, WebP, MP4, MOV, or WebM files, each up to 10 MiB. The server checks sizes and media signatures before contacting GitHub. The multipart request is bounded to 50 MiB plus 64 KiB of form overhead, including diff --git a/papyrus/services/support.py b/papyrus/services/support.py index cb23014..1c4fcd6 100644 --- a/papyrus/services/support.py +++ b/papyrus/services/support.py @@ -140,11 +140,12 @@ def submit_support(payload: SupportRequest, attachments: list[SupportAttachment] try: add_support_to_project(headers, project, required_id(result.get("node_id"))) - except HTTPException: + except HTTPException as error: logger.error( - "Support issue %s#%s was created but project routing failed; reconcile its project fields", + "Support issue %s#%s was created but project routing failed: %s Reconcile its project fields.", repository, number, + error.detail, ) return SupportResponse(number=number, url=f"https://github.com/{repository}/issues/{number}") @@ -257,7 +258,17 @@ def github_graphql(headers: dict[str, str], query: str, variables: dict[str, obj ) ) - if result.get("errors"): + errors = result.get("errors") + + if isinstance(errors, list) and any( + isinstance(error, dict) and error.get("type") == "FORBIDDEN" for error in errors + ): + raise HTTPException( + status_code=502, + detail="GitHub denied project access. Check SUPPORT_GITHUB_TOKEN organization Projects write permission.", + ) + + if errors: raise HTTPException(status_code=502, detail="Submission failed.") return required_object(result.get("data")) diff --git a/tests/api/routes/test_support.py b/tests/api/routes/test_support.py index 4ea89a2..6b9c4d3 100644 --- a/tests/api/routes/test_support.py +++ b/tests/api/routes/test_support.py @@ -342,7 +342,7 @@ async def test_project_configuration_is_checked_before_creating_an_issue_or_uplo @pytest.mark.parametrize("stage", ["add", "fields"]) -@pytest.mark.parametrize("failure", ["timeout", "graphql"]) +@pytest.mark.parametrize("failure", ["timeout", "graphql", "forbidden"]) async def test_project_failure_after_creation_keeps_success_and_logs_issue_for_reconciliation( github, caplog, stage, failure ): @@ -353,6 +353,15 @@ async def test_project_failure_after_creation_keeps_success_and_logs_issue_for_r if failure == "timeout": responses.append(TimeoutError()) + elif failure == "forbidden": + responses.append( + github_response( + { + "data": {"addProjectV2ItemById": None}, + "errors": [{"type": "FORBIDDEN", "message": "private upstream details"}], + } + ) + ) else: responses.append(github_response({"errors": [{"message": "private upstream details"}]})) @@ -371,6 +380,9 @@ async def test_project_failure_after_creation_keeps_success_and_logs_issue_for_r assert "private upstream details" not in caplog.text assert "test-server-secret" not in caplog.text + if failure == "forbidden": + assert "SUPPORT_GITHUB_TOKEN organization Projects write permission" in caplog.text + async def test_issue_creation_failure_is_not_retried_or_added_to_project(github): github.side_effect = [github_response(PROJECT), TimeoutError()] From 3ee37da5d1dd83cb00520046dbfe9daba1be7c11 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 23 Oct 2026 22:05:02 +0300 Subject: [PATCH 3/3] fix: route support issues after sending confirmation --- docs/support.md | 8 +++-- papyrus/api/routes/support.py | 5 +-- papyrus/services/support.py | 24 +++++++++++--- tests/api/routes/test_support.py | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/docs/support.md b/docs/support.md index eca8042..339d832 100644 --- a/docs/support.md +++ b/docs/support.md @@ -24,9 +24,11 @@ By default, bug reports receive the `bug` label and native `Bug` issue type. Fea receive `enhancement` and `Feature`. Both are assigned to `Eoic` and added to the Papyrus project (#1) with Status `Backlog` and Kind `Task`. Priority, milestone, and requirement links are left for triage. Project access and field options are -checked before uploads or issue creation. If project updates fail after the issue -is created, submission still succeeds and the server logs its issue number for -manual reconciliation, avoiding duplicate reports from a retry. +checked before uploads or issue creation. The server sends the confirmed issue +response before applying project updates in a background task. Slow project updates +cannot delay that response. If updates fail, the server logs the issue number for +manual reconciliation. Background routing is not persisted or retried, so an abrupt +server shutdown can leave project metadata incomplete. Reading a public project and its `viewerCanUpdate` value only confirms visibility and the token owner's role. It does not prove the token has Projects write access. diff --git a/papyrus/api/routes/support.py b/papyrus/api/routes/support.py index 8081f8c..1db99e3 100644 --- a/papyrus/api/routes/support.py +++ b/papyrus/api/routes/support.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Coroutine from typing import Annotated, Any -from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile +from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, Response, UploadFile from fastapi.routing import APIRoute from pydantic import Json from starlette.types import Message @@ -51,7 +51,8 @@ async def limited_request(request: Request) -> Response: @router.post("/issues", response_model=SupportResponse, status_code=201) def create_support_issue( request: Request, + background_tasks: BackgroundTasks, payload: Annotated[Json[SupportRequest], Form()], attachments: Annotated[list[UploadFile] | None, File()] = None, ) -> SupportResponse: - return submit_support(payload, read_attachments(attachments or [])) + return submit_support(payload, read_attachments(attachments or []), background_tasks) diff --git a/papyrus/services/support.py b/papyrus/services/support.py index 1c4fcd6..4b44949 100644 --- a/papyrus/services/support.py +++ b/papyrus/services/support.py @@ -8,7 +8,7 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen -from fastapi import HTTPException +from fastapi import BackgroundTasks, HTTPException from papyrus.config import get_settings from papyrus.schemas.support import SupportRequest, SupportResponse @@ -43,7 +43,11 @@ class SupportProject: kind_option: str -def submit_support(payload: SupportRequest, attachments: list[SupportAttachment]) -> SupportResponse: +def submit_support( + payload: SupportRequest, + attachments: list[SupportAttachment], + background_tasks: BackgroundTasks, +) -> SupportResponse: settings = get_settings() token = settings.support_github_token repository = settings.support_github_repository @@ -138,8 +142,20 @@ def submit_support(payload: SupportRequest, attachments: list[SupportAttachment] if not isinstance(number, int) or isinstance(number, bool) or number <= 0: raise HTTPException(status_code=504, detail="Submission failed.") + background_tasks.add_task(route_support_issue, headers, project, result.get("node_id"), repository, number) + return SupportResponse(number=number, url=f"https://github.com/{repository}/issues/{number}") + + +def route_support_issue( + headers: dict[str, str], + project: SupportProject, + issue_id: object, + repository: str, + number: int, +) -> None: + """Apply best-effort project metadata after the confirmed issue response has been sent.""" try: - add_support_to_project(headers, project, required_id(result.get("node_id"))) + add_support_to_project(headers, project, required_id(issue_id)) except HTTPException as error: logger.error( "Support issue %s#%s was created but project routing failed: %s Reconcile its project fields.", @@ -148,8 +164,6 @@ def submit_support(payload: SupportRequest, attachments: list[SupportAttachment] error.detail, ) - return SupportResponse(number=number, url=f"https://github.com/{repository}/issues/{number}") - def required_object(value: object) -> dict[str, object]: if not isinstance(value, dict): diff --git a/tests/api/routes/test_support.py b/tests/api/routes/test_support.py index 6b9c4d3..d107a26 100644 --- a/tests/api/routes/test_support.py +++ b/tests/api/routes/test_support.py @@ -186,6 +186,61 @@ async def test_malformed_github_success_is_an_uncertain_submission(github): ASSET_URL = "https://github.com/user-attachments/assets/11111111-2222-3333-4444-555555555555" +@pytest.mark.parametrize("timeout_stage", [None, "AddSupportToProject", "RouteSupport"]) +async def test_confirmed_response_is_sent_before_project_mutations_with_five_attachments(github, timeout_stage): + messages = [] + app = create_app() + + async def observed_app(scope, receive, send): + async def capture_send(message): + await send(message) + messages.append(message) + + await app(scope, receive, capture_send) + + def respond(request, **kwargs): + if request.full_url == "https://api.github.com/repos/PapyrusReader/client": + return github_response({"id": 123}) + + if request.full_url.startswith("https://uploads.github.com/"): + return github_response({"url": ASSET_URL}) + + if request.full_url.endswith("/graphql"): + query = json.loads(request.data)["query"] + + if "mutation " in query: + assert messages[0]["type"] == "http.response.start" + assert messages[0]["status"] == 201 + assert messages[-1]["type"] == "http.response.body" + assert not messages[-1].get("more_body", False) + body = b"".join(message.get("body", b"") for message in messages) + + assert json.loads(body) == { + "number": 42, + "url": "https://github.com/PapyrusReader/client/issues/42", + } + + if timeout_stage and timeout_stage in query: + raise TimeoutError() + + return github_success(request, **kwargs) + + github.side_effect = respond + + async with AsyncClient(transport=ASGITransport(app=observed_app), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("screen.png", PNG))] * 5, + ) + + assert response.status_code == 201 + requests = [call.args[0] for call in github.call_args_list] + assert sum(request.full_url.startswith("https://uploads.github.com/") for request in requests) == 5 + assert sum(request.full_url.endswith("/issues") for request in requests) == 1 + assert len(requests) == (9 if timeout_stage == "AddSupportToProject" else 10) + + async def test_media_is_uploaded_to_github_and_embedded_in_issue(github): github.side_effect = [ github_response(PROJECT),