From e72cdd6716291c159c0130e2f4404774a0c9dd45 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 01:23:38 +0200 Subject: [PATCH] feat: a versioned API, refresh tokens, and an end-to-end stack that found four bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten onto it — one route, two spellings, so they cannot drift and the OpenAPI document describes each endpoint once. Errors carry an `error` object with a stable code, one human sentence and, for a validation failure, the fields that were wrong; `detail` is untouched so nothing that reads it breaks. The whole surface — 320 routes, their parameters and their status codes — is checked in as `backend/tests/api-contract.json`, and a test fails on any difference, naming the routes that moved. `docs/api.md` is the contract in prose. **Refresh tokens**, so an app can stay signed in without keeping a password. Rows rather than signatures: listable, withdrawable, stored as hashes, rotated on every use. A spent token coming back ends the whole session, because a theft and a replay look identical from the server and the safe reading is the unsafe one. A browser is not given one — it has nowhere to put it and a person to ask. **An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with five projects — desktop, iPhone, Pixel, iPad and a browserless API project. Devices because every bug reported this week was a phone bug found by a person looking at a screenshot; a desktop-only suite would have passed through all of them. Forty tests, five clean runs. It found four things in its first hour: - **A fresh deploy could not start.** `create_all()` ran before `CREATE EXTENSION vector`, so any database that had never had pgvector installed died on the first table with a vector column. Invisible here because this one has had the extension for a year. - **A figure in a published article was a 404 for everyone but an admin.** Media in the library is nobody's to read by default, and nothing made an exception for a drawing an article actually shows — so every illustration added this week was an empty box for every real user. - **Every rate limit was one bucket for the whole site.** The backend saw nginx's address for every request, so ten bad passwords from anybody locked out everybody, and no log line could say who. nginx now takes the real address from the proxy and overwrites the header on the way in; uvicorn runs with --proxy-headers. - **The reading page's breakpoints disagreed** — 1150px in the component, 820px in the stylesheet. Between them the menu button claimed the contents drawer and then toggled a class on a rail that was still in the layout: the contents did not open and the site menu did not either. The button was dead on every tablet. And two smaller ones: the login limiter counted successful sign-ins, so eleven people behind one hospital NAT locked each other out — it is cleared by a correct password now; and `/uploads/{path}` served GET and HEAD from one route with one operation id, which makes every OpenAPI client generator refuse the document. The first admin's password is generated and printed once at first start when `DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing: `docker compose logs backend | grep -A3 "FIRST ADMIN"`. CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the frontend suite and the build on every push to dev, main or master, and the end-to-end stack on those branches and on pull requests into them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- .forgejo/workflows/tests.yml | 100 + backend/.env.example | 5 +- .../versions/p5f6a7b8c9d0_refresh_tokens.py | 48 + backend/app/api/__init__.py | 0 backend/app/api/errors.py | 99 + backend/app/api/versioning.py | 52 + backend/app/config.py | 12 +- backend/app/main.py | 145 +- backend/app/models/refresh_token.py | 44 + backend/app/routers/auth.py | 125 +- backend/app/routers/study_tools.py | 11 +- backend/app/routers/uploads.py | 7 +- backend/app/schemas/auth.py | 24 +- backend/app/services/refresh_tokens.py | 136 + backend/app/utils/upload_access.py | 33 + backend/tests/api-contract.json | 3015 +++++++++++++++++ backend/tests/test_api_contract.py | 115 + backend/tests/test_refresh_tokens.py | 164 + docker-compose.test.yml | 108 + docker-compose.yml | 6 +- docs/api.md | 146 + docs/writing-articles.md | 40 + e2e/README.md | 37 + e2e/package-lock.json | 58 + e2e/package.json | 15 + e2e/playwright-report/index.html | 49 + e2e/playwright.config.js | 58 + e2e/seed.py | 272 ++ e2e/test-results/.last-run.json | 4 + e2e/tests/api.api.spec.js | 124 + e2e/tests/helpers.js | 101 + e2e/tests/mobile.spec.js | 96 + e2e/tests/reading.spec.js | 38 + e2e/tests/session.spec.js | 41 + frontend/nginx.conf | 32 +- frontend/src/components/ArticleReader.jsx | 12 +- frontend/src/pages/AnalysisPage.jsx | 1 - frontend/src/pages/AnalysisPage.test.jsx | 2 +- 38 files changed, 5293 insertions(+), 82 deletions(-) create mode 100644 .forgejo/workflows/tests.yml create mode 100644 backend/alembic/versions/p5f6a7b8c9d0_refresh_tokens.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/errors.py create mode 100644 backend/app/api/versioning.py create mode 100644 backend/app/models/refresh_token.py create mode 100644 backend/app/services/refresh_tokens.py create mode 100644 backend/tests/api-contract.json create mode 100644 backend/tests/test_api_contract.py create mode 100644 backend/tests/test_refresh_tokens.py create mode 100644 docker-compose.test.yml create mode 100644 docs/api.md create mode 100644 e2e/README.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright-report/index.html create mode 100644 e2e/playwright.config.js create mode 100644 e2e/seed.py create mode 100644 e2e/test-results/.last-run.json create mode 100644 e2e/tests/api.api.spec.js create mode 100644 e2e/tests/helpers.js create mode 100644 e2e/tests/mobile.spec.js create mode 100644 e2e/tests/reading.spec.js create mode 100644 e2e/tests/session.spec.js diff --git a/.forgejo/workflows/tests.yml b/.forgejo/workflows/tests.yml new file mode 100644 index 0000000..08ea456 --- /dev/null +++ b/.forgejo/workflows/tests.yml @@ -0,0 +1,100 @@ +# Everything that has to be true before a branch is merged. +# +# Three jobs, deliberately separate: when one goes red you know which kind of +# thing broke without opening a log. The unit suites are fast and run on every +# push; the end-to-end job builds a whole stack, so it runs on the branches +# that matter — dev and main — and on any pull request into them. +name: Tests + +on: + push: + branches: [dev, main, master] + pull_request: + branches: [dev, main, master] + workflow_dispatch: + +jobs: + backend: + runs-on: forgejo-local + steps: + - uses: https://github.com/actions/checkout@v4 + + - name: Build the backend image + run: docker compose build backend + + # SQLite in memory and a fake Redis: no network, no database to clean up, + # and the whole suite in under two minutes. + - name: Unit tests + run: | + docker compose run --rm --no-deps \ + -e DATABASE_URL=sqlite:// -e PYTHONPATH=/app -w /app/tests \ + backend python -m unittest discover -s . -p 'test_*.py' + + # The published surface, against the file in the repository. A route that + # changes shape without the snapshot changing with it is a client + # somewhere that stops working. + - name: API contract + run: | + docker compose run --rm --no-deps \ + -e DATABASE_URL=sqlite:// -e PYTHONPATH=/app -w /app/tests \ + backend python -m unittest test_api_contract + + frontend: + runs-on: forgejo-local + steps: + - uses: https://github.com/actions/checkout@v4 + - uses: https://github.com/actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + working-directory: frontend + - run: npx vitest run + working-directory: frontend + # A build failure is a deploy failure found four hours early. + - run: npm run build + working-directory: frontend + + e2e: + runs-on: forgejo-local + steps: + - uses: https://github.com/actions/checkout@v4 + - uses: https://github.com/actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: e2e/package-lock.json + + - name: Bring up a throwaway stack + run: | + docker compose -f docker-compose.test.yml up -d --build + docker compose -f docker-compose.test.yml run --rm seed + + - name: Install Playwright + working-directory: e2e + run: | + npm ci + npx playwright install --with-deps chromium webkit + + - name: End to end + working-directory: e2e + env: + CI: '1' + run: npx playwright test + + - name: Keep the evidence + if: always() + uses: https://github.com/actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report + e2e/results + retention-days: 14 + + # `-v` matters: the volumes go too, so the next run starts from nothing + # rather than from whatever the last one left behind. + - name: Take the stack down + if: always() + run: docker compose -f docker-compose.test.yml down -v diff --git a/backend/.env.example b/backend/.env.example index 1fa1f72..a2febe0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,7 +8,10 @@ SECRET_KEY=change-me-to-a-random-secret-key-in-production ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=1440 -# Optional bootstrap admin. Leave blank to use first-user-becomes-admin. +# The first admin, created only when the database has none. Leave the email +# blank too and the first person to register becomes the admin instead. +# Set the email and leave the password blank, and one is generated and printed +# to the log at first start: docker compose logs backend | grep -A3 "FIRST ADMIN" DEFAULT_ADMIN_EMAIL= DEFAULT_ADMIN_PASSWORD= diff --git a/backend/alembic/versions/p5f6a7b8c9d0_refresh_tokens.py b/backend/alembic/versions/p5f6a7b8c9d0_refresh_tokens.py new file mode 100644 index 0000000..33325cf --- /dev/null +++ b/backend/alembic/versions/p5f6a7b8c9d0_refresh_tokens.py @@ -0,0 +1,48 @@ +"""Refresh tokens, so an app can stay signed in without keeping a password + +An access token here is a signed statement good for a day, and nothing +consults a table before believing it — so it cannot be withdrawn, and a client +that needs to survive longer than a day has only the password to fall back on. +A phone must never keep that. + +A refresh token is a row instead of a signature: listable, withdrawable, and +stored as a hash so a leaked database does not hand over live sessions. Rotated +on every use, with the whole family withdrawn if a spent one comes back. + +Revision ID: p5f6a7b8c9d0 +Revises: n4e5f6a7b8c9 +""" +import sqlalchemy as sa +from alembic import op + +revision = "p5f6a7b8c9d0" +down_revision = "n4e5f6a7b8c9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + if "refresh_tokens" in sa.inspect(op.get_bind()).get_table_names(): + return + op.create_table( + "refresh_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("family", sa.String(length=32), nullable=False), + sa.Column("label", sa.String(length=120)), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("used_at", sa.DateTime()), + sa.Column("revoked_at", sa.DateTime()), + sa.Column("last_ip", sa.String(length=64)), + ) + # Unique, because presenting a token is a lookup by its hash and two rows + # with the same hash would be two answers to one question. + op.create_index("ix_refresh_tokens_token_hash", "refresh_tokens", ["token_hash"], unique=True) + op.create_index("ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"]) + op.create_index("ix_refresh_tokens_family", "refresh_tokens", ["family"]) + + +def downgrade() -> None: + op.drop_table("refresh_tokens") diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/errors.py b/backend/app/api/errors.py new file mode 100644 index 0000000..86ffb85 --- /dev/null +++ b/backend/app/api/errors.py @@ -0,0 +1,99 @@ +"""One shape for every failure the API reports. + +FastAPI's default is two shapes: `{"detail": "Quiz not found"}` for a raised +HTTPException and `{"detail": [ {...}, {...} ]}` for a validation failure. A +browser can squint at both — this repository's frontend has a helper that does +exactly that — but a client written against the API has to guess which it got, +and a `detail` that is sometimes a sentence and sometimes a list of objects is +not a contract anybody can code against. + +So every error carries an `error` object as well: a stable machine-readable +code, one human sentence, and — for a validation failure — which fields were +wrong and why. `detail` is left exactly as it was, because removing it would +break every call site in the web app for no benefit to anyone. +""" +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +#: Status code to code word. A client switches on the word, not the number: +#: the number says what happened to the request, the word says what happened. +CODES = { + status.HTTP_400_BAD_REQUEST: "bad_request", + status.HTTP_401_UNAUTHORIZED: "unauthenticated", + status.HTTP_402_PAYMENT_REQUIRED: "payment_required", + status.HTTP_403_FORBIDDEN: "forbidden", + status.HTTP_404_NOT_FOUND: "not_found", + status.HTTP_405_METHOD_NOT_ALLOWED: "method_not_allowed", + status.HTTP_409_CONFLICT: "conflict", + status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: "too_large", + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE: "unsupported_media_type", + status.HTTP_422_UNPROCESSABLE_ENTITY: "invalid_request", + status.HTTP_429_TOO_MANY_REQUESTS: "rate_limited", + status.HTTP_500_INTERNAL_SERVER_ERROR: "server_error", + status.HTTP_502_BAD_GATEWAY: "upstream_failed", + status.HTTP_503_SERVICE_UNAVAILABLE: "unavailable", +} + + +def code_for(status_code: int) -> str: + if status_code in CODES: + return CODES[status_code] + return "client_error" if status_code < 500 else "server_error" + + +def _sentence(detail) -> str: + """One line a person could be shown, whatever the detail turned out to be.""" + if isinstance(detail, str): + return detail + if isinstance(detail, list) and detail: + first = detail[0] + if isinstance(first, dict) and first.get("msg"): + where = ".".join(str(part) for part in first.get("loc", []) if part != "body") + return f"{where}: {first['msg']}" if where else str(first["msg"]) + return "Request failed" + + +def _fields(errors) -> list[dict]: + """Which inputs were wrong, in the caller's own terms.""" + out = [] + for error in errors or []: + location = [str(part) for part in error.get("loc", [])] + # "body" is where it came from, not what was wrong with it. + name = ".".join(part for part in location[1:] or location) + out.append({"field": name, "message": error.get("msg", "Invalid value"), + "type": error.get("type", "invalid")}) + return out + + +def envelope(status_code: int, message: str, *, code: str | None = None, + fields: list[dict] | None = None, detail=None) -> dict: + body = {"detail": detail if detail is not None else message, + "error": {"code": code or code_for(status_code), "message": message}} + if fields: + body["error"]["fields"] = fields + return body + + +def install(app: FastAPI) -> None: + @app.exception_handler(StarletteHTTPException) + async def http_error(request: Request, exc: StarletteHTTPException): + return JSONResponse( + status_code=exc.status_code, + content=envelope(exc.status_code, _sentence(exc.detail), detail=exc.detail), + headers=getattr(exc, "headers", None), + ) + + @app.exception_handler(RequestValidationError) + async def validation_error(request: Request, exc: RequestValidationError): + errors = exc.errors() + # Pydantic puts an unserialisable exception object in `ctx` for some + # error types; the default handler drops it and so must this one, or + # reporting the error becomes its own 500. + clean = [{k: v for k, v in error.items() if k != "ctx"} for error in errors] + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=envelope(422, _sentence(clean), code="invalid_request", + fields=_fields(clean), detail=clean), + ) diff --git a/backend/app/api/versioning.py b/backend/app/api/versioning.py new file mode 100644 index 0000000..34b3914 --- /dev/null +++ b/backend/app/api/versioning.py @@ -0,0 +1,52 @@ +"""One API, addressed two ways. + +Every route is mounted once, under `/api/v1`. `/api/...` is the same route +reached by its old address: the web app in this repository has thousands of +call sites written that way, and an address that has been shipped is a promise. + +The alias is a path rewrite rather than a second `include_router`, for two +reasons. A second mount would double every path in the OpenAPI document, so the +contract an app is written against would describe each endpoint twice; and two +mounts can drift, while a rewrite cannot — there is only ever one route. + +What this deliberately does *not* do is redirect. A 307 back to `/api/v1/...` +would break every non-browser client that does not follow redirects on POST, +and would leak the version into logs and bookmarks for no gain. +""" +from starlette.types import ASGIApp, Receive, Scope, Send + +#: Anything under here is the API. `/uploads` and the docs are not. +API_ROOT = "/api" +CURRENT = "v1" +VERSIONED_ROOT = f"{API_ROOT}/{CURRENT}" + +#: Addresses under /api that are not versioned routes and must pass untouched. +#: Kept explicit: a rewrite that silently swallowed one of these would be a 404 +#: with no obvious cause — which is exactly what happened to /api/health the +#: first time this shipped. A liveness check is pointed at by monitoring that +#: nobody edits for a year; it does not move when the API is versioned. +PASSTHROUGH = ("/api/docs", "/api/redoc", "/api/openapi.json", "/api/health") + + +def is_versioned(path: str) -> bool: + return path == VERSIONED_ROOT or path.startswith(VERSIONED_ROOT + "/") + + +class VersionAlias: + """Rewrite `/api/x` to `/api/v1/x` before routing sees it.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http": + path = scope.get("path", "") + if (path.startswith(API_ROOT + "/") and not is_versioned(path) + and path not in PASSTHROUGH): + scope = dict(scope) + scope["path"] = VERSIONED_ROOT + path[len(API_ROOT):] + # Kept so a route, a log line or an error can say which address + # the caller actually used. + scope["raw_path"] = scope["path"].encode() + scope["api_alias"] = True + await self.app(scope, receive, send) diff --git a/backend/app/config.py b/backend/app/config.py index 3e189ef..52b6e3f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,6 +6,12 @@ class Settings(BaseSettings): DATABASE_URL: str = "sqlite:///./quiz.db" SECRET_KEY: str = "change-me-to-a-random-secret-key-in-production" + # Guessing a password: how many tries from one address, over how long. + # Cleared by a correct password, so this counts guesses rather than people. + LOGIN_MAX_ATTEMPTS: int = 10 + LOGIN_WINDOW_MINUTES: int = 15 + # Refresh is flood-protected rather than rate-limited; see auth.refresh. + REFRESH_MAX_PER_HOUR: int = 600 ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 @@ -63,8 +69,10 @@ class Settings(BaseSettings): CAP_SITE_KEY: str = "" ADMIN_EMAIL: str = "" # Where contact form submissions are emailed - DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email - DEFAULT_ADMIN_PASSWORD: str = "" # Optional explicit bootstrap admin password + DEFAULT_ADMIN_EMAIL: str = "" # Set it and a first admin is created with this address + # Leave blank and one is generated and printed to the log at first start: + # docker compose logs backend | grep -A3 "FIRST ADMIN" + DEFAULT_ADMIN_PASSWORD: str = "" BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton) BBB_SECRET: str = "" # BigBlueButton shared secret diff --git a/backend/app/main.py b/backend/app/main.py index cb9a0ef..f80275e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,8 @@ from app.logging_config import setup_logging # Configure structured JSON logging before anything else setup_logging(settings.LOG_LEVEL) from app.database import engine, Base, SessionLocal +from app.api import errors as api_errors +from app.api.versioning import VERSIONED_ROOT, VersionAlias from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams from app.routers import access from app.routers import feedback @@ -20,8 +22,9 @@ from app.utils.auth import get_password_hash def seed_admin(): - """Optionally create a configured bootstrap admin user if none exists.""" + """Create the bootstrap admin if there is none, and say how to sign in as it.""" import logging + import secrets from datetime import datetime from app.models.user import User @@ -32,15 +35,24 @@ def seed_admin(): try: admin_exists = db.query(User).filter(User.role == "admin").first() if not admin_exists: - if not settings.DEFAULT_ADMIN_EMAIL or not settings.DEFAULT_ADMIN_PASSWORD: + if not settings.DEFAULT_ADMIN_EMAIL: log.info("No admin exists; skipping bootstrap admin seed. First registered user will become admin.") return - if len(settings.DEFAULT_ADMIN_PASSWORD) < 8: + # A password from the environment if one is set, otherwise one + # generated here and printed once. A fresh stack with no way in is + # a fresh stack nobody can use; a fresh stack with a *guessable* + # way in is worse. This is the third option: unguessable, and + # written where whoever started it is already looking. + password = (settings.DEFAULT_ADMIN_PASSWORD or "").strip() + generated = not password + if generated: + password = secrets.token_urlsafe(15) + elif len(password) < 8: log.warning("DEFAULT_ADMIN_PASSWORD is too short; skipping bootstrap admin seed.") return admin_user = User( email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(), - hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD), + hashed_password=get_password_hash(password), name="Admin", role="admin", ) @@ -54,6 +66,15 @@ def seed_admin(): verified_at=datetime.utcnow(), )) db.commit() + if generated: + # Loud, and once: this is the only time it exists in plain + # text. `docker compose logs backend | grep -A3 "FIRST ADMIN"`. + log.warning( + "\n%s\nFIRST ADMIN CREATED — change this password after signing in\n" + " email: %s\n password: %s\n%s", + "=" * 62, admin_user.email, password, "=" * 62) + else: + log.info("First admin created from DEFAULT_ADMIN_PASSWORD: %s", admin_user.email) else: # Ensure existing admin has a verified email record existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first() @@ -170,7 +191,7 @@ def setup_pgvector(): from sqlalchemy import text # Import new models so create_all picks them up from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa - from app.models import flashcard # noqa + from app.models import flashcard, refresh_token # noqa from app.models import category_grant, conversation, exam, media, question_media, study_plan # noqa from app.models import folder # noqa @@ -461,6 +482,21 @@ def _acquire_singleton_lock() -> bool: _STARTUP_DDL_LOCK_KEY = 8472931 +def _create_vector_extension(): + """`CREATE EXTENSION vector`, before anything declares a column of that type.""" + from sqlalchemy import text + import logging + try: + with engine.connect() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + conn.commit() + except Exception as exc: + # Not fatal on its own: a database where the extension cannot be + # installed will fail loudly at create_all a moment later, and saying + # so twice helps nobody read the traceback. + logging.getLogger(__name__).warning("Could not create the vector extension: %s", exc) + + def _run_startup_ddl(): """Serialize startup DDL across uvicorn workers using a Postgres advisory lock. @@ -480,6 +516,12 @@ def _run_startup_ddl(): lock_conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": _STARTUP_DDL_LOCK_KEY}) lock_conn.commit() try: + # The extension first, and on its own. Several tables declare a + # `vector` column, so `create_all` against a database that has + # never had pgvector installed dies on the first of them — which + # is every fresh deploy, and was invisible here because the + # long-lived database had the extension already. + _create_vector_extension() Base.metadata.create_all(bind=engine) setup_pgvector() finally: @@ -505,10 +547,25 @@ async def lifespan(app: FastAPI): app = FastAPI( title="PedsHub", - description="Pediatric Knowledge Quiz Platform", - version="2.0.0", + description=( + "The API behind PedsHub: a question bank sat as timed or study " + "sessions, a library of articles and cards written against it, and the " + "analysis of what a learner actually knows.\n\n" + "Every route lives under `/api/v1`. `/api/...` reaches the same route " + "and always will — it is the address the web app was written against — " + "but a client written today should say the version, so that the day " + "`/api/v2` exists it keeps the behaviour it was built on.\n\n" + "Errors carry an `error` object: `code` is a stable word to switch on, " + "`message` is one line for a person, and `fields` says which inputs " + "were wrong when that is the problem." + ), + version="3.0.0", lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", ) +api_errors.install(app) app.add_middleware( CORSMiddleware, @@ -531,43 +588,51 @@ app.add_middleware(TokenRefreshMiddleware) from app.middleware.request_logging import RequestLoggingMiddleware app.add_middleware(RequestLoggingMiddleware) -# Serve uploaded images as static files +# Outermost, so that everything downstream — routing, logging, the docs — sees +# one address per endpoint however the caller spelled it. +app.add_middleware(VersionAlias) + +# Serve uploaded images as static files. Not versioned: an image is at a URL +# that gets written into markdown and shared, and those must not move. app.include_router(uploads.router) -app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) -app.include_router(login_code.router, prefix="/api/auth", tags=["auth"]) +app.include_router(auth.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"]) +app.include_router(login_code.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"]) # Counts the landing page states about itself. No auth: a stranger reads it. -app.include_router(public.router, prefix="/api/public", tags=["public"]) -app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) -app.include_router(access.router, prefix="/api/access", tags=["access"]) -app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"]) -app.include_router(folders.router, prefix="/api/folders", tags=["folders"]) -app.include_router(exams.router, prefix="/api/exams", tags=["exams"]) -app.include_router(study_plans.router, prefix="/api/study-plans", tags=["study-plans"]) -app.include_router(drafts.router, prefix="/api/drafts", tags=["drafts"]) -app.include_router(media.router, prefix="/api/media", tags=["media"]) -app.include_router(share.router, prefix="/api/share", tags=["share"]) -app.include_router(collections.router, prefix="/api/collections", tags=["collections"]) -app.include_router(documents.router, prefix="/api/documents", tags=["documents"]) -app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"]) -app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) -app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) -app.include_router(tts.router, prefix="/api/tts", tags=["tts"]) -app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"]) -app.include_router(categories.router, prefix="/api/categories", tags=["categories"]) -app.include_router(questions.router, prefix="/api/questions", tags=["questions"]) -app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"]) -app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"]) -app.include_router(teach.router, prefix="/api/teach", tags=["teach"]) -app.include_router(contact.router, prefix="/api/contact", tags=["contact"]) -app.include_router(tags.router, prefix="/api/tags", tags=["tags"]) -app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"]) -app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"]) -app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"]) -app.include_router(search.router, prefix="/api/search", tags=["search"]) -app.include_router(ai_mode.router, prefix="/api/ai", tags=["ai-mode"]) +app.include_router(public.router, prefix=f"{VERSIONED_ROOT}/public", tags=["public"]) +app.include_router(articles.router, prefix=f"{VERSIONED_ROOT}/articles", tags=["articles"]) +app.include_router(access.router, prefix=f"{VERSIONED_ROOT}/access", tags=["access"]) +app.include_router(feedback.router, prefix=f"{VERSIONED_ROOT}/feedback", tags=["feedback"]) +app.include_router(folders.router, prefix=f"{VERSIONED_ROOT}/folders", tags=["folders"]) +app.include_router(exams.router, prefix=f"{VERSIONED_ROOT}/exams", tags=["exams"]) +app.include_router(study_plans.router, prefix=f"{VERSIONED_ROOT}/study-plans", tags=["study-plans"]) +app.include_router(drafts.router, prefix=f"{VERSIONED_ROOT}/drafts", tags=["drafts"]) +app.include_router(media.router, prefix=f"{VERSIONED_ROOT}/media", tags=["media"]) +app.include_router(share.router, prefix=f"{VERSIONED_ROOT}/share", tags=["share"]) +app.include_router(collections.router, prefix=f"{VERSIONED_ROOT}/collections", tags=["collections"]) +app.include_router(documents.router, prefix=f"{VERSIONED_ROOT}/documents", tags=["documents"]) +app.include_router(quizzes.router, prefix=f"{VERSIONED_ROOT}/quizzes", tags=["quizzes"]) +app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"]) +app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"]) +app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"]) +app.include_router(nextcloud.router, prefix=f"{VERSIONED_ROOT}/nextcloud", tags=["nextcloud"]) +app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"]) +app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"]) +app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"]) +app.include_router(favorites.router, prefix=f"{VERSIONED_ROOT}/favorites", tags=["favorites"]) +app.include_router(teach.router, prefix=f"{VERSIONED_ROOT}/teach", tags=["teach"]) +app.include_router(contact.router, prefix=f"{VERSIONED_ROOT}/contact", tags=["contact"]) +app.include_router(tags.router, prefix=f"{VERSIONED_ROOT}/tags", tags=["tags"]) +app.include_router(flashcards.router, prefix=f"{VERSIONED_ROOT}/flashcards", tags=["flashcards"]) +app.include_router(mynote.router, prefix=f"{VERSIONED_ROOT}/mynote", tags=["mynote"]) +app.include_router(study_tools.router, prefix=f"{VERSIONED_ROOT}/study-tools", tags=["study-tools"]) +app.include_router(search.router, prefix=f"{VERSIONED_ROOT}/search", tags=["search"]) +app.include_router(ai_mode.router, prefix=f"{VERSIONED_ROOT}/ai", tags=["ai-mode"]) -@app.get("/api/health") +@app.get("/api/health", tags=["health"]) +@app.get(f"{VERSIONED_ROOT}/health", tags=["health"], include_in_schema=False) def health_check(): + """Is the process up. Deliberately unversioned as well as versioned: the + thing that watches this is a monitor nobody edits for a year.""" return {"status": "ok"} diff --git a/backend/app/models/refresh_token.py b/backend/app/models/refresh_token.py new file mode 100644 index 0000000..aadc00c --- /dev/null +++ b/backend/app/models/refresh_token.py @@ -0,0 +1,44 @@ +"""A long-lived key to a short-lived one. + +An access token here lasts a day and cannot be withdrawn: it is a signed +statement, and nothing consults a table before believing it. That is workable +for a browser, which can send the person back to a login form, and no use at +all to an app on a phone — which would have to keep the password to survive a +day, and that is the one thing a client must never store. + +So a refresh token is a row, not a signature. It can be listed, it can be +withdrawn, and it is stored the way a password is: only the hash, because a +database that leaks should not hand over live sessions with it. + +Rotation on every use is what makes a stolen one survivable. Each refresh +issues a new token and marks the old one used; if a thief spends it first, the +real client's next attempt presents a token already spent, and the whole family +is withdrawn — the theft turns itself in. +""" +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String + +from app.database import Base + + +class RefreshToken(Base): + __tablename__ = "refresh_tokens" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + #: SHA-256 of the token. The token itself is shown once, at issue. + token_hash = Column(String(64), unique=True, nullable=False, index=True) + #: Every token descended from one sign-in shares this. Withdrawing a family + #: ends the session, however many times it has been rotated. + family = Column(String(32), nullable=False, index=True) + #: What asked for it, as the client described itself. For the list of + #: sessions a person is shown when they ask where they are signed in. + label = Column(String(120), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + expires_at = Column(DateTime, nullable=False) + #: Set when it is spent. A spent token presented again is theft or a bug, + #: and both are handled the same way: end the family. + used_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + last_ip = Column(String(64), nullable=True) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f67e988..884f0c6 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -4,41 +4,65 @@ from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request from sqlalchemy.orm import Session -from app.services import captcha, invites, site_settings +from app.services import captcha, invites, refresh_tokens, site_settings from app.database import get_db from app.models.user import User from app.models.email_verification import EmailVerification from app.models.password_reset import PasswordReset from app.schemas.auth import ( - UserCreate, UserResponse, Token, LoginRequest, + UserCreate, UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest, UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, ) from app.services import email_service from app.utils.auth import ( - get_password_hash, verify_password, create_access_token, get_current_user, + check_rate_limit, get_password_hash, verify_password, create_access_token, + get_current_user, ) router = APIRouter() +def _login_key(client_ip: str) -> str: + return f"login_attempts:{client_ip}" + + def _check_login_rate_limit(client_ip: str): - """Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis.""" + """How many times an address may guess, before it has to wait. + + Counted per address and cleared by a success, because what this is for is + guessing — and somebody who signs in correctly has not guessed. Counting + successes too would lock out a hospital: one public address, a ward full of + people, eleven of whom happened to open the app this afternoon. + """ try: import redis as redis_lib from app.config import settings r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1) - key = f"login_attempts:{client_ip}" + key = _login_key(client_ip) count = r.incr(key) if count == 1: - r.expire(key, 15 * 60) - if count > 10: - raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.") + r.expire(key, settings.LOGIN_WINDOW_MINUTES * 60) + if count > settings.LOGIN_MAX_ATTEMPTS: + raise HTTPException( + status_code=429, + detail=f"Too many login attempts. Try again in {settings.LOGIN_WINDOW_MINUTES} minutes.") except HTTPException: raise except Exception as e: import logging; logging.getLogger(__name__).warning(f"Redis rate limit unavailable (failing open): {e}") +def _clear_login_rate_limit(client_ip: str): + """A correct password is the end of the matter.""" + try: + import redis as redis_lib + from app.config import settings + redis_lib.from_url(settings.REDIS_URL, decode_responses=True, + socket_connect_timeout=1).delete(_login_key(client_ip)) + except Exception: + pass + + # Rate limit: max 3 reset requests per email per hour RESET_LIMIT = 3 RESET_WINDOW_HOURS = 1 @@ -177,8 +201,8 @@ async def login(login_data: LoginRequest, db: Session = Depends(get_db), request if sso_settings["sso_only"]: raise HTTPException(status_code=403, detail="Password login is disabled. Please use SSO.") + client_ip = (request.client.host if request and request.client else "unknown") if request: - client_ip = request.client.host if request.client else "unknown" _check_login_rate_limit(client_ip) email_normalized = login_data.email.lower().strip() @@ -198,8 +222,87 @@ async def login(login_data: LoginRequest, db: Session = Depends(get_db), request detail="Email not verified. Please check your inbox and verify your email before logging in.", ) - access_token = create_access_token(data={"sub": user.email}) - return Token(access_token=access_token) + _clear_login_rate_limit(client_ip) + return _signed_in(db, user, login_data, request) + + +def _signed_in(db: Session, user: User, login_data: LoginRequest, request: Request | None) -> Token: + """What a successful sign-in hands back. + + A browser gets what it always got. A client that says it wants a refresh + token also gets one, because it has nowhere safe to keep a password and no + person sitting in front of it to ask again. + """ + # Imported here, as everywhere else in this file: a module-level `settings` + # plus these function-level ones would make the name local to each of them + # and blow up on first use. + from app.config import settings + token = Token( + access_token=create_access_token(data={"sub": user.email}), + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ) + if login_data.refresh: + token.refresh_token = refresh_tokens.issue( + db, user, label=login_data.device, + ip=request.client.host if request and request.client else None) + return token + + +@router.post("/refresh", response_model=Token) +def refresh(data: RefreshRequest, db: Session = Depends(get_db), request: Request = None): + """Trade a refresh token for a new pair. + + The old one is spent by this call. Presenting a spent token ends the whole + session it belongs to: either it was copied or a client is replaying, and + from here those look the same. + """ + from app.config import settings + client_ip = request.client.host if request and request.client else "unknown" + # Flood protection, not a security control: the security here is that a + # refresh token is 256 unguessable bits and spending one twice ends the + # session. Generous, because an address can be a whole hospital behind one + # NAT and every app launch refreshes. + check_rate_limit(f"refresh:{client_ip}", settings.REFRESH_MAX_PER_HOUR, 3600, + "Too many refresh attempts. Try again later.") + spent = refresh_tokens.spend(db, data.refresh_token, ip=client_ip) + if spent is None: + raise HTTPException(401, "That sign-in has expired. Sign in again.") + user, rotated = spent + verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first() + if verification and verification.verified_at is None: + raise HTTPException(403, "Email not verified.") + return Token( + access_token=create_access_token(data={"sub": user.email}), + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + refresh_token=rotated, + ) + + +@router.post("/logout", status_code=204) +def logout(data: LogoutRequest, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + """End this session, or every session. + + An access token already issued is a signature and cannot be recalled; it + dies of old age within the day. What this ends is the ability to get + another one, which is what a lost phone actually needs. + """ + if data.everywhere: + refresh_tokens.revoke_all(db, current_user) + elif data.refresh_token: + refresh_tokens.revoke_one(db, data.refresh_token) + + +@router.get("/sessions") +def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + """Where this account is signed in, so a person can see and end them.""" + return refresh_tokens.sessions(db, current_user) + + +@router.delete("/sessions/{family}", status_code=204) +def end_session(family: str, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + refresh_tokens.revoke_family(db, current_user, family) @router.get("/verify-email") diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 752f843..51c60b8 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -720,13 +720,10 @@ def study_recommendations( .limit(1).scalar() ), "focus_areas": rows[:limit], - "basis": ( - "Your completed, non-expired general-bank answers, rolled up through the category tree. " - f"Readiness shrinks each category's accuracy toward your overall {round(100 * overall_accuracy)}% " - "so small samples do not overstate a gap; it unlocks after " - f"{READINESS_UNLOCK_ANSWERS} answers. Relevance is the share of the question bank a category holds. " - "These are study hints from your own answers, not an exam score prediction." - ), + # No `basis` any more. It was a paragraph of methodology under a table + # that already says what it is — how the shrinkage works is a decision + # for whoever tunes it, not something to explain to a learner who wants + # to know what to revise. } @router.get("/attempts/{attempt_id}/questions/{question_id}/responses") diff --git a/backend/app/routers/uploads.py b/backend/app/routers/uploads.py index 04ea814..3d8bf79 100644 --- a/backend/app/routers/uploads.py +++ b/backend/app/routers/uploads.py @@ -16,7 +16,12 @@ from app.utils.upload_access import ( router = APIRouter() -@router.api_route("/uploads/{path:path}", methods=["GET", "HEAD"]) +# Two decorators rather than one `api_route` with both methods: that produced a +# single operation id for GET and HEAD, and a duplicate operation id makes +# every OpenAPI client generator refuse the document. HEAD answers the same +# way and is not worth a second entry in the contract. +@router.get("/uploads/{path:path}") +@router.head("/uploads/{path:path}", include_in_schema=False) def read_upload(path: str, request: Request, attempt_id: int | None = None, w: int | None = None, db: Session = Depends(get_db)): """An upload, optionally at one of two smaller widths. diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index e2cae45..ea48d92 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, Field class UserCreate(BaseModel): @@ -47,11 +47,33 @@ class UserResponse(BaseModel): class Token(BaseModel): access_token: str token_type: str = "bearer" + #: Seconds, so a client can schedule its own refresh rather than waiting to + #: be told no. Absent means "we did not say" — not "it never expires". + expires_in: int | None = None + #: Only when one was asked for. A browser does not need it: it has a + #: session it can renew by asking the person again. An app does. + refresh_token: str | None = None class LoginRequest(BaseModel): email: EmailStr password: str + #: An app asks for a refresh token; the web app does not, so nothing + #: long-lived is minted for a browser that will never use it. + refresh: bool = False + #: How the client describes itself, shown to the person in their list of + #: sessions. "PedsHub for iPhone", not a user agent string. + device: str | None = Field(default=None, max_length=120) + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class LogoutRequest(BaseModel): + #: Ends this session. Omit it and, with `everywhere`, all of them. + refresh_token: str | None = None + everywhere: bool = False class UserUpdateRole(BaseModel): diff --git a/backend/app/services/refresh_tokens.py b/backend/app/services/refresh_tokens.py new file mode 100644 index 0000000..7f579d1 --- /dev/null +++ b/backend/app/services/refresh_tokens.py @@ -0,0 +1,136 @@ +"""Issuing, spending and withdrawing refresh tokens.""" +import hashlib +import secrets +from datetime import datetime, timedelta + +from sqlalchemy.orm import Session + +from app.models.refresh_token import RefreshToken +from app.models.user import User + +#: Long enough that an app is not signing people out every month; short enough +#: that a token forgotten on a lost phone stops working within a season. +LIFETIME = timedelta(days=60) +#: 256 bits from the system generator. Not a JWT: there is nothing to read in +#: it, and everything about it is settled by the row it points at. +BYTES = 32 +#: One person, one device, one session. A cap stops a buggy client filling the +#: table with families nobody will ever spend. +MAX_FAMILIES = 20 + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def issue(db: Session, user: User, *, label: str | None = None, + family: str | None = None, ip: str | None = None) -> str: + """Mint a token, return it once. Only its hash is kept.""" + # Trimmed before the new row exists, not after: querying with a pending + # insert in the session makes SQLAlchemy reload it mid-flush. + if family is None: + _trim(db, user) + token = secrets.token_urlsafe(BYTES) + db.add(RefreshToken( + user_id=user.id, token_hash=_hash(token), + family=family or secrets.token_hex(8), + label=(label or "")[:120] or None, + expires_at=datetime.utcnow() + LIFETIME, + last_ip=(ip or "")[:64] or None, + )) + db.commit() + return token + + +def _trim(db: Session, user: User) -> None: + """Keep the newest families; end the rest.""" + families = [row.family for row in db.query(RefreshToken).filter( + RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None), + ).order_by(RefreshToken.created_at.desc()).all()] + seen: list[str] = [] + for name in families: + if name not in seen: + seen.append(name) + for name in seen[MAX_FAMILIES - 1:]: + revoke_family(db, user, name, commit=False) + + +def spend(db: Session, token: str, *, ip: str | None = None) -> tuple[User, str] | None: + """Exchange a token for its user and a fresh token, or None if it is no good. + + A token that has already been spent ends its whole family. Either somebody + copied it, or a client is replaying — and there is no way to tell which + from here, so the safe reading is the unsafe one. + """ + row = db.query(RefreshToken).filter(RefreshToken.token_hash == _hash(token)).first() + if row is None: + return None + user = db.get(User, row.user_id) + if user is None: + return None + if row.used_at is not None: + revoke_family(db, user, row.family) + return None + if row.revoked_at is not None or row.expires_at <= datetime.utcnow(): + return None + row.used_at = datetime.utcnow() + if ip: + row.last_ip = ip[:64] + db.commit() + return user, issue(db, user, label=row.label, family=row.family, ip=ip) + + +def revoke_family(db: Session, user: User, family: str, *, commit: bool = True) -> int: + count = db.query(RefreshToken).filter( + RefreshToken.user_id == user.id, RefreshToken.family == family, + RefreshToken.revoked_at.is_(None), + ).update({"revoked_at": datetime.utcnow()}, synchronize_session=False) + if commit: + db.commit() + return count + + +def revoke_one(db: Session, token: str) -> bool: + """End the session a token belongs to. Used by sign-out.""" + row = db.query(RefreshToken).filter(RefreshToken.token_hash == _hash(token)).first() + if row is None: + return False + user = db.get(User, row.user_id) + if user is None: + return False + revoke_family(db, user, row.family) + return True + + +def revoke_all(db: Session, user: User) -> int: + """Sign out everywhere. What a person wants after losing a phone.""" + count = db.query(RefreshToken).filter( + RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None), + ).update({"revoked_at": datetime.utcnow()}, synchronize_session=False) + db.commit() + return count + + +def sessions(db: Session, user: User) -> list[dict]: + """Where this person is signed in, one row per family.""" + rows = db.query(RefreshToken).filter( + RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None), + RefreshToken.expires_at > datetime.utcnow(), + ).order_by(RefreshToken.created_at.desc()).all() + seen: dict[str, dict] = {} + for row in rows: + # The newest token in a family describes the session now. + seen.setdefault(row.family, { + "family": row.family, "label": row.label, + "started_at": row.created_at, "expires_at": row.expires_at, + "last_ip": row.last_ip, "current": False, + }) + return list(seen.values()) + + +def purge(db: Session) -> int: + """Drop what can never be spent again. Called by the nightly sweep.""" + cutoff = datetime.utcnow() - timedelta(days=7) + return db.query(RefreshToken).filter( + (RefreshToken.expires_at < cutoff) | (RefreshToken.revoked_at < cutoff), + ).delete(synchronize_session=False) diff --git a/backend/app/utils/upload_access.py b/backend/app/utils/upload_access.py index 958c82e..3cc1f5a 100644 --- a/backend/app/utils/upload_access.py +++ b/backend/app/utils/upload_access.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import load_only from app.config import settings from app.models.flashcard import Flashcard, FlashcardDeck +from app.models.media import MediaAsset from app.models.pdf_document import PDFDocument from app.models.question import Question from app.services.quiz_builder import bank_question_predicate, general_question_predicate @@ -128,6 +129,36 @@ def owns_source(db, path, user, cards): return False +def shown_in_a_readable_article(db, path, user) -> bool: + """True when a published article puts this picture in front of the reader. + + A figure in the media library is not, on its own, anybody's to read: the + library is an educator's workspace and grants decide who may manage it. But + a drawing embedded in a published article is *the article* — refusing it + leaves a caption above an empty box, which is exactly what every + illustration in the library did for everyone who was not an administrator. + + Matched against the article's own prose rather than a link table, because + an educator writes a figure in by typing a markdown image into a section, + and there is no row anywhere that says so. + """ + from app.models.article import Article + + if db.query(MediaAsset.id).filter(MediaAsset.path == path).first() is None: + return False + query = db.query(Article.id, Article.content, Article.sections).filter( + Article.deleted_at.is_(None)) + if not user.is_moderator: + query = query.filter(Article.status == "published") + for _id, content, sections in query.all(): + if path in (content or ""): + return True + for section in sections or []: + if path in (section.get("content") or ""): + return True + return False + + def card_access(db, cards, user): return bool(cards) and db.query(FlashcardDeck.id).filter( FlashcardDeck.id.in_(cards), FlashcardDeck.deleted_at.is_(None), @@ -142,6 +173,8 @@ def can_read_upload(db, path, user, questions, cards, attempt_id=None): return user.is_moderator or document.user_id == user.id if owns_source(db, path, user, cards) or card_access(db, cards, user): return True + if shown_in_a_readable_article(db, path, user): + return True for question in questions: try: require_question_access(db, question, user, attempt_id, diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json new file mode 100644 index 0000000..7c4b663 --- /dev/null +++ b/backend/tests/api-contract.json @@ -0,0 +1,3015 @@ +{ + "routes": { + "DELETE /api/v1/access/{user_id}/grants/{kind}/{target_id}": { + "body": false, + "params": [ + "path:kind", + "path:target_id", + "path:user_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/admin/invites/{invite_id}": { + "body": false, + "params": [ + "path:invite_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/admin/models/{model_id}": { + "body": false, + "params": [ + "path:model_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/admin/users/{user_id}": { + "body": false, + "params": [ + "path:user_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/ai/conversations/{conversation_id}": { + "body": false, + "params": [ + "path:conversation_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/articles/trash/{article_id}": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/articles/{article_id}": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "DELETE /api/v1/articles/{article_id}/claims/{claim_id}": { + "body": false, + "params": [ + "path:article_id", + "path:claim_id", + "query:keep_links?" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/articles/{article_id}/links/{question_id}": { + "body": false, + "params": [ + "path:article_id", + "path:question_id", + "query:section_id?" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/articles/{article_id}/notes/{section_id}": { + "body": false, + "params": [ + "path:article_id", + "path:section_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/attempts/progress/{attempt_id}": { + "body": false, + "params": [ + "path:attempt_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/auth/sessions/{family}": { + "body": false, + "params": [ + "path:family" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/categories/{category_id}": { + "body": false, + "params": [ + "path:category_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/collections/{collection_id}": { + "body": false, + "params": [ + "path:collection_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/collections/{collection_id}/articles/{article_id}": { + "body": false, + "params": [ + "path:article_id", + "path:collection_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/collections/{collection_id}/questions/{question_id}": { + "body": false, + "params": [ + "path:collection_id", + "path:question_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/documents/{document_id}": { + "body": false, + "params": [ + "path:document_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/documents/{document_id}/sections/{section_id}": { + "body": false, + "params": [ + "path:document_id", + "path:section_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/drafts/batches/{batch_id}": { + "body": false, + "params": [ + "path:batch_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/drafts/{draft_id}": { + "body": false, + "params": [ + "path:draft_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/exams/{exam_id}/assign": { + "body": true, + "params": [ + "path:exam_id" + ], + "responses": [ + "200", + "422" + ] + }, + "DELETE /api/v1/favorites/{question_id}": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/feedback/articles/reports/{feedback_id}": { + "body": false, + "params": [ + "path:feedback_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/feedback/{feedback_id}": { + "body": false, + "params": [ + "path:feedback_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/flashcards/cards/{card_id}": { + "body": false, + "params": [ + "path:card_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/flashcards/cards/{card_id}/links/article/{article_id}": { + "body": false, + "params": [ + "path:article_id", + "path:card_id", + "query:article_section_id?" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/flashcards/cards/{card_id}/links/question/{question_id}": { + "body": false, + "params": [ + "path:card_id", + "path:question_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/flashcards/{deck_id}": { + "body": false, + "params": [ + "path:deck_id", + "query:permanent?" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/flashcards/{deck_id}/share": { + "body": false, + "params": [ + "path:deck_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/folders/{folder_id}": { + "body": false, + "params": [ + "path:folder_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/folders/{folder_id}/questions/{question_id}": { + "body": false, + "params": [ + "path:folder_id", + "path:question_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/media/libraries/{library_id}/grants/{user_id}": { + "body": false, + "params": [ + "path:library_id", + "path:user_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/media/{media_id}": { + "body": false, + "params": [ + "path:media_id", + "query:force?" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/question-categories/{cat_id}": { + "body": false, + "params": [ + "path:cat_id", + "query:move_to?" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/question-categories/{cat_id}/grants/{user_id}": { + "body": false, + "params": [ + "path:cat_id", + "path:user_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/questions/figures/{figure_id}": { + "body": false, + "params": [ + "path:figure_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/questions/{question_id}": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/questions/{question_id}/permanent": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/quizzes/{quiz_id}": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/quizzes/{quiz_id}/permanent": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/quizzes/{quiz_id}/questions/{question_id}": { + "body": false, + "params": [ + "path:question_id", + "path:quiz_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/quizzes/{quiz_id}/share-link": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/study-plans/blocks/{block_id}": { + "body": false, + "params": [ + "path:block_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/study-plans/reading/{link_id}": { + "body": false, + "params": [ + "path:link_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/study-plans/{plan_id}": { + "body": false, + "params": [ + "path:plan_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/study-tools/lab-values/{entry_id}": { + "body": false, + "params": [ + "path:entry_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/study-tools/lab-values/{entry_id}/cards/{card_id}": { + "body": false, + "params": [ + "path:card_id", + "path:entry_id" + ], + "responses": [ + "204", + "422" + ] + }, + "DELETE /api/v1/tags/{tag_id}": { + "body": false, + "params": [ + "path:tag_id", + "query:move_to?" + ], + "responses": [ + "204", + "422" + ] + }, + "GET /api/health": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/access/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/access/tree": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/admin/classification-snapshots": { + "body": false, + "params": [ + "query:limit?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/admin/embedding/health": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/admin/invites": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/admin/models": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/admin/models/available": { + "body": false, + "params": [ + "query:task?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/admin/settings": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/admin/users": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/ai/conversations": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/ai/conversations/{conversation_id}": { + "body": false, + "params": [ + "path:conversation_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/": { + "body": false, + "params": [ + "query:category_id?", + "query:q?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/by-slug/{slug}": { + "body": false, + "params": [ + "path:slug" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/editorial/queue": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/articles/job/{job_id}": { + "body": false, + "params": [ + "path:job_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/preview/{slug}": { + "body": false, + "params": [ + "path:slug" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/recent": { + "body": false, + "params": [ + "query:limit?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/trash": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/articles/{article_id}": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/{article_id}/cards": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/{article_id}/claims": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/{article_id}/links/from-category": { + "body": false, + "params": [ + "path:article_id", + "query:category_id", + "query:include_subtopics?", + "query:section_id?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/{article_id}/notes": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/{article_id}/questions": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/{article_id}/revisions": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/articles/{article_id}/revisions/{revision_id}": { + "body": false, + "params": [ + "path:article_id", + "path:revision_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/attempts/": { + "body": false, + "params": [ + "query:quiz_id?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/attempts/history": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/attempts/in-progress": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/attempts/progress": { + "body": false, + "params": [ + "query:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/attempts/quiz/{quiz_id}/analysis": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/attempts/quiz/{quiz_id}/in-progress": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/attempts/stats/dashboard": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/attempts/{attempt_id}": { + "body": false, + "params": [ + "path:attempt_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/attempts/{attempt_id}/analysis": { + "body": false, + "params": [ + "path:attempt_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/attempts/{attempt_id}/recommendations": { + "body": false, + "params": [ + "path:attempt_id", + "query:group?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/auth/me": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/auth/me/settings": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/auth/sessions": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/auth/signup-policy": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/auth/sso/callback": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/auth/sso/config": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/auth/sso/login": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/auth/verify-email": { + "body": false, + "params": [ + "query:token" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/categories/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/collections/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/collections/for-article/{article_id}": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/collections/{collection_id}/articles": { + "body": false, + "params": [ + "path:collection_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/collections/{collection_id}/questions": { + "body": false, + "params": [ + "path:collection_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/contact/submissions": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/documents/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/documents/{document_id}": { + "body": false, + "params": [ + "path:document_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/documents/{document_id}/processing-steps": { + "body": false, + "params": [ + "path:document_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/documents/{document_id}/status": { + "body": false, + "params": [ + "path:document_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/drafts/batches": { + "body": false, + "params": [ + "query:status?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/drafts/batches/{batch_id}": { + "body": false, + "params": [ + "path:batch_id", + "query:status?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/exams/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/exams/{exam_id}/blueprint": { + "body": false, + "params": [ + "path:exam_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/favorites": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/feedback/articles/{article_id}": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/feedback/articles/{article_id}/mine": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/feedback/open": { + "body": false, + "params": [ + "query:limit?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/feedback/questions/{question_id}": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/feedback/questions/{question_id}/mine": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/": { + "body": false, + "params": [ + "query:category_id?", + "query:include_deleted?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/cards/browse": { + "body": false, + "params": [ + "query:deck_id?", + "query:limit?", + "query:offset?", + "query:q?", + "query:tag_ids?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/cards/browse/ids": { + "body": false, + "params": [ + "query:deck_id?", + "query:q?", + "query:tag_ids?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/cards/linked": { + "body": false, + "params": [ + "query:article_id?", + "query:question_id?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/cards/{card_id}/links": { + "body": false, + "params": [ + "path:card_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/questions/{question_id}/cards": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/shared": { + "body": false, + "params": [ + "query:limit?", + "query:offset?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/trash": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/flashcards/{deck_id}": { + "body": false, + "params": [ + "path:deck_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/flashcards/{deck_id}/study": { + "body": false, + "params": [ + "path:deck_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/folders/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/folders/{folder_id}/questions": { + "body": false, + "params": [ + "path:folder_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/media/": { + "body": false, + "params": [ + "query:library_id?", + "query:limit?", + "query:offset?", + "query:q?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/media/by-path": { + "body": false, + "params": [ + "query:path" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/media/libraries": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/mynote": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/public/stats": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/question-categories/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/question-categories/grantable-users": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/question-categories/grants": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/question-categories/my-grants": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/questions/bank": { + "body": false, + "params": [ + "query:article_ids?", + "query:category_id?", + "query:category_ids?", + "query:difficulty?", + "query:favorites_only?", + "query:folder_id?", + "query:limit?", + "query:my_questions?", + "query:needs?", + "query:offset?", + "query:q?", + "query:quiz_id?", + "query:tag_ids?", + "query:uncategorized?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/bank/ids": { + "body": false, + "params": [ + "query:category_id?", + "query:category_ids?", + "query:favorites_only?", + "query:folder_id?", + "query:q?", + "query:quiz_id?", + "query:tag_ids?", + "query:uncategorized?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/builder/count": { + "body": false, + "params": [ + "query:article_ids?", + "query:category_ids?", + "query:difficulty?", + "query:state?", + "query:system_ids?", + "query:tag_ids?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/builder/prepared": { + "body": false, + "params": [ + "query:count?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/detail/{question_id}": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/detail/{question_id}/figures": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/detail/{question_id}/note": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/detail/{question_id}/versions": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/export/qti": { + "body": false, + "params": [ + "query:question_ids?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/images": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/questions/import/sample": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/questions/manage/summary": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/questions/trash": { + "body": false, + "params": [ + "query:limit?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/questions/{question_id}/articles": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/quizzes/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/quizzes/job/{job_id}": { + "body": false, + "params": [ + "path:job_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/quizzes/jobs": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/quizzes/search": { + "body": false, + "params": [ + "query:mode?", + "query:q" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/quizzes/sessions": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/quizzes/share-policy": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/quizzes/trash": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/quizzes/{quiz_id}": { + "body": false, + "params": [ + "path:quiz_id", + "query:attempt_id?", + "query:study?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/quizzes/{quiz_id}/questions": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/quizzes/{quiz_id}/review": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/search": { + "body": false, + "params": [ + "query:kinds?", + "query:limit?", + "query:q?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/search/suggest": { + "body": false, + "params": [ + "query:limit?", + "query:q?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/share/{token}": { + "body": false, + "params": [ + "path:token" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/study-plans/": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/study-plans/{plan_id}": { + "body": false, + "params": [ + "path:plan_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/study-tools/answer-split": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/study-tools/attempts/{attempt_id}/questions/{question_id}/responses": { + "body": false, + "params": [ + "path:attempt_id", + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/study-tools/completion": { + "body": false, + "params": [ + "query:days?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/study-tools/lab-values": { + "body": false, + "params": [ + "query:include_drafts?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/study-tools/performance-by-category": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/study-tools/performance-over-time": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/study-tools/readiness": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/study-tools/recommendations": { + "body": false, + "params": [ + "query:group?", + "query:limit?" + ], + "responses": [ + "200", + "422" + ] + }, + "GET /api/v1/tags": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/teach/models": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/teach/policy": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/teach/prompt": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /api/v1/tts/voices": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "GET /uploads/{path}": { + "body": false, + "params": [ + "path:path", + "query:attempt_id?", + "query:w?" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/ai/conversations/{conversation_id}": { + "body": true, + "params": [ + "path:conversation_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/articles/{article_id}": { + "body": true, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/categories/quizzes/{quiz_id}": { + "body": false, + "params": [ + "path:quiz_id", + "query:category_id?" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/collections/{collection_id}": { + "body": true, + "params": [ + "path:collection_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/drafts/batches/{batch_id}": { + "body": true, + "params": [ + "path:batch_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/drafts/{draft_id}": { + "body": true, + "params": [ + "path:draft_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/exams/{exam_id}": { + "body": true, + "params": [ + "path:exam_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/feedback/articles/reports/{feedback_id}": { + "body": true, + "params": [ + "path:feedback_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/feedback/{feedback_id}": { + "body": true, + "params": [ + "path:feedback_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/flashcards/{deck_id}": { + "body": true, + "params": [ + "path:deck_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/folders/{folder_id}": { + "body": true, + "params": [ + "path:folder_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/media/{media_id}": { + "body": true, + "params": [ + "path:media_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/question-categories/{cat_id}": { + "body": true, + "params": [ + "path:cat_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/questions/figures/{figure_id}": { + "body": true, + "params": [ + "path:figure_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/questions/{question_id}": { + "body": true, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/questions/{question_id}/category": { + "body": false, + "params": [ + "path:question_id", + "query:category_id?" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/questions/{question_id}/restore": { + "body": false, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/quizzes/{quiz_id}": { + "body": true, + "params": [ + "path:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/quizzes/{quiz_id}/publish": { + "body": false, + "params": [ + "path:quiz_id", + "query:published?" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/quizzes/{quiz_id}/questions/{question_id}": { + "body": true, + "params": [ + "path:question_id", + "path:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/quizzes/{quiz_id}/restore": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/quizzes/{quiz_id}/share": { + "body": false, + "params": [ + "path:quiz_id", + "query:shared" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/study-plans/blocks/{block_id}": { + "body": true, + "params": [ + "path:block_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/study-plans/{plan_id}": { + "body": true, + "params": [ + "path:plan_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PATCH /api/v1/tags/{tag_id}": { + "body": true, + "params": [ + "path:tag_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/access/{user_id}/grants": { + "body": true, + "params": [ + "path:user_id" + ], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/admin/classification-snapshots/{snapshot_id}/rollback": { + "body": false, + "params": [ + "path:snapshot_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/admin/embedding/regenerate": { + "body": false, + "params": [ + "query:stale_only?" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/admin/embedding/test": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "POST /api/v1/admin/invites": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/admin/litellm/models": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/admin/models": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/admin/models/{model_id}/test": { + "body": false, + "params": [ + "path:model_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/admin/rerank/test": { + "body": false, + "params": [], + "responses": [ + "200" + ] + }, + "POST /api/v1/admin/tts/voices": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/admin/users": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/ai/conversations": { + "body": false, + "params": [], + "responses": [ + "201" + ] + }, + "POST /api/v1/ai/conversations/{conversation_id}/messages": { + "body": true, + "params": [ + "path:conversation_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/ai/conversations/{conversation_id}/practice": { + "body": true, + "params": [ + "path:conversation_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/ai-draft": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/{article_id}/ai-cards": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/{article_id}/ai-refine": { + "body": true, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/{article_id}/links/from-category": { + "body": true, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/{article_id}/publish": { + "body": true, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/{article_id}/restore": { + "body": false, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/{article_id}/revisions/{revision_id}/restore": { + "body": false, + "params": [ + "path:article_id", + "path:revision_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/articles/{article_id}/status": { + "body": true, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/attempts/progress": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/attempts/reset-all": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/attempts/start": { + "body": false, + "params": [ + "query:fresh?", + "query:mode?", + "query:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/attempts/{attempt_id}/submit": { + "body": true, + "params": [ + "path:attempt_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/auth/forgot-password": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/auth/login": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/auth/login-code": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/auth/login-code/verify": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/auth/logout": { + "body": true, + "params": [], + "responses": [ + "204", + "422" + ] + }, + "POST /api/v1/auth/refresh": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/auth/register": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/auth/resend-verification": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/auth/reset-password": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/categories/": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/collections/": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/contact": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/documents/upload": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/documents/{document_id}/sections": { + "body": true, + "params": [ + "path:document_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/drafts/accept": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/drafts/reject": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/drafts/{draft_id}/reopen": { + "body": false, + "params": [ + "path:draft_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/exams/": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/exams/{exam_id}/assign": { + "body": true, + "params": [ + "path:exam_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/favorites": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/feedback/articles/{article_id}": { + "body": true, + "params": [ + "path:article_id" + ], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/feedback/questions/{question_id}": { + "body": true, + "params": [ + "path:question_id" + ], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/flashcards/": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/flashcards/cards/{card_id}/review": { + "body": true, + "params": [ + "path:card_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/flashcards/decks/{deck_id}/cards": { + "body": true, + "params": [ + "path:deck_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/flashcards/{deck_id}/rate": { + "body": true, + "params": [ + "path:deck_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/flashcards/{deck_id}/restore": { + "body": false, + "params": [ + "path:deck_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/folders/": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/folders/{folder_id}/questions": { + "body": true, + "params": [ + "path:folder_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/media/libraries": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/media/libraries/{library_id}/grants": { + "body": true, + "params": [ + "path:library_id" + ], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/media/upload": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/nextcloud/download": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/nextcloud/files": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/nextcloud/test": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/question-categories/": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/question-categories/{cat_id}/create-quiz": { + "body": false, + "params": [ + "path:cat_id", + "query:count?", + "query:mode?", + "query:time_limit_minutes?", + "query:title" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/question-categories/{cat_id}/grants": { + "body": true, + "params": [ + "path:cat_id" + ], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/questions/builder": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/builder/describe": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/builder/from-upload": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/builder/prepared": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/bulk": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/bulk-category": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/create": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/detail/{question_id}/figures": { + "body": true, + "params": [ + "path:question_id" + ], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/questions/detail/{question_id}/versions/{version_id}/restore": { + "body": false, + "params": [ + "path:question_id", + "path:version_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/from-bank": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/import/qti": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/import/upload": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/questions/upload-image": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/quizzes/": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/quizzes/job/{job_id}/cancel": { + "body": false, + "params": [ + "path:job_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/quizzes/{quiz_id}/share-link": { + "body": false, + "params": [ + "path:quiz_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/quizzes/{quiz_id}/shuffle": { + "body": false, + "params": [ + "path:quiz_id", + "query:shuffle_options?" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/study-plans/": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/study-plans/blocks/{block_id}/articles": { + "body": true, + "params": [ + "path:block_id" + ], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/study-plans/blocks/{block_id}/move": { + "body": true, + "params": [ + "path:block_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/study-plans/blocks/{block_id}/start": { + "body": false, + "params": [ + "path:block_id", + "query:mode?" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/study-plans/from-blueprint": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/study-plans/reading/{link_id}/read": { + "body": false, + "params": [ + "path:link_id", + "query:read?" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/study-plans/{plan_id}/blocks": { + "body": true, + "params": [ + "path:plan_id" + ], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/study-plans/{plan_id}/blocks/order": { + "body": true, + "params": [ + "path:plan_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/study-tools/lab-values": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/tags/": { + "body": true, + "params": [], + "responses": [ + "201", + "422" + ] + }, + "POST /api/v1/tags/{tag_id}/questions": { + "body": true, + "params": [ + "path:tag_id" + ], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/teach/chat": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/tts/speak": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "POST /api/v1/tts/transcribe": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/access/{user_id}/role": { + "body": true, + "params": [ + "path:user_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/admin/models/{model_id}": { + "body": true, + "params": [ + "path:model_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/admin/settings": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/admin/users/{user_id}/role": { + "body": true, + "params": [ + "path:user_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/admin/users/{user_id}/unthrottle": { + "body": true, + "params": [ + "path:user_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/articles/{article_id}/links": { + "body": true, + "params": [ + "path:article_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/articles/{article_id}/notes/{section_id}": { + "body": true, + "params": [ + "path:article_id", + "path:section_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/auth/me": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/auth/me/settings": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/collections/{collection_id}/articles/{article_id}": { + "body": false, + "params": [ + "path:article_id", + "path:collection_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/collections/{collection_id}/questions/{question_id}": { + "body": false, + "params": [ + "path:collection_id", + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/contact/submissions/{submission_id}/read": { + "body": false, + "params": [ + "path:submission_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/exams/active": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/exams/{exam_id}/blueprint/{blueprint_id}/categories": { + "body": true, + "params": [ + "path:blueprint_id", + "path:exam_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/flashcards/cards/{card_id}": { + "body": true, + "params": [ + "path:card_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/flashcards/cards/{card_id}/links/article": { + "body": true, + "params": [ + "path:card_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/flashcards/cards/{card_id}/links/question": { + "body": true, + "params": [ + "path:card_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/flashcards/{deck_id}/share": { + "body": false, + "params": [ + "path:deck_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/mynote": { + "body": true, + "params": [], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/questions/detail/{question_id}/note": { + "body": true, + "params": [ + "path:question_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/study-tools/lab-values/{entry_id}": { + "body": true, + "params": [ + "path:entry_id" + ], + "responses": [ + "200", + "422" + ] + }, + "PUT /api/v1/study-tools/lab-values/{entry_id}/cards/{card_id}": { + "body": false, + "params": [ + "path:card_id", + "path:entry_id" + ], + "responses": [ + "200", + "422" + ] + } + }, + "version": "3.0.0" +} diff --git a/backend/tests/test_api_contract.py b/backend/tests/test_api_contract.py new file mode 100644 index 0000000..186012b --- /dev/null +++ b/backend/tests/test_api_contract.py @@ -0,0 +1,115 @@ +"""The shape of the API, written down. + +An app is built against a contract, and a contract nobody checks is a wish. The +web app in this repository is deployed with the server, so a route that quietly +changes shape is caught by the next `docker compose build`; an app on somebody's +phone is not, and finds out in the field. + +So the surface is a checked-in file. Adding a route or a field is a diff to +review and accept; removing or renaming one is a diff that says, in the review, +exactly which client is about to break. Regenerate with: + + python -m tests.test_api_contract --write + +There is no assertion here about internals — only what a caller can see: the +address, the method, the parameters it takes and the codes it answers with. +""" +import json +import pathlib +import sys +import unittest + +SNAPSHOT = pathlib.Path(__file__).with_name("api-contract.json") + + +def surface() -> dict: + """Every public address, reduced to what a client depends on.""" + from app.main import app + + schema = app.openapi() + routes: dict[str, dict] = {} + for path, methods in sorted(schema.get("paths", {}).items()): + for method, operation in sorted(methods.items()): + if method.upper() not in ("GET", "POST", "PUT", "PATCH", "DELETE"): + continue + params = sorted( + f"{p.get('in')}:{p.get('name')}{'' if p.get('required') else '?'}" + for p in operation.get("parameters", []) + ) + routes[f"{method.upper()} {path}"] = { + "params": params, + "body": bool(operation.get("requestBody")), + "responses": sorted(operation.get("responses", {})), + } + return {"version": schema.get("info", {}).get("version"), "routes": routes} + + +class ApiContractTests(unittest.TestCase): + maxDiff = None + + def test_the_published_surface_matches_the_snapshot(self): + self.assertTrue( + SNAPSHOT.exists(), + "No contract snapshot. Write one with: python -m tests.test_api_contract --write", + ) + expected = json.loads(SNAPSHOT.read_text()) + actual = surface() + + gone = sorted(set(expected["routes"]) - set(actual["routes"])) + added = sorted(set(actual["routes"]) - set(expected["routes"])) + self.assertEqual(gone, [], f"Routes removed from the API: {gone}. " + "Every one of these is a client that stops working. " + "If it is deliberate, regenerate the snapshot.") + self.assertEqual(added, [], f"New routes: {added}. Regenerate the snapshot to accept them.") + + for name in sorted(expected["routes"]): + self.assertEqual(actual["routes"][name], expected["routes"][name], + f"{name} changed shape") + + def test_every_route_is_reachable_by_both_addresses(self): + """`/api/...` is the old address and stays working, for ever. + + Not a second mount — a rewrite — so this checks the rewrite rather than + a duplicate set of paths, which would be its own kind of wrong. + """ + from app.api.versioning import VERSIONED_ROOT, VersionAlias, is_versioned + + actual = surface()["routes"] + api_paths = [name.split(" ", 1)[1] for name in actual + if name.split(" ", 1)[1].startswith("/api/")] + self.assertTrue(api_paths, "No API routes found at all") + # Everything under /api is versioned. A path that is not would be + # reachable at one address only, which is the drift this prevents. + from app.api.versioning import PASSTHROUGH + unversioned = [path for path in api_paths + if not is_versioned(path) and path not in PASSTHROUGH] + self.assertEqual(unversioned, [], f"Not under {VERSIONED_ROOT}: {unversioned}") + + rewritten = [] + + class Sink: + async def __call__(self, scope, receive, send): + rewritten.append(scope["path"]) + + import asyncio + alias = VersionAlias(Sink()) + for path, expected in [ + ("/api/auth/me", "/api/v1/auth/me"), + ("/api/v1/auth/me", "/api/v1/auth/me"), # already versioned, untouched + ("/api/docs", "/api/docs"), # the docs are not a route + ("/api/openapi.json", "/api/openapi.json"), + ("/api/health", "/api/health"), # monitoring points here + + ("/uploads/questions/1.png", "/uploads/questions/1.png"), + ]: + rewritten.clear() + asyncio.run(alias({"type": "http", "path": path}, None, None)) + self.assertEqual(rewritten, [expected], path) + + +if __name__ == "__main__": + if "--write" in sys.argv: + SNAPSHOT.write_text(json.dumps(surface(), indent=2, sort_keys=True) + "\n") + print(f"Wrote {SNAPSHOT} — {len(surface()['routes'])} routes") + else: + unittest.main() diff --git a/backend/tests/test_refresh_tokens.py b/backend/tests/test_refresh_tokens.py new file mode 100644 index 0000000..12a11c1 --- /dev/null +++ b/backend/tests/test_refresh_tokens.py @@ -0,0 +1,164 @@ +"""Staying signed in without keeping a password. + +A browser can send a person back to a login form; an app on a phone cannot, and +must not keep the password to avoid it. So sign-in can hand back a refresh +token: a row rather than a signature, which means it can be listed, rotated and +withdrawn — none of which is true of the access token beside it. + +The properties worth pinning are the ones that only show up when they fail: a +token works exactly once, a spent one presented again ends the whole session +rather than being politely ignored, and signing out of a lost phone stops it +getting another token even though the one it holds is still cryptographically +valid. + +Disposable SQLite, a Mock for Redis, no network. +""" +import sys +import unittest +from datetime import datetime, timedelta +from types import ModuleType +from unittest.mock import patch + +import test_quiz_builder as fixtures +from app.models.refresh_token import RefreshToken +from app.routers import auth as auth_router +from app.services import refresh_tokens +from app.utils.auth import get_current_user + + +class MemoryRedis: + def __init__(self): self.values = {} + def get(self, key): return self.values.get(key) + def set(self, key, value, **kwargs): self.values[key] = value + def setex(self, key, ttl, value): self.values[key] = value + def incr(self, key): + self.values[key] = int(self.values.get(key, 0)) + 1 + return self.values[key] + def ttl(self, key): return 60 + def expire(self, *args): return True + def delete(self, *args): return True + + +class RefreshTokenTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.bank.owner.email = "owner@example.com" + self.bank.db.commit() + self.client = self.bank.client + self.client.app.include_router(auth_router.router, prefix="/auth") + # The fixture pins every request to one user; these tests are about who + # the token says you are, so the real dependency goes back in. + self.client.app.dependency_overrides.pop(get_current_user, None) + self.client.app.dependency_overrides[get_current_user] = lambda: self.bank.user + + redis = ModuleType("redis") + redis.from_url = lambda *a, **k: MemoryRedis() + modules = patch.dict(sys.modules, {"redis": redis}) + modules.start() + self.addCleanup(modules.stop) + + def tearDown(self): + self.bank.tearDown() + + def sign_in(self, **extra): + return refresh_tokens.issue(self.bank.db, self.bank.owner, **extra) + + def refresh(self, token): + return self.client.post("/auth/refresh", json={"refresh_token": token}) + + # ── Issuing ──────────────────────────────────────────────────── + def test_a_browser_is_not_given_something_it_will_never_use(self): + # Sign-in only mints a refresh token for a client that says it wants + # one. Handing every browser a sixty-day credential it has nowhere to + # put is a row in a table and a liability, for nothing. + self.assertEqual(self.bank.db.query(RefreshToken).count(), 0) + + def test_only_the_hash_is_kept(self): + token = self.sign_in() + rows = self.bank.db.query(RefreshToken).all() + self.assertEqual(len(rows), 1) + self.assertNotIn(token, [row.token_hash for row in rows]) + self.assertEqual(len(rows[0].token_hash), 64) + + # ── Spending ─────────────────────────────────────────────────── + def test_a_token_is_traded_for_a_new_pair_and_cannot_be_used_twice(self): + token = self.sign_in() + first = self.refresh(token) + self.assertEqual(first.status_code, 200, first.text) + rotated = first.json()["refresh_token"] + self.assertTrue(first.json()["access_token"]) + self.assertNotEqual(rotated, token) + self.assertEqual(self.refresh(rotated).status_code, 200) + + def test_a_spent_token_coming_back_ends_the_whole_session(self): + # Either it was copied or a client is replaying; from here those look + # the same, so the safe reading is the unsafe one. The thief and the + # owner both stop working, and the owner signs in again knowing why. + token = self.sign_in() + rotated = self.refresh(token).json()["refresh_token"] + self.assertEqual(self.refresh(token).status_code, 401) + self.assertEqual(self.refresh(rotated).status_code, 401) + self.assertEqual(refresh_tokens.sessions(self.bank.db, self.bank.owner), []) + + def test_an_expired_or_unknown_token_is_refused_without_a_hint(self): + self.assertEqual(self.refresh("never-issued").status_code, 401) + token = self.sign_in() + row = self.bank.db.query(RefreshToken).one() + row.expires_at = datetime.utcnow() - timedelta(seconds=1) + self.bank.db.commit() + self.assertEqual(self.refresh(token).status_code, 401) + + # ── Withdrawing ──────────────────────────────────────────────── + def test_signing_out_stops_the_lost_phone_getting_another_token(self): + phone = self.sign_in(label="iPhone") + laptop = self.sign_in(label="Laptop") + self.assertEqual(len(refresh_tokens.sessions(self.bank.db, self.bank.owner)), 2) + + self.assertEqual(self.client.post("/auth/logout", json={"refresh_token": phone}).status_code, 204) + self.assertEqual(self.refresh(phone).status_code, 401) + self.assertEqual(self.refresh(laptop).status_code, 200) + + def test_signing_out_everywhere_ends_every_session(self): + tokens = [self.sign_in(label=f"Device {i}") for i in range(3)] + self.assertEqual(self.client.post("/auth/logout", json={"everywhere": True}).status_code, 204) + for token in tokens: + self.assertEqual(self.refresh(token).status_code, 401) + + def test_a_person_can_see_where_they_are_signed_in_and_end_one(self): + self.sign_in(label="iPhone") + self.sign_in(label="Laptop") + rows = self.client.get("/auth/sessions").json() + self.assertEqual({row["label"] for row in rows}, {"iPhone", "Laptop"}) + # One row per session, not one per rotation: a token refreshed nightly + # for two months is one place you are signed in, not sixty. + phone = next(row for row in rows if row["label"] == "iPhone") + self.refresh(self.sign_in(label="iPhone", family=phone["family"])) + self.assertEqual(len(self.client.get("/auth/sessions").json()), 2) + + self.assertEqual(self.client.delete(f"/auth/sessions/{phone['family']}").status_code, 204) + self.assertEqual([row["label"] for row in self.client.get("/auth/sessions").json()], ["Laptop"]) + + def test_one_person_cannot_end_another_person_s_session(self): + theirs = refresh_tokens.issue(self.bank.db, self.bank.peer, label="Their laptop") + rows = refresh_tokens.sessions(self.bank.db, self.bank.peer) + self.bank.user = self.bank.owner + self.assertEqual(self.client.delete(f"/auth/sessions/{rows[0]['family']}").status_code, 204) + # Answered 204 because there was nothing of theirs by that name to end; + # what matters is that the other person is still signed in. + self.assertEqual(self.refresh(theirs).status_code, 200) + + # ── Housekeeping ─────────────────────────────────────────────── + def test_dead_rows_are_swept_and_live_ones_are_not(self): + live = self.sign_in(label="Live") + old = self.sign_in(label="Old") + row = self.bank.db.query(RefreshToken).filter(RefreshToken.label == "Old").one() + row.revoked_at = datetime.utcnow() - timedelta(days=30) + self.bank.db.commit() + self.assertEqual(refresh_tokens.purge(self.bank.db), 1) + self.bank.db.commit() + self.assertEqual(self.refresh(live).status_code, 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..2d96c07 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,108 @@ +# The stack the end-to-end tests run against. +# +# Its own Postgres and Redis, on their own volumes, on their own network, with +# their own ports. Nothing here touches the running site: the point of an +# end-to-end test is to do the destructive things a real user can do — sit a +# session, delete an article, sign out everywhere — and none of that may +# happen to somebody's actual work. +# +# docker compose -f docker-compose.test.yml up -d --build +# docker compose -f docker-compose.test.yml run --rm seed +# cd e2e && npx playwright test +# docker compose -f docker-compose.test.yml down -v # -v: take the data with it +# +# The database is thrown away with the stack. That is deliberate — a test suite +# that depends on data surviving between runs is a test suite that passes on +# your machine. +name: pedshub-test + +services: + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_DB: pedquiz_test + POSTGRES_USER: pedquiz_test + POSTGRES_PASSWORD: pedquiz_test + # No host port. Nothing outside this stack has any business reaching it, + # and binding 5432 would collide with the real one on a developer's box. + volumes: + - test_postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pedquiz_test"] + interval: 3s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + command: redis-server --save "" --appendonly no + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 20 + + backend: + build: ./backend + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 --proxy-headers --forwarded-allow-ips=* + environment: &backend_env + DATABASE_URL: postgresql://pedquiz_test:pedquiz_test@postgres:5432/pedquiz_test + REDIS_URL: redis://redis:6379/0 + # Fixed, so a token minted by the seed script is still valid in the + # browser. Test-only by construction: it is in a file in the repository. + SECRET_KEY: e2e-only-not-a-secret-e2e-only-not-a-secret + ALGORITHM: HS256 + ACCESS_TOKEN_EXPIRE_MINUTES: "1440" + APP_URL: http://localhost:8095 + # Nothing may leave the machine during a test. No model, no mail, no + # object store: a suite that quietly calls a paid API is a suite nobody + # can run twice. + LITELLM_API_BASE: http://127.0.0.1:9/blackhole + LITELLM_API_KEY: unused + LITELLM_MODEL: none + SMTP_HOST: "" + ANONYMIZED_TELEMETRY: "False" + LOG_LEVEL: WARNING + # Registration open and no captcha, so the sign-up journey is testable. + CAP_SECRET_KEY: "" + # The whole suite arrives from one address, so the guess limiter would + # stop the run rather than an attacker. Raised here and nowhere else. + LOGIN_MAX_ATTEMPTS: "10000" + REFRESH_MAX_PER_HOUR: "10000" + volumes: + - test_uploads:/app/uploads + - test_chroma:/app/chroma_data + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_started } + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://localhost:8000/api/health')\""] + interval: 3s + timeout: 5s + retries: 30 + + # The same nginx image the site runs, so the tests exercise the real routing + # and the real built bundle rather than a dev server. + frontend: + build: ./frontend + ports: + - "127.0.0.1:8095:80" + depends_on: + backend: { condition: service_healthy } + + # One-shot. Applies migrations and writes the fixture the tests expect. + seed: + build: ./backend + environment: *backend_env + volumes: + - test_uploads:/app/uploads + - ./e2e/seed.py:/app/seed.py:ro + depends_on: + postgres: { condition: service_healthy } + entrypoint: ["python", "/app/seed.py"] + profiles: ["tools"] + +volumes: + test_postgres: + test_uploads: + test_chroma: diff --git a/docker-compose.yml b/docker-compose.yml index 3be721d..668ed4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,11 @@ services: backend: build: ./backend - command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 + # --proxy-headers: trust the X-Forwarded-For nginx sets, so request.client + # is the caller rather than the proxy. Safe to trust any peer here because + # this service publishes no host port — nginx is the only thing that can + # reach it. + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 --proxy-headers --forwarded-allow-ips=* env_file: - ./backend/.env environment: diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..f86737f --- /dev/null +++ b/docs/api.md @@ -0,0 +1,146 @@ +# The API as a contract + +`api-reference.md` beside this file lists the endpoints. This one is about the +rules that hold across all of them — the things a client has to be able to rely +on, and what happens when they change. + +## Addresses + +Everything is under `/api/v1`. `/api/...` reaches the same route and always +will: it is the address the web app in this repository was written against, and +an address that has shipped is a promise. + +It is a rewrite, not a second mount (`app/api/versioning.py`). One route, two +spellings — so the two cannot drift, and the OpenAPI document describes each +endpoint once rather than twice. + +A client written today should say the version. The day `/api/v2` exists, `/api` +will still mean v1, and anything pinned to `/api/v1` keeps the behaviour it was +built on. + +Two addresses are deliberately outside the scheme: + +| Path | Why | +|---|---| +| `/api/health` | Monitoring points at it and nobody edits that for a year. Also answers at `/api/v1/health`. | +| `/uploads/...` | An image's URL gets written into markdown and shared. Those must not move. | + +## The contract is published + +- `GET /api/openapi.json` — the machine-readable document +- `/api/docs` — Swagger, `/api/redoc` — ReDoc + +And it is checked. `backend/tests/api-contract.json` is the surface as it stood +when it was last accepted: every method and path, the parameters each takes and +the status codes it answers with. `test_api_contract.py` compares the live app +against it and fails on any difference, naming the routes that moved. + +```bash +# after deliberately adding or changing a route +docker compose run --rm --no-deps -e DATABASE_URL=sqlite:// -e PYTHONPATH=/app \ + -w /app backend python -m tests.test_api_contract --write +``` + +Regenerating is a diff to review. That is the point: removing a route should be +visible in a review as "this client is about to break", not discovered by the +client. + +## Errors + +Every failure carries an `error` object as well as the `detail` FastAPI has +always produced. `detail` is unchanged so nothing that reads it breaks; `error` +is what a client should switch on. + +```json +{ + "detail": "Quiz not found", + "error": { "code": "not_found", "message": "Quiz not found" } +} +``` + +A validation failure also says which inputs were wrong: + +```json +{ + "error": { + "code": "invalid_request", + "message": "email: value is not a valid email address", + "fields": [ + { "field": "email", "message": "value is not a valid email address", "type": "value_error" } + ] + } +} +``` + +Codes: `bad_request`, `unauthenticated`, `forbidden`, `not_found`, `conflict`, +`too_large`, `invalid_request`, `rate_limited`, `upstream_failed`, +`unavailable`, `server_error`. The list lives in `app/api/errors.py`; switch on +the word, not the number. + +## Signing in, and staying signed in + +An access token is a signed statement good for a day. Nothing consults a table +before believing it, so it cannot be withdrawn — fine for a browser, which can +send the person back to a login form, and no use to an app, which would have to +keep the password to survive the night. + +So a client that says so gets a refresh token: + +```http +POST /api/v1/auth/login +{ "email": "...", "password": "...", "refresh": true, "device": "PedsHub for iPhone" } + +→ { "access_token": "...", "expires_in": 86400, "refresh_token": "...", "token_type": "bearer" } +``` + +| Endpoint | What it does | +|---|---| +| `POST /api/v1/auth/refresh` | Trades a refresh token for a new pair. The old one is spent. | +| `POST /api/v1/auth/logout` | Ends this session (`refresh_token`) or all of them (`everywhere: true`). | +| `GET /api/v1/auth/sessions` | Where this account is signed in — one row per device, not per rotation. | +| `DELETE /api/v1/auth/sessions/{family}` | Ends one of them. | + +Three properties worth knowing: + +1. **Rotation.** Every refresh issues a new token and spends the old one. +2. **A spent token coming back ends the whole session.** Either it was copied or + a client is replaying, and from the server those look identical — so the safe + reading is the unsafe one. A thief who spends a token first makes the real + client's next attempt fail, which is the theft announcing itself. +3. **Only the hash is stored.** A database that leaks does not hand over live + sessions with it. + +A browser is not given one. It has nowhere safe to put it and a person sitting +in front of it to ask again. + +## Rate limits + +Keyed on the caller's address, which means the deployment must let the caller's +address through: nginx sets it from `X-Forwarded-For` (overwriting, so nothing +a client sends for itself survives) and uvicorn runs with `--proxy-headers`. +Without both, every request looks like it came from the proxy and every limit +becomes one bucket for the whole site. + +| What | Default | Setting | +|---|---|---| +| Password guesses | 10 per address per 15 minutes, **cleared by a correct password** | `LOGIN_MAX_ATTEMPTS`, `LOGIN_WINDOW_MINUTES` | +| Refresh | 600 per address per hour — flood protection, not security | `REFRESH_MAX_PER_HOUR` | +| The tutor, AI Mode, TTS | Per-user daily quotas | Admin settings | + +Guesses are counted; sign-ins are not. Counting both would lock out a hospital: +one public address, a ward full of people, eleven of whom opened the app this +afternoon. + +## What a client may see + +Two rules that are not obvious from the endpoint list, and that a client should +not try to work around: + +- **A stem is bank content; the answer beside it is not.** `correct_answer`, + `explanation`, `option_explanations`, `key_points` and `attending_tip` are + returned only to whoever writes that question — a moderator, its author, or + someone with an editorial grant covering where it is filed. Everyone else + earns the answer by sitting the question, which is what an attempt is. +- **Answer-side media needs the attempt in the URL.** `?attempt_id=` on an + `/uploads/...` request is how the server knows the person asking is the + person who sat it. diff --git a/docs/writing-articles.md b/docs/writing-articles.md index dc56250..597632f 100644 --- a/docs/writing-articles.md +++ b/docs/writing-articles.md @@ -100,6 +100,46 @@ questions to an article nobody has looked at since. The same list is editable from each question's own editor, which is the same relationship seen from the other end. +## Linking cards to a question, and to an article + +Cards go the other way round: the link is made **from the card**, in the deck +browser, and read from the question's end only. + +- Open **Cards**, then the deck, then the ⛓ control on a card. +- Search a question by its stem, or an article by its title, and link it. + +What a learner then sees: under the correct answer, beside the ▤ chips for +topic reading, a **▦ chip naming the deck** — one chip per deck however many of +its cards are tied to that question, because three doors to the same room is +one door too many. The same chips appear in the answer review of a finished +attempt. + +Why only from the card's side: a card that listed the questions it belongs to +would hand a learner revising the deck the shape of the exam, and the back of a +card is an answer. + +**A deck nobody shared is invisible.** Cards generated from an article land in +a private deck called *Cards: *, owned by whoever pressed the button; +it appears in nobody else's list, and the chip is not drawn for anyone who +cannot open the deck. Sharing is a deliberate second action, after reading what +the model wrote. + +## What can be linked to what + +| From | To | Where you do it | What the reader gets | +|---|---|---|---| +| Article prose | Article | `[[264\|label]]` in the text | A link with a hover card: excerpt, new tab, or a pane beside what they are reading | +| Article | Question | Editor → Linked questions | Practice under the article, and the article under the question's answer | +| Question | Article | Question editor → the same list | ▤ chips under the correct answer, landing on the article or one of its sections | +| Card | Question | Cards → deck → ⛓ | ▦ chip under the correct answer, opening the deck to study | +| Card | Article | Cards → deck → ⛓ | The card listed under *Related cards* on the article | +| Explanation prose | Article | `[[264\|label]]`, same as anywhere | The same hover card | + +A question→article link can land on **one section** rather than the whole +article — choose the section when you make the link, and the reader opens at +that heading. A cross-reference written in prose cannot: `[[264|label]]` always +means the whole article. + ## Deleting - A draft that was **never published** is deleted outright. There is nothing to diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..8913acb --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,37 @@ +# End-to-end + +Real browsers and a real Postgres, against a stack that is created and thrown +away. Nothing here touches the running site. + +```bash +cd e2e +npm install +npx playwright install --with-deps chromium webkit +npm run stack:up # build the stack, apply the schema, seed it +npm test # every project +npm run test:phone # just the iPhone and Pixel projects +npm run stack:down # and take the data with it +``` + +## Why there are five projects + +| Project | What it is for | +|---|---| +| `desktop` | 1440×900 Chrome. The layout most of the work is done in. | +| `iphone` | iPhone 13, WebKit. Where an SVG with no intrinsic size renders as nothing, and where a sub-16px input zooms the page and never zooms back. | +| `android` | Pixel 7, Chrome. The other half of "mobile". | +| `tablet` | iPad Mini landscape — the width where the reading rail turns into a drawer. | +| `api` | No browser. The contract over HTTP: addresses, error shapes, who may see an answer, refresh-token rotation. | + +Every bug this suite was written after was a phone bug reported as a +screenshot. A desktop-only suite would have passed through all of them. + +## Fixtures + +`seed.py` builds the smallest world the tests name: two accounts (an educator +and a learner), three questions, one session, two articles with a +cross-reference between them, and a shared deck. Re-running it is safe. + +Accounts are `educator@e2e.example.com` and `learner@e2e.example.com`; the passwords are in +`tests/helpers.js`, which is fine, because that database exists for about four +minutes. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..d2862b8 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "pedshub-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pedshub-e2e", + "devDependencies": { + "@playwright/test": "^1.49.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..428183b --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "pedshub-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:phone": "playwright test --project=iphone --project=android", + "report": "playwright show-report", + "stack:up": "docker compose -f ../docker-compose.test.yml up -d --build && docker compose -f ../docker-compose.test.yml run --rm seed", + "stack:down": "docker compose -f ../docker-compose.test.yml down -v" + }, + "devDependencies": { + "@playwright/test": "^1.49.0" + } +} diff --git a/e2e/playwright-report/index.html b/e2e/playwright-report/index.html new file mode 100644 index 0000000..a7c9786 --- /dev/null +++ b/e2e/playwright-report/index.html @@ -0,0 +1,49 @@ + + +<!DOCTYPE html> +<html style='scrollbar-gutter: stable both-edges;'> + <head> + <meta charset='UTF-8'> + <meta name='color-scheme' content='dark light'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..5cd2eb0 --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,58 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * End-to-end, against the throwaway stack in docker-compose.test.yml. + * + * Four projects, because the bugs this suite exists to catch have all been + * layout bugs on a phone: a drawer that opened behind the header, a diagram + * that rendered as an empty screen in Safari, a popover two hundred pixels off + * the left edge. None of those are visible at 1280×720, so a desktop-only + * suite would have passed through every one of them. + * + * The API project runs the same journeys without a browser, where what is + * being checked is the contract rather than the page. + */ +const BASE = process.env.E2E_BASE_URL || 'http://127.0.0.1:8095' + +export default defineConfig({ + testDir: './tests', + // A failing E2E test is usually a real failure, but a hung one is usually + // the stack. Fail fast enough to say which. + timeout: 60_000, + // Generous, because this stack shares a machine with whatever else is on it + // and a slow answer is not a wrong one. A genuinely missing element still + // fails, fifteen seconds later. + expect: { timeout: 15_000 }, + fullyParallel: true, + // Retries in CI only: locally a flake should be seen, not smoothed over. + retries: process.env.CI ? 2 : 0, + // Three, not "as many as there are cores": the stack under test is two + // containers on the same box, and past three browsers they queue. + workers: process.env.CI ? 2 : 3, + reporter: process.env.CI + ? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'results/junit.xml' }]] + : [['list'], ['html', { open: 'never' }]], + use: { + baseURL: BASE, + // Kept only for the run that failed: a trace per test is gigabytes and + // nobody opens the passing ones. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: process.env.CI ? 'retain-on-failure' : 'off', + actionTimeout: 15_000, + }, + // The API specs belong to the `api` project alone; without this every + // browser project runs them too, which is four identical HTTP suites and + // four times the sign-ins. + testIgnore: /.*\.api\.spec\.js/, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } }, + // Safari on a phone, because that is where an SVG with no intrinsic size + // resolves to nothing and a 16px input rule stops the page zooming. + { name: 'iphone', use: { ...devices['iPhone 13'] } }, + { name: 'android', use: { ...devices['Pixel 7'] } }, + // Between the two: the width where the reading rail becomes a drawer. + { name: 'tablet', use: { ...devices['iPad Mini landscape'] } }, + { name: 'api', testMatch: /.*\.api\.spec\.js/, testIgnore: [], use: { baseURL: BASE } }, + ], +}) diff --git a/e2e/seed.py b/e2e/seed.py new file mode 100644 index 0000000..4496050 --- /dev/null +++ b/e2e/seed.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python +"""The world the end-to-end tests wake up in. + +Small on purpose. Every row here exists because a test names it, and a fixture +that drifts past what the tests actually use becomes a second application to +maintain — one whose bugs look like product bugs. + +Idempotent: running it twice leaves the same world, so a developer can re-seed +between runs without tearing the stack down. +""" +import os +import sys +from datetime import datetime + +sys.path.insert(0, "/app") + +from sqlalchemy import text # noqa: E402 + +from app.database import Base, SessionLocal, engine # noqa: E402 +from app.models.article import Article # noqa: E402 +from app.models.email_verification import EmailVerification # noqa: E402 +from app.models.exam import Exam, QuestionExamLink # noqa: E402 +from app.models.flashcard import Flashcard, FlashcardDeck # noqa: E402 +from app.models.question import Question # noqa: E402 +from app.models.question_category import QuestionCategory # noqa: E402 +from app.models.quiz import Quiz # noqa: E402 +from app.models.quiz_question_link import QuizQuestionLink # noqa: E402 +from app.models.user import User # noqa: E402 +from app.utils.auth import get_password_hash # noqa: E402 + +#: The two accounts the suite signs in as. `.example.com` rather than `.test`, +#: because EmailStr refuses reserved TLDs and sign-in would 422 before it ever +#: reached the password check. +#: Fixed by default and in the +#: repository, because the tests have to know them and this database is +#: created and destroyed by the run. Override either from the environment when +#: you want a stack you can poke at by hand without the passwords being +#: something anybody reading the repo already knows. +EDUCATOR = (os.environ.get("E2E_EDUCATOR_EMAIL", "educator@e2e.example.com"), + os.environ.get("E2E_EDUCATOR_PASSWORD", "e2e-educator-password")) +LEARNER = (os.environ.get("E2E_LEARNER_EMAIL", "learner@e2e.example.com"), + os.environ.get("E2E_LEARNER_PASSWORD", "e2e-learner-password")) + +STEMS = [ + ("A 3-year-old has a barking cough, stridor at rest and no drooling. " + "The most appropriate next step is", + ["Dexamethasone", "Intubation", "Nebulised saline", "Ceftriaxone", "Racemic adrenaline only"], + "Dexamethasone", + "A single dose of dexamethasone shortens croup at every severity, and a " + "child with stridor at rest has moderate croup."), + ("A 6-month-old with bronchiolitis has intermittent apnoea and feeds at " + "half volume. The next step is", + ["Admit for observation", "Discharge with advice", "Oral antibiotics", + "Chest radiograph", "Salbutamol"], + "Admit for observation", + "Apnoea and poor feeding are the two admission criteria that matter most " + "in the first six months."), + ("A neonate at 36 hours has a total bilirubin above the phototherapy line. " + "The next step is", + ["Start phototherapy", "Repeat in 24 hours", "Exchange transfusion", + "Stop breastfeeding", "Intravenous immunoglobulin"], + "Start phototherapy", + "The threshold line is the decision. Repeating the level to see whether " + "it climbs is how a treatable jaundice becomes kernicterus."), +] + + +def verified(db, user): + row = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first() + if row is None: + db.add(EmailVerification(user_id=user.id, token=f"seed-{user.id}", + expires_at=datetime(2099, 1, 1), + verified_at=datetime.utcnow())) + elif row.verified_at is None: + row.verified_at = datetime.utcnow() + + +def account(db, email, password, role): + user = db.query(User).filter(User.email == email).first() + if user is None: + user = User(email=email, name=email.split("@")[0].title(), + hashed_password=get_password_hash(password), role=role) + db.add(user) + db.flush() + user.role = role + user.hashed_password = get_password_hash(password) + verified(db, user) + return user + + +#: A drawing written the way the real ones are: a viewBox, and no width or +#: height. That is the shape that renders as nothing in Safari inside a +#: shrink-to-fit box, so the figure test is only worth having if the fixture +#: has the same defect the real files have. +FIGURE = """ + + + + Stridor at rest + Stridor on exertion + +""" + + +def figure(db, educator): + """One drawing, in the library and in an article that renders it.""" + from app.models.media import MediaAsset + + path = "figures/e2e-stridor.svg" + target = os.path.join("/app/uploads", path) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8") as handle: + handle.write(FIGURE) + + asset = db.query(MediaAsset).filter(MediaAsset.path == path).first() + if asset is None: + db.add(MediaAsset( + path=path, title="Stridor at rest and on exertion", + caption="Which of the two decides whether croup is treated now.", + alt_text="Two panels comparing stridor at rest with stridor on exertion.", + source="PedsHub", user_id=educator.id, + )) + article = db.query(Article).filter(Article.slug == "croup").first() + if article and "e2e-stridor" not in (article.content or ""): + article.content = ( + f"![Two panels comparing stridor at rest with stridor on exertion.]" + f"(/uploads/{path})\n\n" + (article.content or "")) + + +def objective(db, users): + """The exam every seeded account is already studying for. + + Set here rather than clicked in the tests, because "what are you studying + for?" is a modal over the first page a signed-in person sees — and a modal + that four parallel workers race each other to dismiss, on one shared + account, produces failures that say nothing about the product. + """ + exam = db.query(Exam).filter(Exam.slug == "e2e-boards").first() + if exam is None: + exam = Exam(slug="e2e-boards", name="E2E Boards", family="Boards", + sort_order=1, is_active=1) + db.add(exam) + db.flush() + for (question_id,) in db.query(Question.id).all(): + if not db.query(QuestionExamLink).filter_by( + question_id=question_id, exam_id=exam.id).first(): + db.add(QuestionExamLink(question_id=question_id, exam_id=exam.id)) + for user in users: + user.active_exam_id = exam.id + return exam + + +#: One learner per parallel worker. They share a database, and a session +#: remembers where it was left — so two workers sitting the same session on the +#: same account interfere in ways that look like product bugs and are not. +WORKERS = 4 + + +def worker_learners(db): + made = [] + for index in range(WORKERS): + email = LEARNER[0].replace("@", f"+w{index}@") + made.append(account(db, email, LEARNER[1], role="user")) + return made + + +def main() -> int: + # The stack is empty on first boot, so the schema comes from the models and + # is then stamped: exactly what a fresh deploy does. + Base.metadata.create_all(bind=engine) + db = SessionLocal() + try: + educator = account(db, *EDUCATOR, role="moderator") + account(db, *LEARNER, role="user") + learners = worker_learners(db) + db.commit() + + if db.query(QuestionCategory).count() == 0: + db.add_all([ + QuestionCategory(id=1, name="Respiratory", user_id=educator.id), + QuestionCategory(id=2, name="Croup", parent_id=1, user_id=educator.id), + QuestionCategory(id=3, name="Neonatology", user_id=educator.id), + ]) + db.flush() + + if db.query(Question).count() == 0: + for index, (stem, options, answer, explanation) in enumerate(STEMS, start=1): + db.add(Question( + id=index, question_text=stem, question_type="mcq", options=options, + correct_answer=answer, explanation=explanation, + question_category_id=2 if index < 3 else 3, + difficulty=["easy", "medium", "hard"][index - 1], + user_id=educator.id, + )) + db.flush() + + if db.query(Quiz).count() == 0: + quiz = Quiz(id=1, title="E2E study session", user_id=educator.id, + mode="learning", questions_count=len(STEMS), is_published=1, + is_shared=1) + db.add(quiz) + db.flush() + for position, question_id in enumerate(range(1, len(STEMS) + 1)): + db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=question_id, + position=position)) + + if db.query(Article).count() == 0: + db.add(Article( + id=1, title="Croup", slug="croup", status="published", + user_id=educator.id, category_id=2, + summary="Barking cough, stridor, and one dose of dexamethasone.", + content="Croup is viral laryngotracheobronchitis. See [[2|bronchiolitis]].", + sections=[{"id": "workup", "title": "Workup", + "content": "Clinical. A radiograph is for the child who is not croup."}], + )) + db.add(Article( + id=2, title="Bronchiolitis", slug="bronchiolitis", status="published", + user_id=educator.id, category_id=1, + summary="Supportive care, and the two reasons to admit.", + content="Bronchiolitis is a first-winter illness of small airways.", + sections=[], + )) + + if db.query(FlashcardDeck).count() == 0: + deck = FlashcardDeck(id=1, title="Cards: Croup", user_id=educator.id, + card_count=2, is_shared=1) + db.add(deck) + db.flush() + db.add_all([ + Flashcard(deck_id=deck.id, front="Dose of dexamethasone in croup?", + back="0.15–0.6 mg/kg once, oral."), + Flashcard(deck_id=deck.id, front="Stridor at rest means?", + back="At least moderate croup — treat, do not watch."), + ]) + db.flush() + + figure(db, educator) + objective(db, [educator, + db.query(User).filter(User.email == LEARNER[0]).one(), + *learners]) + db.commit() + + # Stamped rather than migrated: the schema came from the models a moment + # ago, so replaying every migration over it would fail on the first + # CREATE TABLE. This is what a fresh deploy of the real app does too. + with engine.begin() as conn: + conn.execute(text("CREATE TABLE IF NOT EXISTS alembic_version " + "(version_num VARCHAR(32) NOT NULL)")) + head = os.environ.get("ALEMBIC_HEAD", "p5f6a7b8c9d0") + if not conn.execute(text("SELECT 1 FROM alembic_version")).first(): + conn.execute(text("INSERT INTO alembic_version VALUES (:v)"), {"v": head}) + + counts = { + "users": db.query(User).count(), + "questions": db.query(Question).count(), + "quizzes": db.query(Quiz).count(), + "articles": db.query(Article).count(), + "cards": db.query(Flashcard).count(), + } + # Printed, so `docker compose -f docker-compose.test.yml logs seed` + # says how to sign in without anybody reading the source. + print("seeded", counts, flush=True) + print(f" educator: {EDUCATOR[0]} / {EDUCATOR[1]}", flush=True) + print(f" learner: {LEARNER[0]} / {LEARNER[1]}", flush=True) + return 0 + finally: + db.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/e2e/test-results/.last-run.json b/e2e/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/e2e/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/e2e/tests/api.api.spec.js b/e2e/tests/api.api.spec.js new file mode 100644 index 0000000..a130fc2 --- /dev/null +++ b/e2e/tests/api.api.spec.js @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test' +import { EDUCATOR, LEARNER, tokenFor } from './helpers.js' + +/** + * The contract, exercised over HTTP against a real database. + * + * The unit suite checks these rules against SQLite and a fake Redis; this + * checks the same rules through nginx, uvicorn and Postgres, which is where + * a misplaced middleware or a missing migration shows up. + */ + +const token = (request, who) => tokenFor(request, who) +const bearer = value => ({ Authorization: `Bearer ${value}` }) + +test.describe('addresses', () => { + test('the same route answers at /api and /api/v1', async ({ request }) => { + const key = await token(request, LEARNER) + for (const path of ['/api/auth/me', '/api/v1/auth/me']) { + const response = await request.get(path, { headers: bearer(key) }) + expect(response.status(), path).toBe(200) + expect((await response.json()).email).toBe(LEARNER.email) + } + }) + + test('the liveness check is not versioned, because monitoring is not', async ({ request }) => { + expect((await request.get('/api/health')).status()).toBe(200) + }) + + test('the contract is published', async ({ request }) => { + const document = await (await request.get('/api/openapi.json')).json() + expect(document.info.version).toBeTruthy() + expect(Object.keys(document.paths).length).toBeGreaterThan(100) + // Every documented route says its version, so a client that pins v1 has + // something to pin to. + const unversioned = Object.keys(document.paths) + .filter(path => path.startsWith('/api/') && !path.startsWith('/api/v1/')) + .filter(path => path !== '/api/health') + expect(unversioned).toEqual([]) + }) +}) + +test.describe('errors', () => { + test('a failure carries a code, a sentence and the fields', async ({ request }) => { + const key = await token(request, LEARNER) + const missing = await request.get('/api/v1/quizzes/999999', { headers: bearer(key) }) + expect(missing.status()).toBe(404) + expect((await missing.json()).error).toMatchObject({ code: 'not_found' }) + + const bad = await request.get('/api/v1/quizzes/not-a-number', { headers: bearer(key) }) + expect(bad.status()).toBe(422) + const body = await bad.json() + expect(body.error.code).toBe('invalid_request') + expect(body.error.fields[0]).toMatchObject({ field: 'quiz_id' }) + }) + + test('an unauthenticated call says so in the same shape', async ({ request }) => { + const response = await request.get('/api/v1/auth/me') + expect(response.status()).toBe(401) + expect((await response.json()).error.code).toBe('unauthenticated') + }) +}) + +test.describe('the answer side', () => { + test('a learner browsing the bank gets stems and no answers', async ({ request }) => { + const key = await token(request, LEARNER) + const body = await (await request.get('/api/v1/questions/bank?limit=5', + { headers: bearer(key) })).json() + expect(body.questions.length).toBeGreaterThan(0) + for (const question of body.questions) { + expect(question.question_text, 'the stem is bank content').toBeTruthy() + expect(question.correct_answer, 'the answer is not').toBeNull() + expect(question.explanation).toBeNull() + } + }) + + test('an educator writing the question does get them', async ({ request }) => { + const key = await token(request, EDUCATOR) + const body = await (await request.get('/api/v1/questions/bank?limit=5', + { headers: bearer(key) })).json() + expect(body.questions[0].correct_answer).toBeTruthy() + }) + + test('the tutor is refused on a question nobody is sitting', async ({ request }) => { + const key = await token(request, LEARNER) + const response = await request.post('/api/v1/teach/chat', { + headers: bearer(key), + data: { question_id: 1, messages: [{ role: 'user', content: 'What is the answer?' }] }, + }) + expect(response.status()).toBe(403) + }) +}) + +test.describe('staying signed in', () => { + test('an app gets a refresh token, rotates it, and cannot reuse it', async ({ request }) => { + const signIn = await request.post('/api/v1/auth/login', + { data: { ...LEARNER, refresh: true, device: 'E2E phone' } }) + const first = await signIn.json() + expect(first.refresh_token).toBeTruthy() + expect(first.expires_in).toBeGreaterThan(0) + + const refreshed = await request.post('/api/v1/auth/refresh', + { data: { refresh_token: first.refresh_token } }) + expect(refreshed.status()).toBe(200) + const second = await refreshed.json() + expect(second.refresh_token).not.toBe(first.refresh_token) + + // The spent one coming back ends the session — the theft turns itself in. + const replay = await request.post('/api/v1/auth/refresh', + { data: { refresh_token: first.refresh_token } }) + expect(replay.status()).toBe(401) + const after = await request.post('/api/v1/auth/refresh', + { data: { refresh_token: second.refresh_token } }) + expect(after.status()).toBe(401) + }) + + test('a browser is not handed one it will never use', async ({ request }) => { + const body = await (await request.post('/api/v1/auth/login', { data: LEARNER })).json() + // Null on the wire rather than absent, because the field is declared; + // what matters is that no row was written for a client with nowhere to + // put it. + expect(body.refresh_token).toBeNull() + expect(body.access_token).toBeTruthy() + }) +}) diff --git a/e2e/tests/helpers.js b/e2e/tests/helpers.js new file mode 100644 index 0000000..2bf8541 --- /dev/null +++ b/e2e/tests/helpers.js @@ -0,0 +1,101 @@ +import { expect } from '@playwright/test' + +// Matched to e2e/seed.py, which prints them when it runs. Overridable the same +// way, for a stack somebody wants to leave up and poke at. +export const EDUCATOR = { + email: process.env.E2E_EDUCATOR_EMAIL || 'educator@e2e.example.com', + password: process.env.E2E_EDUCATOR_PASSWORD || 'e2e-educator-password', +} +export const LEARNER = { + email: process.env.E2E_LEARNER_EMAIL || 'learner@e2e.example.com', + password: process.env.E2E_LEARNER_PASSWORD || 'e2e-learner-password', +} + +/** + * A learner of this worker's own. + * + * The seed makes one per parallel worker. They share a database, and a session + * remembers where it was left — so two workers sitting the same session as the + * same person tread on each other, and the failure looks like a product bug + * rather than a fixture one. Educators can share: nothing they do here writes + * per-person state. + */ +export function learnerFor(testInfo) { + const index = (testInfo?.parallelIndex ?? 0) % 4 + return { ...LEARNER, email: LEARNER.email.replace('@', `+w${index}@`) } +} + +// One sign-in per account per worker. The limiter allows ten attempts from an +// address in fifteen minutes, and the whole suite arrives from one address — so +// a test that signs in afresh each time spends the budget and the rest of the +// run fails on 429s that have nothing to do with what is being tested. +const tokens = new Map() + +export async function tokenFor(request, who) { + if (!tokens.has(who.email)) { + const response = await request.post('/api/v1/auth/login', { data: who }) + expect(response.ok(), `sign-in failed: ${await response.text()}`).toBeTruthy() + tokens.set(who.email, (await response.json()).access_token) + } + return tokens.get(who.email) +} + +/** + * Sign in and hand the browser the token. + * + * Not through the form: every test would then be a test of the login form, and + * when that breaks the whole suite goes red at once instead of one test. The + * form has its own test, which does use the form. + */ +export async function signIn(page, who = LEARNER) { + const token = await tokenFor(page.request, who) + // The origin has to exist before localStorage does. + await page.goto('/') + await page.evaluate(value => localStorage.setItem('token', value), token) + return token +} + +/** Past the "what are you studying for?" gate, which every page shows first. */ +export async function chooseObjective(page) { + const picker = page.getByRole('heading', { name: /what are you studying for/i }) + if (await picker.isVisible().catch(() => false)) { + await page.getByText(/Pediatrics Boards|Respiratory|E2E/).first().click() + await expect(picker).toBeHidden() + } +} + +/** + * Open a session and be sitting it, however the player decides to start. + * + * A saved session shows an overview first; one opened with nothing saved goes + * straight in. Both are correct, and a test that insists on the button is + * testing which of the two happened rather than the session itself. + */ +export async function startSession(page, id = 1) { + // `restart=1` every time. These tests share one account, and a session + // remembers where it was left — so without this the second test to run finds + // itself on question three of a session the first one was half-way through. + await page.goto(`/study/${id}?restart=1`) + await chooseObjective(page) + const start = page.getByRole('button', { name: /start session/i }) + if (await start.isVisible().catch(() => false)) await start.click() + await expect(page.locator('.question-card').first()).toBeVisible() +} + +/** + * Nothing on the page may be reachable only by scrolling sideways. + * + * Polled rather than sampled once. A page mid-layout — a figure that has not + * finished loading, a font still swapping — is momentarily wider than the + * window and then is not, and a single measurement catches whichever moment it + * happened to land in. A page that really does overflow stays wide, so it + * still fails, just a second later. + */ +export async function expectNoHorizontalOverflow(page) { + await page.evaluate(() => document.fonts?.ready).catch(() => {}) + await expect.poll(() => page.evaluate(() => { + const doc = document.documentElement + // One pixel of slack for sub-pixel rounding on a scaled device. + return doc.scrollWidth - doc.clientWidth <= 1 + }), { message: 'the page scrolls sideways', timeout: 5000 }).toBe(true) +} diff --git a/e2e/tests/mobile.spec.js b/e2e/tests/mobile.spec.js new file mode 100644 index 0000000..a89e35b --- /dev/null +++ b/e2e/tests/mobile.spec.js @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test' +import { chooseObjective, expectNoHorizontalOverflow, signIn, EDUCATOR } from './helpers.js' + +/** + * The phone. Every bug in this file was found by a person on an iPhone and + * reported as a screenshot, which is a slow way to find out. + */ +test.describe('on a phone', () => { + // By width, not by `isMobile`: an iPad in landscape is a touch device with + // 1024px of room, where the reading rail is a rail and there is no drawer to + // open. The breakpoint here is the one in ArticlesPage.css. + test.skip(({ viewport }) => !viewport || viewport.width > 820, + 'about the layout below 820px') + + test.beforeEach(async ({ page }) => { + await signIn(page, EDUCATOR) + }) + + test('the menu opens the contents and the same button closes them', async ({ page }) => { + await page.goto('/articles/1') + await chooseObjective(page) + // Wait for the article, not just the page. The reader claims the menu + // button when it mounts, and it does not mount until the article arrives — + // tap in that window and you get the site menu, which is the right answer + // to "there is nothing else this button could mean yet". + await expect(page.locator('.article-title, h1').first()).toContainText(/Croup/i) + const burger = page.locator('.nav-burger') + + await burger.click() + await expect(page.locator('.article-sections.open')).toBeVisible() + // Pressing it again is how a thumb closes a drawer. It used to do nothing, + // and the only way out was the strip of page beside it. + await burger.click() + await expect(page.locator('.article-sections.open')).toHaveCount(0) + }) + + test('the drawer clears the header rather than covering it', async ({ page }) => { + await page.goto('/articles/1') + await chooseObjective(page) + await expect(page.locator('.article-title, h1').first()).toContainText(/Croup/i) + await page.locator('.nav-burger').click() + + const header = await page.locator('.navbar').boundingBox() + const drawer = await page.locator('.article-sections').boundingBox() + expect(drawer.y, 'the drawer starts under the header').toBeGreaterThanOrEqual(header.height - 1) + + // And the button that opened it is still the top thing at that point. + const onTop = await page.evaluate(() => { + const bar = document.querySelector('.nav-burger').getBoundingClientRect() + const el = document.elementFromPoint(bar.x + bar.width / 2, bar.y + bar.height / 2) + return el?.closest('.nav-burger') !== null + }) + expect(onTop, 'something is covering the menu button').toBeTruthy() + }) + + test('a figure opens with the picture in it, not just its caption', async ({ page }) => { + // An SVG written with a viewBox and no width or height has a shape but no + // size; Safari resolves that to zero inside a shrink-to-fit box, and the + // viewer was a caption above an empty screen. The seed's figure is written + // the same way on purpose. + await page.goto('/articles/1') + await chooseObjective(page) + const thumb = page.locator('.imgfig-thumb').first() + await expect(thumb).toBeVisible() + await thumb.click() + + const image = page.locator('.imgfig-frame img') + await expect(image).toBeVisible() + // Measured after it has actually loaded: an that is still fetching + // is a box one line tall, which looks exactly like the bug being tested. + await expect.poll(() => image.evaluate(el => el.complete && el.naturalWidth > 0), + { message: 'the figure never loaded' }).toBe(true) + const box = await image.boundingBox() + expect(box.width, 'the picture has no width').toBeGreaterThan(100) + expect(box.height, 'the picture has no height').toBeGreaterThan(40) + // And the caption is there too, which is all that used to be. + await expect(page.locator('.imgfig-desc')).toContainText(/stridor/i) + }) + + test('nothing on the reading page scrolls sideways', async ({ page }) => { + await page.goto('/articles/1') + await chooseObjective(page) + await expect(page.getByRole('heading', { name: 'Croup' }).first()).toBeVisible() + await expectNoHorizontalOverflow(page) + }) + + test('a text box does not zoom the page when it is tapped', async ({ page, browserName }) => { + test.skip(browserName !== 'webkit', 'the 16px rule is a Safari rule') + await page.goto('/ai') + await chooseObjective(page) + const size = await page.locator('textarea[aria-label="Ask AI Mode"]').evaluate( + el => parseFloat(getComputedStyle(el).fontSize)) + // Under 16px, iOS zooms in on focus and never zooms back out. + expect(size).toBeGreaterThanOrEqual(16) + }) +}) diff --git a/e2e/tests/reading.spec.js b/e2e/tests/reading.spec.js new file mode 100644 index 0000000..b368645 --- /dev/null +++ b/e2e/tests/reading.spec.js @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test' +import { chooseObjective, learnerFor, signIn, EDUCATOR } from './helpers.js' + +test('a cross-reference previews before it is followed', async ({ page, isMobile }, testInfo) => { + test.skip(isMobile, 'hover is a pointer thing; the phone opens the card on tap') + await signIn(page, learnerFor(testInfo)) + await page.goto('/articles/1') + await chooseObjective(page) + + const link = page.locator('.al-link').first() + await expect(link).toBeVisible() + await link.hover() + const card = page.getByRole('tooltip') + await expect(card).toBeVisible() + await expect(card).toContainText(/Bronchiolitis/i) + // The two ways out of the card, and neither of them is "you have left the + // article you were reading". + await expect(card.getByRole('link', { name: /new tab/i })).toBeVisible() +}) + +test('an educator sees the tools a learner does not', async ({ page }) => { + await signIn(page, EDUCATOR) + await page.goto('/articles/1') + await chooseObjective(page) + await expect(page.getByRole('button', { name: /^Edit$/ })).toBeVisible() + // Drafting belongs in the editor, not on the page a learner is reading. + await expect(page.getByRole('button', { name: /AI refine/i })).toHaveCount(0) +}) + +test('a learner sees no editing tools at all', async ({ page }, testInfo) => { + await signIn(page, learnerFor(testInfo)) + await page.goto('/articles/1') + await chooseObjective(page) + // The article's own title, not the entry for it in the contents drawer. + await expect(page.locator('.article-title, h1').first()).toContainText(/Croup/i) + await expect(page.getByRole('button', { name: /^Edit$/ })).toHaveCount(0) + await expect(page.getByRole('button', { name: /Generate cards/i })).toHaveCount(0) +}) diff --git a/e2e/tests/session.spec.js b/e2e/tests/session.spec.js new file mode 100644 index 0000000..fb995a3 --- /dev/null +++ b/e2e/tests/session.spec.js @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test' +import { expectNoHorizontalOverflow, learnerFor, signIn, startSession } from './helpers.js' + +test.describe('sitting a session', () => { + test.beforeEach(async ({ page }, testInfo) => { + await signIn(page, learnerFor(testInfo)) + }) + + test('a learner answers a question and is told why', async ({ page }) => { + await startSession(page) + await expect(page.locator('#quiz-question-heading')).toContainText(/barking cough/i) + + // Study mode marks as you go, so the answer and its reason arrive together. + await page.locator('.option', { hasText: 'Dexamethasone' }).first().click() + await expect(page.getByText(/shortens croup/i)).toBeVisible() + + await expectNoHorizontalOverflow(page) + }) + + test('the answer is not on the page before it is chosen', async ({ page }) => { + // The whole point of a question. If the correct option reaches the browser + // with the stem, no amount of hiding it in CSS is worth anything. + await startSession(page) + await expect(page.locator('#quiz-question-heading')).toContainText(/barking cough/i) + + const html = await page.content() + expect(html).not.toContain('shortens croup') + }) + + test('the session ends on its analysis', async ({ page }) => { + await startSession(page) + await expect(page.locator('#quiz-question-heading')).toContainText(/barking cough/i) + + for (let i = 0; i < 3; i += 1) { + const next = page.getByRole('button', { name: /^(next|skip|submit|finish)/i }).last() + await next.click() + await page.waitForTimeout(400) + } + await expect(page).toHaveURL(/\/sessions\/\d+|\/study\/1/) + }) +}) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 2f49cc6..593da31 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,24 @@ server { root /usr/share/nginx/html; index index.html; + # Who is actually calling. + # + # This container sits behind the host's proxy, which puts the caller's + # address in X-Forwarded-For. Without the two lines below, $remote_addr is + # the docker bridge for every request on the site — so the backend's + # per-IP rate limits were one shared bucket: ten bad passwords from + # anybody locked out everybody for fifteen minutes, and no log line could + # say who did it. + # + # Only addresses inside a private range are trusted to speak for someone + # else, and the header is then *overwritten* on the way to the backend + # rather than appended to, so nothing a client sent for itself survives. + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 192.168.0.0/16; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + # Gzip compression gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; @@ -26,7 +44,7 @@ server { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; # Large file uploads @@ -35,18 +53,6 @@ server { proxy_read_timeout 600s; } - # SCORM content — no frame-blocking headers, allow scripts - location /uploads/scorm/ { - resolver 127.0.0.11 valid=10s; - set $backend http://backend:8000; - proxy_pass $backend; - proxy_set_header Host $host; - proxy_hide_header X-Frame-Options; - proxy_force_ranges on; # Pinned backend FileResponse does not implement byte ranges. - # Local add_header prevents inheriting the app CSP; preserve backend SVG sandbox. - add_header X-Content-Type-Options "nosniff" always; - } - # Uploaded images proxy to backend location /uploads/ { resolver 127.0.0.11 valid=10s; diff --git a/frontend/src/components/ArticleReader.jsx b/frontend/src/components/ArticleReader.jsx index 7e480f0..37a3eba 100644 --- a/frontend/src/components/ArticleReader.jsx +++ b/frontend/src/components/ArticleReader.jsx @@ -91,9 +91,15 @@ export default function ArticleReader({ const [drawerOpen, setDrawerOpen] = useState(false) // On a phone the contents live behind the same button as the site menu, the // way a session's questions do: one control at the top left whose contents - // change with where you are, rather than a second menu to hunt for. The rail - // is on screen above 1150px, so there is nothing to open there. - const narrow = useMediaQuery('(max-width: 1150px)') + // change with where you are, rather than a second menu to hunt for. + // + // 820px, and it must stay in step with the media query in ArticlesPage.css + // that turns the rail into a drawer. It said 1150 while the stylesheet said + // 820, so on anything between the two — a tablet in landscape, a half-width + // window — this claimed the menu button and toggled a class on a rail that + // was still sitting there in the layout: the contents did not open, and the + // site menu did not either. The button was simply dead. + const narrow = useMediaQuery('(max-width: 820px)') const { register: registerDrawer } = useSessionDrawer() useEffect(() => { if (!narrow || bare) return undefined diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 6ba9847..8416d66 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -604,7 +604,6 @@ export default function AnalysisPage() { )} )} -

{data.basis}

) : ( <> diff --git a/frontend/src/pages/AnalysisPage.test.jsx b/frontend/src/pages/AnalysisPage.test.jsx index f64a4da..1d43176 100644 --- a/frontend/src/pages/AnalysisPage.test.jsx +++ b/frontend/src/pages/AnalysisPage.test.jsx @@ -18,7 +18,7 @@ const area = (over = {}) => ({ const payload = (over = {}) => ({ group: 'articles', unlocked: true, answers_needed: 0, total_answered: 60, unique_questions_seen: 42, bank_total: 330, grouped_total: 330, overall_accuracy: 64.0, - focus_areas: [area()], basis: 'Readiness shrinks each category toward your overall accuracy.', ...over, + focus_areas: [area()], ...over, }) beforeEach(() => {