Fill e2e gaps and add PunimTag ROADMAP #65

Merged
ilia merged 2 commits from test/e2e-gaps-roadmap into dev 2026-07-14 18:28:59 -05:00
11 changed files with 171 additions and 21 deletions

2
.gitignore vendored
View File

@ -63,6 +63,8 @@ Thumbs.db
*.jpg
*.jpeg
*.png
!e2e/fixtures/
!e2e/fixtures/**
*.gif
*.bmp
*.tiff

45
ROADMAP.md Normal file
View File

@ -0,0 +1,45 @@
# PunimTag roadmap
Living plan for product quality, auth/email reliability, and automation.
## Now — shipped / in flight
- [x] Fix public-host auth redirects (Kolby #57 family) — `NEXTAUTH_URL` + verify-email
- [x] DEV mail trap via Mailpit (not Mailtrap SaaS / not Spamhaus-blocked Mailcow for e2e)
- [x] Playwright e2e suite (`e2e/`) on `@levkin/playkit` against `punimtagdev.levkin.ca`
- [x] Dedicated e2e user `e2e@levkine.ca` (Vault / Infisical / Gitea Actions)
- [x] Vitest unit tests for `viewer-frontend` in Gitea Actions
- [x] API smoke + Zod schema checks (health, people, tags, 401 contracts)
- [x] storageState setup project + real upload smoke
- [x] ROADMAP (this file)
## Next (near-term)
- [ ] **Wire QA/PROD SMTP on live guests** when LXCs 9102/9103 exist (`make punimtag-sync-smtp ENV=qa|prod`)
- [ ] **FastAPI e2e user** — mirror `e2e@levkine.ca` (or service account) into FastAPI user DB; set `E2E_API_USERNAME`/`PASSWORD` in CI
- [ ] **Search / filter e2e** — tag + person filters on public gallery
- [ ] **Role permissions e2e** — viewer vs admin write gates
- [ ] **admin-frontend Vitest** (or Playwright admin project)
- [ ] **PROD smoke** — health + public login page only (no mutating tests)
- [ ] **Stop seeding `admin@admin.com` in docs** as the day-to-day login; keep bootstrap scripts but point operators at Vaultwarden `PunimTag e2e`
## Later
- [ ] Proper DEV deploy (`next start` + CI image) instead of long-lived `next dev`
- [ ] Ansible/app_setup path aligned with `/opt/punimtag/viewer-frontend/.env` (not `/srv/app`)
- [ ] OpenAPI-driven contract suite (playkit / consumer)
- [ ] Multi-browser matrix (firefox/webkit)
- [ ] Hermes/Mattermost report on e2e failure
## Test map
| Layer | Where | Status |
|-------|--------|--------|
| Viewer unit | `viewer-frontend` Vitest | CI `viewer-unit` |
| Backend unit/integration | `tests/` pytest | existing |
| E2E browser + mail | `e2e/tests/*` | CI `e2e` |
| E2E API (unauth + catalog) | `e2e/tests/api.*` | CI |
| E2E FastAPI authed | `api.fastapi-login.spec.ts` | skips without `E2E_API_*` |
| Admin UI | — | gap |
See also: `e2e/README.md`, playkit `ROADMAP.md` (kit-level).

View File

@ -4,10 +4,15 @@
PLAYKIT_PROJECT=punimtag
PLAYKIT_ENV=dev
# Dedicated e2e user — never commit real passwords
# Dedicated e2e user — prefer e2e@levkine.ca (never commit real passwords)
# Do not use admin@admin.com for Playwright; that account is bootstrap-only.
E2E_ADMIN_EMAIL=
E2E_ADMIN_PASSWORD=
# Optional FastAPI bearer tests (separate user DB from NextAuth)
# E2E_API_USERNAME=
# E2E_API_PASSWORD=
# Homelab Mailpit (preferred for DEV e2e)
PLAYKIT_MAIL_PROVIDER=mailpit
MAILPIT_BASE_URL=http://10.0.10.45:8025

3
e2e/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.auth/
.env

View File

@ -23,8 +23,9 @@ Never commit lockfile URLs that embed tokens.
|----------|----------|---------|
| `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 |
| `E2E_ADMIN_EMAIL` | for auth specs | Prefer `e2e@levkine.ca`**not** `admin@admin.com` |
| `E2E_ADMIN_PASSWORD` | for auth specs | Dedicated e2e password (Vaultwarden / Infisical) |
| `E2E_API_USERNAME` / `E2E_API_PASSWORD` | optional | FastAPI bearer login (separate DB from NextAuth) |
| `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 |
@ -40,10 +41,13 @@ See ansible `docs/hardening/SECRETS.md`.
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
7. Upload: submit a tiny fixture via `#file-upload` (uses storageState)
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
11. Viewer `/api/auth/session` returns email when storageState is loaded
See repo root [`ROADMAP.md`](../ROADMAP.md) for gaps and next steps.
Defaults for base URLs live in `env-defaults.json` (imported by `env-defaults.ts`,
`fixtures.ts`, `playwright.config.ts`, and the Gitea `e2e` job).

BIN
e2e/fixtures/tiny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@ -21,5 +21,13 @@ export default defineConfig({
...playkitFailureArtifacts(),
...devices['Desktop Chrome'],
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
testIgnore: /auth\.setup\.ts/,
},
],
});

View File

@ -0,0 +1,27 @@
import { test, expect } from '../fixtures';
/**
* FastAPI `/api/v1/auth/login` uses a *separate* user DB from NextAuth.
* Set E2E_API_USERNAME + E2E_API_PASSWORD (or reuse admin FastAPI creds) to enable.
*/
test.describe('fastapi login @smoke', () => {
test('login returns bearer token when API creds set', async ({ api, timings }) => {
const username = process.env.E2E_API_USERNAME || '';
const password = process.env.E2E_API_PASSWORD || '';
test.skip(!username || !password, 'E2E_API_USERNAME/PASSWORD required for FastAPI auth');
const res = await timings.measure('api_login', () =>
api.post<{ access_token: string }>('/api/v1/auth/login', {
body: { username, password },
expectedStatus: 200,
}),
);
expect(res.data.access_token).toBeTruthy();
const authed = api.withAuthBearer(res.data.access_token);
const me = await timings.measure('api_me', () =>
authed.get('/api/v1/auth/me', { expectedStatus: 200 }),
);
expect(me.status).toBe(200);
});
});

20
e2e/tests/auth.setup.ts Normal file
View File

@ -0,0 +1,20 @@
import { assertPublicHost, saveStorageState, waitForUrlHost } from '@levkin/playkit';
import { test as setup } from '../fixtures';
import path from 'node:path';
const authFile = path.join(__dirname, '../.auth/admin.json');
setup('authenticate e2e admin', async ({ page, playkitConfig, loginPage, e2eCredentials, timings }) => {
setup.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required');
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('setup_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 waitForUrlHost(page, playkitConfig.expectedHost);
});
await saveStorageState(page, authFile);
});

View File

@ -1,34 +1,46 @@
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
import path from 'node:path';
import { test, expect } from '../fixtures';
const tinyPng = path.join(__dirname, '../fixtures/tiny.png');
test.describe('upload @smoke', () => {
test('authenticated user can open upload page', async ({
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
test('authenticated user can select a file and POST upload', 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();
await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 });
await timings.measure('upload_file', async () => {
await page.locator('#file-upload').setInputFiles(tinyPng);
await expect(page.getByText(/tiny\.png/i).first()).toBeVisible({ timeout: 15_000 });
const submit = page.getByRole('button', { name: /Submit for Review/i });
await expect(submit).toBeEnabled({ timeout: 15_000 });
const uploadRespPromise = page.waitForResponse(
(r) => r.url().includes('/api/photos/upload') && r.request().method() === 'POST',
{ timeout: 60_000 },
);
await submit.click();
const uploadResp = await uploadRespPromise;
expect(uploadResp.request().method()).toBe('POST');
// Success banner or inline error/alert — either proves the submit path ran.
const outcome = page
.getByText(/submitted successfully|failed|error|not authorized|permission/i)
.first();
await expect(outcome).toBeVisible({ timeout: 30_000 });
});
});
});

View File

@ -0,0 +1,24 @@
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
import path from 'node:path';
import { test, expect } from '../fixtures';
/**
* Viewer (NextAuth) session cookies from storageState.
* FastAPI bearer auth is a separate user store; see api.fastapi-login.spec.ts.
*/
test.describe('viewer session @smoke', () => {
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
test('session endpoint returns authenticated user', async ({ page, playkitConfig, timings }) => {
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('session', async () => {
await page.goto(`${playkitConfig.baseUrl}/`);
await waitForUrlHost(page, playkitConfig.expectedHost);
const res = await page.request.get(`${playkitConfig.baseUrl}/api/auth/session`);
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body?.user?.email).toBeTruthy();
});
});
});