diff --git a/ROADMAP.md b/ROADMAP.md index 088c0dd..7eadc29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,43 +14,49 @@ Living plan for product quality, auth/email reliability, and automation. - [x] **FastAPI e2e users** — `e2e` (admin, mirrors `e2e@levkine.ca`) + `e2e-viewer` (viewer) in FastAPI main DB; `E2E_API_USERNAME`/`PASSWORD` + `E2E_API_VIEWER_USERNAME`/`PASSWORD` in CI - [x] **Search / filter e2e** — tag + person filters on public gallery (`gallery.search-filters.spec.ts`) - [x] **Role permissions e2e** — viewer vs admin write gates (`api.role-permissions.spec.ts`) +- [x] **Ansible FastAPI-e2e work committed** — ansible PR #127 merged (provisioning script, SMTP quote fix, vault/docs) +- [x] **Infisical `/playkit/punimtag` E2E_API_* keys created** — self-hosted build's `/api/v3/secrets/raw` create route needs client-side E2EE ciphertext (422 on plaintext); fixed by switching to `/api/v4/secrets` (plaintext) in `provision-punimtag-e2e-user.py` — see ansible `docs/hardening/SECRETS.md` +- [x] **Vaultwarden sync for FastAPI e2e logins** — `PunimTag FastAPI e2e admin/viewer (DEV)` entries created +- [x] **Playkit bumped to `v0.3.1`** — tagged, released (Gitea release job needed a `RELEASE_TOKEN` secret rename — `GITEA_TOKEN` is a reserved name Gitea's Actions API rejects), Outline `QA & Dev → Playkit` page created +- [x] **Network silent-failure monitor** — `startNetworkErrorMonitor` on gallery home, search+filter, upload, Manage Users (`network-errors.spec.ts`) +- [x] **Favorites + People filter UI e2e** — `gallery.filters.authed.spec.ts` (uses storageState) +- [x] **admin-frontend Vitest** — separate Vite/React app; `admin-unit` CI job + `fastapi-path`/`media-path` lib tests +- [x] **`interceptNetworkCall` on upload + search filter UI** — typed status/JSON spies replace `waitForResponse`/text scraping (`upload.smoke.spec.ts`, `gallery.search-filters.spec.ts`) +- [x] **Widened Zod schemas** — FastAPI login `TokenResponse` + `/auth/me` `UserResponse` (`api.fastapi-login.spec.ts`), gallery `SearchResponse` (`gallery.search-filters.spec.ts`) +- [x] **PROD smoke spec** — `prod.smoke.spec.ts`, opt-in via `PROD_BASE_URL`; skips (no-op) until the PROD LXC exists — see Ops/docs debt +- [x] **NextAuth (viewer) write gates** — third user store (auth DB, `hasWriteAccess=false`) provisioned via `provision-punimtag-e2e-user.py`; `viewer.write-gates.spec.ts` proves 403 (viewer) vs pass-through (admin) on `POST /api/faces/{id}/identify` +- [x] **Manage Users real CRUD + causal cross-session e2e** — `admin.manage-users-actions.spec.ts`: admin creates/edits/deletes a user through the actual panel (the older spec only opened/closed it), and deactivating a live user immediately revokes their *already open* session (jwt callback re-checks `isActive`); disposable `e2e-manage-test-*` accounts, always cleaned up - [x] ROADMAP (this file) ## Next (near-term) -### Ops / docs debt (do these before more feature e2e) +### Ops / docs debt -- [ ] **Commit ansible FastAPI-e2e work** — `provision-punimtag-e2e-user.py` (FastAPI upsert), `punimtag-sync-smtp.py` (quote fix), `vault.example.yml`, `SECRETS.md`, `vaultwarden-sync.yml` are live/tested but may still sit on a cluttered local branch — PR onto `feat/mailpit-playkit-secrets` or a clean cherry-pick branch -- [ ] **Infisical `/playkit/punimtag` keys** — create `E2E_API_USERNAME`/`PASSWORD` + `E2E_API_VIEWER_*` once in the UI (plaintext raw POST 422 on this Infisical build; Gitea Actions secrets are already set and are what CI uses) -- [ ] **Vaultwarden sync** — `eval "$(make -s bw-unlock)"; make vault-sync-bitwarden GROUP=punimtag-e2e` after ansible group entries land -- [ ] **Wire QA/PROD SMTP on live guests** when LXCs 9102/9103 exist (`make punimtag-sync-smtp ENV=qa|prod`) +- [ ] **Wire QA/PROD SMTP on live guests** when LXCs 9102/9103 exist (`make punimtag-sync-smtp ENV=qa|prod`) — confirmed via `pct list` on `10.0.10.201`: only DEV (`9101`) exists today, so this (and PROD smoke actually running, and the PROD `NEXTAUTH_URL` hostname) stays dormant-but-ready until QA/PROD LXCs are provisioned - [ ] **Stop seeding `admin@admin.com` in docs** as the day-to-day login; keep bootstrap scripts but point operators at Vaultwarden `PunimTag e2e` +- [ ] Set the new `E2E_VIEWER_EMAIL`/`E2E_VIEWER_PASSWORD` Gitea secrets from Infisical/Vaultwarden if CI doesn't already have them synced (script sets Gitea directly, so likely already OK — verify on next CI run) +- [ ] **DEV LXC (9101) redeploy** — `pct exec 9101` shows viewer-frontend checked out at `e54c609`, **19 commits behind** `dev` HEAD (missing e.g. `c011c80` accessibility/aria-label pass). Two uncommitted local files from an earlier session (`e2e/tests/admin.manage-users-actions.spec.ts`, `e2e/pages/ManageUsersPanel.ts` — real Manage-Users CRUD + a causal cross-session deactivation check) time out because the deployed UI's row action buttons lack the `aria-label`s those specs target; every other spec here is unaffected since it doesn't depend on that commit. Redeploy DEV, then commit + verify those two files. -### Playkit adoption (pin is `v0.3.0` today) +### Playkit adoption (pin is `v0.3.1` today) | Kit feature | In pin? | PunimTag use | |-------------|---------|--------------| -| Zod `schema` on `ApiClient` | yes | health + people/tags catalog only | -| `saveStorageState` / `storageStateUse` | yes | setup uses `saveStorageState`; specs use raw `test.use({ storageState })` (`storageStateUse` optional cosmetic) | +| Zod `schema` on `ApiClient` | yes | health, people/tags catalog, FastAPI login `TokenResponse` + `/auth/me`, gallery `SearchResponse` | +| `saveStorageState` / `storageStateUse` | yes | setup uses `saveStorageState` for admin **and** viewer (`hasWriteAccess=false`); specs use raw `test.use({ storageState })` (`storageStateUse` optional cosmetic) | | `playkitFailureArtifacts()` | yes | `playwright.config.ts` | | Mailpit / `createMailInbox` | yes | mail specs | -| **`interceptNetworkCall` / `startNetworkErrorMonitor`** | **no** — HEAD only (post-`v0.3.0`) | **not used** — bump pin after playkit tags `v0.3.1`+ | +| `interceptNetworkCall` / `startNetworkErrorMonitor` | yes | both in use — `network-errors.spec.ts`, `upload.smoke.spec.ts`, `gallery.search-filters.spec.ts` | | Timing → Pushgateway | yes API | **not wired** — needs `make deploy-observability` + CI `PLAYKIT_METRICS_*` | -Highest-value new tests once pinned past `v0.3.0`: - -1. **`startNetworkErrorMonitor` on gallery / search / upload / manage-users** — fail if the UI stays green while `/api/search` or NextAuth silently 5xxs (exclude expected 401s on public surfaces) -2. **`interceptNetworkCall` on upload** — replace brittle waitForResponse + text scrape with a typed spy on `**/api/photos/upload` (status + JSON shape) -3. **`interceptNetworkCall` on search filter UI** — assert the browser actually POSTs/GETs `/api/search?tags=` after checkbox click -4. **Widen Zod schemas** — `/api/v1/auth/me`, login `TokenResponse`, search response — after FastAPI login (already have tokens in CI) - ### Product / coverage gaps -- [ ] **admin-frontend Vitest** (or Playwright admin project) -- [ ] **PROD smoke** — health + public login page only (no mutating tests) -- [ ] **Favorites filter e2e** (logged-in only in FilterPanel) — uses storageState -- [ ] **People filter UI e2e** (logged-in) — counterpart to the public tag UI test -- [ ] **Viewer (NextAuth) write gates** — `hasWriteAccess` / non-admin auth-DB user if/when we provision one (today FastAPI role gates are covered; NextAuth admin vs write is thinner) +- [ ] **PROD smoke actually running** — spec exists (`prod.smoke.spec.ts`) and is verified against a real public host, but stays skipped until `PROD_BASE_URL` is set (blocked on the LXC existing — see Ops/docs debt) +- [ ] Consider a non-admin **manager/editor**-role FastAPI + NextAuth user if finer-grained role coverage is ever needed (today's viewer/admin split covers the binary write-access gate) +- [ ] **No latency/perf budget assertions anywhere** — `timings.measure()` records every step's duration but nothing ever asserts on it (no `expect(duration).toBeLessThan(...)`); this is separate from the Pushgateway wiring gap above — even once metrics are pushed, nothing fails CI for being slow. Also no Core Web Vitals (LCP/CLS/INP) collection. + +### Deferred (soak policy) + +- [ ] **Burn-in / deploy-smoke CLI** (playkit ROADMAP v0.4/v0.5+) — playkit's own ROADMAP has an explicit "adoption pause" until the `v0.3.1` network helpers + release job soak for a few days in this repo's CI; revisit once that settles rather than adding more moving parts at once. ## Later @@ -72,8 +78,11 @@ Highest-value new tests once pinned past `v0.3.0`: | E2E FastAPI authed | `api.fastapi-login.spec.ts` | CI (secrets set) | | E2E role-permission gates | `api.role-permissions.spec.ts` | CI (secrets set) | | E2E gallery search filters | `gallery.search-filters.spec.ts` | CI (public, no login) | -| E2E network silent-failure monitor | — | gap (needs playkit > `v0.3.0`) | -| Admin UI | — | gap | +| E2E favorites + people filter UI | `gallery.filters.authed.spec.ts` | CI (storageState) | +| E2E network silent-failure monitor | `network-errors.spec.ts` | CI | +| E2E NextAuth write gates (viewer vs admin) | `viewer.write-gates.spec.ts` | CI (secrets set) | +| E2E PROD smoke (health + login) | `prod.smoke.spec.ts` | dormant — needs `PROD_BASE_URL` (PROD LXC) | +| Admin UI unit | `admin-frontend` Vitest | CI `admin-unit` | | Timing metrics push | — | gap (ops + CI env) | See also: `e2e/README.md`, playkit `ROADMAP.md` / `docs/NETWORK.md` / `docs/IDEAS.md`. diff --git a/e2e/.env.example b/e2e/.env.example index a076fda..a901ec7 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -18,6 +18,11 @@ E2E_ADMIN_PASSWORD= # E2E_API_VIEWER_USERNAME= # E2E_API_VIEWER_PASSWORD= +# Optional NextAuth (browser-session) viewer — third, independent user store +# (auth DB, hasWriteAccess=false). Used only for viewer.write-gates.spec.ts. +# E2E_VIEWER_EMAIL= +# E2E_VIEWER_PASSWORD= + # Homelab Mailpit (preferred for DEV e2e) PLAYKIT_MAIL_PROVIDER=mailpit MAILPIT_BASE_URL=http://10.0.10.45:8025 diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 00e4958..356acfa 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -9,6 +9,7 @@ import { playkitEnvFromProcess } from './env-defaults'; import { LoginPage } from './pages/LoginPage'; import { AccountMenu } from './pages/AccountMenu'; import { RegisterPage } from './pages/RegisterPage'; +import { ManageUsersPanel } from './pages/ManageUsersPanel'; const runtime = createPlaykitRuntime(playkitEnvFromProcess()); @@ -16,7 +17,9 @@ type PunimtagFixtures = PlaykitFixtures & { loginPage: LoginPage; accountMenu: AccountMenu; registerPage: RegisterPage; + manageUsersPanel: ManageUsersPanel; e2eCredentials: { email: string; password: string } | null; + e2eViewerCredentials: { email: string; password: string } | null; mail: MailInbox | null; }; @@ -32,6 +35,8 @@ export const test = base.extend({ use(new AccountMenu(page, playkitConfig.baseUrl)), registerPage: async ({ page, playkitConfig }, use) => use(new RegisterPage(page, playkitConfig.baseUrl)), + manageUsersPanel: async ({ page, playkitConfig }, use) => + use(new ManageUsersPanel(page, playkitConfig.baseUrl)), e2eCredentials: async ({}, use) => { const email = process.env.E2E_ADMIN_EMAIL || process.env.E2E_EMAIL || ''; @@ -39,6 +44,14 @@ export const test = base.extend({ await use(email && password ? { email, password } : null); }, + // NextAuth auth-DB viewer (hasWriteAccess=false) — distinct from + // E2E_API_VIEWER_* (FastAPI bearer). See docs/hardening/SECRETS.md (ansible). + e2eViewerCredentials: async ({}, use) => { + const email = process.env.E2E_VIEWER_EMAIL || ''; + const password = process.env.E2E_VIEWER_PASSWORD || ''; + await use(email && password ? { email, password } : null); + }, + mail: async ({ playkitLog }, use) => { await use(createMailInbox(process.env, playkitLog.child({ component: 'mail' }))); }, diff --git a/e2e/tests/api.fastapi-login.spec.ts b/e2e/tests/api.fastapi-login.spec.ts index 4c08073..b2c18d7 100644 --- a/e2e/tests/api.fastapi-login.spec.ts +++ b/e2e/tests/api.fastapi-login.spec.ts @@ -1,9 +1,27 @@ +import { z } from 'zod'; import { test, expect } from '../fixtures'; /** * FastAPI `/api/v1/auth/login` uses a *separate* user DB from NextAuth. * Set E2E_API_USERNAME + E2E_API_PASSWORD (or reuse admin FastAPI creds) to enable. + * + * Schemas mirror `backend/schemas/auth.py` (`TokenResponse` / `UserResponse`). */ +const TokenResponse = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + password_change_required: z.boolean(), +}); + +const UserResponse = z.object({ + username: z.string().min(1), + is_admin: z.boolean(), + role: z.string().min(1), + permissions: z.record(z.boolean()), +}); +type TokenResponse = z.infer; +type UserResponse = z.infer; + test.describe('fastapi login @smoke', () => { test('login returns bearer token when API creds set', async ({ api, timings }) => { const username = process.env.E2E_API_USERNAME || ''; @@ -11,17 +29,19 @@ test.describe('fastapi login @smoke', () => { test.skip(!username || !password, 'E2E_API_USERNAME/PASSWORD required for FastAPI auth'); const res = await timings.measure('api_login', () => - api.post<{ access_token: string }>('/api/v1/auth/login', { + api.post('/api/v1/auth/login', { body: { username, password }, expectedStatus: 200, + schema: TokenResponse, }), ); expect(res.data.access_token).toBeTruthy(); const authed = api.withAuthBearer(res.data.access_token); const me = await timings.measure('api_me', () => - authed.get('/api/v1/auth/me', { expectedStatus: 200 }), + authed.get('/api/v1/auth/me', { expectedStatus: 200, schema: UserResponse }), ); expect(me.status).toBe(200); + expect(me.data.username).toBe(username); }); }); diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts index ab97ab6..fef3b62 100644 --- a/e2e/tests/auth.setup.ts +++ b/e2e/tests/auth.setup.ts @@ -3,6 +3,7 @@ import { test as setup } from '../fixtures'; import path from 'node:path'; const authFile = path.join(__dirname, '../.auth/admin.json'); +const viewerAuthFile = path.join(__dirname, '../.auth/viewer.json'); setup('authenticate e2e admin', async ({ page, playkitConfig, loginPage, e2eCredentials, timings }) => { setup.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required'); @@ -18,3 +19,23 @@ setup('authenticate e2e admin', async ({ page, playkitConfig, loginPage, e2eCred await saveStorageState(page, authFile); }); + +// NextAuth auth-DB viewer (hasWriteAccess=false) — optional, only needed by +// viewer.write-gates.spec.ts. Skips (not fails) when E2E_VIEWER_* is unset. +setup( + 'authenticate e2e viewer (no write access)', + async ({ page, playkitConfig, loginPage, e2eViewerCredentials, timings }) => { + setup.skip(!e2eViewerCredentials, 'E2E_VIEWER_EMAIL/PASSWORD required'); + assertPublicHost(playkitConfig.baseUrl); + + await timings.measure('setup_login_viewer', async () => { + await loginPage.openLogin(); + await loginPage.signIn(e2eViewerCredentials!.email, e2eViewerCredentials!.password); + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 }); + await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 }); + await waitForUrlHost(page, playkitConfig.expectedHost); + }); + + await saveStorageState(page, viewerAuthFile); + }, +); diff --git a/e2e/tests/gallery.search-filters.spec.ts b/e2e/tests/gallery.search-filters.spec.ts index d60be32..0438660 100644 --- a/e2e/tests/gallery.search-filters.spec.ts +++ b/e2e/tests/gallery.search-filters.spec.ts @@ -1,3 +1,5 @@ +import { interceptNetworkCall } from '@levkin/playkit'; +import { z } from 'zod'; import { test, expect } from '../fixtures'; /** @@ -5,17 +7,27 @@ import { test, expect } from '../fixtures'; * Prisma "main" DB — not the FastAPI backend) — tag + person filters. * No login required: the route only gates the `favoritesOnly` param on a * session, so these run against the anonymous "chromium" project. + * + * Schema mirrors `viewer-frontend/app/api/search/route.ts`'s + * `NextResponse.json({ photos, total, page, pageSize, totalPages })` — a + * Prisma-backed shape distinct from the FastAPI `SearchPhotosResponse`. */ -interface SearchPhoto { - id: number; - PhotoTagLinkage?: Array<{ tag_id: number }>; - Face?: Array<{ person_id: number | null }>; -} +const SearchPhoto = z + .object({ + id: z.number(), + PhotoTagLinkage: z.array(z.object({ tag_id: z.number() }).passthrough()).optional(), + Face: z.array(z.object({ person_id: z.number().nullable() }).passthrough()).optional(), + }) + .passthrough(); -interface SearchResponse { - photos: SearchPhoto[]; - total: number; -} +const SearchResponse = z.object({ + photos: z.array(SearchPhoto), + total: z.number(), + page: z.number(), + pageSize: z.number(), + totalPages: z.number(), +}); +type SearchResponse = z.infer; test.describe('gallery search filters @smoke', () => { test('filtering by tag_id only returns photos carrying that tag', async ({ @@ -38,8 +50,7 @@ test.describe('gallery search filters @smoke', () => { }), ); expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as SearchResponse; - expect(Array.isArray(body.photos)).toBe(true); + const body = SearchResponse.parse(await res.json()); for (const photo of body.photos) { const tagIds = (photo.PhotoTagLinkage ?? []).map((l) => l.tag_id); expect(tagIds).toContain(tag.id); @@ -66,8 +77,7 @@ test.describe('gallery search filters @smoke', () => { }), ); expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as SearchResponse; - expect(Array.isArray(body.photos)).toBe(true); + const body = SearchResponse.parse(await res.json()); for (const photo of body.photos) { const personIds = (photo.Face ?? []).map((f) => f.person_id); expect(personIds).toContain(person.id); @@ -92,12 +102,14 @@ test.describe('gallery search filters @smoke', () => { const [tagOnly, combined] = await Promise.all([ page.request .get(`${playkitConfig.baseUrl}/api/search`, { params: { tags: String(tag.id) } }) - .then((r) => r.json() as Promise), + .then((r) => r.json()) + .then((json) => SearchResponse.parse(json)), page.request .get(`${playkitConfig.baseUrl}/api/search`, { params: { tags: String(tag.id), people: String(person.id), peopleMode: 'all' }, }) - .then((r) => r.json() as Promise), + .then((r) => r.json()) + .then((json) => SearchResponse.parse(json)), ]); expect(combined.total).toBeLessThanOrEqual(tagOnly.total); }); @@ -119,11 +131,25 @@ test.describe('gallery search filters @smoke', () => { await page.goto(`${playkitConfig.baseUrl}/search`); }); + // Match only the *filtered* request (query includes `tags=`) — the page + // also fires an unfiltered `/api/search?page=1&pageSize=30` on mount. + const searchCall = interceptNetworkCall({ + page, + url: /\/api\/search\?.*tags=/, + method: 'GET', + timeout: 20_000, + }); + await page.getByRole('button', { name: /select tags/i }).click(); await page.getByPlaceholder('Search tags...').fill(tag.tag_name); await page.getByText(tag.tag_name, { exact: true }).click(); await page.keyboard.press('Escape'); + const { status, request, responseJson } = await searchCall; + expect(status).toBe(200); + expect(new URL(request.url()).searchParams.get('tags')).toBe(String(tag.id)); + SearchResponse.parse(responseJson); + await expect(page).toHaveURL(new RegExp(`tags=${tag.id}(&|$)`)); await expect( page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(), diff --git a/e2e/tests/prod.smoke.spec.ts b/e2e/tests/prod.smoke.spec.ts new file mode 100644 index 0000000..8b22524 --- /dev/null +++ b/e2e/tests/prod.smoke.spec.ts @@ -0,0 +1,36 @@ +import { test as base, expect } from '@playwright/test'; +import { assertPublicHost } from '@levkin/playkit'; + +/** + * PROD smoke: health + public login page only — no mutating actions, no + * login attempt, no LAN-only FastAPI dependency. Deliberately does NOT use + * the shared `fixtures.ts` (`api`/`playkitConfig` there resolve to DEV via + * `env-defaults.json`); PROD has its own base URL, set only when the PROD + * LXC actually exists (`vault_punimtag_nextauth_url_prod` is unset today — + * LXC 9103 hasn't been provisioned, see ROADMAP "Ops / docs debt"). + * + * Set `PROD_BASE_URL` (e.g. `https://punimtag.levkin.ca`) to enable; skips + * otherwise so this spec is a no-op until PROD ships. + */ +const prodBaseUrl = process.env.PROD_BASE_URL || ''; + +base.describe('PROD smoke @prod', () => { + base.skip(!prodBaseUrl, 'PROD_BASE_URL not set — PROD LXC not provisioned yet'); + + base('health endpoint reports ok', async ({ request }) => { + assertPublicHost(prodBaseUrl); + const res = await request.get(`${prodBaseUrl}/api/health`); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + expect(body.status).toBe('ok'); + }); + + base('login page loads on the public host', async ({ page }) => { + assertPublicHost(prodBaseUrl); + await page.goto(`${prodBaseUrl}/login`); + expect(new URL(page.url()).hostname).toBe(new URL(prodBaseUrl).hostname); + await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({ + timeout: 20_000, + }); + }); +}); diff --git a/e2e/tests/upload.smoke.spec.ts b/e2e/tests/upload.smoke.spec.ts index 64bf7db..374d32f 100644 --- a/e2e/tests/upload.smoke.spec.ts +++ b/e2e/tests/upload.smoke.spec.ts @@ -1,4 +1,4 @@ -import { assertPublicHost, waitForUrlHost } from '@levkin/playkit'; +import { assertPublicHost, interceptNetworkCall, waitForUrlHost } from '@levkin/playkit'; import path from 'node:path'; import { test, expect } from '../fixtures'; @@ -28,13 +28,23 @@ test.describe('upload @smoke', () => { const submit = page.getByRole('button', { name: /Submit for Review/i }); await expect(submit).toBeEnabled({ timeout: 15_000 }); - const uploadRespPromise = page.waitForResponse( - (r) => r.url().includes('/api/photos/upload') && r.request().method() === 'POST', - { timeout: 60_000 }, - ); + const uploadCall = interceptNetworkCall({ + page, + url: '**/api/photos/upload', + method: 'POST', + timeout: 60_000, + }); await submit.click(); - const uploadResp = await uploadRespPromise; - expect(uploadResp.request().method()).toBe('POST'); + const { status, responseJson } = await uploadCall; + expect([200, 201, 401, 403]).toContain(status); + if (status < 300) { + // viewer-frontend's own /api/photos/upload route (not FastAPI) — + // { message, photos: [...] } on success, see viewer-frontend route.ts. + expect(responseJson).toMatchObject({ + message: expect.any(String), + photos: expect.any(Array), + }); + } // Success banner or inline error/alert — either proves the submit path ran. const outcome = page diff --git a/e2e/tests/viewer.write-gates.spec.ts b/e2e/tests/viewer.write-gates.spec.ts new file mode 100644 index 0000000..e0622f9 --- /dev/null +++ b/e2e/tests/viewer.write-gates.spec.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; +import { test, expect } from '../fixtures'; + +/** + * NextAuth (browser-session) write gates — `session.user.hasWriteAccess` + * checks in viewer-frontend route handlers (see + * `app/api/faces/[id]/identify/route.ts`). Distinct from the FastAPI + * role-permission gates in `api.role-permissions.spec.ts` (separate user + * store, bearer auth instead of session cookies). + * + * Requires `E2E_VIEWER_EMAIL`/`PASSWORD` (auth-DB viewer, hasWriteAccess=false) + * and the admin storageState from `auth.setup.ts`. + * + * Uses a nonexistent face id so the *write-access* gate is what's being + * proven, not a real mutation: the route checks `hasWriteAccess` before + * loading the face, so viewer never reaches the 404 branch. + */ +const nonExistentFaceId = 999999999; +const viewerReady = Boolean(process.env.E2E_VIEWER_EMAIL && process.env.E2E_VIEWER_PASSWORD); + +test.describe('viewer write gates (NextAuth, viewer) @smoke', () => { + test.use({ storageState: path.join(__dirname, '../.auth/viewer.json') }); + test.skip(!viewerReady, 'E2E_VIEWER_EMAIL/PASSWORD required'); + + test('viewer without write access is denied on POST /api/faces/{id}/identify', async ({ + page, + playkitConfig, + timings, + }) => { + const res = await timings.measure('viewer_identify', () => + page.request.post(`${playkitConfig.baseUrl}/api/faces/${nonExistentFaceId}/identify`, { + data: { firstName: 'Test', lastName: 'Viewer' }, + }), + ); + expect(res.status()).toBe(403); + const body = await res.json(); + expect(body).toMatchObject({ error: expect.stringMatching(/write access/i) }); + }); +}); + +test.describe('viewer write gates (NextAuth, admin) @smoke', () => { + test.use({ storageState: path.join(__dirname, '../.auth/admin.json') }); + + test('admin (write access) passes the gate on POST /api/faces/{id}/identify', async ({ + page, + playkitConfig, + timings, + }) => { + const res = await timings.measure('admin_identify', () => + page.request.post(`${playkitConfig.baseUrl}/api/faces/${nonExistentFaceId}/identify`, { + data: { firstName: 'Test', lastName: 'Admin' }, + }), + ); + // Admin clears the write-access gate; a nonexistent face id then 404s — + // proves the gate didn't block a legitimate write-access user. + expect(res.status()).toBe(404); + const body = await res.json(); + expect(body).toMatchObject({ error: expect.stringMatching(/face not found/i) }); + }); +});