From 47b15049bc3ab7058a24eab0b27ba8a8b234100b Mon Sep 17 00:00:00 2001 From: ilia Date: Tue, 14 Jul 2026 18:49:42 -0400 Subject: [PATCH] Expand e2e auth coverage, Vitest CI, and shared URL defaults Wire viewer-frontend Vitest into Gitea Actions, centralize DEV base URLs in env-defaults.json, expand playkit API catalog specs, and land auth/mail flows plus verify-email public-host redirect and idle-logout e2e hooks. --- .gitea/workflows/ci.yml | 44 ++++++++-- e2e/.env.example | 19 +++-- e2e/README.md | 31 ++++--- e2e/env-defaults.json | 6 ++ e2e/env-defaults.ts | 27 ++++++ e2e/fixtures.ts | 30 +++---- e2e/package-lock.json | 6 +- e2e/package.json | 2 +- e2e/pages/AccountMenu.ts | 10 +++ e2e/pages/LoginPage.ts | 26 +++++- e2e/pages/RegisterPage.ts | 27 ++++++ e2e/playwright.config.ts | 5 +- e2e/tests/admin.manage-users.spec.ts | 40 +++++++++ e2e/tests/api.auth.spec.ts | 18 ++++ e2e/tests/api.catalog.spec.ts | 82 +++++++++++++++++++ e2e/tests/auth.signout.spec.ts | 3 +- e2e/tests/forgot-password.mail.spec.ts | 41 ++++------ e2e/tests/idle.logout.spec.ts | 40 +++++++++ e2e/tests/register.verify.mail.spec.ts | 82 +++++++++++++++++++ e2e/tests/reset-password.flow.spec.ts | 56 +++++++++++++ e2e/tests/upload.smoke.spec.ts | 34 ++++++++ e2e/tsconfig.json | 3 +- .../app/api/auth/verify-email/route.ts | 60 +++++++------- .../components/IdleLogoutHandler.tsx | 27 +++--- .../components/SessionProviderWrapper.tsx | 8 +- 25 files changed, 602 insertions(+), 125 deletions(-) create mode 100644 e2e/env-defaults.json create mode 100644 e2e/env-defaults.ts create mode 100644 e2e/pages/RegisterPage.ts create mode 100644 e2e/tests/admin.manage-users.spec.ts create mode 100644 e2e/tests/api.auth.spec.ts create mode 100644 e2e/tests/api.catalog.spec.ts create mode 100644 e2e/tests/idle.logout.spec.ts create mode 100644 e2e/tests/register.verify.mail.spec.ts create mode 100644 e2e/tests/reset-password.flow.spec.ts create mode 100644 e2e/tests/upload.smoke.spec.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e7d478d..f36c259 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,9 +5,12 @@ name: CI on: push: - branches: [master, main] + branches: [master, main, dev] pull_request: types: [opened, synchronize, reopened] + # Daily DEV smoke (~06:00 America/Toronto) + schedule: + - cron: '0 10 * * *' jobs: skip-ci-check: @@ -68,6 +71,30 @@ jobs: docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \ detect --source /repo --no-banner --redact ${extra} + # Vitest unit tests (viewer-frontend) — landed in PR #63, wired here. + viewer-unit: + needs: skip-ci-check + if: needs.skip-ci-check.outputs.should-skip != '1' + runs-on: [homelab, self-hosted, linux] + defaults: + run: + working-directory: viewer-frontend + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: viewer-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: @@ -86,9 +113,9 @@ jobs: node-version: '20' - name: Allow private playkit clone - if: ${{ secrets.GITEA_TOKEN != '' }} + if: ${{ secrets.PLAYKIT_GIT_TOKEN != '' }} run: | - git config --global url."https://oauth2:${{ secrets.GITEA_TOKEN }}@git.levkin.ca/".insteadOf "https://git.levkin.ca/" + git config --global url."https://oauth2:${{ secrets.PLAYKIT_GIT_TOKEN }}@git.levkin.ca/".insteadOf "https://git.levkin.ca/" - name: Install e2e deps run: npm ci @@ -107,10 +134,17 @@ jobs: MAILTRAP_API_TOKEN: ${{ secrets.MAILTRAP_API_TOKEN }} MAILTRAP_ACCOUNT_ID: ${{ secrets.MAILTRAP_ACCOUNT_ID }} MAILTRAP_INBOX_ID: ${{ secrets.MAILTRAP_INBOX_ID }} + PLAYKIT_MAIL_PROVIDER: ${{ secrets.PLAYKIT_MAIL_PROVIDER }} + MAILPIT_BASE_URL: ${{ secrets.MAILPIT_BASE_URL }} + MAILPIT_USER: ${{ secrets.MAILPIT_USER }} + MAILPIT_PASSWORD: ${{ secrets.MAILPIT_PASSWORD }} CI: 'true' run: | - export PLAYKIT_BASE_URL="${PLAYKIT_BASE_URL:-https://punimtagdev.levkin.ca}" - export PLAYKIT_API_BASE_URL="${PLAYKIT_API_BASE_URL:-http://10.0.10.121:8000}" + # Fallbacks from e2e/env-defaults.json (single source of truth with TS) + DEFAULT_BASE_URL=$(node -p "require('./env-defaults.json').DEFAULT_BASE_URL") + DEFAULT_API_BASE_URL=$(node -p "require('./env-defaults.json').DEFAULT_API_BASE_URL") + export PLAYKIT_BASE_URL="${PLAYKIT_BASE_URL:-$DEFAULT_BASE_URL}" + export PLAYKIT_API_BASE_URL="${PLAYKIT_API_BASE_URL:-$DEFAULT_API_BASE_URL}" npx playwright test - name: Upload report diff --git a/e2e/.env.example b/e2e/.env.example index 5656bcd..52aacf8 100644 --- a/e2e/.env.example +++ b/e2e/.env.example @@ -1,17 +1,22 @@ -# Public UI (required for host-guard tests) -PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca +# Defaults also live in env-defaults.json — override here for local runs. +# PLAYKIT_BASE_URL / PLAYKIT_API_BASE_URL fall back to that file when unset. + PLAYKIT_PROJECT=punimtag PLAYKIT_ENV=dev -# FastAPI on the DEV LXC (runners on LAN can reach this) -PLAYKIT_API_BASE_URL=http://10.0.10.121:8000 - # Dedicated e2e user — never commit real passwords E2E_ADMIN_EMAIL= E2E_ADMIN_PASSWORD= -# Mailtrap Email Testing (optional — forgot-password specs skip if unset) +# Homelab Mailpit (preferred for DEV e2e) +PLAYKIT_MAIL_PROVIDER=mailpit +MAILPIT_BASE_URL=http://10.0.10.45:8025 +MAILPIT_USER= +MAILPIT_PASSWORD= +# E2E_MAIL_TO= # defaults to E2E_ADMIN_EMAIL + +# Mailtrap Email Testing (optional SaaS fallback) +# PLAYKIT_MAIL_PROVIDER=mailtrap # MAILTRAP_API_TOKEN= # MAILTRAP_ACCOUNT_ID= # MAILTRAP_INBOX_ID= -# E2E_MAIL_TO= # defaults to E2E_ADMIN_EMAIL diff --git a/e2e/README.md b/e2e/README.md index 46dbe0a..69383ae 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,7 +13,7 @@ npx playwright install chromium npm test ``` -Private Gitea installs need auth once: set a local git `insteadOf` with `GITEA_TOKEN` +Private Gitea installs need auth once: set a local git `insteadOf` with a token before `npm ci`, then unset it. CI does this in `.gitea/workflows/ci.yml`. Never commit lockfile URLs that embed tokens. @@ -21,20 +21,29 @@ Never commit lockfile URLs that embed tokens. | Variable | Required | Purpose | |----------|----------|---------| -| `PLAYKIT_BASE_URL` | yes (default in config) | `https://punimtagdev.levkin.ca` | -| `PLAYKIT_API_BASE_URL` | no | Default `http://10.0.10.121:8000` (LAN API) | -| `E2E_ADMIN_EMAIL` | for sign-out spec | Dedicated e2e user (not a human password) | -| `E2E_ADMIN_PASSWORD` | for sign-out spec | Dedicated e2e password | +| `PLAYKIT_BASE_URL` | yes (default in `env-defaults.json`) | Public viewer host | +| `PLAYKIT_API_BASE_URL` | no | LAN API (see `env-defaults.json`) | +| `E2E_ADMIN_EMAIL` | for auth specs | Dedicated e2e user (not a human password) | +| `E2E_ADMIN_PASSWORD` | for auth specs | Dedicated e2e password | +| `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 | -Without credentials, the public-host login-page + API health specs still run; -sign-out is skipped. - -**Secrets:** store in Infisical `LevkinOps` → sync to Gitea Actions. See ansible -`docs/hardening/SECRETS.md`. +**Secrets:** Infisical `LevkinOps` → `dev` → `/playkit/punimtag` + Gitea Actions. +See ansible `docs/hardening/SECRETS.md`. ## What we assert 1. Login page loads on the public hostname 2. Sign-out stays on that hostname (never `10.x`) 3. FastAPI `/health` → `{ "status": "ok" }` -4. Forgot-password email appears in Mailtrap; reset link uses public host +4. Forgot-password / reset-password email in Mailpit; links use public host +5. Register → unverified block → verify email via Mailpit → login +6. Idle logout (`?e2e_idle_ms=`) stays on public host +7. Upload page loads when authenticated +8. Manage Users open/close then sign-out (Kolby #57 overlay path) +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 + +Defaults for base URLs live in `env-defaults.json` (imported by `env-defaults.ts`, +`fixtures.ts`, `playwright.config.ts`, and the Gitea `e2e` job). diff --git a/e2e/env-defaults.json b/e2e/env-defaults.json new file mode 100644 index 0000000..8c214da --- /dev/null +++ b/e2e/env-defaults.json @@ -0,0 +1,6 @@ +{ + "DEFAULT_BASE_URL": "https://punimtagdev.levkin.ca", + "DEFAULT_API_BASE_URL": "http://10.0.10.121:8000", + "DEFAULT_PROJECT": "punimtag", + "DEFAULT_ENV": "dev" +} diff --git a/e2e/env-defaults.ts b/e2e/env-defaults.ts new file mode 100644 index 0000000..a3d5e27 --- /dev/null +++ b/e2e/env-defaults.ts @@ -0,0 +1,27 @@ +/** + * Canonical punimtag DEV targets — single source of truth for fixtures, + * playwright.config.ts, and the Gitea Actions `e2e` job defaults. + * + * Values live in env-defaults.json so CI bash can read them without + * compiling TypeScript (see `.gitea/workflows/ci.yml`). + */ +import defaults from './env-defaults.json' with { type: 'json' }; + +export const DEFAULT_BASE_URL = defaults.DEFAULT_BASE_URL; +export const DEFAULT_API_BASE_URL = defaults.DEFAULT_API_BASE_URL; +export const DEFAULT_PROJECT = defaults.DEFAULT_PROJECT; +export const DEFAULT_ENV = defaults.DEFAULT_ENV; + +/** Env bag for createPlaykitRuntime — fills gaps only, never overrides set vars. */ +export function playkitEnvFromProcess( + env: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { + ...env, + PLAYKIT_PROJECT: env.PLAYKIT_PROJECT || env.CI_PROJECT || DEFAULT_PROJECT, + PLAYKIT_ENV: env.PLAYKIT_ENV || env.APP_ENV || DEFAULT_ENV, + PLAYKIT_BASE_URL: env.PLAYKIT_BASE_URL || env.BASE_URL || DEFAULT_BASE_URL, + PLAYKIT_API_BASE_URL: + env.PLAYKIT_API_BASE_URL || env.API_BASE_URL || DEFAULT_API_BASE_URL, + }; +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 6e5b68d..00e4958 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,31 +1,23 @@ import { test as base, expect } from '@playwright/test'; import { createPlaykitRuntime, - MailtrapClient, + createMailInbox, type PlaykitFixtures, + type MailInbox, } from '@levkin/playkit'; +import { playkitEnvFromProcess } from './env-defaults'; import { LoginPage } from './pages/LoginPage'; import { AccountMenu } from './pages/AccountMenu'; +import { RegisterPage } from './pages/RegisterPage'; -const runtime = createPlaykitRuntime({ - ...process.env, - PLAYKIT_PROJECT: process.env.PLAYKIT_PROJECT || 'punimtag', - PLAYKIT_ENV: process.env.PLAYKIT_ENV || 'dev', - PLAYKIT_API_BASE_URL: - process.env.PLAYKIT_API_BASE_URL || - process.env.API_BASE_URL || - 'http://10.0.10.121:8000', - PLAYKIT_BASE_URL: - process.env.PLAYKIT_BASE_URL || - process.env.BASE_URL || - 'https://punimtagdev.levkin.ca', -}); +const runtime = createPlaykitRuntime(playkitEnvFromProcess()); type PunimtagFixtures = PlaykitFixtures & { loginPage: LoginPage; accountMenu: AccountMenu; + registerPage: RegisterPage; e2eCredentials: { email: string; password: string } | null; - mailtrap: MailtrapClient | null; + mail: MailInbox | null; }; export const test = base.extend({ @@ -38,6 +30,8 @@ export const test = base.extend({ use(new LoginPage(page, playkitConfig.baseUrl)), accountMenu: async ({ page, playkitConfig }, use) => use(new AccountMenu(page, playkitConfig.baseUrl)), + registerPage: async ({ page, playkitConfig }, use) => + use(new RegisterPage(page, playkitConfig.baseUrl)), e2eCredentials: async ({}, use) => { const email = process.env.E2E_ADMIN_EMAIL || process.env.E2E_EMAIL || ''; @@ -45,10 +39,8 @@ export const test = base.extend({ await use(email && password ? { email, password } : null); }, - mailtrap: async ({ playkitLog }, use) => { - await use( - MailtrapClient.fromEnv(process.env, playkitLog.child({ component: 'mailtrap' })), - ); + 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 88aed51..7adac58 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.2.0", + "@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.2.1", "@playwright/test": "^1.52.0", "@types/node": "^22.15.0", "typescript": "^5.8.0" @@ -18,8 +18,8 @@ } }, "node_modules/@levkin/playkit": { - "version": "0.2.0", - "resolved": "git+https://git.levkin.ca/ilia/playkit.git#2f3991dadc172aae86b98538eb5654cdb3237e67", + "version": "0.2.1", + "resolved": "git+https://git.levkin.ca/ilia/playkit.git#a52fe8937217a1a6fbe539d89d66b739b5fac607", "dev": true, "license": "MIT", "engines": { diff --git a/e2e/package.json b/e2e/package.json index fe990ef..7e81703 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -13,7 +13,7 @@ "node": ">=20" }, "devDependencies": { - "@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.2.0", + "@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.2.1", "@playwright/test": "^1.52.0", "@types/node": "^22.15.0", "typescript": "^5.8.0" diff --git a/e2e/pages/AccountMenu.ts b/e2e/pages/AccountMenu.ts index ed7ada8..06e1a61 100644 --- a/e2e/pages/AccountMenu.ts +++ b/e2e/pages/AccountMenu.ts @@ -15,4 +15,14 @@ export class AccountMenu extends BasePage { await this.openMenu(); await this.click(this.page.getByRole('button', { name: 'Sign out' })); } + + async openManageUsers(): Promise { + await this.openMenu(); + await this.click(this.page.getByRole('button', { name: 'Manage Users' })); + await waitForVisible(this.page.getByRole('heading', { name: /Manage Users/i })); + } + + async closeManageUsers(): Promise { + await this.click(this.page.getByLabel('Close manage users')); + } } diff --git a/e2e/pages/LoginPage.ts b/e2e/pages/LoginPage.ts index 6840e07..ed61113 100644 --- a/e2e/pages/LoginPage.ts +++ b/e2e/pages/LoginPage.ts @@ -8,11 +8,31 @@ export class LoginPage extends BasePage { async openLogin(): Promise { await this.open('/login'); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator('#email').waitFor({ state: 'visible' }); + // Wait for client bundle / React hydration (native GET submit otherwise) + await this.page.waitForFunction(() => { + const form = document.querySelector('form'); + const btn = document.querySelector('button[type="submit"]'); + return !!form && !!btn && !!(window as unknown as { next?: unknown }).next; + }).catch(() => undefined); + await this.page.waitForTimeout(600); } async signIn(email: string, password: string): Promise { - await this.fill(this.page.locator('#email'), email); - await this.fill(this.page.locator('#password'), password); - await this.click(this.page.locator('button[type="submit"]')); + for (let attempt = 0; attempt < 3; attempt++) { + await this.fill(this.page.locator('#email'), email); + await this.fill(this.page.locator('#password'), password); + await this.page.waitForTimeout(250 * (attempt + 1)); + await this.click(this.page.getByRole('button', { name: /Sign in/i })); + await this.page.waitForTimeout(400); + // Native GET fallback leaks credentials into the query string — retry. + if (!this.page.url().includes('password=')) { + return; + } + this.log.warn('login GET fallback detected; retrying after re-open', { attempt }); + await this.openLogin(); + } + throw new Error('Login form submitted as GET (page not hydrated)'); } } diff --git a/e2e/pages/RegisterPage.ts b/e2e/pages/RegisterPage.ts new file mode 100644 index 0000000..55bd2f0 --- /dev/null +++ b/e2e/pages/RegisterPage.ts @@ -0,0 +1,27 @@ +import { type Page } from '@playwright/test'; +import { BasePage } from '@levkin/playkit'; + +export class RegisterPage extends BasePage { + constructor(page: Page, baseUrl: string) { + super(page, baseUrl); + } + + async openRegister(): Promise { + await this.open('/register'); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.locator('#email').waitFor({ state: 'visible' }); + await this.page.waitForTimeout(300); + } + + async register(input: { + name: string; + email: string; + password: string; + }): Promise { + await this.fill(this.page.locator('#name'), input.name); + await this.fill(this.page.locator('#email'), input.email); + await this.fill(this.page.locator('#password'), input.password); + await this.fill(this.page.locator('#confirmPassword'), input.password); + await this.click(this.page.locator('button[type="submit"]')); + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 2f8f621..c05f5ef 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,9 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; +import { DEFAULT_BASE_URL } from './env-defaults'; const baseURL = - process.env.PLAYKIT_BASE_URL || - process.env.BASE_URL || - 'https://punimtagdev.levkin.ca'; + process.env.PLAYKIT_BASE_URL || process.env.BASE_URL || DEFAULT_BASE_URL; export default defineConfig({ testDir: './tests', diff --git a/e2e/tests/admin.manage-users.spec.ts b/e2e/tests/admin.manage-users.spec.ts new file mode 100644 index 0000000..3077ba9 --- /dev/null +++ b/e2e/tests/admin.manage-users.spec.ts @@ -0,0 +1,40 @@ +import { assertPublicHost, waitForUrlHost } from '@levkin/playkit'; +import { test, expect } from '../fixtures'; + +/** + * Kolby #57 class: Manage Users overlay must close cleanly before sign-out + * and the post-logout URL must stay on the public host. + */ +test.describe('admin users @smoke', () => { + test('manage users then sign-out stays on public host', async ({ + page, + playkitConfig, + loginPage, + accountMenu, + e2eCredentials, + timings, + }) => { + test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)'); + assertPublicHost(playkitConfig.baseUrl); + + await timings.measure('login', async () => { + 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 timings.measure('manage_users', async () => { + await accountMenu.openManageUsers(); + await expect(page.getByRole('heading', { name: /Manage Users/i })).toBeVisible(); + await accountMenu.closeManageUsers(); + }); + + await timings.measure('sign_out', () => accountMenu.signOut()); + await waitForUrlHost(page, playkitConfig.expectedHost); + expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/); + await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({ + timeout: 15_000, + }); + }); +}); diff --git a/e2e/tests/api.auth.spec.ts b/e2e/tests/api.auth.spec.ts new file mode 100644 index 0000000..0945702 --- /dev/null +++ b/e2e/tests/api.auth.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '../fixtures'; + +test.describe('api auth @smoke', () => { + test('protected /api/v1/auth/me returns 401 without token', async ({ api, timings }) => { + const res = await timings.measure('api_me_unauth', () => + api.get('/api/v1/auth/me', { expectedStatus: 401 }), + ); + expect(res.status).toBe(401); + expect(res.data).toMatchObject({ detail: expect.stringMatching(/not authenticated/i) }); + }); + + test('protected /api/v1/photos returns 401 without token', async ({ api, timings }) => { + const res = await timings.measure('api_photos_unauth', () => + api.get('/api/v1/photos', { expectedStatus: 401 }), + ); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/api.catalog.spec.ts b/e2e/tests/api.catalog.spec.ts new file mode 100644 index 0000000..a6d8b83 --- /dev/null +++ b/e2e/tests/api.catalog.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '../fixtures'; + +/** + * Public / lightly-gated FastAPI surfaces — shape + status contracts via playkit ApiClient. + * Does not require e2e login credentials. + */ +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<{ items: Array<{ id: number; first_name?: string }> }>('/api/v1/people', { + expectedStatus: 200, + }), + ); + expect(Array.isArray(res.data.items)).toBe(true); + expect(res.data.items.length).toBeGreaterThan(0); + 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<{ items: unknown[] }>('/api/v1/people/with-faces', { expectedStatus: 200 }), + ); + expect(Array.isArray(res.data.items)).toBe(true); + }); + + test('GET /api/v1/tags returns items list', async ({ api, timings }) => { + const res = await timings.measure('api_tags', () => + api.get<{ items: Array<{ id: number; tag_name?: string }> }>('/api/v1/tags', { + expectedStatus: 200, + }), + ); + expect(Array.isArray(res.data.items)).toBe(true); + expect(res.data.items.length).toBeGreaterThan(0); + expect(res.data.items[0]).toEqual( + expect.objectContaining({ id: expect.any(Number) }), + ); + }); + + test('POST /api/v1/auth/login rejects empty body with 422', async ({ api, timings }) => { + const res = await timings.measure('api_login_empty', () => + api.post('/api/v1/auth/login', { body: {}, expectedStatus: 422 }), + ); + expect(res.status).toBe(422); + }); + + test('POST /api/v1/auth/login rejects bad credentials with 401', async ({ api, timings }) => { + const res = await timings.measure('api_login_bad', () => + api.post('/api/v1/auth/login', { + body: { username: 'e2e-no-such-user', password: 'wrong-password' }, + expectedStatus: 401, + }), + ); + expect(res.status).toBe(401); + expect(res.data).toMatchObject({ + detail: expect.stringMatching(/incorrect|password|username/i), + }); + }); + + test('GET /api/v1/users returns 401 without token', async ({ api, timings }) => { + const res = await timings.measure('api_users_unauth', () => + api.get('/api/v1/users', { expectedStatus: 401 }), + ); + expect(res.status).toBe(401); + }); + + test('GET /api/v1/role-permissions returns 401 without token', async ({ api, timings }) => { + const res = await timings.measure('api_roles_unauth', () => + api.get('/api/v1/role-permissions', { expectedStatus: 401 }), + ); + expect(res.status).toBe(401); + }); + + test('GET /api/v1/photos/{id} for missing photo is 404 (or 401)', async ({ api, timings }) => { + // Current DEV returns 404 without auth for missing ids; accept either contract. + const res = await timings.measure('api_photo_missing', () => + api.get('/api/v1/photos/999999999', { expectedStatus: [401, 404] }), + ); + expect([401, 404]).toContain(res.status); + }); +}); diff --git a/e2e/tests/auth.signout.spec.ts b/e2e/tests/auth.signout.spec.ts index 2c669db..e7fca1b 100644 --- a/e2e/tests/auth.signout.spec.ts +++ b/e2e/tests/auth.signout.spec.ts @@ -39,7 +39,8 @@ test.describe('auth @smoke', () => { await timings.measure('login', async () => { await loginPage.openLogin(); await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password); - await page.getByLabel('Account menu').waitFor({ state: 'visible' }); + 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); diff --git a/e2e/tests/forgot-password.mail.spec.ts b/e2e/tests/forgot-password.mail.spec.ts index 63ad463..6ddbd79 100644 --- a/e2e/tests/forgot-password.mail.spec.ts +++ b/e2e/tests/forgot-password.mail.spec.ts @@ -1,31 +1,26 @@ import { assertPublicHost, firstLinkMatching, + readMailHtml, waitForUrlHost, } from '@levkin/playkit'; import { test, expect } from '../fixtures'; /** - * Kolby #56 class: forgot-password must actually produce a message we can read, - * and the reset link must use the public host (not 10.x). + * Forgot-password must land in the mail trap (Mailpit) with a public reset link. * - * Requires: - * - MAILTRAP_API_TOKEN + MAILTRAP_INBOX_ID (+ optional MAILTRAP_ACCOUNT_ID) - * - punimtag DEV SMTP → sandbox.smtp.mailtrap.io (see SECRETS.md) - * - E2E_ADMIN_EMAIL (or E2E_MAIL_TO) for an existing verified user + * Requires PLAYKIT_MAIL_PROVIDER=mailpit + MAILPIT_* (or Mailtrap SaaS envs), + * punimtag DEV SMTP → Mailpit, and E2E_ADMIN_EMAIL / E2E_MAIL_TO. */ test.describe('mail @smoke', () => { - test('forgot-password email lands in Mailtrap with public reset link', async ({ + test('forgot-password email lands in mail trap with public reset link', async ({ request, playkitConfig, timings, - mailtrap, + mail, e2eCredentials, }) => { - test.skip( - !mailtrap, - 'Set MAILTRAP_API_TOKEN and MAILTRAP_INBOX_ID (Infisical /playkit/punimtag)', - ); + test.skip(!mail, 'Set MAILPIT_BASE_URL (+ auth) or MAILTRAP_* secrets'); const to = process.env.E2E_MAIL_TO || @@ -45,8 +40,8 @@ test.describe('mail @smoke', () => { ); expect(res.ok()).toBeTruthy(); - const msg = await timings.measure('mailtrap_wait', () => - mailtrap!.waitForEmail({ + const msg = await timings.measure('mail_wait', () => + mail!.waitForEmail({ to, subject: /reset|password/i, after, @@ -55,7 +50,7 @@ test.describe('mail @smoke', () => { }), ); - const html = await mailtrap!.getHtml(msg.id, msg.html_path); + const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string }); const link = firstLinkMatching(html, /reset-password/); expect(link, 'reset link missing from email HTML').toBeTruthy(); @@ -65,14 +60,14 @@ test.describe('mail @smoke', () => { expect(url.pathname).toMatch(/reset-password/); }); - test('forgot-password UI shows success and Mailtrap gets mail', async ({ + test('forgot-password UI shows success and mail trap gets mail', async ({ page, playkitConfig, timings, - mailtrap, + mail, e2eCredentials, }) => { - test.skip(!mailtrap, 'Set MAILTRAP_API_TOKEN and MAILTRAP_INBOX_ID'); + test.skip(!mail, 'Set MAILPIT_BASE_URL (+ auth) or MAILTRAP_* secrets'); const to = process.env.E2E_MAIL_TO || e2eCredentials?.email || @@ -89,17 +84,17 @@ test.describe('mail @smoke', () => { await page.locator('#forgot-email').fill(to); await page.getByRole('button', { name: /Send reset link/i }).click(); - await expect(page.getByRole('status')).toContainText(/sent|check your inbox/i, { - timeout: 20_000, - }); + await expect( + page.getByText(/Password reset email sent|check your inbox/i).first(), + ).toBeVisible({ timeout: 30_000 }); await waitForUrlHost(page, playkitConfig.expectedHost); - const msg = await mailtrap!.waitForEmail({ + const msg = await mail!.waitForEmail({ to, subject: /reset|password/i, after, timeoutMs: 90_000, }); - expect(msg.id).toBeTruthy(); + expect(msg).toBeTruthy(); }); }); diff --git a/e2e/tests/idle.logout.spec.ts b/e2e/tests/idle.logout.spec.ts new file mode 100644 index 0000000..cd18692 --- /dev/null +++ b/e2e/tests/idle.logout.spec.ts @@ -0,0 +1,40 @@ +import { assertPublicHost, waitForUrlHost } from '@levkin/playkit'; +import { test, expect } from '../fixtures'; + +/** + * Idle logout must redirect to the public host (same class as #57). + * Uses ?e2e_idle_ms= to shorten the IdleLogoutHandler timeout. + */ +test.describe('auth idle @smoke', () => { + test('idle logout stays on public host', async ({ + page, + playkitConfig, + loginPage, + e2eCredentials, + timings, + }) => { + test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required'); + assertPublicHost(playkitConfig.baseUrl); + + await timings.measure('login', async () => { + 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 }); + }); + + // Short idle window — requires IdleLogoutHandler e2e_idle_ms support on DEV + await page.goto(`${playkitConfig.baseUrl}/?e2e_idle_ms=2500`); + await waitForUrlHost(page, playkitConfig.expectedHost); + await expect(page.getByLabel('Account menu')).toBeVisible(); + + // Stop activity; wait for idle sign-out + await page.waitForTimeout(4_000); + + await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({ + timeout: 20_000, + }); + await waitForUrlHost(page, playkitConfig.expectedHost); + expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/); + }); +}); diff --git a/e2e/tests/register.verify.mail.spec.ts b/e2e/tests/register.verify.mail.spec.ts new file mode 100644 index 0000000..71e1a84 --- /dev/null +++ b/e2e/tests/register.verify.mail.spec.ts @@ -0,0 +1,82 @@ +import { + assertPublicHost, + firstLinkMatching, + readMailHtml, + waitForUrlHost, +} from '@levkin/playkit'; +import { test, expect } from '../fixtures'; + +/** + * Register → confirmation mail in Mailpit → verify link on public host. + * Also asserts unverified users cannot sign in. + */ +test.describe('auth register @mail', () => { + test('register, block unverified login, verify via mail trap', async ({ + page, + playkitConfig, + registerPage, + loginPage, + mail, + timings, + }) => { + test.skip(!mail, 'Mail trap not configured'); + + assertPublicHost(playkitConfig.baseUrl); + const stamp = Date.now(); + const email = `e2e.register.${stamp}@levkin.test`; + const password = `E2eReg${stamp}!a`; + const after = new Date(Date.now() - 5_000); + + await timings.measure('register', async () => { + await registerPage.openRegister(); + await registerPage.register({ + name: `E2E Register ${stamp}`, + email, + password, + }); + await page.waitForURL(/\/login/, { timeout: 30_000 }); + }); + + await expect(page.getByText(/Account created|check your email|confirm/i).first()).toBeVisible({ + timeout: 15_000, + }); + + // Unverified login must be blocked + await loginPage.signIn(email, password); + await expect( + page.getByText(/verify your email|confirmation email/i).first(), + ).toBeVisible({ timeout: 20_000 }); + await waitForUrlHost(page, playkitConfig.expectedHost); + + const msg = await timings.measure('mail_wait', () => + mail!.waitForEmail({ + to: email, + subject: /confirm|verify|welcome|account/i, + after, + timeoutMs: 90_000, + }), + ); + const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string }); + const link = firstLinkMatching(html, /verify-email|confirm|token=/); + expect(link, 'verification link missing from email').toBeTruthy(); + + const url = new URL(link!); + expect(url.hostname).toBe(playkitConfig.expectedHost); + + // DEV next can briefly refuse during HMR — retry navigation + await expect(async () => { + const res = await page.goto(link!, { waitUntil: 'domcontentloaded', timeout: 20_000 }); + expect(res?.status() ?? 0).toBeLessThan(500); + }).toPass({ timeout: 45_000 }); + + await waitForUrlHost(page, playkitConfig.expectedHost); + await expect(page.getByText(/verified|sign in/i).first()).toBeVisible({ timeout: 20_000 }); + + // After verify, login should succeed + await loginPage.openLogin(); + await loginPage.signIn(email, password); + await page.waitForURL((u) => !u.pathname.includes('/login'), { timeout: 30_000 }); + await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 }); + await waitForUrlHost(page, playkitConfig.expectedHost); + }); +}); diff --git a/e2e/tests/reset-password.flow.spec.ts b/e2e/tests/reset-password.flow.spec.ts new file mode 100644 index 0000000..0c5a60b --- /dev/null +++ b/e2e/tests/reset-password.flow.spec.ts @@ -0,0 +1,56 @@ +import { + assertPublicHost, + firstLinkMatching, + readMailHtml, + waitForUrlHost, +} from '@levkin/playkit'; +import { test, expect } from '../fixtures'; + +/** + * Assert the reset link from the mail trap opens on the public host with a form. + * Does not submit a new password (avoids rotating shared DEV admin credentials). + */ +test.describe('auth reset @mail', () => { + test('reset-password link opens public form', async ({ + page, + request, + playkitConfig, + mail, + e2eCredentials, + timings, + }) => { + test.skip(!mail, 'Mail trap not configured'); + const to = + process.env.E2E_MAIL_TO || + e2eCredentials?.email || + process.env.E2E_ADMIN_EMAIL || + ''; + test.skip(!to, 'E2E_MAIL_TO or E2E_ADMIN_EMAIL required'); + + assertPublicHost(playkitConfig.baseUrl); + const after = new Date(Date.now() - 5_000); + + const res = await request.post(`${playkitConfig.baseUrl}/api/auth/forgot-password`, { + data: { email: to }, + headers: { 'Content-Type': 'application/json' }, + }); + expect(res.ok()).toBeTruthy(); + + 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 }); + const link = firstLinkMatching(html, /reset-password/); + expect(link).toBeTruthy(); + + const url = new URL(link!); + expect(url.hostname).toBe(playkitConfig.expectedHost); + + await page.goto(link!); + await waitForUrlHost(page, playkitConfig.expectedHost); + await expect(page.locator('#password, input[name="password"]').first()).toBeVisible({ + timeout: 15_000, + }); + expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/); + }); +}); diff --git a/e2e/tests/upload.smoke.spec.ts b/e2e/tests/upload.smoke.spec.ts new file mode 100644 index 0000000..b4eb9e9 --- /dev/null +++ b/e2e/tests/upload.smoke.spec.ts @@ -0,0 +1,34 @@ +import { assertPublicHost, waitForUrlHost } from '@levkin/playkit'; +import { test, expect } from '../fixtures'; + +test.describe('upload @smoke', () => { + test('authenticated user can open upload page', async ({ + page, + playkitConfig, + loginPage, + e2eCredentials, + timings, + }) => { + test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required'); + assertPublicHost(playkitConfig.baseUrl); + + await timings.measure('login', async () => { + 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 timings.measure('open_upload', async () => { + await page.goto(`${playkitConfig.baseUrl}/upload`); + await waitForUrlHost(page, playkitConfig.expectedHost); + }); + + expect(new URL(page.url()).pathname).toMatch(/\/upload/); + // Upload UI chrome + await expect( + page.getByText(/upload|drop|select|choose file|photos/i).first(), + ).toBeVisible({ timeout: 20_000 }); + await expect(page.getByLabel('Account menu')).toBeVisible(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index f6ec33b..977101f 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -6,7 +6,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "resolveJsonModule": true, "types": ["node"] }, - "include": ["./**/*.ts"] + "include": ["./**/*.ts", "./**/*.json"] } diff --git a/viewer-frontend/app/api/auth/verify-email/route.ts b/viewer-frontend/app/api/auth/verify-email/route.ts index b4e17ae..39d2849 100644 --- a/viewer-frontend/app/api/auth/verify-email/route.ts +++ b/viewer-frontend/app/api/auth/verify-email/route.ts @@ -1,43 +1,53 @@ import { NextRequest, NextResponse } from 'next/server'; import { prismaAuth } from '@/lib/db'; +/** + * Public site origin for redirects. Behind Caddy, `request.url` is often + * http://localhost:3001 which sends browsers (and e2e) to a LAN/loopback + * host — same class of bug as NEXTAUTH_URL → 10.x (#57). + */ +function publicOrigin(request: NextRequest): string { + const fromEnv = (process.env.NEXTAUTH_URL || process.env.AUTH_URL || '').replace(/\/$/, ''); + if (fromEnv) return fromEnv; + + const xfProto = request.headers.get('x-forwarded-proto'); + const xfHost = request.headers.get('x-forwarded-host') || request.headers.get('host'); + if (xfHost) { + const proto = xfProto || 'https'; + return `${proto}://${xfHost.split(',')[0].trim()}`; + } + return request.nextUrl.origin; +} + +function redirectLogin(request: NextRequest, query: string): NextResponse { + return NextResponse.redirect(new URL(`/login?${query}`, publicOrigin(request))); +} + export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; const token = searchParams.get('token'); if (!token) { - return NextResponse.redirect( - new URL('/login?error=missing_token', request.url) - ); + return redirectLogin(request, 'error=missing_token'); } - // Find user with this token const user = await prismaAuth.user.findUnique({ where: { emailConfirmationToken: token }, }); if (!user) { - return NextResponse.redirect( - new URL('/login?error=invalid_token', request.url) - ); + return redirectLogin(request, 'error=invalid_token'); } - // Check if token has expired if (user.emailConfirmationTokenExpiry && user.emailConfirmationTokenExpiry < new Date()) { - return NextResponse.redirect( - new URL('/login?error=token_expired', request.url) - ); + return redirectLogin(request, 'error=token_expired'); } - // Check if already verified if (user.emailVerified) { - return NextResponse.redirect( - new URL('/login?message=already_verified', request.url) - ); + return redirectLogin(request, 'message=already_verified'); } - // Verify the email await prismaAuth.user.update({ where: { id: user.id }, data: { @@ -47,21 +57,9 @@ export async function GET(request: NextRequest) { }, }); - // Redirect to login with success message - return NextResponse.redirect( - new URL('/login?verified=true', request.url) - ); - } catch (error: any) { + return redirectLogin(request, 'verified=true'); + } catch (error: unknown) { console.error('Error verifying email:', error); - return NextResponse.redirect( - new URL('/login?error=verification_failed', request.url) - ); + return redirectLogin(request, 'error=verification_failed'); } } - - - - - - - diff --git a/viewer-frontend/components/IdleLogoutHandler.tsx b/viewer-frontend/components/IdleLogoutHandler.tsx index 77a3c61..f486f49 100644 --- a/viewer-frontend/components/IdleLogoutHandler.tsx +++ b/viewer-frontend/components/IdleLogoutHandler.tsx @@ -1,23 +1,24 @@ 'use client'; +import { useMemo } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useIdleLogout } from '@/hooks/useIdleLogout'; /** - * Component that handles idle logout functionality - * Must be rendered inside SessionProvider to use useSession hook + * Idle logout — default 2 hours. + * Optional e2e override: ?e2e_idle_ms=3000 (clamped 1s–60s) so Playwright can + * assert sign-out stays on the public host without waiting two hours. */ export function IdleLogoutHandler() { - // Log out users after 2 hours of inactivity - useIdleLogout(2 * 60 * 60 * 1000); // 2 hours in milliseconds + const searchParams = useSearchParams(); + const timeoutMs = useMemo(() => { + const raw = searchParams.get('e2e_idle_ms'); + if (!raw) return 2 * 60 * 60 * 1000; + const n = Number(raw); + if (!Number.isFinite(n)) return 2 * 60 * 60 * 1000; + return Math.min(60_000, Math.max(1_000, Math.floor(n))); + }, [searchParams]); - // This component doesn't render anything + useIdleLogout(timeoutMs); return null; } - - - - - - - - diff --git a/viewer-frontend/components/SessionProviderWrapper.tsx b/viewer-frontend/components/SessionProviderWrapper.tsx index 1cc55d8..0ad8e22 100644 --- a/viewer-frontend/components/SessionProviderWrapper.tsx +++ b/viewer-frontend/components/SessionProviderWrapper.tsx @@ -1,5 +1,6 @@ 'use client'; +import { Suspense } from 'react'; import { SessionProvider } from 'next-auth/react'; import { IdleLogoutHandler } from '@/components/IdleLogoutHandler'; @@ -10,11 +11,10 @@ export function SessionProviderWrapper({ }) { return ( - + + + {children} ); } - - -