test: playkit v0.3.1 adoption, Zod widening, PROD smoke, NextAuth write gates #68
+32
-23
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })));
|
||||
},
|
||||
|
||||
@@ -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