diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 483cc69..e7d478d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -67,3 +67,58 @@ jobs: fi docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \ detect --source /repo --no-banner --redact ${extra} + + # Playwright smoke via @levkin/playkit — public host guards + API health. + # Sign-out auth spec runs only when E2E_ADMIN_* secrets are present. + e2e: + needs: skip-ci-check + if: needs.skip-ci-check.outputs.should-skip != '1' + runs-on: [homelab, self-hosted, linux] + defaults: + run: + working-directory: e2e + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Allow private playkit clone + if: ${{ secrets.GITEA_TOKEN != '' }} + run: | + git config --global url."https://oauth2:${{ secrets.GITEA_TOKEN }}@git.levkin.ca/".insteadOf "https://git.levkin.ca/" + + - name: Install e2e deps + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run Playwright smoke + env: + PLAYKIT_BASE_URL: ${{ secrets.PLAYKIT_BASE_URL }} + PLAYKIT_API_BASE_URL: ${{ secrets.PLAYKIT_API_BASE_URL }} + PLAYKIT_PROJECT: punimtag + PLAYKIT_ENV: dev + E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }} + E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} + MAILTRAP_API_TOKEN: ${{ secrets.MAILTRAP_API_TOKEN }} + MAILTRAP_ACCOUNT_ID: ${{ secrets.MAILTRAP_ACCOUNT_ID }} + MAILTRAP_INBOX_ID: ${{ secrets.MAILTRAP_INBOX_ID }} + 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}" + npx playwright test + + - name: Upload report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report/ + e2e/test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 2816edf..ae779a0 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,11 @@ data/thumbnails/ # PM2 ecosystem config (server-specific paths) ecosystem.config.js data/web_videos/ + +# Playwright e2e +e2e/node_modules/ +e2e/playwright-report/ +e2e/test-results/ +e2e/blob-report/ +e2e/.auth/ +e2e/.env diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 0000000..5656bcd --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,17 @@ +# Public UI (required for host-guard tests) +PLAYKIT_BASE_URL=https://punimtagdev.levkin.ca +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) +# 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 new file mode 100644 index 0000000..46dbe0a --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,40 @@ +# PunimTag e2e (Playwright + @levkin/playkit) + +Smoke tests against the **public** DEV URL so we catch LAN-redirect bugs +(e.g. `NEXTAUTH_URL=http://10.0.10.121:3001` → Kolby #57). + +## Quick start + +```bash +cd e2e +cp .env.example .env # edit credentials +npm ci +npx playwright install chromium +npm test +``` + +Private Gitea installs need auth once: set a local git `insteadOf` with `GITEA_TOKEN` +before `npm ci`, then unset it. CI does this in `.gitea/workflows/ci.yml`. +Never commit lockfile URLs that embed tokens. + +## Environment + +| 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 | + +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`. + +## 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 diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..6e5b68d --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,55 @@ +import { test as base, expect } from '@playwright/test'; +import { + createPlaykitRuntime, + MailtrapClient, + type PlaykitFixtures, +} from '@levkin/playkit'; +import { LoginPage } from './pages/LoginPage'; +import { AccountMenu } from './pages/AccountMenu'; + +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', +}); + +type PunimtagFixtures = PlaykitFixtures & { + loginPage: LoginPage; + accountMenu: AccountMenu; + e2eCredentials: { email: string; password: string } | null; + mailtrap: MailtrapClient | null; +}; + +export const test = base.extend({ + playkitConfig: async ({}, use) => use(runtime.playkitConfig), + playkitLog: async ({}, use) => use(runtime.playkitLog), + api: async ({}, use) => use(runtime.api), + timings: async ({}, use) => use(runtime.timings), + + loginPage: async ({ page, playkitConfig }, use) => + use(new LoginPage(page, playkitConfig.baseUrl)), + accountMenu: async ({ page, playkitConfig }, use) => + use(new AccountMenu(page, playkitConfig.baseUrl)), + + e2eCredentials: async ({}, use) => { + const email = process.env.E2E_ADMIN_EMAIL || process.env.E2E_EMAIL || ''; + const password = process.env.E2E_ADMIN_PASSWORD || process.env.E2E_PASSWORD || ''; + await use(email && password ? { email, password } : null); + }, + + mailtrap: async ({ playkitLog }, use) => { + await use( + MailtrapClient.fromEnv(process.env, playkitLog.child({ component: 'mailtrap' })), + ); + }, +}); + +export { expect }; diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..88aed51 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,117 @@ +{ + "name": "punimtag-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "punimtag-e2e", + "version": "1.0.0", + "devDependencies": { + "@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.2.0", + "@playwright/test": "^1.52.0", + "@types/node": "^22.15.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@levkin/playkit": { + "version": "0.2.0", + "resolved": "git+https://git.levkin.ca/ilia/playkit.git#2f3991dadc172aae86b98538eb5654cdb3237e67", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@playwright/test": ">=1.40.0" + }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + } + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..fe990ef --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,21 @@ +{ + "name": "punimtag-e2e", + "version": "1.0.0", + "private": true, + "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.2.0", + "@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 new file mode 100644 index 0000000..ed7ada8 --- /dev/null +++ b/e2e/pages/AccountMenu.ts @@ -0,0 +1,18 @@ +import { type Page } from '@playwright/test'; +import { BasePage, waitForVisible } from '@levkin/playkit'; + +export class AccountMenu extends BasePage { + constructor(page: Page, baseUrl: string) { + super(page, baseUrl); + } + + async openMenu(): Promise { + await this.click(this.page.getByLabel('Account menu')); + await waitForVisible(this.page.getByRole('button', { name: 'Sign out' })); + } + + async signOut(): Promise { + await this.openMenu(); + await this.click(this.page.getByRole('button', { name: 'Sign out' })); + } +} diff --git a/e2e/pages/LoginPage.ts b/e2e/pages/LoginPage.ts new file mode 100644 index 0000000..6840e07 --- /dev/null +++ b/e2e/pages/LoginPage.ts @@ -0,0 +1,18 @@ +import { type Page } from '@playwright/test'; +import { BasePage } from '@levkin/playkit'; + +export class LoginPage extends BasePage { + constructor(page: Page, baseUrl: string) { + super(page, baseUrl); + } + + async openLogin(): Promise { + await this.open('/login'); + } + + 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"]')); + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..2f8f621 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = + process.env.PLAYKIT_BASE_URL || + process.env.BASE_URL || + 'https://punimtagdev.levkin.ca'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI + ? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'test-results/junit.xml' }]] + : [['list'], ['html', { open: 'on-failure' }]], + timeout: 60_000, + expect: { timeout: 15_000 }, + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + ...devices['Desktop Chrome'], + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/e2e/tests/api.health.spec.ts b/e2e/tests/api.health.spec.ts new file mode 100644 index 0000000..6f6675e --- /dev/null +++ b/e2e/tests/api.health.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from '../fixtures'; + +test.describe('api @smoke', () => { + test('backend /health returns ok', async ({ api, timings }) => { + const res = await timings.measure('api_health', () => + api.get<{ status: string }>('/health', { expectedStatus: 200 }), + ); + expect(res.data).toMatchObject({ status: 'ok' }); + }); +}); diff --git a/e2e/tests/auth.signout.spec.ts b/e2e/tests/auth.signout.spec.ts new file mode 100644 index 0000000..2c669db --- /dev/null +++ b/e2e/tests/auth.signout.spec.ts @@ -0,0 +1,58 @@ +import { + assertPublicHost, + waitForUrlHost, +} from '@levkin/playkit'; +import { test, expect } from '../fixtures'; + +test.describe('auth @smoke', () => { + test('login page loads on public host', async ({ + page, + playkitConfig, + timings, + loginPage, + }) => { + assertPublicHost(playkitConfig.baseUrl); + + await timings.measure('login_page', () => loginPage.openLogin()); + + await expect(page.getByRole('heading', { name: /Sign in/i })).toBeVisible(); + await expect(page.locator('#email')).toBeVisible(); + await waitForUrlHost(page, playkitConfig.expectedHost); + expect(new URL(page.url()).hostname).toBe(playkitConfig.expectedHost); + }); + + test('sign-out stays on public host (Kolby #57)', async ({ + page, + playkitConfig, + timings, + loginPage, + accountMenu, + e2eCredentials, + }) => { + test.skip( + !e2eCredentials, + 'Set E2E_ADMIN_EMAIL and E2E_ADMIN_PASSWORD (Infisical / Gitea secrets)', + ); + + assertPublicHost(playkitConfig.baseUrl); + + await timings.measure('login', async () => { + await loginPage.openLogin(); + await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password); + await page.getByLabel('Account menu').waitFor({ state: 'visible' }); + }); + + await waitForUrlHost(page, playkitConfig.expectedHost); + + await timings.measure('sign_out', () => accountMenu.signOut()); + + // After sign-out, NextAuth must redirect to the public host — never 10.x. + await waitForUrlHost(page, playkitConfig.expectedHost); + expect(new URL(page.url()).hostname).toBe(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/forgot-password.mail.spec.ts b/e2e/tests/forgot-password.mail.spec.ts new file mode 100644 index 0000000..63ad463 --- /dev/null +++ b/e2e/tests/forgot-password.mail.spec.ts @@ -0,0 +1,105 @@ +import { + assertPublicHost, + firstLinkMatching, + 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). + * + * 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 + */ +test.describe('mail @smoke', () => { + test('forgot-password email lands in Mailtrap with public reset link', async ({ + request, + playkitConfig, + timings, + mailtrap, + e2eCredentials, + }) => { + test.skip( + !mailtrap, + 'Set MAILTRAP_API_TOKEN and MAILTRAP_INBOX_ID (Infisical /playkit/punimtag)', + ); + + const to = + process.env.E2E_MAIL_TO || + e2eCredentials?.email || + process.env.E2E_ADMIN_EMAIL || + ''; + test.skip(!to, 'Set E2E_MAIL_TO or E2E_ADMIN_EMAIL'); + + assertPublicHost(playkitConfig.baseUrl); + const after = new Date(Date.now() - 5_000); + + const res = await timings.measure('forgot_password_api', () => + 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('mailtrap_wait', () => + mailtrap!.waitForEmail({ + to, + subject: /reset|password/i, + after, + timeoutMs: 90_000, + pollMs: 2_500, + }), + ); + + const html = await mailtrap!.getHtml(msg.id, msg.html_path); + const link = firstLinkMatching(html, /reset-password/); + expect(link, 'reset link missing from email HTML').toBeTruthy(); + + const url = new URL(link!); + expect(url.hostname).toBe(playkitConfig.expectedHost); + expect(url.hostname).not.toMatch(/^10\./); + expect(url.pathname).toMatch(/reset-password/); + }); + + test('forgot-password UI shows success and Mailtrap gets mail', async ({ + page, + playkitConfig, + timings, + mailtrap, + e2eCredentials, + }) => { + test.skip(!mailtrap, 'Set MAILTRAP_API_TOKEN and MAILTRAP_INBOX_ID'); + const to = + process.env.E2E_MAIL_TO || + e2eCredentials?.email || + process.env.E2E_ADMIN_EMAIL || + ''; + test.skip(!to, 'Set E2E_MAIL_TO or E2E_ADMIN_EMAIL'); + + assertPublicHost(playkitConfig.baseUrl); + const after = new Date(Date.now() - 5_000); + + await timings.measure('open_home', () => page.goto(playkitConfig.baseUrl)); + await page.getByRole('button', { name: /Sign in/i }).click(); + await page.getByRole('button', { name: /Forgot password/i }).click(); + 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 waitForUrlHost(page, playkitConfig.expectedHost); + + const msg = await mailtrap!.waitForEmail({ + to, + subject: /reset|password/i, + after, + timeoutMs: 90_000, + }); + expect(msg.id).toBeTruthy(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..f6ec33b --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/package.json b/package.json index d0eeba9..5d12fe7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503,W293,E305,F401,F811,W291,W391,E712,W504,F841,E402,F824,E128,E226,F402,F541,E302,E117,E722 || true", "lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;", "test:backend": "export PYTHONPATH=$(pwd) && export SKIP_DEEPFACE_IN_TESTS=1 && ./venv/bin/python3 -m pytest tests/ -v", + "test:e2e": "npm test --prefix e2e", "test:all": "npm run test:backend", + "install:e2e": "npm install --prefix e2e", "ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all", "deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to dev server (10.0.10.121)'", "deploy:dev:prepare": "npm run build:all && mkdir -p deploy/package && cp -r backend deploy/package/ && cp -r admin-frontend/dist deploy/package/admin-frontend-dist && cp -r viewer-frontend/.next deploy/package/viewer-frontend-next && cp requirements.txt deploy/package/ && cp .env.example deploy/package/ && echo '✅ Deployment package prepared in deploy/package/'"