geocrop-platform./apps/nextgen/client/e2e/fixes.spec.ts

183 lines
7.4 KiB
TypeScript

/**
* 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('<div id="root">');
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();
});
});