/** * 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('
'); await ctx.dispose(); }); test('teacher can reach the /exams/manage page without runtime errors', async ({ page }) => { // Login as teacher through the UI await page.goto('/login'); await page.fill('input[type="email"]', TEACHER.email); await page.fill('input[type="password"]', TEACHER.password); await page.click('button[type="submit"]'); // Wait for redirect away from /login await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 }); // Navigate to the exam registry. The "Module Under Construction" stub // now renders without a duplicate-symbol runtime crash because the // build is clean. await page.goto('/exams/manage'); // Wait for the page to settle (catch any runtime error overlay) await page.waitForLoadState('networkidle', { timeout: 10_000 }); // No React error overlay const errorOverlay = await page.locator('vite-error-overlay').count(); expect(errorOverlay).toBe(0); }); }); test.describe('general: full stack is reachable', () => { test('GET /api/dashboard/stats with admin returns students > 0', async () => { const { ctx, token } = await loginAs(ADMIN); const res = await ctx.get('/api/dashboard/stats', { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status()).toBe(200); const body = await res.json(); expect(body.stats.totalStudents).toBeGreaterThan(0); await ctx.dispose(); }); });