/** * E2E tests for the 3 deployment-readiness fixes * (commit 039bb37 on fix/deployment-readiness-blockers). * * Each test exercises the running server on :3001 (and the Vite client * on :3000 when Playwright `page` is used) and verifies the runtime * behavior the fix was meant to enforce. */ import { test, expect, request as pwRequest } from '@playwright/test'; const API = 'http://localhost:3001'; const ADMIN = { email: 'admin@school.com', password: 'admin123' }; const PARENT = { email: 'parent@school.com', password: 'parent123' }; const STUDENT = { email: 'student@school.com', password: 'student123' }; const TEACHER = { email: 'teacher@school.com', password: 'teacher123' }; async function loginAs(creds: { email: string; password: string }) { const ctx = await pwRequest.newContext({ baseURL: API }); const res = await ctx.post('/api/auth/login', { data: creds }); expect(res.status(), `login as ${creds.email}`).toBe(200); const body = await res.json(); return { ctx, token: body.token as string, user: body.user }; } test.describe('fix 1: password_hash no longer in login response', () => { test('admin login response has no password_hash field', async () => { const { ctx, user } = await loginAs(ADMIN); expect(user).not.toHaveProperty('password_hash'); await ctx.dispose(); }); test('parent login response has no password_hash field', async () => { const { ctx, user } = await loginAs(PARENT); expect(user).not.toHaveProperty('password_hash'); await ctx.dispose(); }); test('teacher login response has no password_hash field', async () => { const { ctx, user } = await loginAs(TEACHER); expect(user).not.toHaveProperty('password_hash'); await ctx.dispose(); }); test('student login response has no password_hash field', async () => { const { ctx, user } = await loginAs(STUDENT); expect(user).not.toHaveProperty('password_hash'); await ctx.dispose(); }); test('caller can still fetch their own hash via /api/auth/me/credentials', async () => { const { ctx, token, user } = await loginAs(ADMIN); const res = await ctx.get('/api/auth/me/credentials', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status()).toBe(200); const body = await res.json(); expect(body).toHaveProperty('password_hash'); expect(body.password_hash).toMatch(/^\$2[aby]\$/); // bcrypt hash shape expect(body.email).toBe(user.email); await ctx.dispose(); }); test('/api/auth/me/credentials rejects unauthenticated callers', async () => { const ctx = await pwRequest.newContext({ baseURL: API }); const res = await ctx.get('/api/auth/me/credentials'); expect(res.status()).toBe(401); await ctx.dispose(); }); }); test.describe('fix 2: /api/messages/contacts is role-scoped', () => { test('parent sees only staff + their children\'s teachers (no other students)', async () => { const { ctx, token } = await loginAs(PARENT); const res = await ctx.get('/api/messages/contacts', { headers: { Authorization: `Bearer ${token}` }, }); const contacts = await res.json(); expect(Array.isArray(contacts)).toBe(true); // No 'student' or 'parent' rows should appear for a parent's contact list. const studentRows = contacts.filter((c: any) => c.role === 'student'); const parentRows = contacts.filter((c: any) => c.role === 'parent'); expect(studentRows, 'parent must not see other students').toHaveLength(0); expect(parentRows, 'parent must not see other parents').toHaveLength(0); // The result should be small (was 100 before the fix; now ~12). expect(contacts.length).toBeLessThan(40); expect(contacts.length).toBeGreaterThan(0); await ctx.dispose(); }); test('student sees only staff + their class teachers (no other students/parents)', async () => { const { ctx, token } = await loginAs(STUDENT); const res = await ctx.get('/api/messages/contacts', { headers: { Authorization: `Bearer ${token}` }, }); const contacts = await res.json(); expect(contacts.every((c: any) => c.role !== 'student')).toBe(true); expect(contacts.every((c: any) => c.role !== 'parent')).toBe(true); expect(contacts.length).toBeLessThan(40); await ctx.dispose(); }); test('teacher sees students in their classes + colleagues + staff', async () => { const { ctx, token } = await loginAs(TEACHER); const res = await ctx.get('/api/messages/contacts', { headers: { Authorization: `Bearer ${token}` }, }); const contacts = await res.json(); // Teachers may see students in their classes but never other parents. expect(contacts.every((c: any) => c.role !== 'parent')).toBe(true); await ctx.dispose(); }); test('admin sees staff + teachers but no parent PII', async () => { const { ctx, token } = await loginAs(ADMIN); const res = await ctx.get('/api/messages/contacts', { headers: { Authorization: `Bearer ${token}` }, }); const contacts = await res.json(); expect(contacts.every((c: any) => c.role !== 'parent')).toBe(true); await ctx.dispose(); }); test('contacts endpoint is authenticated', async () => { const ctx = await pwRequest.newContext({ baseURL: API }); const res = await ctx.get('/api/messages/contacts'); expect(res.status()).toBe(401); await ctx.dispose(); }); }); test.describe('fix 3: client builds cleanly (ExamEditor no longer has duplicate symbol)', () => { // The strongest proof for fix 3 is `npm run build` succeeding (which we // verify outside this test). Here we prove the side-effect: the React // app shell loads, and navigating to the teacher-only /exams/manage // route does NOT produce a vite/react runtime error overlay. test('client SPA shell loads on http://localhost:3000', async () => { const ctx = await pwRequest.newContext({ baseURL: 'http://localhost:3000' }); const res = await ctx.get('/'); expect(res.status()).toBe(200); const body = await res.text(); expect(body).toContain('