test: interceptNetworkCall + Zod widening, PROD smoke, NextAuth write gates
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 11s
CI / viewer-unit (pull_request) Failing after 1m3s
CI / admin-unit (pull_request) Failing after 1m1s
CI / e2e (pull_request) Failing after 59s

- upload.smoke.spec.ts / gallery.search-filters.spec.ts: replace
  waitForResponse/text-scrape with typed interceptNetworkCall spies (status
  + JSON shape) on the real upload POST and the filtered /api/search GET.
- api.fastapi-login.spec.ts: Zod-validate the FastAPI TokenResponse and
  /auth/me UserResponse instead of loose casts. gallery.search-filters.spec.ts
  gets the same treatment for the Prisma-backed SearchResponse.
- prod.smoke.spec.ts: opt-in (PROD_BASE_URL) health + login-page check on a
  real PROD host; skips as a no-op until the PROD LXC exists (none does yet
  per `pct list` — see ROADMAP).
- viewer.write-gates.spec.ts: NextAuth (browser-session) hasWriteAccess gate
  on POST /api/faces/{id}/identify — viewer 403s, admin passes through.
  Provisioned a third, independent auth-DB user (e2e-viewer@levkine.ca,
  hasWriteAccess=false) for this via ansible's provision-punimtag-e2e-user.py
  (see that repo for the vault/Infisical/Gitea/Vaultwarden side).
This commit is contained in:
2026-07-14 22:07:30 -04:00
parent ccb961d87a
commit 4273162d9e
9 changed files with 247 additions and 47 deletions
+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) });
});
});