Expand e2e auth, Vitest CI, env-defaults, API catalog #64

Merged
ilia merged 2 commits from test/e2e-auth-vitest-ci into dev 2026-07-14 18:11:06 -05:00
26 changed files with 626 additions and 133 deletions

View File

@ -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

View File

@ -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

View File

@ -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).

6
e2e/env-defaults.json Normal file
View File

@ -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"
}

27
e2e/env-defaults.ts Normal file
View File

@ -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,
};
}

View File

@ -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<PunimtagFixtures>({
@ -38,6 +30,8 @@ export const test = base.extend<PunimtagFixtures>({
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<PunimtagFixtures>({
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' })));
},
});

22
e2e/package-lock.json generated
View File

@ -8,20 +8,24 @@
"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.3.0",
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.0",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"zod": "^3.25.76"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@levkin/playkit": {
"version": "0.2.0",
"resolved": "git+https://git.levkin.ca/ilia/playkit.git#2f3991dadc172aae86b98538eb5654cdb3237e67",
"version": "0.3.0",
"resolved": "git+https://git.levkin.ca/ilia/playkit.git#7369b1c5e53e1f7511bea96be9042e663b60b315",
"dev": true,
"license": "MIT",
"dependencies": {
"zod": "^3.24.0"
},
"engines": {
"node": ">=20"
},
@ -112,6 +116,16 @@
"version": "6.21.0",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@ -2,7 +2,7 @@
"name": "punimtag-e2e",
"version": "1.0.0",
"private": true,
"description": "PunimTag Playwright e2e powered by @levkin/playkit",
"description": "PunimTag Playwright e2e \u2014 powered by @levkin/playkit",
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
@ -13,9 +13,10 @@
"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.3.0",
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.0",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"zod": "^3.25.76"
}
}

View File

@ -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<void> {
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<void> {
await this.click(this.page.getByLabel('Close manage users'));
}
}

View File

@ -8,11 +8,31 @@ export class LoginPage extends BasePage {
async openLogin(): Promise<void> {
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<void> {
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)');
}
}

27
e2e/pages/RegisterPage.ts Normal file
View File

@ -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<void> {
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<void> {
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"]'));
}
}

View File

@ -1,9 +1,9 @@
import { defineConfig, devices } from '@playwright/test';
import { playkitFailureArtifacts } from '@levkin/playkit';
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',
@ -18,9 +18,7 @@ export default defineConfig({
expect: { timeout: 15_000 },
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
...playkitFailureArtifacts(),
...devices['Desktop Chrome'],
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],

View File

@ -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,
});
});
});

View File

@ -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);
});
});

View File

@ -0,0 +1,81 @@
import { z } from 'zod';
import { test, expect } from '../fixtures';
const PeopleList = z.object({
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
});
const TagsList = z.object({
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
});
/**
* 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('/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', {
expectedStatus: 200,
schema: z.object({ items: z.array(z.unknown()) }),
}),
);
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('/api/v1/tags', { expectedStatus: 200, schema: TagsList }),
);
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 }) => {
const res = await timings.measure('api_photo_missing', () =>
api.get('/api/v1/photos/999999999', { expectedStatus: [401, 404] }),
);
expect([401, 404]).toContain(res.status);
});
});

View File

@ -1,10 +1,13 @@
import { z } from 'zod';
import { test, expect } from '../fixtures';
const HealthSchema = z.object({ status: z.literal('ok') });
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 }),
api.get('/health', { expectedStatus: 200, schema: HealthSchema }),
);
expect(res.data).toMatchObject({ status: 'ok' });
expect(res.data).toEqual({ status: 'ok' });
});
});

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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+/);
});
});

View File

@ -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);
});
});

View File

@ -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+/);
});
});

View File

@ -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();
});
});

View File

@ -6,7 +6,8 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
"include": ["./**/*.ts", "./**/*.json"]
}

View File

@ -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');
}
}

View File

@ -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 1s60s) 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;
}

View File

@ -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 (
<SessionProvider>
<IdleLogoutHandler />
<Suspense fallback={null}>
<IdleLogoutHandler />
</Suspense>
{children}
</SessionProvider>
);
}