diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 44eecf9f..36e65ab7 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -298,8 +298,9 @@ class EmailPasswordIn(BaseModel): # The language the sign-up page was in. Captured at registration because it is the ONLY moment # the choice exists anywhere the server can see: it lives in the browser until an account does, # and a preference nobody recorded cannot be backfilled later (as `google_email` showed: those - # rows still read NULL until each user signs in again). Nothing reads it yet; the bilingual - # mail templates will. + # rows still read NULL until each user signs in again). It is stored as the account's + # `preferences["language"]`, which the UI starts in and every mail to the account is written in + # (`mailtext.lang_for_user` / `lang_for_email`). lang: str | None = None diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml index 057f0290..e3d18c67 100644 --- a/backend/docker-compose.test.yml +++ b/backend/docker-compose.test.yml @@ -1,7 +1,8 @@ # DB-backed pytest lane: an ephemeral Postgres + the test image, wired together so the # `db` fixture (tests/conftest.py) has a real database. The whole thing is throwaway — the pg data -# lives in tmpfs (RAM) and nothing is persisted. The pure-logic lane still runs WITHOUT this file -# (plain `docker run … python -m pytest` — DB tests skip when no Postgres is reachable). +# lives in tmpfs (RAM) and nothing is persisted. A pure-logic run without this file is possible but +# must say so: `docker run -e SIFTLODE_TEST_REQUIRE_DB=0 … python -m pytest` (without it, a missing +# Postgres fails the run instead of skipping the DB tests). # # docker compose -f backend/docker-compose.test.yml up --build --abort-on-container-exit \ # --exit-code-from test diff --git a/backend/tests/test_review_round.py b/backend/tests/test_review_round.py index 9f9266b0..62202993 100644 --- a/backend/tests/test_review_round.py +++ b/backend/tests/test_review_round.py @@ -1,10 +1,13 @@ -"""Review round — the invariants the canonical-email work's own review found unpinned. +"""The onboarding and access invariants a review of that work found unpinned. Every test here corresponds to a defect a reviewer could still introduce with the whole suite green: -a mail whose body never renders in any test, an activation that only works for the exact spelling, a -"we already wrote to them" stamp for a mail that was dropped, an env allow-list that answers a -different question than the gate it backs, and the address pre-emption that the order-independent -gates' argument covered only while the address stayed unverified. +a mail whose body never renders in some language, a link lifetime described in the wrong unit, a +registration that rewrites an existing password or kills a link already in flight, an activation +that only works for the exact spelling, a "we already wrote to them" stamp for a mail that was +dropped, an env allow-list that answers a different question than the gate it backs, a reset or +resend that reports success it did not have, and a throttle that leaks whether an account exists. +Approval's password revoke was called redundant because `password_login` refuses an unverified +account; that holds only until the address is verified, which is what the tests on pre-emption pin. """ from datetime import datetime, timedelta, timezone diff --git a/frontend/e2e/speed.measure.ts b/frontend/e2e/speed.measure.ts index f723f0cd..8a661ca8 100644 --- a/frontend/e2e/speed.measure.ts +++ b/frontend/e2e/speed.measure.ts @@ -38,19 +38,17 @@ function shareTokens(): string[] { " join media_assets a on a.id = j.asset_id" + " where l.password_hash is null and (l.expires_at is null or l.expires_at > now())" + " and a.status = 'ready' order by l.id"; - try { - const out = execFileSync( - "docker", - ["exec", CONTAINER, "psql", "-U", "siftlode", "-d", "siftlode", "-tAc", sql], - { encoding: "utf8" }, - ); - return out - .split("\n") - .map((t) => t.trim()) - .filter(Boolean); - } catch { - return []; - } + // Not caught: a database this cannot reach (no docker, a renamed container) is a failed + // measurement, not "no playable share link on the local stack". + const out = execFileSync( + "docker", + ["exec", CONTAINER, "psql", "-U", "siftlode", "-d", "siftlode", "-tAc", sql], + { encoding: "utf8" }, + ); + return out + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); } test("speed baseline", async ({ browser, page: page0 }) => { diff --git a/frontend/src/components/ui/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx index b30d226c..9a323a8c 100644 --- a/frontend/src/components/ui/AnchoredPopover.tsx +++ b/frontend/src/components/ui/AnchoredPopover.tsx @@ -28,7 +28,8 @@ import type { useAnchoredPopover } from "./anchoredPopoverHooks"; // - autoUpdate → follows the trigger on scroll/resize (the old rAF loop), coalesced by the library // Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the // house did for TagsCell). Every popover is a node in the app's so Escape / -// outside-press dispatch is topmost-only across nested layers (the Modal and players included). +// outside-press dispatch is topmost-only across nested popovers; against the Modal and the players, +// which are not nodes, it is lib/layerStack that decides who is topmost. // // This layer adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's // textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine: diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts index b9a166f9..294faef0 100644 --- a/frontend/src/lib/api/core.test.ts +++ b/frontend/src/lib/api/core.test.ts @@ -434,7 +434,7 @@ describe("the debug session a tab records into", () => { // P1 — the upload's wire shape is a security boundary, not a detail. A multipart part is a DECLARED // body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets -// `require_human` resolve first. A revert to FormData here would silently re-open that, and the +// `current_user` resolve first. A revert to FormData here would silently re-open that, and the // backend's own test only sees the shape the client actually sends. describe("uploadDownload's wire shape", () => { it("sends the File as the raw body, with the names as percent-encoded headers", () => { diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index 2a4afec4..ca3ea310 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -198,7 +198,7 @@ export const downloadsApi = { // show upload progress; any file type is accepted (media gets the full function set, a generic // file is download-only). Returns the already-`done` job the ingest creates. // The body IS the file, and the two names ride as headers. Not a style choice: a multipart part is - // a DECLARED body, which FastAPI reads and spools BEFORE the route's `require_human` dependency + // a DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency // runs — so with the old shape an unauthenticated caller could make the server write the whole // per-file allowance to temp (P1). Headers are latin-1 on the wire, hence `encodeURIComponent`; // the server percent-decodes both. Content-Type is left to the browser (the File's own type). diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d9faab1e..61d41044 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -77,7 +77,8 @@ const root = ) : ( {/* One tree for the whole app: every anchored popover registers as a node so Escape and - outside-press act only on the topmost layer (the Modal and the players join it too). */} + outside-press act only on the topmost popover. The Modal and the players are not nodes: + they order against the popovers through lib/layerStack. */} {/* Nothing below renders until the tab knows which account it is: every provider diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d7f1f568..51197592 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,10 +12,7 @@ const target = "http://127.0.0.1:8080"; // server returns to :5173 instead of the fixed :8080 callback. (Both are registered in the console.) const proxyOpts = { target, changeOrigin: false }; const proxy = { - // ws: the in-app messages socket (/api/messages/ws) is a WebSocket. Without it Vite hands that upgrade - // to nobody, so the socket never connects on :5173 — and the first reset on it (a closed tab, a dropped - // connection) throws an unhandled ECONNRESET that takes the whole dev server down. - "/api": { ...proxyOpts, ws: true }, + "/api": proxyOpts, "/auth": proxyOpts, "/healthz": proxyOpts, };