feat: a versioned API, refresh tokens, and an end-to-end stack that found four bugs

**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.

**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.

**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.

It found four things in its first hour:

- **A fresh deploy could not start.** `create_all()` ran before
  `CREATE EXTENSION vector`, so any database that had never had pgvector
  installed died on the first table with a vector column. Invisible here
  because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
  Media in the library is nobody's to read by default, and nothing made an
  exception for a drawing an article actually shows — so every illustration
  added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
  nginx's address for every request, so ten bad passwords from anybody locked
  out everybody, and no log line could say who. nginx now takes the real
  address from the proxy and overwrites the header on the way in; uvicorn runs
  with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
  820px in the stylesheet. Between them the menu button claimed the contents
  drawer and then toggled a class on a rail that was still in the layout: the
  contents did not open and the site menu did not either. The button was dead
  on every tablet.

And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.

The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.

CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 01:23:38 +02:00
parent 1f52c1217f
commit e72cdd6716
38 changed files with 5293 additions and 82 deletions

View file

@ -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

View file

@ -8,7 +8,10 @@ SECRET_KEY=change-me-to-a-random-secret-key-in-production
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440 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_EMAIL=
DEFAULT_ADMIN_PASSWORD= DEFAULT_ADMIN_PASSWORD=

View file

@ -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")

View file

99
backend/app/api/errors.py Normal file
View file

@ -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),
)

View file

@ -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)

View file

@ -6,6 +6,12 @@ class Settings(BaseSettings):
DATABASE_URL: str = "sqlite:///./quiz.db" DATABASE_URL: str = "sqlite:///./quiz.db"
SECRET_KEY: str = "change-me-to-a-random-secret-key-in-production" 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" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
@ -63,8 +69,10 @@ class Settings(BaseSettings):
CAP_SITE_KEY: str = "" CAP_SITE_KEY: str = ""
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email DEFAULT_ADMIN_EMAIL: str = "" # Set it and a first admin is created with this address
DEFAULT_ADMIN_PASSWORD: str = "" # Optional explicit bootstrap admin password # 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_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
BBB_SECRET: str = "" # BigBlueButton shared secret BBB_SECRET: str = "" # BigBlueButton shared secret

View file

@ -10,6 +10,8 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else # Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL) setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal 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 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 access
from app.routers import feedback from app.routers import feedback
@ -20,8 +22,9 @@ from app.utils.auth import get_password_hash
def seed_admin(): 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 logging
import secrets
from datetime import datetime from datetime import datetime
from app.models.user import User from app.models.user import User
@ -32,15 +35,24 @@ def seed_admin():
try: try:
admin_exists = db.query(User).filter(User.role == "admin").first() admin_exists = db.query(User).filter(User.role == "admin").first()
if not admin_exists: 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.") log.info("No admin exists; skipping bootstrap admin seed. First registered user will become admin.")
return 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.") log.warning("DEFAULT_ADMIN_PASSWORD is too short; skipping bootstrap admin seed.")
return return
admin_user = User( admin_user = User(
email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(), email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(),
hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD), hashed_password=get_password_hash(password),
name="Admin", name="Admin",
role="admin", role="admin",
) )
@ -54,6 +66,15 @@ def seed_admin():
verified_at=datetime.utcnow(), verified_at=datetime.utcnow(),
)) ))
db.commit() 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: else:
# Ensure existing admin has a verified email record # Ensure existing admin has a verified email record
existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first() existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first()
@ -170,7 +191,7 @@ def setup_pgvector():
from sqlalchemy import text from sqlalchemy import text
# Import new models so create_all picks them up # 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 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 category_grant, conversation, exam, media, question_media, study_plan # noqa
from app.models import folder # noqa from app.models import folder # noqa
@ -461,6 +482,21 @@ def _acquire_singleton_lock() -> bool:
_STARTUP_DDL_LOCK_KEY = 8472931 _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(): def _run_startup_ddl():
"""Serialize startup DDL across uvicorn workers using a Postgres advisory lock. """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.execute(text("SELECT pg_advisory_lock(:k)"), {"k": _STARTUP_DDL_LOCK_KEY})
lock_conn.commit() lock_conn.commit()
try: 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) Base.metadata.create_all(bind=engine)
setup_pgvector() setup_pgvector()
finally: finally:
@ -505,10 +547,25 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="PedsHub", title="PedsHub",
description="Pediatric Knowledge Quiz Platform", description=(
version="2.0.0", "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, lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
) )
api_errors.install(app)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -531,43 +588,51 @@ app.add_middleware(TokenRefreshMiddleware)
from app.middleware.request_logging import RequestLoggingMiddleware from app.middleware.request_logging import RequestLoggingMiddleware
app.add_middleware(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(uploads.router)
app.include_router(auth.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="/api/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. # 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(public.router, prefix=f"{VERSIONED_ROOT}/public", tags=["public"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) app.include_router(articles.router, prefix=f"{VERSIONED_ROOT}/articles", tags=["articles"])
app.include_router(access.router, prefix="/api/access", tags=["access"]) app.include_router(access.router, prefix=f"{VERSIONED_ROOT}/access", tags=["access"])
app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"]) app.include_router(feedback.router, prefix=f"{VERSIONED_ROOT}/feedback", tags=["feedback"])
app.include_router(folders.router, prefix="/api/folders", tags=["folders"]) app.include_router(folders.router, prefix=f"{VERSIONED_ROOT}/folders", tags=["folders"])
app.include_router(exams.router, prefix="/api/exams", tags=["exams"]) app.include_router(exams.router, prefix=f"{VERSIONED_ROOT}/exams", tags=["exams"])
app.include_router(study_plans.router, prefix="/api/study-plans", tags=["study-plans"]) app.include_router(study_plans.router, prefix=f"{VERSIONED_ROOT}/study-plans", tags=["study-plans"])
app.include_router(drafts.router, prefix="/api/drafts", tags=["drafts"]) app.include_router(drafts.router, prefix=f"{VERSIONED_ROOT}/drafts", tags=["drafts"])
app.include_router(media.router, prefix="/api/media", tags=["media"]) app.include_router(media.router, prefix=f"{VERSIONED_ROOT}/media", tags=["media"])
app.include_router(share.router, prefix="/api/share", tags=["share"]) app.include_router(share.router, prefix=f"{VERSIONED_ROOT}/share", tags=["share"])
app.include_router(collections.router, prefix="/api/collections", tags=["collections"]) app.include_router(collections.router, prefix=f"{VERSIONED_ROOT}/collections", tags=["collections"])
app.include_router(documents.router, prefix="/api/documents", tags=["documents"]) app.include_router(documents.router, prefix=f"{VERSIONED_ROOT}/documents", tags=["documents"])
app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"]) app.include_router(quizzes.router, prefix=f"{VERSIONED_ROOT}/quizzes", tags=["quizzes"])
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"])
app.include_router(tts.router, prefix="/api/tts", tags=["tts"]) app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"])
app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"]) app.include_router(nextcloud.router, prefix=f"{VERSIONED_ROOT}/nextcloud", tags=["nextcloud"])
app.include_router(categories.router, prefix="/api/categories", tags=["categories"]) app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"])
app.include_router(questions.router, prefix="/api/questions", tags=["questions"]) app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"])
app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"]) app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"])
app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"]) app.include_router(favorites.router, prefix=f"{VERSIONED_ROOT}/favorites", tags=["favorites"])
app.include_router(teach.router, prefix="/api/teach", tags=["teach"]) app.include_router(teach.router, prefix=f"{VERSIONED_ROOT}/teach", tags=["teach"])
app.include_router(contact.router, prefix="/api/contact", tags=["contact"]) app.include_router(contact.router, prefix=f"{VERSIONED_ROOT}/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"]) app.include_router(tags.router, prefix=f"{VERSIONED_ROOT}/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"]) app.include_router(flashcards.router, prefix=f"{VERSIONED_ROOT}/flashcards", tags=["flashcards"])
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"]) app.include_router(mynote.router, prefix=f"{VERSIONED_ROOT}/mynote", tags=["mynote"])
app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"]) app.include_router(study_tools.router, prefix=f"{VERSIONED_ROOT}/study-tools", tags=["study-tools"])
app.include_router(search.router, prefix="/api/search", tags=["search"]) app.include_router(search.router, prefix=f"{VERSIONED_ROOT}/search", tags=["search"])
app.include_router(ai_mode.router, prefix="/api/ai", tags=["ai-mode"]) 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(): 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"} return {"status": "ok"}

View file

@ -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)

View file

@ -4,41 +4,65 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session 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.database import get_db
from app.models.user import User from app.models.user import User
from app.models.email_verification import EmailVerification from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset from app.models.password_reset import PasswordReset
from app.schemas.auth import ( from app.schemas.auth import (
UserCreate, UserResponse, Token, LoginRequest, UserCreate, UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest,
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest,
) )
from app.services import email_service from app.services import email_service
from app.utils.auth import ( 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() router = APIRouter()
def _login_key(client_ip: str) -> str:
return f"login_attempts:{client_ip}"
def _check_login_rate_limit(client_ip: str): 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: try:
import redis as redis_lib import redis as redis_lib
from app.config import settings from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1) 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) count = r.incr(key)
if count == 1: if count == 1:
r.expire(key, 15 * 60) r.expire(key, settings.LOGIN_WINDOW_MINUTES * 60)
if count > 10: if count > settings.LOGIN_MAX_ATTEMPTS:
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.") raise HTTPException(
status_code=429,
detail=f"Too many login attempts. Try again in {settings.LOGIN_WINDOW_MINUTES} minutes.")
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Redis rate limit unavailable (failing open): {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 # Rate limit: max 3 reset requests per email per hour
RESET_LIMIT = 3 RESET_LIMIT = 3
RESET_WINDOW_HOURS = 1 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"]: if sso_settings["sso_only"]:
raise HTTPException(status_code=403, detail="Password login is disabled. Please use SSO.") 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: if request:
client_ip = request.client.host if request.client else "unknown"
_check_login_rate_limit(client_ip) _check_login_rate_limit(client_ip)
email_normalized = login_data.email.lower().strip() 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.", detail="Email not verified. Please check your inbox and verify your email before logging in.",
) )
access_token = create_access_token(data={"sub": user.email}) _clear_login_rate_limit(client_ip)
return Token(access_token=access_token) 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") @router.get("/verify-email")

View file

@ -720,13 +720,10 @@ def study_recommendations(
.limit(1).scalar() .limit(1).scalar()
), ),
"focus_areas": rows[:limit], "focus_areas": rows[:limit],
"basis": ( # No `basis` any more. It was a paragraph of methodology under a table
"Your completed, non-expired general-bank answers, rolled up through the category tree. " # that already says what it is — how the shrinkage works is a decision
f"Readiness shrinks each category's accuracy toward your overall {round(100 * overall_accuracy)}% " # for whoever tunes it, not something to explain to a learner who wants
"so small samples do not overstate a gap; it unlocks after " # to know what to revise.
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."
),
} }
@router.get("/attempts/{attempt_id}/questions/{question_id}/responses") @router.get("/attempts/{attempt_id}/questions/{question_id}/responses")

View file

@ -16,7 +16,12 @@ from app.utils.upload_access import (
router = APIRouter() 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, def read_upload(path: str, request: Request, attempt_id: int | None = None,
w: int | None = None, db: Session = Depends(get_db)): w: int | None = None, db: Session = Depends(get_db)):
"""An upload, optionally at one of two smaller widths. """An upload, optionally at one of two smaller widths.

View file

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel): class UserCreate(BaseModel):
@ -47,11 +47,33 @@ class UserResponse(BaseModel):
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" 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): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str 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): class UserUpdateRole(BaseModel):

View file

@ -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)

View file

@ -11,6 +11,7 @@ from sqlalchemy.orm import load_only
from app.config import settings from app.config import settings
from app.models.flashcard import Flashcard, FlashcardDeck from app.models.flashcard import Flashcard, FlashcardDeck
from app.models.media import MediaAsset
from app.models.pdf_document import PDFDocument from app.models.pdf_document import PDFDocument
from app.models.question import Question from app.models.question import Question
from app.services.quiz_builder import bank_question_predicate, general_question_predicate 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 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): def card_access(db, cards, user):
return bool(cards) and db.query(FlashcardDeck.id).filter( return bool(cards) and db.query(FlashcardDeck.id).filter(
FlashcardDeck.id.in_(cards), FlashcardDeck.deleted_at.is_(None), 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 return user.is_moderator or document.user_id == user.id
if owns_source(db, path, user, cards) or card_access(db, cards, user): if owns_source(db, path, user, cards) or card_access(db, cards, user):
return True return True
if shown_in_a_readable_article(db, path, user):
return True
for question in questions: for question in questions:
try: try:
require_question_access(db, question, user, attempt_id, require_question_access(db, question, user, attempt_id,

File diff suppressed because it is too large Load diff

View file

@ -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()

View file

@ -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()

108
docker-compose.test.yml Normal file
View file

@ -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:

View file

@ -26,7 +26,11 @@ services:
backend: backend:
build: ./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: env_file:
- ./backend/.env - ./backend/.env
environment: environment:

146
docs/api.md Normal file
View file

@ -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.

View file

@ -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 The same list is editable from each question's own editor, which is the same
relationship seen from the other end. 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: <title>*, owned by whoever pressed the button;
it appears in nobody else's list, and the chip is not drawn for anyone who
cannot open the deck. Sharing is a deliberate second action, after reading what
the model wrote.
## What can be linked to what
| From | To | Where you do it | What the reader gets |
|---|---|---|---|
| Article prose | Article | `[[264\|label]]` in the text | A link with a hover card: excerpt, new tab, or a pane beside what they are reading |
| Article | Question | Editor → Linked questions | Practice under the article, and the article under the question's answer |
| Question | Article | Question editor → the same list | ▤ chips under the correct answer, landing on the article or one of its sections |
| Card | Question | Cards → deck → ⛓ | ▦ chip under the correct answer, opening the deck to study |
| Card | Article | Cards → deck → ⛓ | The card listed under *Related cards* on the article |
| Explanation prose | Article | `[[264\|label]]`, same as anywhere | The same hover card |
A question→article link can land on **one section** rather than the whole
article — choose the section when you make the link, and the reader opens at
that heading. A cross-reference written in prose cannot: `[[264|label]]` always
means the whole article.
## Deleting ## Deleting
- A draft that was **never published** is deleted outright. There is nothing to - A draft that was **never published** is deleted outright. There is nothing to

37
e2e/README.md Normal file
View file

@ -0,0 +1,37 @@
# End-to-end
Real browsers and a real Postgres, against a stack that is created and thrown
away. Nothing here touches the running site.
```bash
cd e2e
npm install
npx playwright install --with-deps chromium webkit
npm run stack:up # build the stack, apply the schema, seed it
npm test # every project
npm run test:phone # just the iPhone and Pixel projects
npm run stack:down # and take the data with it
```
## Why there are five projects
| Project | What it is for |
|---|---|
| `desktop` | 1440×900 Chrome. The layout most of the work is done in. |
| `iphone` | iPhone 13, WebKit. Where an SVG with no intrinsic size renders as nothing, and where a sub-16px input zooms the page and never zooms back. |
| `android` | Pixel 7, Chrome. The other half of "mobile". |
| `tablet` | iPad Mini landscape — the width where the reading rail turns into a drawer. |
| `api` | No browser. The contract over HTTP: addresses, error shapes, who may see an answer, refresh-token rotation. |
Every bug this suite was written after was a phone bug reported as a
screenshot. A desktop-only suite would have passed through all of them.
## Fixtures
`seed.py` builds the smallest world the tests name: two accounts (an educator
and a learner), three questions, one session, two articles with a
cross-reference between them, and a shared deck. Re-running it is safe.
Accounts are `educator@e2e.example.com` and `learner@e2e.example.com`; the passwords are in
`tests/helpers.js`, which is fine, because that database exists for about four
minutes.

58
e2e/package-lock.json generated Normal file
View file

@ -0,0 +1,58 @@
{
"name": "pedshub-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pedshub-e2e",
"devDependencies": {
"@playwright/test": "^1.49.0"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

15
e2e/package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "pedshub-e2e",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:phone": "playwright test --project=iphone --project=android",
"report": "playwright show-report",
"stack:up": "docker compose -f ../docker-compose.test.yml up -d --build && docker compose -f ../docker-compose.test.yml run --rm seed",
"stack:down": "docker compose -f ../docker-compose.test.yml down -v"
},
"devDependencies": {
"@playwright/test": "^1.49.0"
}
}

File diff suppressed because one or more lines are too long

58
e2e/playwright.config.js Normal file
View file

@ -0,0 +1,58 @@
import { defineConfig, devices } from '@playwright/test'
/**
* End-to-end, against the throwaway stack in docker-compose.test.yml.
*
* Four projects, because the bugs this suite exists to catch have all been
* layout bugs on a phone: a drawer that opened behind the header, a diagram
* that rendered as an empty screen in Safari, a popover two hundred pixels off
* the left edge. None of those are visible at 1280×720, so a desktop-only
* suite would have passed through every one of them.
*
* The API project runs the same journeys without a browser, where what is
* being checked is the contract rather than the page.
*/
const BASE = process.env.E2E_BASE_URL || 'http://127.0.0.1:8095'
export default defineConfig({
testDir: './tests',
// A failing E2E test is usually a real failure, but a hung one is usually
// the stack. Fail fast enough to say which.
timeout: 60_000,
// Generous, because this stack shares a machine with whatever else is on it
// and a slow answer is not a wrong one. A genuinely missing element still
// fails, fifteen seconds later.
expect: { timeout: 15_000 },
fullyParallel: true,
// Retries in CI only: locally a flake should be seen, not smoothed over.
retries: process.env.CI ? 2 : 0,
// Three, not "as many as there are cores": the stack under test is two
// containers on the same box, and past three browsers they queue.
workers: process.env.CI ? 2 : 3,
reporter: process.env.CI
? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'results/junit.xml' }]]
: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: BASE,
// Kept only for the run that failed: a trace per test is gigabytes and
// nobody opens the passing ones.
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: process.env.CI ? 'retain-on-failure' : 'off',
actionTimeout: 15_000,
},
// The API specs belong to the `api` project alone; without this every
// browser project runs them too, which is four identical HTTP suites and
// four times the sign-ins.
testIgnore: /.*\.api\.spec\.js/,
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
// Safari on a phone, because that is where an SVG with no intrinsic size
// resolves to nothing and a 16px input rule stops the page zooming.
{ name: 'iphone', use: { ...devices['iPhone 13'] } },
{ name: 'android', use: { ...devices['Pixel 7'] } },
// Between the two: the width where the reading rail becomes a drawer.
{ name: 'tablet', use: { ...devices['iPad Mini landscape'] } },
{ name: 'api', testMatch: /.*\.api\.spec\.js/, testIgnore: [], use: { baseURL: BASE } },
],
})

272
e2e/seed.py Normal file
View file

@ -0,0 +1,272 @@
#!/usr/bin/env python
"""The world the end-to-end tests wake up in.
Small on purpose. Every row here exists because a test names it, and a fixture
that drifts past what the tests actually use becomes a second application to
maintain one whose bugs look like product bugs.
Idempotent: running it twice leaves the same world, so a developer can re-seed
between runs without tearing the stack down.
"""
import os
import sys
from datetime import datetime
sys.path.insert(0, "/app")
from sqlalchemy import text # noqa: E402
from app.database import Base, SessionLocal, engine # noqa: E402
from app.models.article import Article # noqa: E402
from app.models.email_verification import EmailVerification # noqa: E402
from app.models.exam import Exam, QuestionExamLink # noqa: E402
from app.models.flashcard import Flashcard, FlashcardDeck # noqa: E402
from app.models.question import Question # noqa: E402
from app.models.question_category import QuestionCategory # noqa: E402
from app.models.quiz import Quiz # noqa: E402
from app.models.quiz_question_link import QuizQuestionLink # noqa: E402
from app.models.user import User # noqa: E402
from app.utils.auth import get_password_hash # noqa: E402
#: The two accounts the suite signs in as. `.example.com` rather than `.test`,
#: because EmailStr refuses reserved TLDs and sign-in would 422 before it ever
#: reached the password check.
#: Fixed by default and in the
#: repository, because the tests have to know them and this database is
#: created and destroyed by the run. Override either from the environment when
#: you want a stack you can poke at by hand without the passwords being
#: something anybody reading the repo already knows.
EDUCATOR = (os.environ.get("E2E_EDUCATOR_EMAIL", "educator@e2e.example.com"),
os.environ.get("E2E_EDUCATOR_PASSWORD", "e2e-educator-password"))
LEARNER = (os.environ.get("E2E_LEARNER_EMAIL", "learner@e2e.example.com"),
os.environ.get("E2E_LEARNER_PASSWORD", "e2e-learner-password"))
STEMS = [
("A 3-year-old has a barking cough, stridor at rest and no drooling. "
"The most appropriate next step is",
["Dexamethasone", "Intubation", "Nebulised saline", "Ceftriaxone", "Racemic adrenaline only"],
"Dexamethasone",
"A single dose of dexamethasone shortens croup at every severity, and a "
"child with stridor at rest has moderate croup."),
("A 6-month-old with bronchiolitis has intermittent apnoea and feeds at "
"half volume. The next step is",
["Admit for observation", "Discharge with advice", "Oral antibiotics",
"Chest radiograph", "Salbutamol"],
"Admit for observation",
"Apnoea and poor feeding are the two admission criteria that matter most "
"in the first six months."),
("A neonate at 36 hours has a total bilirubin above the phototherapy line. "
"The next step is",
["Start phototherapy", "Repeat in 24 hours", "Exchange transfusion",
"Stop breastfeeding", "Intravenous immunoglobulin"],
"Start phototherapy",
"The threshold line is the decision. Repeating the level to see whether "
"it climbs is how a treatable jaundice becomes kernicterus."),
]
def verified(db, user):
row = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if row is None:
db.add(EmailVerification(user_id=user.id, token=f"seed-{user.id}",
expires_at=datetime(2099, 1, 1),
verified_at=datetime.utcnow()))
elif row.verified_at is None:
row.verified_at = datetime.utcnow()
def account(db, email, password, role):
user = db.query(User).filter(User.email == email).first()
if user is None:
user = User(email=email, name=email.split("@")[0].title(),
hashed_password=get_password_hash(password), role=role)
db.add(user)
db.flush()
user.role = role
user.hashed_password = get_password_hash(password)
verified(db, user)
return user
#: A drawing written the way the real ones are: a viewBox, and no width or
#: height. That is the shape that renders as nothing in Safari inside a
#: shrink-to-fit box, so the figure test is only worth having if the fixture
#: has the same defect the real files have.
FIGURE = """<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 320" role="img"
aria-label="Two panels: stridor at rest, and stridor on exertion">
<rect x="20" y="20" width="280" height="280" rx="12" fill="#dbeafe" stroke="#60a5fa"/>
<rect x="340" y="20" width="280" height="280" rx="12" fill="#fef3c7" stroke="#f59e0b"/>
<text x="160" y="170" text-anchor="middle" font-size="22">Stridor at rest</text>
<text x="480" y="170" text-anchor="middle" font-size="22">Stridor on exertion</text>
</svg>
"""
def figure(db, educator):
"""One drawing, in the library and in an article that renders it."""
from app.models.media import MediaAsset
path = "figures/e2e-stridor.svg"
target = os.path.join("/app/uploads", path)
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "w", encoding="utf-8") as handle:
handle.write(FIGURE)
asset = db.query(MediaAsset).filter(MediaAsset.path == path).first()
if asset is None:
db.add(MediaAsset(
path=path, title="Stridor at rest and on exertion",
caption="Which of the two decides whether croup is treated now.",
alt_text="Two panels comparing stridor at rest with stridor on exertion.",
source="PedsHub", user_id=educator.id,
))
article = db.query(Article).filter(Article.slug == "croup").first()
if article and "e2e-stridor" not in (article.content or ""):
article.content = (
f"![Two panels comparing stridor at rest with stridor on exertion.]"
f"(/uploads/{path})\n\n" + (article.content or ""))
def objective(db, users):
"""The exam every seeded account is already studying for.
Set here rather than clicked in the tests, because "what are you studying
for?" is a modal over the first page a signed-in person sees — and a modal
that four parallel workers race each other to dismiss, on one shared
account, produces failures that say nothing about the product.
"""
exam = db.query(Exam).filter(Exam.slug == "e2e-boards").first()
if exam is None:
exam = Exam(slug="e2e-boards", name="E2E Boards", family="Boards",
sort_order=1, is_active=1)
db.add(exam)
db.flush()
for (question_id,) in db.query(Question.id).all():
if not db.query(QuestionExamLink).filter_by(
question_id=question_id, exam_id=exam.id).first():
db.add(QuestionExamLink(question_id=question_id, exam_id=exam.id))
for user in users:
user.active_exam_id = exam.id
return exam
#: One learner per parallel worker. They share a database, and a session
#: remembers where it was left — so two workers sitting the same session on the
#: same account interfere in ways that look like product bugs and are not.
WORKERS = 4
def worker_learners(db):
made = []
for index in range(WORKERS):
email = LEARNER[0].replace("@", f"+w{index}@")
made.append(account(db, email, LEARNER[1], role="user"))
return made
def main() -> int:
# The stack is empty on first boot, so the schema comes from the models and
# is then stamped: exactly what a fresh deploy does.
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
educator = account(db, *EDUCATOR, role="moderator")
account(db, *LEARNER, role="user")
learners = worker_learners(db)
db.commit()
if db.query(QuestionCategory).count() == 0:
db.add_all([
QuestionCategory(id=1, name="Respiratory", user_id=educator.id),
QuestionCategory(id=2, name="Croup", parent_id=1, user_id=educator.id),
QuestionCategory(id=3, name="Neonatology", user_id=educator.id),
])
db.flush()
if db.query(Question).count() == 0:
for index, (stem, options, answer, explanation) in enumerate(STEMS, start=1):
db.add(Question(
id=index, question_text=stem, question_type="mcq", options=options,
correct_answer=answer, explanation=explanation,
question_category_id=2 if index < 3 else 3,
difficulty=["easy", "medium", "hard"][index - 1],
user_id=educator.id,
))
db.flush()
if db.query(Quiz).count() == 0:
quiz = Quiz(id=1, title="E2E study session", user_id=educator.id,
mode="learning", questions_count=len(STEMS), is_published=1,
is_shared=1)
db.add(quiz)
db.flush()
for position, question_id in enumerate(range(1, len(STEMS) + 1)):
db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=question_id,
position=position))
if db.query(Article).count() == 0:
db.add(Article(
id=1, title="Croup", slug="croup", status="published",
user_id=educator.id, category_id=2,
summary="Barking cough, stridor, and one dose of dexamethasone.",
content="Croup is viral laryngotracheobronchitis. See [[2|bronchiolitis]].",
sections=[{"id": "workup", "title": "Workup",
"content": "Clinical. A radiograph is for the child who is not croup."}],
))
db.add(Article(
id=2, title="Bronchiolitis", slug="bronchiolitis", status="published",
user_id=educator.id, category_id=1,
summary="Supportive care, and the two reasons to admit.",
content="Bronchiolitis is a first-winter illness of small airways.",
sections=[],
))
if db.query(FlashcardDeck).count() == 0:
deck = FlashcardDeck(id=1, title="Cards: Croup", user_id=educator.id,
card_count=2, is_shared=1)
db.add(deck)
db.flush()
db.add_all([
Flashcard(deck_id=deck.id, front="Dose of dexamethasone in croup?",
back="0.150.6 mg/kg once, oral."),
Flashcard(deck_id=deck.id, front="Stridor at rest means?",
back="At least moderate croup — treat, do not watch."),
])
db.flush()
figure(db, educator)
objective(db, [educator,
db.query(User).filter(User.email == LEARNER[0]).one(),
*learners])
db.commit()
# Stamped rather than migrated: the schema came from the models a moment
# ago, so replaying every migration over it would fail on the first
# CREATE TABLE. This is what a fresh deploy of the real app does too.
with engine.begin() as conn:
conn.execute(text("CREATE TABLE IF NOT EXISTS alembic_version "
"(version_num VARCHAR(32) NOT NULL)"))
head = os.environ.get("ALEMBIC_HEAD", "p5f6a7b8c9d0")
if not conn.execute(text("SELECT 1 FROM alembic_version")).first():
conn.execute(text("INSERT INTO alembic_version VALUES (:v)"), {"v": head})
counts = {
"users": db.query(User).count(),
"questions": db.query(Question).count(),
"quizzes": db.query(Quiz).count(),
"articles": db.query(Article).count(),
"cards": db.query(Flashcard).count(),
}
# Printed, so `docker compose -f docker-compose.test.yml logs seed`
# says how to sign in without anybody reading the source.
print("seeded", counts, flush=True)
print(f" educator: {EDUCATOR[0]} / {EDUCATOR[1]}", flush=True)
print(f" learner: {LEARNER[0]} / {LEARNER[1]}", flush=True)
return 0
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

124
e2e/tests/api.api.spec.js Normal file
View file

@ -0,0 +1,124 @@
import { expect, test } from '@playwright/test'
import { EDUCATOR, LEARNER, tokenFor } from './helpers.js'
/**
* The contract, exercised over HTTP against a real database.
*
* The unit suite checks these rules against SQLite and a fake Redis; this
* checks the same rules through nginx, uvicorn and Postgres, which is where
* a misplaced middleware or a missing migration shows up.
*/
const token = (request, who) => tokenFor(request, who)
const bearer = value => ({ Authorization: `Bearer ${value}` })
test.describe('addresses', () => {
test('the same route answers at /api and /api/v1', async ({ request }) => {
const key = await token(request, LEARNER)
for (const path of ['/api/auth/me', '/api/v1/auth/me']) {
const response = await request.get(path, { headers: bearer(key) })
expect(response.status(), path).toBe(200)
expect((await response.json()).email).toBe(LEARNER.email)
}
})
test('the liveness check is not versioned, because monitoring is not', async ({ request }) => {
expect((await request.get('/api/health')).status()).toBe(200)
})
test('the contract is published', async ({ request }) => {
const document = await (await request.get('/api/openapi.json')).json()
expect(document.info.version).toBeTruthy()
expect(Object.keys(document.paths).length).toBeGreaterThan(100)
// Every documented route says its version, so a client that pins v1 has
// something to pin to.
const unversioned = Object.keys(document.paths)
.filter(path => path.startsWith('/api/') && !path.startsWith('/api/v1/'))
.filter(path => path !== '/api/health')
expect(unversioned).toEqual([])
})
})
test.describe('errors', () => {
test('a failure carries a code, a sentence and the fields', async ({ request }) => {
const key = await token(request, LEARNER)
const missing = await request.get('/api/v1/quizzes/999999', { headers: bearer(key) })
expect(missing.status()).toBe(404)
expect((await missing.json()).error).toMatchObject({ code: 'not_found' })
const bad = await request.get('/api/v1/quizzes/not-a-number', { headers: bearer(key) })
expect(bad.status()).toBe(422)
const body = await bad.json()
expect(body.error.code).toBe('invalid_request')
expect(body.error.fields[0]).toMatchObject({ field: 'quiz_id' })
})
test('an unauthenticated call says so in the same shape', async ({ request }) => {
const response = await request.get('/api/v1/auth/me')
expect(response.status()).toBe(401)
expect((await response.json()).error.code).toBe('unauthenticated')
})
})
test.describe('the answer side', () => {
test('a learner browsing the bank gets stems and no answers', async ({ request }) => {
const key = await token(request, LEARNER)
const body = await (await request.get('/api/v1/questions/bank?limit=5',
{ headers: bearer(key) })).json()
expect(body.questions.length).toBeGreaterThan(0)
for (const question of body.questions) {
expect(question.question_text, 'the stem is bank content').toBeTruthy()
expect(question.correct_answer, 'the answer is not').toBeNull()
expect(question.explanation).toBeNull()
}
})
test('an educator writing the question does get them', async ({ request }) => {
const key = await token(request, EDUCATOR)
const body = await (await request.get('/api/v1/questions/bank?limit=5',
{ headers: bearer(key) })).json()
expect(body.questions[0].correct_answer).toBeTruthy()
})
test('the tutor is refused on a question nobody is sitting', async ({ request }) => {
const key = await token(request, LEARNER)
const response = await request.post('/api/v1/teach/chat', {
headers: bearer(key),
data: { question_id: 1, messages: [{ role: 'user', content: 'What is the answer?' }] },
})
expect(response.status()).toBe(403)
})
})
test.describe('staying signed in', () => {
test('an app gets a refresh token, rotates it, and cannot reuse it', async ({ request }) => {
const signIn = await request.post('/api/v1/auth/login',
{ data: { ...LEARNER, refresh: true, device: 'E2E phone' } })
const first = await signIn.json()
expect(first.refresh_token).toBeTruthy()
expect(first.expires_in).toBeGreaterThan(0)
const refreshed = await request.post('/api/v1/auth/refresh',
{ data: { refresh_token: first.refresh_token } })
expect(refreshed.status()).toBe(200)
const second = await refreshed.json()
expect(second.refresh_token).not.toBe(first.refresh_token)
// The spent one coming back ends the session — the theft turns itself in.
const replay = await request.post('/api/v1/auth/refresh',
{ data: { refresh_token: first.refresh_token } })
expect(replay.status()).toBe(401)
const after = await request.post('/api/v1/auth/refresh',
{ data: { refresh_token: second.refresh_token } })
expect(after.status()).toBe(401)
})
test('a browser is not handed one it will never use', async ({ request }) => {
const body = await (await request.post('/api/v1/auth/login', { data: LEARNER })).json()
// Null on the wire rather than absent, because the field is declared;
// what matters is that no row was written for a client with nowhere to
// put it.
expect(body.refresh_token).toBeNull()
expect(body.access_token).toBeTruthy()
})
})

101
e2e/tests/helpers.js Normal file
View file

@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
// Matched to e2e/seed.py, which prints them when it runs. Overridable the same
// way, for a stack somebody wants to leave up and poke at.
export const EDUCATOR = {
email: process.env.E2E_EDUCATOR_EMAIL || 'educator@e2e.example.com',
password: process.env.E2E_EDUCATOR_PASSWORD || 'e2e-educator-password',
}
export const LEARNER = {
email: process.env.E2E_LEARNER_EMAIL || 'learner@e2e.example.com',
password: process.env.E2E_LEARNER_PASSWORD || 'e2e-learner-password',
}
/**
* A learner of this worker's own.
*
* The seed makes one per parallel worker. They share a database, and a session
* remembers where it was left so two workers sitting the same session as the
* same person tread on each other, and the failure looks like a product bug
* rather than a fixture one. Educators can share: nothing they do here writes
* per-person state.
*/
export function learnerFor(testInfo) {
const index = (testInfo?.parallelIndex ?? 0) % 4
return { ...LEARNER, email: LEARNER.email.replace('@', `+w${index}@`) }
}
// One sign-in per account per worker. The limiter allows ten attempts from an
// address in fifteen minutes, and the whole suite arrives from one address — so
// a test that signs in afresh each time spends the budget and the rest of the
// run fails on 429s that have nothing to do with what is being tested.
const tokens = new Map()
export async function tokenFor(request, who) {
if (!tokens.has(who.email)) {
const response = await request.post('/api/v1/auth/login', { data: who })
expect(response.ok(), `sign-in failed: ${await response.text()}`).toBeTruthy()
tokens.set(who.email, (await response.json()).access_token)
}
return tokens.get(who.email)
}
/**
* Sign in and hand the browser the token.
*
* Not through the form: every test would then be a test of the login form, and
* when that breaks the whole suite goes red at once instead of one test. The
* form has its own test, which does use the form.
*/
export async function signIn(page, who = LEARNER) {
const token = await tokenFor(page.request, who)
// The origin has to exist before localStorage does.
await page.goto('/')
await page.evaluate(value => localStorage.setItem('token', value), token)
return token
}
/** Past the "what are you studying for?" gate, which every page shows first. */
export async function chooseObjective(page) {
const picker = page.getByRole('heading', { name: /what are you studying for/i })
if (await picker.isVisible().catch(() => false)) {
await page.getByText(/Pediatrics Boards|Respiratory|E2E/).first().click()
await expect(picker).toBeHidden()
}
}
/**
* Open a session and be sitting it, however the player decides to start.
*
* A saved session shows an overview first; one opened with nothing saved goes
* straight in. Both are correct, and a test that insists on the button is
* testing which of the two happened rather than the session itself.
*/
export async function startSession(page, id = 1) {
// `restart=1` every time. These tests share one account, and a session
// remembers where it was left — so without this the second test to run finds
// itself on question three of a session the first one was half-way through.
await page.goto(`/study/${id}?restart=1`)
await chooseObjective(page)
const start = page.getByRole('button', { name: /start session/i })
if (await start.isVisible().catch(() => false)) await start.click()
await expect(page.locator('.question-card').first()).toBeVisible()
}
/**
* Nothing on the page may be reachable only by scrolling sideways.
*
* Polled rather than sampled once. A page mid-layout a figure that has not
* finished loading, a font still swapping is momentarily wider than the
* window and then is not, and a single measurement catches whichever moment it
* happened to land in. A page that really does overflow stays wide, so it
* still fails, just a second later.
*/
export async function expectNoHorizontalOverflow(page) {
await page.evaluate(() => document.fonts?.ready).catch(() => {})
await expect.poll(() => page.evaluate(() => {
const doc = document.documentElement
// One pixel of slack for sub-pixel rounding on a scaled device.
return doc.scrollWidth - doc.clientWidth <= 1
}), { message: 'the page scrolls sideways', timeout: 5000 }).toBe(true)
}

96
e2e/tests/mobile.spec.js Normal file
View file

@ -0,0 +1,96 @@
import { expect, test } from '@playwright/test'
import { chooseObjective, expectNoHorizontalOverflow, signIn, EDUCATOR } from './helpers.js'
/**
* The phone. Every bug in this file was found by a person on an iPhone and
* reported as a screenshot, which is a slow way to find out.
*/
test.describe('on a phone', () => {
// By width, not by `isMobile`: an iPad in landscape is a touch device with
// 1024px of room, where the reading rail is a rail and there is no drawer to
// open. The breakpoint here is the one in ArticlesPage.css.
test.skip(({ viewport }) => !viewport || viewport.width > 820,
'about the layout below 820px')
test.beforeEach(async ({ page }) => {
await signIn(page, EDUCATOR)
})
test('the menu opens the contents and the same button closes them', async ({ page }) => {
await page.goto('/articles/1')
await chooseObjective(page)
// Wait for the article, not just the page. The reader claims the menu
// button when it mounts, and it does not mount until the article arrives —
// tap in that window and you get the site menu, which is the right answer
// to "there is nothing else this button could mean yet".
await expect(page.locator('.article-title, h1').first()).toContainText(/Croup/i)
const burger = page.locator('.nav-burger')
await burger.click()
await expect(page.locator('.article-sections.open')).toBeVisible()
// Pressing it again is how a thumb closes a drawer. It used to do nothing,
// and the only way out was the strip of page beside it.
await burger.click()
await expect(page.locator('.article-sections.open')).toHaveCount(0)
})
test('the drawer clears the header rather than covering it', async ({ page }) => {
await page.goto('/articles/1')
await chooseObjective(page)
await expect(page.locator('.article-title, h1').first()).toContainText(/Croup/i)
await page.locator('.nav-burger').click()
const header = await page.locator('.navbar').boundingBox()
const drawer = await page.locator('.article-sections').boundingBox()
expect(drawer.y, 'the drawer starts under the header').toBeGreaterThanOrEqual(header.height - 1)
// And the button that opened it is still the top thing at that point.
const onTop = await page.evaluate(() => {
const bar = document.querySelector('.nav-burger').getBoundingClientRect()
const el = document.elementFromPoint(bar.x + bar.width / 2, bar.y + bar.height / 2)
return el?.closest('.nav-burger') !== null
})
expect(onTop, 'something is covering the menu button').toBeTruthy()
})
test('a figure opens with the picture in it, not just its caption', async ({ page }) => {
// An SVG written with a viewBox and no width or height has a shape but no
// size; Safari resolves that to zero inside a shrink-to-fit box, and the
// viewer was a caption above an empty screen. The seed's figure is written
// the same way on purpose.
await page.goto('/articles/1')
await chooseObjective(page)
const thumb = page.locator('.imgfig-thumb').first()
await expect(thumb).toBeVisible()
await thumb.click()
const image = page.locator('.imgfig-frame img')
await expect(image).toBeVisible()
// Measured after it has actually loaded: an <img> that is still fetching
// is a box one line tall, which looks exactly like the bug being tested.
await expect.poll(() => image.evaluate(el => el.complete && el.naturalWidth > 0),
{ message: 'the figure never loaded' }).toBe(true)
const box = await image.boundingBox()
expect(box.width, 'the picture has no width').toBeGreaterThan(100)
expect(box.height, 'the picture has no height').toBeGreaterThan(40)
// And the caption is there too, which is all that used to be.
await expect(page.locator('.imgfig-desc')).toContainText(/stridor/i)
})
test('nothing on the reading page scrolls sideways', async ({ page }) => {
await page.goto('/articles/1')
await chooseObjective(page)
await expect(page.getByRole('heading', { name: 'Croup' }).first()).toBeVisible()
await expectNoHorizontalOverflow(page)
})
test('a text box does not zoom the page when it is tapped', async ({ page, browserName }) => {
test.skip(browserName !== 'webkit', 'the 16px rule is a Safari rule')
await page.goto('/ai')
await chooseObjective(page)
const size = await page.locator('textarea[aria-label="Ask AI Mode"]').evaluate(
el => parseFloat(getComputedStyle(el).fontSize))
// Under 16px, iOS zooms in on focus and never zooms back out.
expect(size).toBeGreaterThanOrEqual(16)
})
})

38
e2e/tests/reading.spec.js Normal file
View file

@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test'
import { chooseObjective, learnerFor, signIn, EDUCATOR } from './helpers.js'
test('a cross-reference previews before it is followed', async ({ page, isMobile }, testInfo) => {
test.skip(isMobile, 'hover is a pointer thing; the phone opens the card on tap')
await signIn(page, learnerFor(testInfo))
await page.goto('/articles/1')
await chooseObjective(page)
const link = page.locator('.al-link').first()
await expect(link).toBeVisible()
await link.hover()
const card = page.getByRole('tooltip')
await expect(card).toBeVisible()
await expect(card).toContainText(/Bronchiolitis/i)
// The two ways out of the card, and neither of them is "you have left the
// article you were reading".
await expect(card.getByRole('link', { name: /new tab/i })).toBeVisible()
})
test('an educator sees the tools a learner does not', async ({ page }) => {
await signIn(page, EDUCATOR)
await page.goto('/articles/1')
await chooseObjective(page)
await expect(page.getByRole('button', { name: /^Edit$/ })).toBeVisible()
// Drafting belongs in the editor, not on the page a learner is reading.
await expect(page.getByRole('button', { name: /AI refine/i })).toHaveCount(0)
})
test('a learner sees no editing tools at all', async ({ page }, testInfo) => {
await signIn(page, learnerFor(testInfo))
await page.goto('/articles/1')
await chooseObjective(page)
// The article's own title, not the entry for it in the contents drawer.
await expect(page.locator('.article-title, h1').first()).toContainText(/Croup/i)
await expect(page.getByRole('button', { name: /^Edit$/ })).toHaveCount(0)
await expect(page.getByRole('button', { name: /Generate cards/i })).toHaveCount(0)
})

41
e2e/tests/session.spec.js Normal file
View file

@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test'
import { expectNoHorizontalOverflow, learnerFor, signIn, startSession } from './helpers.js'
test.describe('sitting a session', () => {
test.beforeEach(async ({ page }, testInfo) => {
await signIn(page, learnerFor(testInfo))
})
test('a learner answers a question and is told why', async ({ page }) => {
await startSession(page)
await expect(page.locator('#quiz-question-heading')).toContainText(/barking cough/i)
// Study mode marks as you go, so the answer and its reason arrive together.
await page.locator('.option', { hasText: 'Dexamethasone' }).first().click()
await expect(page.getByText(/shortens croup/i)).toBeVisible()
await expectNoHorizontalOverflow(page)
})
test('the answer is not on the page before it is chosen', async ({ page }) => {
// The whole point of a question. If the correct option reaches the browser
// with the stem, no amount of hiding it in CSS is worth anything.
await startSession(page)
await expect(page.locator('#quiz-question-heading')).toContainText(/barking cough/i)
const html = await page.content()
expect(html).not.toContain('shortens croup')
})
test('the session ends on its analysis', async ({ page }) => {
await startSession(page)
await expect(page.locator('#quiz-question-heading')).toContainText(/barking cough/i)
for (let i = 0; i < 3; i += 1) {
const next = page.getByRole('button', { name: /^(next|skip|submit|finish)/i }).last()
await next.click()
await page.waitForTimeout(400)
}
await expect(page).toHaveURL(/\/sessions\/\d+|\/study\/1/)
})
})

View file

@ -4,6 +4,24 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Who is actually calling.
#
# This container sits behind the host's proxy, which puts the caller's
# address in X-Forwarded-For. Without the two lines below, $remote_addr is
# the docker bridge for every request on the site so the backend's
# per-IP rate limits were one shared bucket: ten bad passwords from
# anybody locked out everybody for fifteen minutes, and no log line could
# say who did it.
#
# Only addresses inside a private range are trusted to speak for someone
# else, and the header is then *overwritten* on the way to the backend
# rather than appended to, so nothing a client sent for itself survives.
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Gzip compression # Gzip compression
gzip on; gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
@ -26,7 +44,7 @@ server {
proxy_pass $backend; proxy_pass $backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Large file uploads # Large file uploads
@ -35,18 +53,6 @@ server {
proxy_read_timeout 600s; proxy_read_timeout 600s;
} }
# SCORM content no frame-blocking headers, allow scripts
location /uploads/scorm/ {
resolver 127.0.0.11 valid=10s;
set $backend http://backend:8000;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_hide_header X-Frame-Options;
proxy_force_ranges on; # Pinned backend FileResponse does not implement byte ranges.
# Local add_header prevents inheriting the app CSP; preserve backend SVG sandbox.
add_header X-Content-Type-Options "nosniff" always;
}
# Uploaded images proxy to backend # Uploaded images proxy to backend
location /uploads/ { location /uploads/ {
resolver 127.0.0.11 valid=10s; resolver 127.0.0.11 valid=10s;

View file

@ -91,9 +91,15 @@ export default function ArticleReader({
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
// On a phone the contents live behind the same button as the site menu, the // On a phone the contents live behind the same button as the site menu, the
// way a session's questions do: one control at the top left whose contents // way a session's questions do: one control at the top left whose contents
// change with where you are, rather than a second menu to hunt for. The rail // change with where you are, rather than a second menu to hunt for.
// is on screen above 1150px, so there is nothing to open there. //
const narrow = useMediaQuery('(max-width: 1150px)') // 820px, and it must stay in step with the media query in ArticlesPage.css
// that turns the rail into a drawer. It said 1150 while the stylesheet said
// 820, so on anything between the two a tablet in landscape, a half-width
// window this claimed the menu button and toggled a class on a rail that
// was still sitting there in the layout: the contents did not open, and the
// site menu did not either. The button was simply dead.
const narrow = useMediaQuery('(max-width: 820px)')
const { register: registerDrawer } = useSessionDrawer() const { register: registerDrawer } = useSessionDrawer()
useEffect(() => { useEffect(() => {
if (!narrow || bare) return undefined if (!narrow || bare) return undefined

View file

@ -604,7 +604,6 @@ export default function AnalysisPage() {
)} )}
</> </>
)} )}
<p className="an-basis">{data.basis}</p>
</> </>
) : ( ) : (
<> <>

View file

@ -18,7 +18,7 @@ const area = (over = {}) => ({
const payload = (over = {}) => ({ const payload = (over = {}) => ({
group: 'articles', unlocked: true, answers_needed: 0, total_answered: 60, group: 'articles', unlocked: true, answers_needed: 0, total_answered: 60,
unique_questions_seen: 42, bank_total: 330, grouped_total: 330, overall_accuracy: 64.0, 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(() => { beforeEach(() => {