Fill e2e gaps and add PunimTag ROADMAP #65
2
.gitignore
vendored
2
.gitignore
vendored
@ -63,6 +63,8 @@ Thumbs.db
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
!e2e/fixtures/
|
||||
!e2e/fixtures/**
|
||||
*.gif
|
||||
*.bmp
|
||||
*.tiff
|
||||
|
||||
45
ROADMAP.md
Normal file
45
ROADMAP.md
Normal 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).
|
||||
@ -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
3
e2e/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.auth/
|
||||
.env
|
||||
|
||||
@ -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
BIN
e2e/fixtures/tiny.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@ -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/,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
27
e2e/tests/api.fastapi-login.spec.ts
Normal file
27
e2e/tests/api.fastapi-login.spec.ts
Normal 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
20
e2e/tests/auth.setup.ts
Normal 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);
|
||||
});
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
24
e2e/tests/viewer.session.spec.ts
Normal file
24
e2e/tests/viewer.session.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user