ae9090948a
Switch default MindSpace HTML delivery to a hybrid inline CSP so onclick handlers and addEventListener pages both run under real HTTP headers, and add Playwright interaction verify beyond HTTP 200 checks. Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
10 KiB
JavaScript
281 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Verify MindSpace published pages under real HTTP CSP — interactions, not just HTTP 200.
|
|
*
|
|
* Requires: local Portal (pnpm dev) + playwright.
|
|
*/
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(__dirname, '..');
|
|
const base = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:8081';
|
|
const userId = process.env.VERIFY_USER_ID || '1e655eff-69ce-4f56-9d74-b02883e4112a';
|
|
|
|
const checks = [];
|
|
|
|
function record(name, ok, details = {}) {
|
|
checks.push({ name, ok: Boolean(ok), ...details });
|
|
const mark = ok ? 'PASS' : 'FAIL';
|
|
console.log(`${mark} ${name}${details.detail ? ` — ${details.detail}` : ''}`);
|
|
}
|
|
|
|
function pageUrl(relativePath) {
|
|
return `${base}/${PUBLISH_ROOT_DIR}/${userId}/${relativePath}`;
|
|
}
|
|
|
|
const ONCLICK_FIXTURE = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>onclick fixture</title>
|
|
</head>
|
|
<body>
|
|
<button id="btn" onclick="document.body.dataset.clicked='1'">点我</button>
|
|
<div id="status">idle</div>
|
|
<script>
|
|
document.getElementById('btn').addEventListener('click', function () {
|
|
document.getElementById('status').textContent = 'listener-ok';
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
const ADD_EVENT_LISTENER_FIXTURE = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head><meta charset="utf-8"><title>listener fixture</title></head>
|
|
<body>
|
|
<button id="btn">点我</button>
|
|
<div id="status">idle</div>
|
|
<script>
|
|
document.getElementById('btn').addEventListener('click', function () {
|
|
document.body.dataset.clicked = '1';
|
|
document.getElementById('status').textContent = 'ok';
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
async function writeFixture(relativePath, html) {
|
|
const diskPath = path.join(repoRoot, PUBLISH_ROOT_DIR, userId, relativePath);
|
|
await fs.mkdir(path.dirname(diskPath), { recursive: true });
|
|
await fs.writeFile(diskPath, html, 'utf8');
|
|
}
|
|
|
|
async function launchBrowser() {
|
|
const { chromium } = await import('playwright');
|
|
const browser = await chromium.launch({ headless: true });
|
|
return browser;
|
|
}
|
|
|
|
function attachDiagnostics(page, label) {
|
|
const cspViolations = [];
|
|
page.on('console', (message) => {
|
|
const text = message.text();
|
|
if (/Content Security Policy|Refused to execute|blocked by CSP/i.test(text)) {
|
|
cspViolations.push(`${label}: ${text}`);
|
|
}
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
cspViolations.push(`${label}: pageerror ${error.message}`);
|
|
});
|
|
return cspViolations;
|
|
}
|
|
|
|
async function testFixturePage(browser, relativePath, { clickSelector, evaluateReady }) {
|
|
const page = await browser.newPage();
|
|
const violations = attachDiagnostics(page, relativePath);
|
|
await page.goto(pageUrl(relativePath), { waitUntil: 'networkidle', timeout: 25_000 });
|
|
await page.click(clickSelector);
|
|
await page.waitForTimeout(250);
|
|
const ready = await page.evaluate(evaluateReady);
|
|
const csp = (await page.evaluate(() => document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content))
|
|
?? '';
|
|
const headerProbe = await fetch(pageUrl(relativePath)).then((res) => res.headers.get('content-security-policy') ?? '');
|
|
await page.close();
|
|
return { ready, violations, csp: headerProbe || csp };
|
|
}
|
|
|
|
async function testStickyNoteInteractions(browser) {
|
|
const page = await browser.newPage();
|
|
const violations = attachDiagnostics(page, 'sticky-note-reminder.html');
|
|
const url = pageUrl('public/sticky-note-reminder.html');
|
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 });
|
|
|
|
await page.click('#prioSelector .priority-option.high');
|
|
const priorityActive = await page.evaluate(() =>
|
|
document.querySelector('#prioSelector .priority-option.high')?.classList.contains('active'),
|
|
);
|
|
|
|
const uniqueTitle = `E2E-${Date.now()}`;
|
|
const beforeTotal = Number(await page.textContent('#statTotal'));
|
|
await page.fill('#inputTitle', uniqueTitle);
|
|
await page.fill('#inputContent', '自动化交互测试内容,用于验证动态 onclick 展开。');
|
|
await page.click('#btnSubmit');
|
|
await page.waitForFunction(
|
|
(title) => [...document.querySelectorAll('.card-title')].some((el) => el.textContent?.includes(title)),
|
|
uniqueTitle,
|
|
{ timeout: 10_000 },
|
|
);
|
|
const afterTotal = Number(await page.textContent('#statTotal'));
|
|
|
|
await page.click(`.card-title:text("${uniqueTitle}")`);
|
|
const cardContent = page.locator('.timeline-item', { hasText: uniqueTitle }).locator('.card-content').first();
|
|
await cardContent.click();
|
|
await page.waitForTimeout(300);
|
|
const expanded = await cardContent.evaluate((el) => el.classList.contains('expanded'));
|
|
|
|
const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? '');
|
|
await page.close();
|
|
|
|
return {
|
|
violations,
|
|
priorityActive,
|
|
totalIncreased: afterTotal > beforeTotal,
|
|
expanded,
|
|
headerCsp,
|
|
};
|
|
}
|
|
|
|
async function testPlantTreeInteractions(browser) {
|
|
const page = await browser.newPage();
|
|
const violations = attachDiagnostics(page, 'plant-tree.html');
|
|
const url = pageUrl('public/plant-tree.html');
|
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 });
|
|
|
|
const username = `E2E-${Date.now()}`;
|
|
await page.fill('#usernameInput', username);
|
|
await page.click('#plantBtn');
|
|
await page.waitForFunction(
|
|
() => {
|
|
const toast = document.querySelector('.toast.show, .toast[style*="opacity: 1"]');
|
|
return toast && /种下/.test(toast.textContent || '');
|
|
},
|
|
{ timeout: 10_000 },
|
|
).catch(async () => {
|
|
await page.waitForTimeout(1500);
|
|
});
|
|
|
|
const toastText = await page.evaluate(() => {
|
|
const nodes = [...document.querySelectorAll('.toast, #toast')];
|
|
return nodes.map((node) => node.textContent?.trim()).filter(Boolean).join(' | ');
|
|
});
|
|
await page.waitForFunction(
|
|
(name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)),
|
|
username,
|
|
{ timeout: 10_000 },
|
|
).catch(() => {});
|
|
const listed = await page.evaluate(
|
|
(name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)),
|
|
username,
|
|
);
|
|
|
|
const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? '');
|
|
await page.close();
|
|
|
|
return {
|
|
violations,
|
|
toastText,
|
|
listed,
|
|
headerCsp,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`\n=== MindSpace public page interaction verify @ ${base} ===\n`);
|
|
|
|
const health = await fetch(`${base}/auth/status`).then((r) => r.ok).catch(() => false);
|
|
record('portal_running', health, { detail: health ? 'auth/status OK' : 'server down' });
|
|
if (!health) {
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
await writeFixture('public/csp-onclick-fixture.html', ONCLICK_FIXTURE);
|
|
await writeFixture('public/csp-listener-fixture.html', ADD_EVENT_LISTENER_FIXTURE);
|
|
|
|
let browser;
|
|
try {
|
|
browser = await launchBrowser();
|
|
} catch (error) {
|
|
record('playwright_available', false, { detail: error?.message || String(error) });
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
record('playwright_available', true, { detail: 'chromium launched' });
|
|
|
|
const onclick = await testFixturePage(browser, 'public/csp-onclick-fixture.html', {
|
|
clickSelector: '#btn',
|
|
evaluateReady: () => ({
|
|
bodyClicked: document.body.dataset.clicked === '1',
|
|
listenerStatus: document.getElementById('status')?.textContent ?? '',
|
|
}),
|
|
});
|
|
record('fixture_onclick_attribute_executes', onclick.ready.bodyClicked, {
|
|
detail: JSON.stringify(onclick.ready),
|
|
});
|
|
record('fixture_onclick_with_listener_both_work', onclick.ready.listenerStatus === 'listener-ok', {
|
|
detail: onclick.ready.listenerStatus,
|
|
});
|
|
record('fixture_onclick_no_csp_violations', onclick.violations.length === 0, {
|
|
detail: onclick.violations.join(' | ') || 'none',
|
|
});
|
|
record('fixture_onclick_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(onclick.csp), {
|
|
detail: onclick.csp.match(/script-src[^;]+/)?.[0] ?? onclick.csp.slice(0, 120),
|
|
});
|
|
|
|
const listener = await testFixturePage(browser, 'public/csp-listener-fixture.html', {
|
|
clickSelector: '#btn',
|
|
evaluateReady: () => ({
|
|
bodyClicked: document.body.dataset.clicked === '1',
|
|
status: document.getElementById('status')?.textContent ?? '',
|
|
}),
|
|
});
|
|
record('fixture_add_event_listener_executes', listener.ready.status === 'ok', {
|
|
detail: JSON.stringify(listener.ready),
|
|
});
|
|
record('fixture_add_event_listener_no_csp_violations', listener.violations.length === 0, {
|
|
detail: listener.violations.join(' | ') || 'none',
|
|
});
|
|
|
|
const sticky = await testStickyNoteInteractions(browser);
|
|
record('sticky_note_priority_click_works', sticky.priorityActive, { detail: String(sticky.priorityActive) });
|
|
record('sticky_note_submit_increases_total', sticky.totalIncreased, { detail: String(sticky.totalIncreased) });
|
|
record('sticky_note_dynamic_onclick_expand_works', sticky.expanded, {
|
|
detail: sticky.expanded ? 'card expanded' : 'onclick expand blocked',
|
|
});
|
|
record('sticky_note_no_csp_violations', sticky.violations.length === 0, {
|
|
detail: sticky.violations.join(' | ') || 'none',
|
|
});
|
|
record('sticky_note_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(sticky.headerCsp), {
|
|
detail: sticky.headerCsp.match(/script-src[^;]+/)?.[0] ?? sticky.headerCsp.slice(0, 120),
|
|
});
|
|
|
|
const plant = await testPlantTreeInteractions(browser);
|
|
record('plant_tree_submit_shows_toast', /种下/.test(plant.toastText), { detail: plant.toastText || 'empty' });
|
|
record('plant_tree_record_listed', plant.listed, { detail: String(plant.listed) });
|
|
record('plant_tree_no_csp_violations', plant.violations.length === 0, {
|
|
detail: plant.violations.join(' | ') || 'none',
|
|
});
|
|
|
|
await browser.close();
|
|
|
|
const ok = checks.every((item) => item.ok);
|
|
console.log(`\n=== ${ok ? 'ALL PASS — published pages interact correctly under CSP' : 'SOME FAILED'} (${checks.filter((item) => item.ok).length}/${checks.length}) ===\n`);
|
|
if (!ok) {
|
|
console.log('Failed checks:');
|
|
for (const item of checks.filter((entry) => !entry.ok)) {
|
|
console.log(` - ${item.name}${item.detail ? `: ${item.detail}` : ''}`);
|
|
}
|
|
console.log('\nTip: restart dev server (pnpm dev) after CSP policy changes.\n');
|
|
}
|
|
process.exitCode = ok ? 0 : 1;
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|