import { expect, type APIResponse, type Page, test } from '@playwright/test'; import { BACKEND_API_URL, createTestUser, deleteOrganizations, deleteTestUsers, loginAsAdmin, type CreatedTestUser, } from './helpers/seeded-users'; /** * Cross-tenant isolation e2e (Workstream 8 / Workstream 2). Proves a non-global * user of one organization cannot read, list, update, or delete a record owned * by a second organization. Uses the seeded second-tenant owner (`owner2`, the * `Rival Academy` org) and the primary-tenant owner (`owner`, `Demo Academy`). * * Requires the backend running with the database migrated + seeded, and the * seed passwords in the environment. */ const ACADEMIC_YEARS = `${BACKEND_API_URL}/academic_years`; interface AcademicYearRow { readonly id: string; readonly name: string | null; } async function logout(page: Page): Promise { await page.request.post(`${BACKEND_API_URL}/auth/signout`); await page.context().clearCookies(); } async function login(page: Page, email: string, password: string): Promise { await logout(page); await page.goto('/login'); await page.getByPlaceholder('you@school.edu').fill(email); await page.getByPlaceholder('Enter your password').fill(password); await page.getByRole('button', { name: 'Sign In', exact: true }).click(); await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 10_000, }); } async function listRows(page: Page): Promise { const res = await page.request.get(ACADEMIC_YEARS); expect(res.status()).toBe(200); const body = (await res.json()) as { rows?: AcademicYearRow[] }; return body.rows ?? []; } async function findByName( page: Page, name: string, ): Promise { return (await listRows(page)).find((row) => row.name === name); } test.describe('Cross-tenant isolation', () => { let primaryOwner: CreatedTestUser; let secondaryOwner: CreatedTestUser; test.beforeAll(async ({ request }) => { const suffix = Date.now(); await loginAsAdmin(request); primaryOwner = await createTestUser(request, { roleName: 'owner', email: `e2e-primary-owner-${suffix}@example.com`, organizationId: null, }); secondaryOwner = await createTestUser(request, { roleName: 'owner', email: `e2e-secondary-owner-${suffix}@example.com`, organizationId: null, }); }); test.afterAll(async ({ request }) => { await deleteTestUsers(request, [secondaryOwner, primaryOwner].filter(Boolean)); await deleteOrganizations(request, [ secondaryOwner?.organizationId, primaryOwner?.organizationId, ]); }); test('a tenant cannot read, list, mutate, or delete another tenant record', async ({ page, }) => { const rivalName = `Rival Year ${Date.now()}`; let rivalId: string | null = null; try { // 1. The secondary tenant owner creates a record in its own tenant. await login(page, secondaryOwner.email, secondaryOwner.password); const created = await page.request.post(ACADEMIC_YEARS, { data: { data: { name: rivalName } }, }); expect(created.status()).toBe(200); const rival = await findByName(page, rivalName); expect(rival, 'secondary owner should see its own record').toBeTruthy(); rivalId = rival!.id; // 2. The primary tenant owner must not see it in any way. await login(page, primaryOwner.email, primaryOwner.password); // 2a. List isolation. const primaryRows = await listRows(page); expect(primaryRows.some((row) => row.id === rivalId)).toBe(false); // 2b. Read-by-id isolation: tenant-scoped read returns no record. const readRes = await page.request.get(`${ACADEMIC_YEARS}/${rivalId}`); const readBody = await readRes.text(); expect(readBody).not.toContain(rivalName); // 2c. Update isolation: a tenant-scoped update finds nothing -> error. const updateRes: APIResponse = await page.request.put( `${ACADEMIC_YEARS}/${rivalId}`, { data: { id: rivalId, data: { name: 'Hijacked' } } }, ); expect(updateRes.ok()).toBe(false); // 2d. Delete isolation: attempt to delete the rival record. await page.request.delete(`${ACADEMIC_YEARS}/${rivalId}`); // 3. The secondary owner confirms its record still exists and is unchanged. await login(page, secondaryOwner.email, secondaryOwner.password); const stillThere = await findByName(page, rivalName); expect( stillThere, 'the rival record must survive the cross-tenant update/delete attempts', ).toBeTruthy(); } finally { if (rivalId) { await login(page, secondaryOwner.email, secondaryOwner.password); await page.request.delete(`${ACADEMIC_YEARS}/${rivalId}`); } } }); });