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
- 新增 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
521 lines
16 KiB
JavaScript
521 lines
16 KiB
JavaScript
// 游戏发行沙箱的真实浏览器检查:把「探针包」发布到本地发行网关,再用 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);
|
||
});
|