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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user