139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
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<void> {
|
|
await page.request.post(`${BACKEND_API_URL}/auth/signout`);
|
|
await page.context().clearCookies();
|
|
}
|
|
|
|
async function login(page: Page, email: string, password: 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(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<AcademicYearRow[]> {
|
|
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<AcademicYearRow | undefined> {
|
|
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}`);
|
|
}
|
|
}
|
|
});
|
|
});
|