test: FastAPI e2e role-permission gates + gallery search filters #66
@ -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 }}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
100
e2e/tests/api.role-permissions.spec.ts
Normal file
100
e2e/tests/api.role-permissions.spec.ts
Normal 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] });
|
||||
});
|
||||
});
|
||||
132
e2e/tests/gallery.search-filters.spec.ts
Normal file
132
e2e/tests/gallery.search-filters.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user