Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ GMI_API_KEY=
NEBIUS_INFERENCE_BASE_URL=
NEBIUS_INFERENCE_API_KEY=

# ── Optional per-user multitenancy (Firebase ID-token auth) ─────────────────
# Set to turn on tenant isolation: every case route then accepts an optional
# `Authorization: Bearer <Firebase ID token>` header, and a verified caller's
# data is scoped to their own `tenants/<uid>/` storage prefix (see
# src/claimscene/auth.py + api.py::get_tenant). Unset (the default) — the
# header is not even inspected, so behaviour is byte-identical to before this
# feature existed: every request is GUEST.
#
# This is a PUBLIC value — the same Firebase/GCP project id already baked
# into client-side Firebase config and visible in any browser's network tab
# (see frontend/.env.example's VITE_FIREBASE_PROJECT_ID, which must match).
# Not a secret; no service-account key or Firebase Admin SDK is needed —
# tokens are verified against Google's public signing certificates.
FIREBASE_PROJECT_ID=

# ── Web app / container (all optional — sane defaults) ───────────────────────
# The FastAPI app can serve the compiled React client from the same origin.
# CLAIMSCENE_WEB_DIR points at the folder holding the built index.html (the
Expand Down
2 changes: 2 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
{ "source": "/scenarios/**", "run": { "serviceId": "claimscene", "region": "europe-west1" } },
{ "source": "/cases", "run": { "serviceId": "claimscene", "region": "europe-west1" } },
{ "source": "/cases/**", "run": { "serviceId": "claimscene", "region": "europe-west1" } },
{ "source": "/me", "run": { "serviceId": "claimscene", "region": "europe-west1" } },
{ "source": "/me/**", "run": { "serviceId": "claimscene", "region": "europe-west1" } },
{ "source": "**", "destination": "/index.html" }
],
"headers": [
Expand Down
21 changes: 21 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,24 @@ VITE_API_BASE=
# local backend at http://localhost:8000). Point it at a running FastAPI dev
# server (`uvicorn claimscene.api:app --reload`) or a deployed Cloud Run URL.
VITE_DEV_PROXY_TARGET=http://localhost:8000

# ── Optional per-user multitenancy (Google sign-in) ──────────────────────────
# All four are PUBLIC Firebase web-config values (the same ones baked into
# any Firebase client bundle and visible in a browser's network tab) — never
# secrets, safe to set as plain build-time env vars.
#
# Leave all four unset (the default) and the app builds and runs exactly as
# it does today: no sign-in button, no "My cases" library, no delete-my-data
# control — guest-only, byte-identical to a build with no auth code at all.
#
# Set all four to turn sign-in on: a "Sign in with Google" button appears in
# the header, and a signed-in visitor gets a "My cases" library plus a
# delete-all-my-data control. The backend also needs its own
# FIREBASE_PROJECT_ID set (see the repo-root .env.example) for the API to
# actually honour the resulting Authorization header — without that, a
# signed-in visitor's calls to the multitenancy routes degrade gracefully
# (the UI shows "Case library is not available on this deployment").
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_APP_ID=
39 changes: 39 additions & 0 deletions frontend/e2e/_mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,37 @@ export async function installApiMocks(page: Page): Promise<void> {

if (pathname.endsWith("/cases/extract")) return route.fulfill(json(EXTRACT_RESPONSE));
if (pathname.endsWith("/cases/preview-schematic")) return route.fulfill(json(PREVIEW_RESPONSE));

// Async submit + poll (POST /cases/render/jobs, GET /cases/render/jobs/{id})
// — the studio wizard's RenderStep uses this pair, never the synchronous
// POST /cases/render below. Both checks are matched by SUBSTRING (not
// endsWith) and placed ABOVE the generic `method === "GET"` fallback
// further down: a poll's URL ends in a job id, e.g.
// ".../cases/render/jobs/job-1", which does NOT satisfy
// `pathname.endsWith("/cases/render/jobs")` — falling through to that
// fallback would return the golden MANIFEST body instead of a job-status
// body, fail RenderJobStatusSchema parsing, and burn the poll loop's
// consecutive-error budget (a real bug caught before it shipped).
if (method === "POST" && pathname.endsWith("/cases/render/jobs")) {
// A brief, deterministic delay so the "Sealing your case" step is
// observable before it auto-advances to the sealed result — mirrors
// the synchronous /cases/render delay below.
return new Promise<void>((resolve) => {
setTimeout(() => resolve(route.fulfill({ status: 202, ...json({ job_id: "job-1", status: "queued" }) })), 700);
});
}
if (method === "GET" && pathname.includes("/cases/render/jobs/")) {
// Already "done" on the very first poll — offline/demo generation can
// finish near-instantly (see usePollRenderJob's first-tick-immediate
// behaviour), so the journey/a11y/responsive specs never need to wait
// out multiple poll intervals for the default path.
return route.fulfill(json({
job_id: "job-1", status: "done",
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:05Z",
result: RENDER_RESPONSE,
}));
}

if (pathname.endsWith("/cases/render")) {
// A brief, deterministic delay so the "Sealing your case" step is
// observable before it auto-advances to the sealed result.
Expand All @@ -201,4 +232,12 @@ export async function installApiMocks(page: Page): Promise<void> {
}
return route.fulfill({ status: 404, contentType: JSON_CT, body: "{}" });
});

// Optional per-user multitenancy (GET /me/library, DELETE /me/data). No
// spec signs in today (isAuthEnabled() is false with no VITE_FIREBASE_*
// build vars — see lib/auth.ts), so these are never reached by the current
// suite; registered for completeness/forward-compat should a future spec
// mock lib/auth as enabled.
await page.route("**/me/library", (route) => route.fulfill(json({ cases: [] })));
await page.route("**/me/data", (route) => route.fulfill(json({ deleted: 0 })));
}
7 changes: 6 additions & 1 deletion frontend/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
await page.getByRole("button", { name: /Rear-end at a red light/i }).click();
await page.getByRole("button", { name: /Extract scene/i }).click();
await expect(page.getByRole("heading", { name: /Review .* adjust the scene/i })).toBeVisible();
expect(await auditSeriousOrCritical(page, "review")).toEqual([]);

Check failure on line 55 in frontend/e2e/a11y.spec.ts

View workflow job for this annotation

GitHub Actions / e2e

[chromium] › e2e/a11y.spec.ts:49:1 › review step has no serious/critical a11y violations

1) [chromium] › e2e/a11y.spec.ts:49:1 › review step has no serious/critical a11y violations ────── Error: expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 373 - Array [] + Array [ + Object { + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + "help": "Elements must meet minimum color contrast ratio thresholds", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast?application=playwright", + "id": "color-contrast", + "impact": "serious", + "nodes": Array [ + Object { + "all": Array [], + "any": Array [ + Object { + "data": Object { + "bgColor": "#09131a", + "contrastRatio": 4.3, + "expectedContrastRatio": "4.5:1", + "fgColor": "#667c8b", + "fontSize": "7.5pt (10px)", + "fontWeight": "normal", + "messageKey": null, + }, + "id": "color-contrast", + "impact": "serious", + "message": "Element has insufficient color contrast of 4.3 (foreground color: #667c8b, background color: #09131a, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + "relatedNodes": Array [ + Object { + "html": "<button type=\"button\" role=\"radio\" aria-checked=\"false\" aria-label=\"10 o'clock\" class=\"absolute grid h-6 w-...\" style=\"left: 13.6269%; top:...\">", + "target": Array [ + ".p-4:nth-child(2) > .bg-steel-950\\/40.p-3.mt-4 > .gap-3.flex.items-center > .shrink-0.relative[role=\"radiogroup\"] > .hover\\:text-cyan-200.hover\\:border-cyan-400\\/60[aria-label=\"10 o'clock\"]", + ], + }, + ], + }, + ], + "failureSummary": "Fix any of the following: + Element has insufficient color contrast of 4.3 (foreground color: #667c8b, background color: #09131a, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + "html": "<button type=\"button\" role=\"radio\" aria-checked=\"false\" aria-label=\"10 o'clock\" class=\"absolute grid h-6 w-...\" style=\"left: 13.6269%; top:...\">", + "impact": "serious", + "none": Array [], + "target": Array [ + ".p-4:nth-child(2) > .bg-steel-950\\/40.p-3.mt-4 > .gap-3.flex.items-center > .shrink-0.relative[role=\"radiogroup\"] > .hover\\:text-cyan-200.hover\\:border-cyan-400\\/60[aria-label=\"10 o'clock\"]", + ], + }, + Object { + "all": Array [], + "any": Array [ + Object { + "data": Object { + "bgColor": "#09131a", + "contrastRatio": 4.3, + "expectedContrastRatio": "4.5:1", + "fgColor": "#667c8b", + "fontSize": "7.5pt (10px)", + "fontWeight": "normal", + "messageKey": null, + }, + "id": "color-contrast", + "impact": "serious", + "message": "Element has insufficient color contrast of 4.3 (foreground color: #667c8b, background color: #09131a, font size: 7.5pt (10px), font weight: normal). Expected contrast ratio of 4.5:1", + "relatedNodes": Array [ + Object { + "html": "<button type=\"button\" role=\"radio\" aria-checked=\"false\" aria-label=\"11 o'clock\" class=\"absolute grid h-6 w-...\" style=\"left: 29%; top: 13.6...\">", + "target": Array [ + ".p-4:nth-child(2) > .bg-steel-950\\/40.p-3.mt-4 > .gap-3.flex.items-center > .shrink-0.relative[role=\"radiogroup\"] > .hover\\:text
});

test("review step with a vehicle selected (rotate handle visible) has no serious/critical a11y violations", async ({
Expand All @@ -77,7 +77,12 @@

test("render step has no serious/critical a11y violations", async ({ page }) => {
// Hold the render step open so it can be audited (it normally auto-advances).
await page.route("**/cases/render", async (route) => {
// The studio wizard submits an async render job (POST /cases/render/jobs),
// not the synchronous POST /cases/render — hold THAT route instead, then
// fall back to the default mock (installApiMocks, registered in
// beforeEach) so the submit still eventually resolves normally.
await page.route("**/cases/render/jobs", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
await new Promise((r) => setTimeout(r, 4000));
await route.fallback();
});
Expand Down
Loading
Loading