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 { 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] }); }); });