geocrop-platform./apps/nextgen/client/e2e/user-roles.spec.ts

201 lines
8.6 KiB
TypeScript

/**
* 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<any>;
post: (path: string, opts?: any) => Promise<any>;
put: (path: string, opts?: any) => Promise<any>;
delete: (path: string, opts?: any) => Promise<any>;
dispose: () => Promise<void>;
}
async function login(email: string, password: string): Promise<Ctx> {
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();
});
});