/** * E2E for Phase 1 PR 1 — auxiliary roles (thin slice). * * Covers the cover-teacher happy path end-to-end through the real * /api/* endpoints: * * 1. Admin grants a teacher a class cover for class X with expires_at = +1 day * 2. The teacher's /api/auth/refresh-roles now reports 'teacher' in effective_roles * 3. The teacher can mark attendance for class X (previously forbidden) * 4. The teacher cannot mark attendance for class Y (no cover there) * 5. Admin revokes the cover * 6. The teacher can no longer mark attendance for class X * * Plus: existing /api/attendance/admin-or-teacher check still works for * admins and form-tutors. (Regression guard — no existing route was * supposed to break.) * * Prerequisites: * cd server && npm run dev (port 3001) * cd client && npm run dev (port 3000) * cd client && npx playwright install chromium (one-off) * cd client && npm run test:e2e -- --grep user-roles * * Seed requirement: at least one teacher user, at least TWO classes * (so test 4 can find a class the cover teacher is NOT form tutor of). * If only one class exists, test 4 skips. */ import { test, expect, request } from '@playwright/test'; const API = 'http://localhost:3001/api'; interface Ctx { token: string; get: (path: string, opts?: any) => Promise; post: (path: string, opts?: any) => Promise; put: (path: string, opts?: any) => Promise; delete: (path: string, opts?: any) => Promise; dispose: () => Promise; } async function login(email: string, password: string): Promise { const ctx = await request.newContext(); const res = await ctx.post(`${API}/auth/login`, { data: { email, password } }); expect(res.ok(), `login failed for ${email}: ${await res.text()}`).toBeTruthy(); const body = await res.json(); const token = body.token as string; const auth = { Authorization: `Bearer ${token}` }; return { token, get: (path, opts = {}) => ctx.get(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), ...auth } }), post: (path, opts = {}) => ctx.post(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), ...auth } }), put: (path, opts = {}) => ctx.put(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), ...auth } }), delete: (path, opts = {}) => ctx.delete(`${API}${path}`, { ...opts, headers: { ...(opts.headers || {}), ...auth } }), dispose: () => ctx.dispose(), }; } // Shared state across the describe's tests. Playwright re-initializes // `let` bindings per test, so we use a plain object that all tests // reference by identity. const state: { admin?: Ctx; teacher?: Ctx; teacherId?: number; classId?: number; grantId: number } = { grantId: 0 }; test.describe('auxiliary roles + class cover (Phase 1 PR 1)', () => { test.beforeAll(async () => { state.admin = await login('admin@school.com', 'admin123'); const usersRes = await state.admin.get('/users?role=teacher&limit=5'); expect(usersRes.ok(), `users fetch failed: ${await usersRes.text()}`).toBeTruthy(); const usersBody = await usersRes.json(); const users = (usersBody.users || usersBody).slice().sort((a: any, b: any) => a.id - b.id); expect(users.length, 'no teachers in seed').toBeGreaterThan(0); state.teacherId = users[0].id; const classesRes = await state.admin.get('/classes?limit=5'); expect(classesRes.ok()).toBeTruthy(); const classes = await classesRes.json(); expect(classes.length, 'no classes in seed').toBeGreaterThan(0); state.classId = classes[0].id; state.teacher = await login(users[0].email, 'teacher123'); }); test.afterAll(async () => { if (state.grantId) { // Best-effort cleanup; ignore if already revoked try { await state.admin!.delete(`/user-roles/${state.grantId}`, { data: { revoke_reason: 'e2e cleanup' } }); } catch { /* ignore */ } } await state.admin?.dispose(); await state.teacher?.dispose(); }); test('1. admin can grant a class cover to a teacher', async () => { const expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); const res = await state.admin!.post(`/users/${state.teacherId}/roles`, { data: { role: 'teacher', scope_class_id: state.classId, starts_at: new Date().toISOString(), expires_at, reason: 'e2e test cover', }, }); expect(res.ok(), `grant failed: ${await res.text()}`).toBeTruthy(); const row = await res.json(); expect(row.role).toBe('teacher'); expect(row.scope_class_id).toBe(state.classId); state.grantId = row.id; }); test('2. teacher refresh-roles sees the cover in effective_roles', async () => { const res = await state.teacher!.get('/auth/refresh-roles'); expect(res.ok()).toBeTruthy(); const body = await res.json(); expect(body.token).toBeTruthy(); expect(body.user.role).toBe('teacher'); expect(body.user.effective_roles).toContain('teacher'); }); test('3. teacher CAN mark attendance for the covered class', async () => { const studentsRes = await state.admin!.get('/users?role=student&limit=5'); const studentsBody = await studentsRes.json(); const students = studentsBody.users || []; if (students.length === 0) { test.skip(true, 'no students in seed'); return; } const studentId = students[0].id; const today = new Date().toISOString().slice(0, 10); const res = await state.teacher!.post('/attendance', { data: { student_id: studentId, class_id: state.classId, date: today, status: 'present' }, }); expect(res.ok(), `mark failed (${res.status()}): ${await res.text()}`).toBeTruthy(); }); test('4. teacher CANNOT mark attendance for a class without a cover (and where they are not form tutor)', async () => { // Find a second class that the covering teacher is NOT form tutor of. // If the only teacher is the form tutor of all classes, this test // skips — the cover-denial case is logically the same as admin-denies- // own-class which we already test in test 7. const classesRes = await state.admin!.get('/classes?limit=100'); const classes = await classesRes.json(); const otherClass = classes.find((c: any) => c.id !== state.classId && c.class_teacher_id !== state.teacherId); if (!otherClass) { test.skip(true, 'no second class with a different form tutor in seed'); return; } const studentsRes = await state.admin!.get('/users?role=student&limit=5'); const studentsBody = await studentsRes.json(); const students = studentsBody.users || []; if (students.length === 0) { test.skip(true, 'no students in seed'); return; } const today = new Date().toISOString().slice(0, 10); const res = await state.teacher!.post('/attendance', { data: { student_id: students[0].id, class_id: otherClass.id, date: today, status: 'present' }, }); expect(res.status(), `expected 403, got ${res.status()}`).toBe(403); }); test('5. admin can revoke the cover', async () => { expect(state.grantId, 'test 1 should have set grantId').toBeGreaterThan(0); const res = await state.admin!.delete(`/user-roles/${state.grantId}`, { data: { revoke_reason: 'e2e test cleanup' }, }); expect(res.ok(), `revoke failed: ${await res.text()}`).toBeTruthy(); }); test('6. revoked cover is reflected in the grants list', async () => { const res = await state.admin!.get(`/users/${state.teacherId}/roles`); expect(res.ok()).toBeTruthy(); const body = await res.json(); const revoked = body.grants.find((g: any) => g.reason === 'e2e test cover' && g.id === state.grantId && g.revoked_at); expect(revoked, 'expected to find the revoked e2e cover grant').toBeTruthy(); }); }); test.describe('regression — existing /api/attendance behavior unchanged for admins', () => { test('admin can still mark attendance for any class', async () => { const admin = await login('admin@school.com', 'admin123'); const classesRes = await admin.get('/classes?limit=5'); const classes = await classesRes.json(); const studentsRes = await admin.get('/users?role=student&limit=5'); const studentsBody = await studentsRes.json(); const students = studentsBody.users || []; if (classes.length === 0 || students.length === 0) { test.skip(true, 'no classes or students in seed'); return; } const today = new Date().toISOString().slice(0, 10); const res = await admin.post('/attendance', { data: { student_id: students[0].id, class_id: classes[0].id, date: today, status: 'present' }, }); expect(res.ok(), `admin mark failed (${res.status()}): ${await res.text()}`).toBeTruthy(); await admin.dispose(); }); });