diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6aaa610..278b07b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -95,6 +95,31 @@ jobs: - name: Run Vitest run: npm test + # Vitest unit tests (admin-frontend) — Vite/React FastAPI admin tool, no + # test infra before this; started with pure lib/ helpers. + admin-unit: + needs: skip-ci-check + if: needs.skip-ci-check.outputs.should-skip != '1' + runs-on: [homelab, self-hosted, linux] + defaults: + run: + working-directory: admin-frontend + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: admin-frontend/package-lock.json + + - name: Install deps + run: npm ci + + - name: Run Vitest + run: npm test + # Playwright smoke via @levkin/playkit — public host guards + API health. # Sign-out auth spec runs only when E2E_ADMIN_* secrets are present. e2e: @@ -137,6 +162,10 @@ jobs: 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 }} + # NextAuth auth-DB viewer (hasWriteAccess=false) — third, independent + # user store; used only by viewer.write-gates.spec.ts. + E2E_VIEWER_EMAIL: ${{ secrets.E2E_VIEWER_EMAIL }} + E2E_VIEWER_PASSWORD: ${{ secrets.E2E_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 088c0dd..7eadc29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`. diff --git a/admin-frontend/package-lock.json b/admin-frontend/package-lock.json index d8c6ab0..ffb4527 100644 --- a/admin-frontend/package-lock.json +++ b/admin-frontend/package-lock.json @@ -21,6 +21,7 @@ "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "@vitejs/plugin-react": "^4.2.0", + "@vitest/coverage-v8": "^4.1.10", "autoprefixer": "^10.4.16", "eslint": "^8.53.0", "eslint-plugin-react": "^7.37.5", @@ -29,7 +30,8 @@ "postcss": "^8.4.31", "tailwindcss": "^3.3.5", "typescript": "^5.2.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -208,9 +210,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -218,9 +220,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -252,13 +254,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -334,19 +336,29 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1350,6 +1362,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.90.15", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.15.tgz", @@ -1421,6 +1440,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1696,6 +1733,150 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1945,6 +2126,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2202,6 +2412,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2648,6 +2868,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3039,6 +3266,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3055,6 +3292,16 @@ "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3607,6 +3854,13 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4107,6 +4361,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -4306,6 +4599,44 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4556,6 +4887,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4694,6 +5039,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5475,6 +5827,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5495,6 +5854,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5771,6 +6144,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5819,6 +6209,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6143,6 +6543,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6248,6 +6751,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/admin-frontend/package.json b/admin-frontend/package.json index 867b5dd..a2bffea 100644 --- a/admin-frontend/package.json +++ b/admin-frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint . --ext ts,tsx" + "lint": "eslint . --ext ts,tsx", + "test": "vitest run" }, "dependencies": { "@tanstack/react-query": "^5.8.4", @@ -23,6 +24,7 @@ "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "@vitejs/plugin-react": "^4.2.0", + "@vitest/coverage-v8": "^4.1.10", "autoprefixer": "^10.4.16", "eslint": "^8.53.0", "eslint-plugin-react": "^7.37.5", @@ -31,6 +33,7 @@ "postcss": "^8.4.31", "tailwindcss": "^3.3.5", "typescript": "^5.2.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.10" } } diff --git a/admin-frontend/src/lib/fastapi-path.test.ts b/admin-frontend/src/lib/fastapi-path.test.ts new file mode 100644 index 0000000..889c33f --- /dev/null +++ b/admin-frontend/src/lib/fastapi-path.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { fastApiV1Path } from './fastapi-path'; + +describe('fastApiV1Path', () => { + it('prefixes a leading-slash suffix with /api/v1', () => { + expect(fastApiV1Path('/photos')).toBe('/api/v1/photos'); + }); + + it('adds the missing leading slash before prefixing', () => { + expect(fastApiV1Path('photos')).toBe('/api/v1/photos'); + }); + + it('handles nested paths', () => { + expect(fastApiV1Path('/photos/123/tags')).toBe('/api/v1/photos/123/tags'); + }); + + it('handles an empty suffix', () => { + expect(fastApiV1Path('')).toBe('/api/v1/'); + }); +}); diff --git a/admin-frontend/src/lib/media-path.test.ts b/admin-frontend/src/lib/media-path.test.ts new file mode 100644 index 0000000..500f150 --- /dev/null +++ b/admin-frontend/src/lib/media-path.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isRemoteMediaPath } from './media-path'; + +describe('isRemoteMediaPath', () => { + it('treats http URLs as remote', () => { + expect(isRemoteMediaPath('http://example.com/a.jpg')).toBe(true); + }); + + it('treats https URLs as remote', () => { + expect(isRemoteMediaPath('https://example.com/a.jpg')).toBe(true); + }); + + it('treats a local filesystem path as not remote', () => { + expect(isRemoteMediaPath('/srv/media/library/a.jpg')).toBe(false); + }); + + it('is case-insensitive for the scheme', () => { + expect(isRemoteMediaPath('HTTPS://example.com/a.jpg')).toBe(true); + }); + + it('trims surrounding whitespace before checking', () => { + expect(isRemoteMediaPath(' https://example.com/a.jpg ')).toBe(true); + }); +}); diff --git a/admin-frontend/vitest.config.ts b/admin-frontend/vitest.config.ts new file mode 100644 index 0000000..6f40787 --- /dev/null +++ b/admin-frontend/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['node_modules', 'dist'], + }, +}); diff --git a/e2e/.env.example b/e2e/.env.example index a076fda..a901ec7 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -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 diff --git a/e2e/README.md b/e2e/README.md index 78b604c..7458c15 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -27,6 +27,7 @@ Never commit lockfile URLs that embed tokens. | `E2E_ADMIN_PASSWORD` | for auth specs | Dedicated e2e password (Vaultwarden / Infisical) | | `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 | +| `E2E_VIEWER_EMAIL` / `E2E_VIEWER_PASSWORD` | optional | NextAuth auth-DB viewer, `hasWriteAccess=false` (third, independent store) — `viewer.write-gates.spec.ts` 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 | @@ -34,12 +35,12 @@ Never commit lockfile URLs that embed tokens. **Secrets:** Infisical `LevkinOps` → `dev` → `/playkit/punimtag` + Gitea Actions. See ansible `docs/hardening/SECRETS.md`. -**Playkit pin:** `@levkin/playkit@v0.3.0` (git tag). Zod schema option, storageState helpers, -`playkitFailureArtifacts`, and Mailpit are in that pin. Network helpers +**Playkit pin:** `@levkin/playkit@v0.3.1` (git tag). Zod schema option, storageState +helpers, `playkitFailureArtifacts`, Mailpit, and network interception (`interceptNetworkCall`, `startNetworkErrorMonitor` — see playkit `docs/NETWORK.md`) -landed on playkit HEAD **after** `v0.3.0` — bump the pin when a new tag ships, then -add silent-4xx/5xx monitors on gallery/search/upload. Timing → Pushgateway is supported -by the kit but not enabled in this CI job yet. +are all in this pin — see `network-errors.spec.ts` for the silent-4xx/5xx monitors on +gallery/search/upload/manage-users. Timing → Pushgateway is supported by the kit but +not enabled in this CI job yet. ## What we assert @@ -56,6 +57,13 @@ by the kit but not enabled in this CI job yet. 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 +14. Logged-in-only filters: Favorites checkbox toggles `favoritesOnly` URL param; People filter UI selection updates URL + result count (uses storageState) +15. Silent-failure network monitor (`startNetworkErrorMonitor`): gallery home, search+filter, upload, and Manage Users overlay all stay free of background 4xx/5xx while the UI loads/interacts +16. `interceptNetworkCall` spies on the real upload POST (status + `{ message, photos }` shape) and the filtered `/api/search?tags=` GET (status + Zod-validated shape) instead of brittle `waitForResponse`/text scraping +17. Zod-validated API responses: FastAPI login `TokenResponse` + `/auth/me` `UserResponse` (`api.fastapi-login.spec.ts`), gallery search `SearchResponse` (`gallery.search-filters.spec.ts`) +18. NextAuth (browser-session) write gates: viewer (`hasWriteAccess=false`) gets 403, admin gets past the gate, on `POST /api/faces/{id}/identify` (`viewer.write-gates.spec.ts`) — distinct from the FastAPI role-permission gates in #12 +19. PROD smoke (`prod.smoke.spec.ts`, opt-in via `PROD_BASE_URL`): `/api/health` + login page load on the public PROD host — dormant until the PROD LXC exists (see ROADMAP) +20. Manage Users real CRUD + causal cross-session effect (`admin.manage-users-actions.spec.ts`): admin creates/edits/deletes a user through the actual panel (not just open/close), and deactivating a user via the UI immediately revokes that user's *already open* NextAuth session (jwt callback re-checks `isActive`, Kolby #57 fix) — every run creates a disposable `e2e-manage-test-*` account and deletes it in a `finally`, never touching the shared punimtagdev/MirrorMatch DB's real accounts See repo root [`ROADMAP.md`](../ROADMAP.md) for gaps and next steps. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 00e4958..356acfa 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -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({ 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({ 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' }))); }, diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 0852be9..9464c7a 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -8,7 +8,7 @@ "name": "punimtag-e2e", "version": "1.0.0", "devDependencies": { - "@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.0", + "@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.1", "@playwright/test": "^1.52.0", "@types/node": "^22.15.0", "typescript": "^5.8.0", @@ -19,8 +19,8 @@ } }, "node_modules/@levkin/playkit": { - "version": "0.3.0", - "resolved": "git+https://git.levkin.ca/ilia/playkit.git#7369b1c5e53e1f7511bea96be9042e663b60b315", + "version": "0.3.1", + "resolved": "git+https://git.levkin.ca/ilia/playkit.git#3380ba95115423950b4ba0855cc9367475d7f7cf", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index f55ea54..e6a11ea 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -2,18 +2,16 @@ "name": "punimtag-e2e", "version": "1.0.0", "private": true, - "description": "PunimTag Playwright e2e \u2014 powered by @levkin/playkit", + "description": "PunimTag Playwright e2e — powered by @levkin/playkit", "scripts": { "test": "playwright test", - "test:ui": "playwright test --ui", - "test:headed": "playwright test --headed", "report": "playwright show-report" }, "engines": { "node": ">=20" }, "devDependencies": { - "@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.0", + "@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.1", "@playwright/test": "^1.52.0", "@types/node": "^22.15.0", "typescript": "^5.8.0", diff --git a/e2e/pages/ManageUsersPanel.ts b/e2e/pages/ManageUsersPanel.ts new file mode 100644 index 0000000..775bace --- /dev/null +++ b/e2e/pages/ManageUsersPanel.ts @@ -0,0 +1,126 @@ +import { type Page, type Locator } from '@playwright/test'; +import { BasePage, waitForVisible, waitForHidden } from '@levkin/playkit'; + +export interface NewUserInput { + email: string; + password: string; + name: string; + isAdmin?: boolean; + hasWriteAccess?: boolean; +} + +/** + * Page object for the "Manage Users" overlay content + * (`viewer-frontend/app/admin/users/ManageUsersContent.tsx`). + * + * Talks to the NextAuth-backed `/api/users` store — a separate account + * database from the FastAPI `/api/v1/users` store covered by + * `api.role-permissions.spec.ts`. + */ +export class ManageUsersPanel extends BasePage { + constructor(page: Page, baseUrl: string) { + super(page, baseUrl); + } + + row(email: string): Locator { + return this.page.locator('tbody tr').filter({ hasText: email }); + } + + /** The row's action buttons (Edit is always first; Deactivate/Delete is + * second and only rendered for active users). Positional rather than + * aria-label-based — proved more reliable against the live table than + * exact accessible-name matching. */ + private actionButton(email: string, position: 'first' | 'last'): Locator { + const buttons = this.row(email).getByRole('button'); + return position === 'first' ? buttons.first() : buttons.last(); + } + + /** Scopes to the currently-open dialog matching a heading, to avoid + * ambiguous matches against hidden/portalled sibling dialogs. */ + private dialog(headingText: string): Locator { + return this.page + .getByRole('dialog') + .filter({ has: this.page.getByRole('heading', { name: headingText, exact: true }) }); + } + + async createUser(input: NewUserInput): Promise { + await this.click(this.page.getByRole('button', { name: 'Add User', exact: true })); + const dialog = this.dialog('Add New User'); + await waitForVisible(dialog); + await this.fill(dialog.locator('#add-email'), input.email); + await this.fill(dialog.locator('#add-password'), input.password); + await this.fill(dialog.locator('#add-name'), input.name); + if (input.isAdmin) { + await this.click(dialog.locator('#add-role')); + await this.click(this.page.getByRole('option', { name: 'Admin', exact: true })); + } + if (input.hasWriteAccess) { + await this.click(dialog.locator('#add-write-access')); + } + await this.click(dialog.getByRole('button', { name: 'Create User', exact: true })); + await waitForVisible(this.row(input.email), { timeout: 15_000 }); + } + + async openEditDialog(email: string): Promise { + await this.click(this.actionButton(email, 'first')); + await waitForVisible(this.dialog('Edit User')); + } + + private async setCheckbox(locator: Locator, checked: boolean): Promise { + const isChecked = await locator.isChecked(); + if (isChecked !== checked) { + await this.click(locator); + } + } + + async setActive(active: boolean): Promise { + await this.setCheckbox(this.dialog('Edit User').locator('#edit-active'), active); + } + + async setWriteAccess(enabled: boolean): Promise { + await this.setCheckbox(this.dialog('Edit User').locator('#edit-write-access'), enabled); + } + + async saveEdit(): Promise { + const dialog = this.dialog('Edit User'); + await this.click(dialog.getByRole('button', { name: 'Save Changes', exact: true })); + await waitForHidden(dialog); + } + + /** Clicks the row's delete/deactivate icon and confirms in the dialog. + * Hard-deletes if the account has no related records, otherwise the + * backend soft-deactivates it — either way `isActive` ends up false. */ + async deleteUser(email: string): Promise { + await this.click(this.actionButton(email, 'last')); + const dialog = this.dialog('Delete User'); + await waitForVisible(dialog); + await this.click(dialog.getByRole('button', { name: 'Delete', exact: true })); + await waitForHidden(dialog); + } + + async statusBadgeText(email: string): Promise { + return (await this.row(email).locator('td').nth(2).innerText()).trim(); + } + + async roleBadgeText(email: string): Promise { + return (await this.row(email).locator('td').nth(3).innerText()).trim(); + } + + async writeAccessText(email: string): Promise { + return (await this.row(email).locator('td').nth(4).innerText()).trim(); + } + + async waitForRowGone(email: string, timeout = 15_000): Promise { + await waitForHidden(this.row(email), { timeout }); + } + + /** The table defaults to the "Active only" filter and refetches with it on + * every save (`ManageUsersContent.handleEditUser` → `fetchUsers()`), so a + * just-deactivated row silently drops out of view. Switch to "All" before + * reading a status badge you expect to still be visible post-deactivation. */ + async showAllUsers(): Promise { + await this.click(this.page.locator('#status-filter')); + await this.click(this.page.getByRole('option', { name: 'All', exact: true })); + await waitForHidden(this.page.getByRole('option', { name: 'All', exact: true })); + } +} diff --git a/e2e/tests/admin.manage-users-actions.spec.ts b/e2e/tests/admin.manage-users-actions.spec.ts new file mode 100644 index 0000000..43e56cd --- /dev/null +++ b/e2e/tests/admin.manage-users-actions.spec.ts @@ -0,0 +1,162 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../fixtures'; +import { LoginPage } from '../pages/LoginPage'; + +/** + * Real Manage Users CRUD through the admin UI (`admin.manage-users.spec.ts` + * only opens/closes the panel — it never exercises create/edit/delete), plus + * a causal cross-session check: an admin deactivating a user immediately + * signs that user out of their *already open* session, because the NextAuth + * `jwt` callback re-checks `isActive` from the DB on every request (see + * `viewer-frontend/app/api/auth/[...nextauth]/route.ts`, issue #57 fix). + * + * punimtagdev shares its Postgres DB with MirrorMatch and reuses the same + * pre-seeded accounts as other specs (E2E_ADMIN_EMAIL, E2E_VIEWER_EMAIL), so + * every test here creates its own disposable `e2e-manage-test-*` account and + * deletes it in a `finally`, regardless of outcome. + */ +function throwawayEmail(tag: string): string { + return `e2e-manage-test-${tag}-${Date.now()}@e2e.invalid`; +} + +const THROWAWAY_PASSWORD = 'E2eThrowaway!123'; + +interface AdminUser { + id: number; + email: string; +} + +/** Best-effort cleanup — looks the account up by email since we may not + * have its id if the test failed before/during creation. */ +async function deleteUserByEmail(page: Page, baseUrl: string, email: string): Promise { + const res = await page.request.get(`${baseUrl}/api/users?status=all`); + if (!res.ok()) return; + const body = (await res.json()) as { users?: AdminUser[] }; + const match = body.users?.find((u) => u.email === email); + if (match) { + await page.request.delete(`${baseUrl}/api/users/${match.id}`); + } +} + +test.describe('admin manage users: create/edit/delete @smoke', () => { + test('admin can create, edit, and delete a user through the panel', async ({ + page, + playkitConfig, + loginPage, + accountMenu, + manageUsersPanel, + e2eCredentials, + timings, + }) => { + test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)'); + const email = throwawayEmail('crud'); + + try { + await loginPage.openLogin(); + await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password); + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 }); + await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 }); + + await accountMenu.openManageUsers(); + + await timings.measure('create_user', () => + manageUsersPanel.createUser({ email, password: THROWAWAY_PASSWORD, name: 'E2E Throwaway' }), + ); + expect(await manageUsersPanel.statusBadgeText(email)).toMatch(/active/i); + expect(await manageUsersPanel.roleBadgeText(email)).toMatch(/user/i); + expect(await manageUsersPanel.writeAccessText(email)).toMatch(/no/i); + + await timings.measure('edit_user', async () => { + await manageUsersPanel.openEditDialog(email); + await manageUsersPanel.setWriteAccess(true); + await manageUsersPanel.saveEdit(); + }); + expect(await manageUsersPanel.writeAccessText(email)).toMatch(/yes/i); + + await timings.measure('delete_user', () => manageUsersPanel.deleteUser(email)); + await manageUsersPanel.waitForRowGone(email); + + await accountMenu.closeManageUsers(); + } finally { + await deleteUserByEmail(page, playkitConfig.baseUrl, email).catch(() => undefined); + } + }); +}); + +test.describe('admin manage users: causal cross-session effect @smoke', () => { + test('deactivating a user via admin UI immediately revokes their open session', async ({ + browser, + page, + playkitConfig, + loginPage, + accountMenu, + manageUsersPanel, + e2eCredentials, + timings, + }) => { + test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)'); + // Two full UI logins + create/edit/verify against a shared-CI DEV LXC push + // this past the default 60s under load (observed: a single login step + // alone took ~90s during a concurrent CI burst) — give it more headroom. + test.setTimeout(120_000); + const email = throwawayEmail('deactivate'); + let userContext: import('@playwright/test').BrowserContext | undefined; + + try { + await loginPage.openLogin(); + await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password); + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 }); + await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 }); + + await accountMenu.openManageUsers(); + await timings.measure('create_throwaway', () => + manageUsersPanel.createUser({ email, password: THROWAWAY_PASSWORD, name: 'E2E Throwaway' }), + ); + await accountMenu.closeManageUsers(); + + // Independent second session/actor — the throwaway user, logged in + // for real, concurrently with the admin. + userContext = await browser.newContext(); + const userPage = await userContext.newPage(); + const userLogin = new LoginPage(userPage, playkitConfig.baseUrl); + + await timings.measure('throwaway_login', async () => { + await userLogin.openLogin(); + await userLogin.signIn(email, THROWAWAY_PASSWORD); + await userPage.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 }); + await userPage.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 }); + }); + + const preRes = await userPage.request.get(`${playkitConfig.baseUrl}/api/auth/session`); + const preBody = await preRes.json(); + expect(preBody?.user?.email).toBe(email); + + // Admin deactivates the throwaway user. The throwaway user's session + // is never touched directly — no sign-out, no reload triggered by us. + await accountMenu.openManageUsers(); + await timings.measure('deactivate_user', async () => { + await manageUsersPanel.openEditDialog(email); + await manageUsersPanel.setActive(false); + await manageUsersPanel.saveEdit(); + }); + // The table defaults to (and refetches with) the "Active only" filter, + // so the just-deactivated row drops out of view immediately — switch to + // "All" before reading its badge, or this hangs forever waiting for a + // row that the current filter will never show again. + await manageUsersPanel.showAllUsers(); + expect(await manageUsersPanel.statusBadgeText(email)).toMatch(/inactive/i); + await accountMenu.closeManageUsers(); + + // The already-open session must now read as signed-out: the jwt + // callback re-checks `isActive` against the DB on every request. + await timings.measure('session_revoked', async () => { + const postRes = await userPage.request.get(`${playkitConfig.baseUrl}/api/auth/session`); + const postBody = await postRes.json(); + expect(postBody?.user).toBeFalsy(); + }); + } finally { + await userContext?.close(); + await deleteUserByEmail(page, playkitConfig.baseUrl, email).catch(() => undefined); + } + }); +}); diff --git a/e2e/tests/api.catalog.spec.ts b/e2e/tests/api.catalog.spec.ts index 7a31674..747d426 100644 --- a/e2e/tests/api.catalog.spec.ts +++ b/e2e/tests/api.catalog.spec.ts @@ -4,10 +4,15 @@ import { test, expect } from '../fixtures'; const PeopleList = z.object({ items: z.array(z.object({ id: z.number() }).passthrough()).min(1), }); +type PeopleList = z.infer; const TagsList = z.object({ items: z.array(z.object({ id: z.number() }).passthrough()).min(1), }); +type TagsList = z.infer; + +const ItemsList = z.object({ items: z.array(z.unknown()) }); +type ItemsList = z.infer; /** * Public / lightly-gated FastAPI surfaces — shape + status contracts via playkit ApiClient. @@ -16,16 +21,16 @@ const TagsList = z.object({ test.describe('api catalog @smoke', () => { test('GET /api/v1/people returns items list', async ({ api, timings }) => { const res = await timings.measure('api_people', () => - api.get('/api/v1/people', { expectedStatus: 200, schema: PeopleList }), + api.get('/api/v1/people', { expectedStatus: 200, schema: PeopleList }), ); expect(res.data.items[0]).toEqual(expect.objectContaining({ id: expect.any(Number) })); }); test('GET /api/v1/people/with-faces returns items list', async ({ api, timings }) => { const res = await timings.measure('api_people_faces', () => - api.get('/api/v1/people/with-faces', { + api.get('/api/v1/people/with-faces', { expectedStatus: 200, - schema: z.object({ items: z.array(z.unknown()) }), + schema: ItemsList, }), ); expect(Array.isArray(res.data.items)).toBe(true); @@ -33,7 +38,7 @@ test.describe('api catalog @smoke', () => { test('GET /api/v1/tags returns items list', async ({ api, timings }) => { const res = await timings.measure('api_tags', () => - api.get('/api/v1/tags', { expectedStatus: 200, schema: TagsList }), + api.get('/api/v1/tags', { expectedStatus: 200, schema: TagsList }), ); expect(res.data.items[0]).toEqual(expect.objectContaining({ id: expect.any(Number) })); }); diff --git a/e2e/tests/api.fastapi-login.spec.ts b/e2e/tests/api.fastapi-login.spec.ts index 4c08073..b2c18d7 100644 --- a/e2e/tests/api.fastapi-login.spec.ts +++ b/e2e/tests/api.fastapi-login.spec.ts @@ -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; +type UserResponse = z.infer; + 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('/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('/api/v1/auth/me', { expectedStatus: 200, schema: UserResponse }), ); expect(me.status).toBe(200); + expect(me.data.username).toBe(username); }); }); diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts index ab97ab6..fef3b62 100644 --- a/e2e/tests/auth.setup.ts +++ b/e2e/tests/auth.setup.ts @@ -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); + }, +); diff --git a/e2e/tests/forgot-password.mail.spec.ts b/e2e/tests/forgot-password.mail.spec.ts index 6ddbd79..b42d89c 100644 --- a/e2e/tests/forgot-password.mail.spec.ts +++ b/e2e/tests/forgot-password.mail.spec.ts @@ -3,6 +3,8 @@ import { firstLinkMatching, readMailHtml, waitForUrlHost, + type MailpitMessage, + type MailtrapMessage, } from '@levkin/playkit'; import { test, expect } from '../fixtures'; @@ -40,7 +42,7 @@ test.describe('mail @smoke', () => { ); expect(res.ok()).toBeTruthy(); - const msg = await timings.measure('mail_wait', () => + const msg = await timings.measure('mail_wait', () => mail!.waitForEmail({ to, subject: /reset|password/i, diff --git a/e2e/tests/gallery.filters.authed.spec.ts b/e2e/tests/gallery.filters.authed.spec.ts new file mode 100644 index 0000000..e4fee79 --- /dev/null +++ b/e2e/tests/gallery.filters.authed.spec.ts @@ -0,0 +1,70 @@ +import path from 'node:path'; +import { test, expect } from '../fixtures'; + +/** + * Logged-in-only gallery filters: Favorites and People are hidden from the + * `FilterPanel` for anonymous visitors (see `FilterPanel.tsx` `isLoggedIn` + * gate), so these need the saved admin `storageState` from `auth.setup.ts`. + * Public tag/person query-param filtering is covered without login in + * `gallery.search-filters.spec.ts`. + */ +test.describe('gallery filters (authed) @smoke', () => { + test.use({ storageState: path.join(__dirname, '../.auth/admin.json') }); + + test('favorites filter checkbox is visible and toggles the URL param', async ({ + page, + playkitConfig, + timings, + }) => { + await timings.measure('open_search', async () => { + await page.goto(`${playkitConfig.baseUrl}/search`); + }); + + const favoritesCheckbox = page.locator('#favorites-only'); + await expect(favoritesCheckbox).toBeVisible({ timeout: 20_000 }); + await expect(favoritesCheckbox).not.toBeChecked(); + + await page.getByText('Show favorites only').click(); + + await expect(favoritesCheckbox).toBeChecked(); + await expect(page).toHaveURL(/favoritesOnly=true/); + await expect( + page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(), + ).toBeVisible({ timeout: 20_000 }); + + // Toggling back off should drop the param again. + await page.getByText('Show favorites only').click(); + await expect(favoritesCheckbox).not.toBeChecked(); + await expect(page).not.toHaveURL(/favoritesOnly=true/); + }); + + test('people filter UI: selecting a person updates URL and result count', async ({ + page, + playkitConfig, + api, + timings, + }) => { + const peopleRes = await api.get<{ items: Array<{ id: number; first_name: string; last_name: string }> }>( + '/api/v1/people', + { expectedStatus: 200 }, + ); + const person = peopleRes.data.items[0]; + expect(person).toBeTruthy(); + + await timings.measure('open_search', async () => { + await page.goto(`${playkitConfig.baseUrl}/search`); + }); + + await page.getByRole('button', { name: /select people/i }).click(); + await page.getByPlaceholder('Search people...').fill(person.first_name); + await page + .getByText(`${person.first_name} ${person.last_name}`, { exact: true }) + .click(); + await page.keyboard.press('Escape'); + + await expect(page).toHaveURL(new RegExp(`people=${person.id}(&|$)`)); + await expect( + page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(), + ).toBeVisible({ timeout: 20_000 }); + }); +}); diff --git a/e2e/tests/gallery.search-filters.spec.ts b/e2e/tests/gallery.search-filters.spec.ts index d60be32..0438660 100644 --- a/e2e/tests/gallery.search-filters.spec.ts +++ b/e2e/tests/gallery.search-filters.spec.ts @@ -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; 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), + .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), + .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(), diff --git a/e2e/tests/network-errors.spec.ts b/e2e/tests/network-errors.spec.ts new file mode 100644 index 0000000..e29920c --- /dev/null +++ b/e2e/tests/network-errors.spec.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import { startNetworkErrorMonitor } from '@levkin/playkit'; +import { test, expect } from '../fixtures'; + +/** + * Silent-failure guard: these pages can *look* fine in the UI while a + * background XHR/fetch quietly 4xx/5xxs (the exact class of bug + * `startNetworkErrorMonitor` exists to catch — see playkit `docs/NETWORK.md`). + * Requires playkit >= 0.3.1 (network helpers landed after the 0.3.0 tag). + * + * `/api/auth/session` 401s on the anonymous project by design (no cookie) — + * excluded rather than asserted on, since it's not the failure mode we're + * guarding against here. + */ +const excludeAnonSession = [/\/api\/auth\/session/]; + +test.describe('network errors (public pages) @smoke', () => { + test('gallery home stays clean', async ({ page, playkitConfig, timings }) => { + const net = startNetworkErrorMonitor(page, { excludePatterns: excludeAnonSession }); + try { + await timings.measure('open_gallery', async () => { + await page.goto(`${playkitConfig.baseUrl}/`); + await expect(page.getByLabel('Account menu').or(page.getByRole('button', { name: /Sign in/i })).first()).toBeVisible({ + timeout: 20_000, + }); + }); + } finally { + net.assertNoErrors(); + } + }); + + test('search page with a tag filter stays clean', 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(); + + const net = startNetworkErrorMonitor(page, { excludePatterns: excludeAnonSession }); + try { + await timings.measure('search_tag_filter', async () => { + await page.goto(`${playkitConfig.baseUrl}/search?tags=${tag.id}`); + await expect( + page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(), + ).toBeVisible({ timeout: 20_000 }); + }); + } finally { + net.assertNoErrors(); + } + }); +}); + +test.describe('network errors (authed) @smoke', () => { + test.use({ storageState: path.join(__dirname, '../.auth/admin.json') }); + + test('upload page stays clean on load', async ({ page, playkitConfig, timings }) => { + const net = startNetworkErrorMonitor(page); + try { + await timings.measure('open_upload', async () => { + await page.goto(`${playkitConfig.baseUrl}/upload`); + await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 }); + }); + } finally { + net.assertNoErrors(); + } + }); + + test('manage users overlay stays clean', async ({ page, playkitConfig, accountMenu, timings }) => { + await page.goto(`${playkitConfig.baseUrl}/`); + await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 }); + + const net = startNetworkErrorMonitor(page); + try { + await timings.measure('manage_users', async () => { + await accountMenu.openManageUsers(); + await expect(page.getByRole('heading', { name: /Manage Users/i })).toBeVisible(); + await accountMenu.closeManageUsers(); + }); + } finally { + net.assertNoErrors(); + } + }); +}); diff --git a/e2e/tests/prod.smoke.spec.ts b/e2e/tests/prod.smoke.spec.ts new file mode 100644 index 0000000..8b22524 --- /dev/null +++ b/e2e/tests/prod.smoke.spec.ts @@ -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, + }); + }); +}); diff --git a/e2e/tests/register.verify.mail.spec.ts b/e2e/tests/register.verify.mail.spec.ts index 71e1a84..5585516 100644 --- a/e2e/tests/register.verify.mail.spec.ts +++ b/e2e/tests/register.verify.mail.spec.ts @@ -3,6 +3,8 @@ import { firstLinkMatching, readMailHtml, waitForUrlHost, + type MailpitMessage, + type MailtrapMessage, } from '@levkin/playkit'; import { test, expect } from '../fixtures'; @@ -48,7 +50,7 @@ test.describe('auth register @mail', () => { ).toBeVisible({ timeout: 20_000 }); await waitForUrlHost(page, playkitConfig.expectedHost); - const msg = await timings.measure('mail_wait', () => + const msg = await timings.measure('mail_wait', () => mail!.waitForEmail({ to: email, subject: /confirm|verify|welcome|account/i, diff --git a/e2e/tests/reset-password.flow.spec.ts b/e2e/tests/reset-password.flow.spec.ts index 0c5a60b..12fbb0b 100644 --- a/e2e/tests/reset-password.flow.spec.ts +++ b/e2e/tests/reset-password.flow.spec.ts @@ -3,6 +3,8 @@ import { firstLinkMatching, readMailHtml, waitForUrlHost, + type MailpitMessage, + type MailtrapMessage, } from '@levkin/playkit'; import { test, expect } from '../fixtures'; @@ -36,7 +38,7 @@ test.describe('auth reset @mail', () => { }); expect(res.ok()).toBeTruthy(); - const msg = await timings.measure('mail_wait', () => + const msg = await timings.measure('mail_wait', () => mail!.waitForEmail({ to, subject: /reset|password/i, after, timeoutMs: 90_000 }), ); const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string }); diff --git a/e2e/tests/upload.smoke.spec.ts b/e2e/tests/upload.smoke.spec.ts index 64bf7db..374d32f 100644 --- a/e2e/tests/upload.smoke.spec.ts +++ b/e2e/tests/upload.smoke.spec.ts @@ -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 diff --git a/e2e/tests/viewer.write-gates.spec.ts b/e2e/tests/viewer.write-gates.spec.ts new file mode 100644 index 0000000..e0622f9 --- /dev/null +++ b/e2e/tests/viewer.write-gates.spec.ts @@ -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) }); + }); +});