diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f36c259..6aaa610 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -131,6 +131,12 @@ jobs: PLAYKIT_ENV: dev E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} + # FastAPI main-DB bearer creds (separate store from NextAuth above) — + # admin mirrors e2e@levkine.ca; viewer is role-gate test coverage only. + E2E_API_USERNAME: ${{ secrets.E2E_API_USERNAME }} + E2E_API_PASSWORD: ${{ secrets.E2E_API_PASSWORD }} + E2E_API_VIEWER_USERNAME: ${{ secrets.E2E_API_VIEWER_USERNAME }} + E2E_API_VIEWER_PASSWORD: ${{ secrets.E2E_API_VIEWER_PASSWORD }} MAILTRAP_API_TOKEN: ${{ secrets.MAILTRAP_API_TOKEN }} MAILTRAP_ACCOUNT_ID: ${{ secrets.MAILTRAP_ACCOUNT_ID }} MAILTRAP_INBOX_ID: ${{ secrets.MAILTRAP_INBOX_ID }} diff --git a/ROADMAP.md b/ROADMAP.md index eabc103..7ed3f55 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,14 +11,14 @@ Living plan for product quality, auth/email reliability, and automation. - [x] Vitest unit tests for `viewer-frontend` in Gitea Actions - [x] API smoke + Zod schema checks (health, people, tags, 401 contracts) - [x] storageState setup project + real upload smoke +- [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] ROADMAP (this file) ## Next (near-term) - [ ] **Wire QA/PROD SMTP on live guests** when LXCs 9102/9103 exist (`make punimtag-sync-smtp ENV=qa|prod`) -- [ ] **FastAPI e2e user** — mirror `e2e@levkine.ca` (or service account) into FastAPI user DB; set `E2E_API_USERNAME`/`PASSWORD` in CI -- [ ] **Search / filter e2e** — tag + person filters on public gallery -- [ ] **Role permissions e2e** — viewer vs admin write gates - [ ] **admin-frontend Vitest** (or Playwright admin project) - [ ] **PROD smoke** — health + public login page only (no mutating tests) - [ ] **Stop seeding `admin@admin.com` in docs** as the day-to-day login; keep bootstrap scripts but point operators at Vaultwarden `PunimTag e2e` @@ -40,6 +40,8 @@ Living plan for product quality, auth/email reliability, and automation. | E2E browser + mail | `e2e/tests/*` | CI `e2e` | | E2E API (unauth + catalog) | `e2e/tests/api.*` | CI | | E2E FastAPI authed | `api.fastapi-login.spec.ts` | skips without `E2E_API_*` | +| E2E role-permission gates | `api.role-permissions.spec.ts` | skips without `E2E_API_*` + `E2E_API_VIEWER_*` | +| E2E gallery search filters | `gallery.search-filters.spec.ts` | CI (public, no login) | | Admin UI | — | gap | See also: `e2e/README.md`, playkit `ROADMAP.md` (kit-level). diff --git a/e2e/.env.example b/e2e/.env.example index cfda3e8..a076fda 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -9,9 +9,14 @@ PLAYKIT_ENV=dev E2E_ADMIN_EMAIL= E2E_ADMIN_PASSWORD= -# Optional FastAPI bearer tests (separate user DB from NextAuth) +# Optional FastAPI bearer tests (separate user DB from NextAuth). +# E2E_API_USERNAME mirrors e2e@levkine.ca (admin role) in the FastAPI main DB. +# E2E_API_VIEWER_USERNAME is a viewer-role account used only for role-permission +# (write-gate) checks — see api.role-permissions.spec.ts. # E2E_API_USERNAME= # E2E_API_PASSWORD= +# E2E_API_VIEWER_USERNAME= +# E2E_API_VIEWER_PASSWORD= # Homelab Mailpit (preferred for DEV e2e) PLAYKIT_MAIL_PROVIDER=mailpit diff --git a/e2e/README.md b/e2e/README.md index 5527804..67f8069 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -25,7 +25,8 @@ Never commit lockfile URLs that embed tokens. | `PLAYKIT_API_BASE_URL` | no | LAN API (see `env-defaults.json`) | | `E2E_ADMIN_EMAIL` | for auth specs | Prefer `e2e@levkine.ca` — **not** `admin@admin.com` | | `E2E_ADMIN_PASSWORD` | for auth specs | Dedicated e2e password (Vaultwarden / Infisical) | -| `E2E_API_USERNAME` / `E2E_API_PASSWORD` | optional | FastAPI bearer login (separate DB from NextAuth) | +| `E2E_API_USERNAME` / `E2E_API_PASSWORD` | optional | FastAPI bearer login, admin role (separate DB from NextAuth; mirrors `e2e@levkine.ca`) | +| `E2E_API_VIEWER_USERNAME` / `E2E_API_VIEWER_PASSWORD` | optional | FastAPI bearer login, viewer role — role-permission gate specs only | | `PLAYKIT_MAIL_PROVIDER` | for mail specs | `mailpit` (homelab) | | `MAILPIT_BASE_URL` | for mail specs | `http://10.0.10.45:8025` | | `MAILPIT_USER` / `MAILPIT_PASSWORD` | for mail specs | Mailpit basic auth | @@ -46,6 +47,8 @@ See ansible `docs/hardening/SECRETS.md`. 9. API `/api/v1/auth/me` and `/api/v1/photos` return 401 without token 10. API catalog: people/tags lists, login 422/401, users/roles 401, missing photo 404|401 11. Viewer `/api/auth/session` returns email when storageState is loaded +12. FastAPI role-permission write gates: viewer 403 vs admin 200 on users, role-permissions, bulk-delete +13. Public gallery search (`/api/search`): tag filter, person filter, and combined-filter narrowing return correct subsets; UI tag-filter interaction updates URL + result count See repo root [`ROADMAP.md`](../ROADMAP.md) for gaps and next steps. diff --git a/e2e/tests/api.role-permissions.spec.ts b/e2e/tests/api.role-permissions.spec.ts new file mode 100644 index 0000000..d4659be --- /dev/null +++ b/e2e/tests/api.role-permissions.spec.ts @@ -0,0 +1,100 @@ +import type { ApiClient } from '@levkin/playkit'; +import { test, expect } from '../fixtures'; + +/** + * FastAPI role-permission write gates: `admin`-role users pass + * `Depends(get_current_admin_user)`, `viewer`-role users get 403. + * + * Requires both FastAPI accounts (separate store from NextAuth): + * E2E_API_USERNAME/PASSWORD — admin role, mirrors e2e@levkine.ca + * E2E_API_VIEWER_USERNAME/PASSWORD — viewer role, gate-testing only + */ +async function login(api: ApiClient, username: string, password: string): Promise { + const res = await api.post<{ access_token: string }>('/api/v1/auth/login', { + body: { username, password }, + expectedStatus: 200, + }); + return res.data.access_token; +} + +test.describe('role permissions @smoke', () => { + const adminUser = process.env.E2E_API_USERNAME || ''; + const adminPass = process.env.E2E_API_PASSWORD || ''; + const viewerUser = process.env.E2E_API_VIEWER_USERNAME || ''; + const viewerPass = process.env.E2E_API_VIEWER_PASSWORD || ''; + const ready = Boolean(adminUser && adminPass && viewerUser && viewerPass); + + test('viewer is denied, admin is allowed on GET /api/v1/users', async ({ api, timings }) => { + test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required'); + + const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass)); + const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass)); + + const viewerRes = await timings.measure('viewer_users', () => + api.withAuthBearer(viewerToken).get('/api/v1/users', { expectedStatus: 403 }), + ); + expect(viewerRes.status).toBe(403); + expect(viewerRes.data).toMatchObject({ detail: expect.stringMatching(/admin/i) }); + + const adminRes = await timings.measure('admin_users', () => + api.withAuthBearer(adminToken).get<{ items: unknown[] }>('/api/v1/users', { + expectedStatus: 200, + }), + ); + expect(adminRes.status).toBe(200); + expect(Array.isArray(adminRes.data.items)).toBe(true); + }); + + test('viewer is denied, admin is allowed on GET /api/v1/role-permissions', async ({ + api, + timings, + }) => { + test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required'); + + const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass)); + const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass)); + + const viewerRes = await timings.measure('viewer_role_perms', () => + api.withAuthBearer(viewerToken).get('/api/v1/role-permissions', { expectedStatus: 403 }), + ); + expect(viewerRes.status).toBe(403); + + const adminRes = await timings.measure('admin_role_perms', () => + api.withAuthBearer(adminToken).get('/api/v1/role-permissions', { expectedStatus: 200 }), + ); + expect(adminRes.status).toBe(200); + expect(adminRes.data).toHaveProperty('permissions'); + }); + + test('viewer is denied, admin is allowed on POST /api/v1/photos/bulk-delete', async ({ + api, + timings, + }) => { + test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required'); + + const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass)); + const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass)); + + // Use a photo id that cannot exist — proves the write *gate*, not deletion. + // The admin dependency runs before the handler body, so this never touches + // real data: viewer never reaches the handler, admin gets a safe no-op. + const nonExistentId = 999999999; + + const viewerRes = await timings.measure('viewer_bulk_delete', () => + api.withAuthBearer(viewerToken).post('/api/v1/photos/bulk-delete', { + body: { photo_ids: [nonExistentId] }, + expectedStatus: 403, + }), + ); + expect(viewerRes.status).toBe(403); + + const adminRes = await timings.measure('admin_bulk_delete', () => + api.withAuthBearer(adminToken).post('/api/v1/photos/bulk-delete', { + body: { photo_ids: [nonExistentId] }, + expectedStatus: 200, + }), + ); + expect(adminRes.status).toBe(200); + expect(adminRes.data).toMatchObject({ deleted_count: 0, missing_photo_ids: [nonExistentId] }); + }); +}); diff --git a/e2e/tests/gallery.search-filters.spec.ts b/e2e/tests/gallery.search-filters.spec.ts new file mode 100644 index 0000000..d60be32 --- /dev/null +++ b/e2e/tests/gallery.search-filters.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '../fixtures'; + +/** + * Public gallery search (`viewer-frontend` `/api/search`, backed by its own + * 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. + */ +interface SearchPhoto { + id: number; + PhotoTagLinkage?: Array<{ tag_id: number }>; + Face?: Array<{ person_id: number | null }>; +} + +interface SearchResponse { + photos: SearchPhoto[]; + total: number; +} + +test.describe('gallery search filters @smoke', () => { + test('filtering by tag_id only returns photos carrying that tag', async ({ + api, + page, + playkitConfig, + timings, + }) => { + const tagsRes = await timings.measure('api_tags', () => + api.get<{ items: Array<{ id: number; tag_name: string }> }>('/api/v1/tags', { + expectedStatus: 200, + }), + ); + const tag = tagsRes.data.items[0]; + expect(tag).toBeTruthy(); + + const res = await timings.measure('search_by_tag', () => + page.request.get(`${playkitConfig.baseUrl}/api/search`, { + params: { tags: String(tag.id) }, + }), + ); + expect(res.ok()).toBeTruthy(); + const body = (await res.json()) as SearchResponse; + expect(Array.isArray(body.photos)).toBe(true); + for (const photo of body.photos) { + const tagIds = (photo.PhotoTagLinkage ?? []).map((l) => l.tag_id); + expect(tagIds).toContain(tag.id); + } + }); + + test('filtering by person_id only returns photos with that person tagged', async ({ + api, + page, + playkitConfig, + timings, + }) => { + const peopleRes = await timings.measure('api_people', () => + api.get<{ items: Array<{ id: number; first_name: string }> }>('/api/v1/people', { + expectedStatus: 200, + }), + ); + const person = peopleRes.data.items[0]; + expect(person).toBeTruthy(); + + const res = await timings.measure('search_by_person', () => + page.request.get(`${playkitConfig.baseUrl}/api/search`, { + params: { people: String(person.id) }, + }), + ); + expect(res.ok()).toBeTruthy(); + const body = (await res.json()) as SearchResponse; + expect(Array.isArray(body.photos)).toBe(true); + for (const photo of body.photos) { + const personIds = (photo.Face ?? []).map((f) => f.person_id); + expect(personIds).toContain(person.id); + } + }); + + test('combined tag+person filters (match-all) narrow results at least as much as either alone', async ({ + api, + page, + playkitConfig, + timings, + }) => { + const [tagsRes, peopleRes] = await Promise.all([ + api.get<{ items: Array<{ id: number }> }>('/api/v1/tags', { expectedStatus: 200 }), + api.get<{ items: Array<{ id: number }> }>('/api/v1/people', { expectedStatus: 200 }), + ]); + const tag = tagsRes.data.items[0]; + const person = peopleRes.data.items[0]; + expect(tag).toBeTruthy(); + expect(person).toBeTruthy(); + + const [tagOnly, combined] = await Promise.all([ + page.request + .get(`${playkitConfig.baseUrl}/api/search`, { params: { tags: String(tag.id) } }) + .then((r) => r.json() as Promise), + page.request + .get(`${playkitConfig.baseUrl}/api/search`, { + params: { tags: String(tag.id), people: String(person.id), peopleMode: 'all' }, + }) + .then((r) => r.json() as Promise), + ]); + expect(combined.total).toBeLessThanOrEqual(tagOnly.total); + }); + + test('gallery search UI: selecting a tag updates URL and result count', async ({ + page, + playkitConfig, + api, + timings, + }) => { + const tagsRes = await api.get<{ items: Array<{ id: number; tag_name: string }> }>( + '/api/v1/tags', + { expectedStatus: 200 }, + ); + const tag = tagsRes.data.items[0]; + expect(tag).toBeTruthy(); + + await timings.measure('open_search', async () => { + await page.goto(`${playkitConfig.baseUrl}/search`); + }); + + 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'); + + await expect(page).toHaveURL(new RegExp(`tags=${tag.id}(&|$)`)); + await expect( + page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(), + ).toBeVisible({ timeout: 20_000 }); + }); +});