Files
Genarrative/scripts/check-game-distribution-sandbox-e2e.mjs
T
suzmii db344426f2 修复发行游戏在沙箱中访问存储报错
将运行期存储兼容层保存为独立 JavaScript 文件,通过 include_str 注入发行 HTML。

保持 opaque sandbox 隔离,仅提供当前运行实例的 localStorage 与 sessionStorage 兼容能力。

同步发行沙箱验收脚本和玩法运行契约文档。
2026-10-02 17:06:46 +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 下载入;运行期 storage 兼容层可用;
// 外站 fetch / WebSocket 被挡;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' };
try {
// opaque origin 的原生 storage 不可用,但发行网关会在游戏脚本前注入运行期兼容层。
try {
window.localStorage.setItem('probe', 'ok');
results.storageAvailable = window.localStorage.getItem('probe') === 'ok';
window.localStorage.removeItem('probe');
} catch {
results.storageAvailable = false;
}
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(
'opaque sandbox 下运行期 storage 兼容层可用',
results.storageAvailable === 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);
});