Files
ruoyi-vue-pro/tools/education-student-harness/acceptance.spec.js

144 lines
7.6 KiB
JavaScript

// @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();
});
});