test: playkit v0.3.1 adoption, Zod widening, PROD smoke, NextAuth write gates #68

Merged
ilia merged 8 commits from feat/admin-manage-users-e2e into dev 2026-07-14 22:24:07 -05:00
9 changed files with 247 additions and 47 deletions
Showing only changes of commit 4273162d9e - Show all commits
+32 -23
View File
@@ -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`.
+5
View File
@@ -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
+13
View File
@@ -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<PunimtagFixtures>({
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<PunimtagFixtures>({
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' })));
},
+22 -2
View File
@@ -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<typeof TokenResponse>;
type UserResponse = z.infer<typeof UserResponse>;
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<TokenResponse>('/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<UserResponse>('/api/v1/auth/me', { expectedStatus: 200, schema: UserResponse }),
);
expect(me.status).toBe(200);
expect(me.data.username).toBe(username);
});
});
+21
View File
@@ -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);
},
);
+41 -15
View File
@@ -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<typeof SearchResponse>;
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<SearchResponse>),
.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<SearchResponse>),
.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(),
+36
View File
@@ -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,
});
});
});
+17 -7
View File
@@ -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
+60
View File
@@ -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) });
});
});