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:
{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(() => {