Files
Genarrative/scripts/check-game-distribution-sandbox-e2e.mjs
T
kdletters ad2ab4cd60
Project CI / AI game creator shell Rust crates (push) Successful in 1m32s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / Backend tests (push) Successful in 5m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
补齐游戏发行沙箱的真实浏览器取证并勾选阶段 B 第 5/6 条
- 新增 scripts/check-game-distribution-sandbox-e2e.mjs:把探针包发布到本地发行网关,再用 Chromium 以 sandbox=allow-scripts 的 iframe 打开,断言 module 载入、storage/cookie/父文档 DOM 隔离、跨源 fetch 与 WebSocket、Worker、弹窗、顶层跳转、敏感权限全部被挡
- 同一次运行核对发行网关策略:最小 CSP(connect-src self / worker-src none)、nosniff、Access-Control-Allow-Origin *、带 Cookie 403、未知扩展名 404,共 22 项 PASS
- 游戏分发里程碑阶段 B 第 5、6 条改为已勾选,并按主规范 2026 决策说明独立发行域名已由「平台同源路径 + sandbox 不透明来源」替代
- 第 7 条缺口收窄到只剩「撤销传播符合最大缓存窗口」需要生产 CDN/TTL
2026-09-28 19:43:59 +08:00

521 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏发行沙箱的真实浏览器检查:把「探针包」发布到本地发行网关,再用 Chromium 以
// `sandbox="allow-scripts"` 的 iframe 打开它,断言不透明来源下能载入什么、被挡掉了什么。
//
// 需要:本地 dev 栈(SpacetimeDB + api-server)+ 管理员账号 + playwright。
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
// E2E_CHROMIUM_EXECUTABLE=<ms-playwright 里的 chrome.exe,可省略>
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-sandbox-e2e
//
// 覆盖:ES module 与同包资源能在 opaque sandbox 下载入;外站 fetch / WebSocket 被挡;
// localStorage / document.cookie / 父文档 DOM 都拿不到;顶层跳转与弹窗被挡;
// 敏感权限(定位)被拒;同时核对发行网关的 CSP / nosniff / CORS / Cookie 403 策略。
import { createRequire } from 'node:module';
import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开写入口并审核探针包。',
);
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return {
status: response.status,
json,
text,
data: json?.data,
error: json?.error,
};
}
function gameMetadata(title) {
return {
title,
summary: '发行沙箱探针',
description: '',
category: '休闲',
tags: ['e2e'],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
async function uploadCover(token, id) {
const fileName = `sandbox-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'sandbox', String(id)],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: COVER_PNG.length,
metadata: { asset_kind: 'game_distribution_cover' },
},
});
if (ticket.status !== 200) {
throw new Error(
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
);
}
const upload = ticket.data.upload;
const form = new FormData();
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
if (value !== null && value !== undefined) form.append(key, String(value));
}
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
const put = await fetch(upload.host, { method: 'POST', body: form });
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
const confirm = await api('/api/assets/objects/confirm', {
method: 'POST',
token,
body: {
bucket: upload.bucket,
objectKey: upload.objectKey,
contentType: 'image/png',
contentLength: COVER_PNG.length,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-sandbox',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
const PROBE_HELPER = "export const marker = 'helper-ok';\n";
const PROBE_APP = `import { marker } from './helper.js';
const results = { moduleLoaded: marker === 'helper-ok' };
// 探针自身也可能被浏览器直接抛错挡下(例如 opaque origin 读 cookie),
// 所以每一步单独兜底,最后无论如何都把结果 postMessage 给父页面。
try {
try {
window.localStorage.getItem('probe');
results.storageBlocked = false;
} catch {
results.storageBlocked = true;
}
try {
results.cookieHidden = document.cookie === '';
} catch {
results.cookieHidden = true;
}
try {
void window.parent.document.body;
results.parentDomBlocked = false;
} catch {
results.parentDomBlocked = true;
}
results.crossOriginFetchBlocked = await fetch('http://127.0.0.1:4199/readyz')
.then(() => false)
.catch(() => true);
results.crossOriginWebSocketBlocked = await new Promise((resolve) => {
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
try {
const socket = new WebSocket('ws://127.0.0.1:4199/probe');
const timer = setTimeout(() => {
try {
socket.close();
} catch {}
finish(false);
}, 1500);
socket.onopen = () => {
clearTimeout(timer);
socket.close();
finish(false);
};
socket.onerror = () => {
clearTimeout(timer);
finish(true);
};
} catch {
finish(true);
}
});
// CSP 挡 Worker 时不一定同步抛错,会以 worker 的 error 事件报出来。
results.workerBlocked = await new Promise((resolve) => {
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
try {
const worker = new Worker(
URL.createObjectURL(
new Blob(['self.postMessage(1)'], { type: 'text/javascript' }),
),
);
const timer = setTimeout(() => {
worker.terminate();
finish(false);
}, 1500);
worker.onerror = () => {
clearTimeout(timer);
worker.terminate();
finish(true);
};
worker.onmessage = () => {
clearTimeout(timer);
worker.terminate();
finish(false);
};
} catch {
finish(true);
}
});
try {
results.popupBlocked = window.open('https://example.com') === null;
} catch {
results.popupBlocked = true;
}
try {
window.top.location.href = 'about:blank#probe-top';
results.topNavigationBlocked = false;
} catch {
results.topNavigationBlocked = true;
}
let permissionState = 'unknown';
try {
const status = await navigator.permissions.query({ name: 'geolocation' });
permissionState = status.state;
} catch {
permissionState = 'blocked';
}
results.sensitivePermissionDenied =
permissionState === 'denied' || permissionState === 'blocked';
} catch (error) {
results.probeError = String(error);
}
parent.postMessage({ type: 'probe-results', results }, '*');
`;
async function buildProbePackage() {
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>sandbox probe</title>' +
'<script type="module" src="assets/app.js"></script></head><body><h1>sandbox probe</h1></body></html>',
);
zip.file('assets/app.js', PROBE_APP);
zip.file('assets/helper.js', PROBE_HELPER);
const bytes = await zip.generateAsync({ type: 'uint8array' });
return Buffer.from(bytes);
}
async function loadPlaywright() {
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
if (!dir) return import('playwright');
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
return requireFromDir('playwright');
}
async function main() {
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
const stamp = Date.now();
const register = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `134${String(stamp).slice(-8)}`,
password: DEV_PASSWORD,
},
});
const author = register.data?.token;
check(
'作者注册拿到 token',
register.status === 200 && Boolean(author),
`status=${register.status}`,
);
if (!author) process.exit(1);
const setGate = (enabled, rolloutPercent) =>
api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发行沙箱探针',
},
});
const gateOpen = await setGate(true, 100);
check(
'发布灰度可开启并放量',
gateOpen.status === 200,
`status=${gateOpen.status}`,
);
const packageBytes = await buildProbePackage();
const packageSha256 = (await import('node:crypto'))
.createHash('sha256')
.update(packageBytes)
.digest('hex');
const coverAssetId = await uploadCover(author, stamp);
const title = `发行沙箱 ${String(stamp).slice(-6)}`;
const metadata = { ...gameMetadata(title), coverAssetId };
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `sandbox-game-${stamp}` },
body: metadata,
});
const gameId = created.data?.id;
check(
'创建游戏成功',
created.status === 200 && Boolean(gameId),
`status=${created.status} msg=${created.error?.message ?? ''}`,
);
if (!gameId) process.exit(1);
const versionResponse = await api(
`/api/game-distribution/games/${gameId}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `sandbox-version-${stamp}` },
body: {
packageSha256,
packageBytes: packageBytes.length,
packageFileCount: 3,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
},
);
const versionId = versionResponse.data?.versionId;
const uploadPackage = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `sandbox-upload-${stamp}`,
'Content-Type': 'application/zip',
},
binary: packageBytes,
},
);
check(
'探针包上传成功',
uploadPackage.status === 200 && uploadPackage.data?.status === 'uploaded',
`status=${uploadPackage.status}`,
);
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `sandbox-submit-${stamp}` },
body: { expectedPublicationRevision: 0 },
},
);
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `sandbox-approve-${stamp}` },
body: { decision: 'approve', expectedPublicationRevision: 0 },
},
);
check(
'探针包送审并通过审核',
submitted.status === 202 && approved.status === 200,
`submit=${submitted.status} approve=${approved.status}`,
);
const entryUrl = `${API}/api/game-distribution/releases/${gameId}/index.html`;
const entryResponse = await fetch(entryUrl);
const csp = entryResponse.headers.get('content-security-policy') ?? '';
check(
'发行文档带最小权限 CSP(connect-src self / worker-src none)',
entryResponse.status === 200 &&
csp.includes("connect-src 'self'") &&
csp.includes("worker-src 'none'"),
`status=${entryResponse.status} csp=${csp.slice(0, 60)}…`,
);
check(
'发行响应带 nosniff 与跨源 CORS',
entryResponse.headers.get('x-content-type-options') === 'nosniff' &&
entryResponse.headers.get('access-control-allow-origin') === '*',
`nosniff=${entryResponse.headers.get('x-content-type-options')} acao=${entryResponse.headers.get('access-control-allow-origin')}`,
);
const cookieRequest = await fetch(entryUrl, {
headers: { Cookie: 'genarrative_access_token=probe' },
});
check(
'带平台 Cookie 的发行请求 403',
cookieRequest.status === 403,
`status=${cookieRequest.status}`,
);
const unknownExtension = await fetch(
`${API}/api/game-distribution/releases/${gameId}/secrets.env`,
);
check(
'未知扩展名不通过发行网关下发',
unknownExtension.status === 404,
`status=${unknownExtension.status}`,
);
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
try {
const page = await browser.newPage();
page.on('console', (message) => {
console.log(` [page:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
console.log(` [pageerror] ${error}`);
});
const harness = `<!doctype html><html><head><meta charset="utf-8"><title>harness</title></head><body>
<script>
window.__probeResults = null;
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'probe-results') window.__probeResults = event.data.results;
});
const frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-scripts');
frame.setAttribute('allow', 'fullscreen');
frame.src = ${JSON.stringify(entryUrl)};
document.body.appendChild(frame);
</script></body></html>`;
await page.setContent(harness);
await page.waitForFunction(() => window.__probeResults !== null, null, {
timeout: 30_000,
});
const results = await page.evaluate(() => window.__probeResults);
console.log('探针结果:', JSON.stringify(results));
check(
'opaque sandbox 下 ES module 与同包资源能载入',
results.moduleLoaded === true,
);
check(
'localStorage 在 opaque sandbox 下不可用',
results.storageBlocked === true,
);
check(
'document.cookie 在 opaque sandbox 下为空',
results.cookieHidden === true,
);
check('读不到父文档 DOM', results.parentDomBlocked === true);
check('跨源 fetch 被挡', results.crossOriginFetchBlocked === true);
check('跨源 WebSocket 被挡', results.crossOriginWebSocketBlocked === true);
check('Worker 被 CSP 挡下', results.workerBlocked === true);
check('弹窗被 sandbox 挡下', results.popupBlocked === true);
check('顶层跳转被挡', results.topNavigationBlocked === true);
check('敏感权限(定位)被拒', results.sensitivePermissionDenied === true);
const harnessUrl = page.url();
check(
'探针没有改变父页面地址',
harnessUrl === 'about:blank',
`url=${harnessUrl}`,
);
} finally {
await browser.close();
}
const gateClosed = await setGate(false, 0);
check(
'发布灰度恢复关闭',
gateClosed.status === 200,
`status=${gateClosed.status}`,
);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(`[check:game-distribution-sandbox-e2e] 运行失败:${error}`);
process.exit(1);
});