160 lines
6.0 KiB
TypeScript
160 lines
6.0 KiB
TypeScript
import { expect, type Page, test } from '@playwright/test';
|
|
|
|
/**
|
|
* Policy documents & acknowledgments e2e (Workstream 11). Proves the RBAC and
|
|
* persistence contract: director/office_manager manage documents; the four
|
|
* campus staff roles read + acknowledge; external roles are locked out;
|
|
* acknowledgment is per-version and idempotent.
|
|
*
|
|
* Requires the backend running with the database migrated + seeded.
|
|
*/
|
|
const USER_PASSWORD = 'flatlogicUser123!';
|
|
|
|
const BACKEND_API_URL =
|
|
process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api';
|
|
|
|
const DOCS = `${BACKEND_API_URL}/policy_documents`;
|
|
const ACKS = `${BACKEND_API_URL}/policy_acknowledgments`;
|
|
|
|
const DIRECTOR = 'director@flatlogic.com';
|
|
const OFFICE_MANAGER = 'office_manager@flatlogic.com';
|
|
const TEACHER = 'teacher@flatlogic.com';
|
|
const STUDENT = 'student@flatlogic.com';
|
|
|
|
/** Denials surface as 400 (permission middleware) or 401/403. */
|
|
const DENIED = [400, 401, 403];
|
|
|
|
interface DocRow {
|
|
readonly id: string;
|
|
readonly title: string;
|
|
readonly version: number;
|
|
}
|
|
interface AckRow {
|
|
readonly policyDocumentId: string;
|
|
readonly version: number;
|
|
}
|
|
|
|
async function logout(page: Page): Promise<void> {
|
|
await page.request.post(`${BACKEND_API_URL}/auth/signout`);
|
|
await page.context().clearCookies();
|
|
}
|
|
|
|
async function login(page: Page, email: string): Promise<void> {
|
|
await logout(page);
|
|
await page.goto('/login');
|
|
await page.getByPlaceholder('you@school.edu').fill(email);
|
|
await page.getByPlaceholder('Enter your password').fill(USER_PASSWORD);
|
|
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
|
|
await page.waitForURL((url) => !url.pathname.startsWith('/login'), {
|
|
timeout: 10_000,
|
|
});
|
|
}
|
|
|
|
async function findDoc(page: Page, title: string): Promise<DocRow | undefined> {
|
|
const res = await page.request.get(DOCS);
|
|
expect(res.status()).toBe(200);
|
|
const body = (await res.json()) as { rows?: DocRow[] };
|
|
return (body.rows ?? []).find((row) => row.title === title);
|
|
}
|
|
|
|
test.describe('Policy documents & acknowledgments', () => {
|
|
test('director creates a policy document that persists', async ({ page }) => {
|
|
await login(page, DIRECTOR);
|
|
const title = `Safety Protocol ${Date.now()}`;
|
|
const res = await page.request.post(DOCS, {
|
|
data: { data: { title, category: 'safety_protocol', body: 'v1' } },
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
|
|
const doc = await findDoc(page, title);
|
|
expect(doc, 'created document is listed').toBeTruthy();
|
|
expect(doc!.version).toBe(1);
|
|
});
|
|
|
|
test('office_manager may manage; teacher may read but not create', async ({
|
|
page,
|
|
}) => {
|
|
await login(page, OFFICE_MANAGER);
|
|
const title = `Handbook Policy ${Date.now()}`;
|
|
const created = await page.request.post(DOCS, {
|
|
data: { data: { title, category: 'handbook_policy' } },
|
|
});
|
|
expect(created.ok()).toBe(true);
|
|
|
|
// Teacher reads the org's documents but cannot create one.
|
|
await login(page, TEACHER);
|
|
expect(await findDoc(page, title), 'teacher can read').toBeTruthy();
|
|
const teacherCreate = await page.request.post(DOCS, {
|
|
data: { data: { title: 'Teacher should not create', category: 'handbook_policy' } },
|
|
});
|
|
expect(DENIED).toContain(teacherCreate.status());
|
|
});
|
|
|
|
test('a campus staff member acknowledges a document (idempotent per version)', async ({
|
|
page,
|
|
}) => {
|
|
// Director publishes a document.
|
|
await login(page, DIRECTOR);
|
|
const title = `Ack Target ${Date.now()}`;
|
|
await page.request.post(DOCS, {
|
|
data: { data: { title, category: 'safety_protocol', body: 'v1' } },
|
|
});
|
|
const doc = await findDoc(page, title);
|
|
expect(doc).toBeTruthy();
|
|
const documentId = doc!.id;
|
|
|
|
// Teacher acknowledges it twice — the second is idempotent.
|
|
await login(page, TEACHER);
|
|
expect((await page.request.post(ACKS, { data: { data: { policyDocumentId: documentId } } })).ok()).toBe(true);
|
|
expect((await page.request.post(ACKS, { data: { data: { policyDocumentId: documentId } } })).ok()).toBe(true);
|
|
|
|
const acksRes = await page.request.get(`${ACKS}?policyDocumentId=${documentId}`);
|
|
expect(acksRes.status()).toBe(200);
|
|
const acks = ((await acksRes.json()) as { rows?: AckRow[] }).rows ?? [];
|
|
const forDoc = acks.filter((a) => a.policyDocumentId === documentId);
|
|
expect(forDoc).toHaveLength(1);
|
|
expect(forDoc[0]?.version).toBe(1);
|
|
});
|
|
|
|
test('editing a document bumps the version and requires re-acknowledgment', async ({
|
|
page,
|
|
}) => {
|
|
await login(page, DIRECTOR);
|
|
const title = `Versioned Doc ${Date.now()}`;
|
|
await page.request.post(DOCS, {
|
|
data: { data: { title, category: 'handbook_policy', body: 'v1' } },
|
|
});
|
|
const v1 = await findDoc(page, title);
|
|
expect(v1!.version).toBe(1);
|
|
|
|
// Teacher acknowledges v1.
|
|
await login(page, TEACHER);
|
|
await page.request.post(ACKS, { data: { data: { policyDocumentId: v1!.id } } });
|
|
|
|
// Director edits → version bumps to 2.
|
|
await login(page, DIRECTOR);
|
|
const put = await page.request.put(`${DOCS}/${v1!.id}`, {
|
|
data: { id: v1!.id, data: { title, body: 'v2 updated' } },
|
|
});
|
|
expect(put.ok()).toBe(true);
|
|
const v2 = await findDoc(page, title);
|
|
expect(v2!.version).toBe(2);
|
|
|
|
// Teacher re-acknowledges → a second ack row (for v2) now exists.
|
|
await login(page, TEACHER);
|
|
await page.request.post(ACKS, { data: { data: { policyDocumentId: v1!.id } } });
|
|
const acksRes = await page.request.get(`${ACKS}?policyDocumentId=${v1!.id}`);
|
|
const acks = ((await acksRes.json()) as { rows?: AckRow[] }).rows ?? [];
|
|
expect(acks.filter((a) => a.policyDocumentId === v1!.id).length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('external roles cannot read or acknowledge policies', async ({ page }) => {
|
|
await login(page, STUDENT);
|
|
expect(DENIED).toContain((await page.request.get(DOCS)).status());
|
|
const ack = await page.request.post(ACKS, {
|
|
data: { data: { policyDocumentId: '00000000-0000-4000-8000-000000000000' } },
|
|
});
|
|
expect(DENIED).toContain(ack.status());
|
|
});
|
|
});
|