test: FastAPI e2e role-permission gates + gallery search filters #66

Merged
ilia merged 1 commits from test/fastapi-e2e-role-search into dev 2026-07-14 19:47:07 -05:00
6 changed files with 253 additions and 5 deletions

View File

@ -131,6 +131,12 @@ jobs:
PLAYKIT_ENV: dev
E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
# FastAPI main-DB bearer creds (separate store from NextAuth above) —
# admin mirrors e2e@levkine.ca; viewer is role-gate test coverage only.
E2E_API_USERNAME: ${{ secrets.E2E_API_USERNAME }}
E2E_API_PASSWORD: ${{ secrets.E2E_API_PASSWORD }}
E2E_API_VIEWER_USERNAME: ${{ secrets.E2E_API_VIEWER_USERNAME }}
E2E_API_VIEWER_PASSWORD: ${{ secrets.E2E_API_VIEWER_PASSWORD }}
MAILTRAP_API_TOKEN: ${{ secrets.MAILTRAP_API_TOKEN }}
MAILTRAP_ACCOUNT_ID: ${{ secrets.MAILTRAP_ACCOUNT_ID }}
MAILTRAP_INBOX_ID: ${{ secrets.MAILTRAP_INBOX_ID }}

View File

@ -11,14 +11,14 @@ Living plan for product quality, auth/email reliability, and automation.
- [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] **FastAPI e2e users**`e2e` (admin, mirrors `e2e@levkine.ca`) + `e2e-viewer` (viewer) in FastAPI main DB; `E2E_API_USERNAME`/`PASSWORD` + `E2E_API_VIEWER_USERNAME`/`PASSWORD` in CI
- [x] **Search / filter e2e** — tag + person filters on public gallery (`gallery.search-filters.spec.ts`)
- [x] **Role permissions e2e** — viewer vs admin write gates (`api.role-permissions.spec.ts`)
- [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`
@ -40,6 +40,8 @@ Living plan for product quality, auth/email reliability, and automation.
| 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_*` |
| E2E role-permission gates | `api.role-permissions.spec.ts` | skips without `E2E_API_*` + `E2E_API_VIEWER_*` |
| E2E gallery search filters | `gallery.search-filters.spec.ts` | CI (public, no login) |
| Admin UI | — | gap |
See also: `e2e/README.md`, playkit `ROADMAP.md` (kit-level).

View File

@ -9,9 +9,14 @@ PLAYKIT_ENV=dev
E2E_ADMIN_EMAIL=
E2E_ADMIN_PASSWORD=
# Optional FastAPI bearer tests (separate user DB from NextAuth)
# Optional FastAPI bearer tests (separate user DB from NextAuth).
# E2E_API_USERNAME mirrors e2e@levkine.ca (admin role) in the FastAPI main DB.
# E2E_API_VIEWER_USERNAME is a viewer-role account used only for role-permission
# (write-gate) checks — see api.role-permissions.spec.ts.
# E2E_API_USERNAME=
# E2E_API_PASSWORD=
# E2E_API_VIEWER_USERNAME=
# E2E_API_VIEWER_PASSWORD=
# Homelab Mailpit (preferred for DEV e2e)
PLAYKIT_MAIL_PROVIDER=mailpit

View File

@ -25,7 +25,8 @@ Never commit lockfile URLs that embed tokens.
| `PLAYKIT_API_BASE_URL` | no | LAN API (see `env-defaults.json`) |
| `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) |
| `E2E_API_USERNAME` / `E2E_API_PASSWORD` | optional | FastAPI bearer login, admin role (separate DB from NextAuth; mirrors `e2e@levkine.ca`) |
| `E2E_API_VIEWER_USERNAME` / `E2E_API_VIEWER_PASSWORD` | optional | FastAPI bearer login, viewer role — role-permission gate specs only |
| `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 |
@ -46,6 +47,8 @@ See ansible `docs/hardening/SECRETS.md`.
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
12. FastAPI role-permission write gates: viewer 403 vs admin 200 on users, role-permissions, bulk-delete
13. Public gallery search (`/api/search`): tag filter, person filter, and combined-filter narrowing return correct subsets; UI tag-filter interaction updates URL + result count
See repo root [`ROADMAP.md`](../ROADMAP.md) for gaps and next steps.

View File

@ -0,0 +1,100 @@
import type { ApiClient } from '@levkin/playkit';
import { test, expect } from '../fixtures';
/**
* FastAPI role-permission write gates: `admin`-role users pass
* `Depends(get_current_admin_user)`, `viewer`-role users get 403.
*
* Requires both FastAPI accounts (separate store from NextAuth):
* E2E_API_USERNAME/PASSWORD admin role, mirrors e2e@levkine.ca
* E2E_API_VIEWER_USERNAME/PASSWORD viewer role, gate-testing only
*/
async function login(api: ApiClient, username: string, password: string): Promise<string> {
const res = await api.post<{ access_token: string }>('/api/v1/auth/login', {
body: { username, password },
expectedStatus: 200,
});
return res.data.access_token;
}
test.describe('role permissions @smoke', () => {
const adminUser = process.env.E2E_API_USERNAME || '';
const adminPass = process.env.E2E_API_PASSWORD || '';
const viewerUser = process.env.E2E_API_VIEWER_USERNAME || '';
const viewerPass = process.env.E2E_API_VIEWER_PASSWORD || '';
const ready = Boolean(adminUser && adminPass && viewerUser && viewerPass);
test('viewer is denied, admin is allowed on GET /api/v1/users', async ({ api, timings }) => {
test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required');
const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass));
const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass));
const viewerRes = await timings.measure('viewer_users', () =>
api.withAuthBearer(viewerToken).get('/api/v1/users', { expectedStatus: 403 }),
);
expect(viewerRes.status).toBe(403);
expect(viewerRes.data).toMatchObject({ detail: expect.stringMatching(/admin/i) });
const adminRes = await timings.measure('admin_users', () =>
api.withAuthBearer(adminToken).get<{ items: unknown[] }>('/api/v1/users', {
expectedStatus: 200,
}),
);
expect(adminRes.status).toBe(200);
expect(Array.isArray(adminRes.data.items)).toBe(true);
});
test('viewer is denied, admin is allowed on GET /api/v1/role-permissions', async ({
api,
timings,
}) => {
test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required');
const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass));
const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass));
const viewerRes = await timings.measure('viewer_role_perms', () =>
api.withAuthBearer(viewerToken).get('/api/v1/role-permissions', { expectedStatus: 403 }),
);
expect(viewerRes.status).toBe(403);
const adminRes = await timings.measure('admin_role_perms', () =>
api.withAuthBearer(adminToken).get('/api/v1/role-permissions', { expectedStatus: 200 }),
);
expect(adminRes.status).toBe(200);
expect(adminRes.data).toHaveProperty('permissions');
});
test('viewer is denied, admin is allowed on POST /api/v1/photos/bulk-delete', async ({
api,
timings,
}) => {
test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required');
const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass));
const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass));
// Use a photo id that cannot exist — proves the write *gate*, not deletion.
// The admin dependency runs before the handler body, so this never touches
// real data: viewer never reaches the handler, admin gets a safe no-op.
const nonExistentId = 999999999;
const viewerRes = await timings.measure('viewer_bulk_delete', () =>
api.withAuthBearer(viewerToken).post('/api/v1/photos/bulk-delete', {
body: { photo_ids: [nonExistentId] },
expectedStatus: 403,
}),
);
expect(viewerRes.status).toBe(403);
const adminRes = await timings.measure('admin_bulk_delete', () =>
api.withAuthBearer(adminToken).post('/api/v1/photos/bulk-delete', {
body: { photo_ids: [nonExistentId] },
expectedStatus: 200,
}),
);
expect(adminRes.status).toBe(200);
expect(adminRes.data).toMatchObject({ deleted_count: 0, missing_photo_ids: [nonExistentId] });
});
});

View File

@ -0,0 +1,132 @@
import { test, expect } from '../fixtures';
/**
* Public gallery search (`viewer-frontend` `/api/search`, backed by its own
* Prisma "main" DB not the FastAPI backend) tag + person filters.
* No login required: the route only gates the `favoritesOnly` param on a
* session, so these run against the anonymous "chromium" project.
*/
interface SearchPhoto {
id: number;
PhotoTagLinkage?: Array<{ tag_id: number }>;
Face?: Array<{ person_id: number | null }>;
}
interface SearchResponse {
photos: SearchPhoto[];
total: number;
}
test.describe('gallery search filters @smoke', () => {
test('filtering by tag_id only returns photos carrying that tag', async ({
api,
page,
playkitConfig,
timings,
}) => {
const tagsRes = await timings.measure('api_tags', () =>
api.get<{ items: Array<{ id: number; tag_name: string }> }>('/api/v1/tags', {
expectedStatus: 200,
}),
);
const tag = tagsRes.data.items[0];
expect(tag).toBeTruthy();
const res = await timings.measure('search_by_tag', () =>
page.request.get(`${playkitConfig.baseUrl}/api/search`, {
params: { tags: String(tag.id) },
}),
);
expect(res.ok()).toBeTruthy();
const body = (await res.json()) as SearchResponse;
expect(Array.isArray(body.photos)).toBe(true);
for (const photo of body.photos) {
const tagIds = (photo.PhotoTagLinkage ?? []).map((l) => l.tag_id);
expect(tagIds).toContain(tag.id);
}
});
test('filtering by person_id only returns photos with that person tagged', async ({
api,
page,
playkitConfig,
timings,
}) => {
const peopleRes = await timings.measure('api_people', () =>
api.get<{ items: Array<{ id: number; first_name: string }> }>('/api/v1/people', {
expectedStatus: 200,
}),
);
const person = peopleRes.data.items[0];
expect(person).toBeTruthy();
const res = await timings.measure('search_by_person', () =>
page.request.get(`${playkitConfig.baseUrl}/api/search`, {
params: { people: String(person.id) },
}),
);
expect(res.ok()).toBeTruthy();
const body = (await res.json()) as SearchResponse;
expect(Array.isArray(body.photos)).toBe(true);
for (const photo of body.photos) {
const personIds = (photo.Face ?? []).map((f) => f.person_id);
expect(personIds).toContain(person.id);
}
});
test('combined tag+person filters (match-all) narrow results at least as much as either alone', async ({
api,
page,
playkitConfig,
timings,
}) => {
const [tagsRes, peopleRes] = await Promise.all([
api.get<{ items: Array<{ id: number }> }>('/api/v1/tags', { expectedStatus: 200 }),
api.get<{ items: Array<{ id: number }> }>('/api/v1/people', { expectedStatus: 200 }),
]);
const tag = tagsRes.data.items[0];
const person = peopleRes.data.items[0];
expect(tag).toBeTruthy();
expect(person).toBeTruthy();
const [tagOnly, combined] = await Promise.all([
page.request
.get(`${playkitConfig.baseUrl}/api/search`, { params: { tags: String(tag.id) } })
.then((r) => r.json() as Promise<SearchResponse>),
page.request
.get(`${playkitConfig.baseUrl}/api/search`, {
params: { tags: String(tag.id), people: String(person.id), peopleMode: 'all' },
})
.then((r) => r.json() as Promise<SearchResponse>),
]);
expect(combined.total).toBeLessThanOrEqual(tagOnly.total);
});
test('gallery search UI: selecting a tag updates URL and result count', async ({
page,
playkitConfig,
api,
timings,
}) => {
const tagsRes = await api.get<{ items: Array<{ id: number; tag_name: string }> }>(
'/api/v1/tags',
{ expectedStatus: 200 },
);
const tag = tagsRes.data.items[0];
expect(tag).toBeTruthy();
await timings.measure('open_search', async () => {
await page.goto(`${playkitConfig.baseUrl}/search`);
});
await page.getByRole('button', { name: /select tags/i }).click();
await page.getByPlaceholder('Search tags...').fill(tag.tag_name);
await page.getByText(tag.tag_name, { exact: true }).click();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(new RegExp(`tags=${tag.id}(&|$)`));
await expect(
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
).toBeVisible({ timeout: 20_000 });
});
});