// @ts-check const { test, expect } = require('@playwright/test'); const token = (name) => name; const LOOPBACK = /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?(?:\/|$)/i; const FORBIDDEN = /(scalar|supabase|(?:sk|pk|anon|service)[_-]?key|api[_-]?key|access[_-]?token|provider[_-]?token|anthropic|openai|gemini|deepseek)/i; function installRequestGuard(page) { const blocked = []; const allowedViolations = []; page.route('**/*', async (route) => { const url = route.request().url(); if (!LOOPBACK.test(url) || FORBIDDEN.test(url)) { blocked.push(`${route.request().method()} ${url}`); await route.abort('blockedbyclient'); return; } await route.continue(); }); return (expectedBlocked = 0) => { expect(allowedViolations, `unexpected request guard violations: ${allowedViolations.join(', ')}`).toEqual([]); expect(blocked.length, `expected ${expectedBlocked} blocked requests, saw ${blocked.length}`).toBe(expectedBlocked); }; } async function connect(page, student) { await page.goto('/'); await page.waitForLoadState('networkidle'); await page.getByLabel('Local access token').fill(token(student)); await page.getByTestId('connect').click(); await expect(page.getByTestId('status')).toContainText(/Catalog ready|Session recovered|No active session/); await expect(page.getByTestId('identity-chip')).toContainText(student.includes('tenant-a') ? 'Student A1' : 'Student B1'); } async function start(page) { await page.getByRole('button', { name: 'Start practice' }).click(); await expect(page.getByTestId('practice')).toContainText('Q1'); } test.describe('education student core loop', () => { test('request guard aborts external and provider-token URLs', async ({ page }) => { const checkGuard = installRequestGuard(page); await page.goto('/'); await page.waitForLoadState('networkidle'); const blocked = await page.evaluate(async () => { const urls = ['https://example.invalid/scalar', 'https://provider.invalid/api?access_token=redacted']; return Promise.all(urls.map(async (url) => { try { await fetch(url); return false; } catch (_) { return true; } })); }); checkGuard(2); }); test('desktop recovery, submit, wrong questions, and favorites', async ({ page }) => { const checkGuard = installRequestGuard(page); await connect(page, 'tenant-a-student-1'); await start(page); const requests = []; page.on('request', (request) => { if (request.url().includes('/practice-session/answer')) requests.push(request); }); await page.getByLabel('Database').check(); await expect(page.getByTestId('practice')).toContainText(/Saved|Ready/); expect(requests.length).toBeGreaterThan(0); await page.waitForLoadState('networkidle'); await page.getByLabel('Local access token').fill('tenant-a-student-1'); await page.getByTestId('connect').click(); await expect(page.getByTestId('status')).toContainText(/Session recovered|Catalog ready/); await expect(page.getByTestId('practice')).toContainText('Database'); await page.getByLabel('Random delay').check(); await page.getByLabel('Version check').check(); await page.getByRole('button', { name: 'Submit practice' }).click(); await expect(page.getByTestId('status')).toContainText(/Submitted|Wrong questions loaded/); await page.getByTestId('load-wrong').click(); await expect(page.getByTestId('wrong')).toBeVisible(); const favorite = await page.evaluate(async () => (await fetch('/app-api/education/favorite/create', { method: 'POST', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ targetType: 'QUESTION', targetId: 'q-a-1' }) })).json()); expect(favorite.code).toBe(0); await page.getByTestId('load-favorites').click(); await expect(page.getByTestId('favorites-list')).toBeVisible(); await expect(page.getByTestId('favorites-list')).not.toContainText('No favorites yet.'); await page.screenshot({ path: 'artifacts/desktop-core-loop.png', fullPage: true }); checkGuard(); }); test('H5 viewport core loop', async ({ page }) => { const checkGuard = installRequestGuard(page); await page.setViewportSize({ width: 390, height: 844 }); await connect(page, 'tenant-a-student-1'); await start(page); await page.getByLabel('Controller').check(); await expect(page.getByTestId('practice')).toContainText(/Saved|Ready/); await page.screenshot({ path: 'artifacts/h5-core-loop.png', fullPage: true }); checkGuard(); }); test('timeout after commit retries with the same idempotency key', async ({ page }) => { const checkGuard = installRequestGuard(page); await connect(page, 'tenant-a-student-1'); const result = await page.evaluate(async () => { const create = await fetch('/app-api/education/practice-session/create', { method: 'POST', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ clientSessionId: `browser-timeout-${crypto.randomUUID()}`, collectionId: 'col-a-core', questionCount: 1 }), }); const session = (await create.json()).data; const body = JSON.stringify({ sessionId: session.id, questionSequence: 1, selectedAnswer: 'A', idempotencyKey: 'same-key', clientSequence: 1, expectedSessionVersion: 0 }); const first = await fetch('/app-api/education/practice-session/answer?fault=answer-timeout-after-commit', { method: 'PUT', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body }); const retry = await fetch('/app-api/education/practice-session/answer', { method: 'PUT', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body }); return { first: first.status, retry: retry.status, retryBody: await retry.json() }; }); expect(result.first).toBe(504); expect(result.retry).toBe(200); expect(result.retryBody.data.selectedAnswer).toBe('A'); checkGuard(); }); test('logout clears the in-memory student session', async ({ page }) => { const checkGuard = installRequestGuard(page); await connect(page, 'tenant-a-student-1'); await page.getByTestId('logout').click(); await expect(page.getByTestId('identity-chip')).toHaveText('Offline'); await expect(page.getByTestId('status')).toHaveText('Logged out'); checkGuard(); }); test('two tenants and two students cannot see each other resources', async ({ page, request }) => { const checkGuard = installRequestGuard(page); const a = await request.get('/app-api/education/context', { headers: { Authorization: 'Bearer tenant-a-student-1' } }); const b = await request.get('/app-api/education/context', { headers: { Authorization: 'Bearer tenant-b-student-1' } }); expect((await a.json()).data.userId).toBe('student-a1'); expect((await b.json()).data.userId).toBe('student-b1'); const create = await request.post('/app-api/education/practice-session/create', { headers: { Authorization: 'Bearer tenant-a-student-1' }, data: { clientSessionId: 'isolation', collectionId: 'col-a-core', questionCount: 1 } }); const session = (await create.json()).data; const stolen = await request.get(`/app-api/education/practice-session/get?id=${session.id}`, { headers: { Authorization: 'Bearer tenant-a-student-2' } }); expect(stolen.status()).toBe(404); const otherTenant = await request.get('/app-api/education/questions/page?collectionId=col-a-core', { headers: { Authorization: 'Bearer tenant-b-student-1' } }); expect((await otherTenant.json()).data.list).toHaveLength(0); checkGuard(); }); });