Files
Genarrative/scripts/check-game-distribution-ops-rollback-e2e.mjs
T
kdletters e8b76da809
Project CI / AI game creator shell Rust crates (push) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m59s
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (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 / AI game creator shell Rust lane 1/2 (push) Has been cancelled
补齐游戏分发发布回滚窗口与发行网关缓存取证
- 新增 scripts/check-game-distribution-ops-rollback-e2e.mjs:关闭/开放发布开关、关投稿保在线、管理员批准被拦、拒绝审核与安全下架仍可用、换版保留旧版本、发行网关响应头与 60 秒缓存窗口、日志脱敏、边缘 /games/<gameId>/ 路由映射(40 项 PASS)
- package.json 注册 npm run check:game-distribution-ops-rollback-e2e
- 里程碑阶段 D 第 4、5 条勾选并补「本轮核对(阶段 D 发布回滚窗口与发行网关缓存)」,含变异验证结果
- pitfalls 记录发布开关用例要连拒绝审核与安全下架一起验,以及本机 check:production-health-patrol 必然 FAILED
2026-09-28 21:46:29 +08:00

742 lines
24 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.
// 游戏分发「发布开关回滚窗口 + 发行网关缓存与响应头」真实栈检查。
//
// 对应里程碑阶段 D 的上线准备两条:
// - 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;
// - 撤销只改变后端公开投影,源站立即拒绝新请求,边缘最多多留一个获批缓存窗口。
//
// 用本地真实栈(api-server + SpacetimeDB + 真实 OSS)与真实后台灰度开关取证:
// 1. 关闭 `game-distribution:publish` 后:创建游戏/版本、上传包、送审、撤回、作者下架
// 与管理员批准新版本全部 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`;
// 2. 同一窗口内目录、详情、发行网关、`/my-games` 与审核队列读取继续可用,
// 当前公开版本与 `publicationRevision` 不变(关闭投稿、保在线);
// 3. 拒绝审核与管理员安全下架 / 恢复始终可用;
// 4. 发行网关响应头与获批缓存窗口(`public, max-age=60, must-revalidate`)和运维文档一致;
// 5. 换版与下架后**新的**发行请求立刻被源站拒绝(404),旧内容只可能留在 60 秒边缘缓存里;
// 6. 运行期日志不出现访问令牌、刷新 Cookie 与 OSS signed URL,边缘 log_format 不记录请求头。
//
// 需要:本地 dev 栈 + 管理员账号。
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-ops-rollback-e2e
import { createHash, randomBytes } from 'node:crypto';
import { readdirSync, readFileSync, statSync } from 'node:fs';
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:8082';
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!';
const GATE_KEY = 'game-distribution:publish';
const RELEASE_CACHE_CONTROL = 'public, max-age=60, must-revalidate';
const RELEASE_TTL_SECONDS = 60;
const PUBLISH_DISABLED_CODE = 'GAME_DISTRIBUTION_PUBLISH_DISABLED';
const OPS_DOC_PATH = 'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md';
const EDGE_TEMPLATES = [
'deploy/nginx/genarrative.conf',
'deploy/nginx/genarrative-dev-http.conf',
'deploy/container/nginx.conf',
];
const API_LOG_DIR = path.resolve('logs/api-server');
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}` : ''}`,
);
}
function section(title) {
console.log(`\n--- ${title} ---`);
}
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,
text,
json,
data: json?.data,
error: json?.error,
headers: Object.fromEntries(response.headers.entries()),
setCookies: response.headers.getSetCookie?.() ?? [],
};
}
async function release(pathname) {
const response = await fetch(`${API}${pathname}`);
const body = await response.text();
return { status: response.status, body, headers: response.headers };
}
async function adminToken() {
const login = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const token = login.data?.token ?? login.data?.accessToken;
check(
'管理员登录成功',
login.status === 200 && Boolean(token),
`status=${login.status}`,
);
return token;
}
async function setPublishGate(admin, enabled, rolloutPercent) {
const response = await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: GATE_KEY,
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发布回滚窗口',
},
});
return response;
}
async function uploadCover(token, id) {
const fileName = `ops-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'ops-rollback', 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}`);
}
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-ops-rollback',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
return confirm.data.assetObject.assetObjectId;
}
function gameMetadata(title, coverAssetId) {
return {
title,
summary: '发布开关回滚窗口 E2E',
description: '',
category: '休闲',
tags: ['ops'],
coverAssetId,
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
/// 包内同时放 HTML 与 CSS:发行网关对 HTML 单独下发 CSP,对 CSS 只下发通用安全头。
async function buildPackage(marker) {
const zip = new JSZip();
zip.file(
'index.html',
`<!doctype html><html><head><link rel="stylesheet" href="app.css"></head><body><h1>${marker}</h1></body></html>`,
);
zip.file(
'app.css',
`body { color: ${marker.startsWith('V1') ? '#111' : '#222'}; }`,
);
zip.file('filler.bin', randomBytes(256 * 1024));
const bytes = Buffer.from(
await zip.generateAsync({ type: 'uint8array', compression: 'STORE' }),
);
return { bytes, fileCount: 3 };
}
async function createVersion(token, gameId, metadata, bytes, fileCount, key) {
return api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token,
headers: { 'Idempotency-Key': key },
body: {
packageSha256: createHash('sha256').update(bytes).digest('hex'),
packageBytes: bytes.length,
packageFileCount: fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
}
async function uploadVersion(token, versionId, bytes, key) {
return api(`/api/game-distribution/versions/${versionId}/package`, {
method: 'PUT',
token,
headers: {
'Idempotency-Key': key,
'Content-Type': 'application/zip',
},
binary: bytes,
});
}
async function submitVersion(token, versionId, revision, key) {
return api(`/api/game-distribution/versions/${versionId}/submit`, {
method: 'POST',
token,
headers: { 'Idempotency-Key': key },
body: { expectedPublicationRevision: revision },
});
}
async function reviewVersion(admin, versionId, payload, key) {
return api(`/admin/api/game-distribution/versions/${versionId}/review`, {
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': key },
body: payload,
});
}
async function adminGameRow(admin, gameId) {
const response = await api('/admin/api/game-distribution/games', {
token: admin,
});
const game = (response.data?.games ?? []).find(
(row) => row.gameId === gameId,
);
return { status: response.status, game };
}
function newestApiLog() {
let entries = [];
try {
entries = readdirSync(API_LOG_DIR).filter((name) => name.endsWith('.log'));
} catch {
return null;
}
if (entries.length === 0) return null;
const files = entries
.map((name) => path.join(API_LOG_DIR, name))
.sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs);
return files[files.length - 1];
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
/// 发行网关的响应头契约:通用安全头始终下发,CSP 只跟 HTML 走。
function assertReleaseHeaders(label, response, { html }) {
const headers = response.headers;
check(
`${label}:缓存窗口等于获批 TTL`,
headers.get('cache-control') === RELEASE_CACHE_CONTROL,
`cache-control=${headers.get('cache-control') ?? ''}`,
);
check(
`${label}:nosniff + no-referrer + 无凭据 CORS/跨来源`,
headers.get('x-content-type-options') === 'nosniff' &&
headers.get('referrer-policy') === 'no-referrer' &&
headers.get('cross-origin-resource-policy') === 'cross-origin' &&
headers.get('access-control-allow-origin') === '*',
`nosniff=${headers.get('x-content-type-options') ?? ''} corp=${headers.get('cross-origin-resource-policy') ?? ''}`,
);
const csp = headers.get('content-security-policy') ?? '';
check(
html ? `${label}:HTML 下发收紧 CSP` : `${label}:非 HTML 不下发 CSP`,
html
? csp.includes("default-src 'none'") &&
csp.includes("frame-src 'none'") &&
csp.includes("base-uri 'none'") &&
csp.includes("object-src 'none'")
: csp === '',
`csp=${csp.slice(0, 40)}`,
);
}
async function main() {
const stamp = Date.now();
const key = (label) => `${label}-${stamp}`;
const admin = await adminToken();
if (!admin) process.exit(1);
const gateOn = await setPublishGate(admin, true, 100);
check(
'发布开关可开启(灰度 100%)',
gateOn.status === 200,
`status=${gateOn.status}`,
);
const phone = `135${String(stamp).slice(-8)}`;
const register = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
const author = register.data?.token;
check(
'作者注册拿到 token',
register.status === 200 && Boolean(author),
`status=${register.status}`,
);
if (!author) process.exit(1);
const refreshCookieValue = (register.setCookies ?? [])
.map((cookie) => cookie.split(';')[0])
.map((pair) => pair.split('=').slice(1).join('='))
.find((value) => value && value.length > 20);
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': key('ops-game') },
body: metadata,
});
const gameId = created.data?.id;
check('创建游戏成功', created.status === 200 && Boolean(gameId));
if (!gameId) process.exit(1);
section('基线:v1 公开、v2/v3 待审、v4 只有版本记录');
const v1 = await buildPackage('V1-OK');
const v1Version = await createVersion(
author,
gameId,
metadata,
v1.bytes,
v1.fileCount,
key('ops-v1'),
);
const v1Id = v1Version.data?.versionId;
await uploadVersion(author, v1Id, v1.bytes, key('ops-upload-v1'));
await submitVersion(author, v1Id, 0, key('ops-submit-v1'));
const approved = await reviewVersion(
admin,
v1Id,
{ decision: 'approve', expectedPublicationRevision: 0 },
key('ops-approve-v1'),
);
check(
'v1 审核通过并公开',
approved.status === 200,
`status=${approved.status}`,
);
const baselineDetail = await api(`/api/game-distribution/games/${gameId}`);
check(
'公开基线:revision=1 且当前公开版本是 v1',
baselineDetail.status === 200 &&
baselineDetail.data?.publicationRevision === 1 &&
baselineDetail.data?.currentVersion?.id === v1Id,
`status=${baselineDetail.status} revision=${baselineDetail.data?.publicationRevision ?? ''}`,
);
const baselineRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'公开基线:发行入口返回 v1 内容',
baselineRelease.status === 200 && baselineRelease.body.includes('V1-OK'),
`status=${baselineRelease.status}`,
);
assertReleaseHeaders('v1 HTML', baselineRelease, { html: true });
const baselineCss = await release(
`/api/game-distribution/releases/${gameId}/app.css`,
);
assertReleaseHeaders('v1 CSS', baselineCss, { html: false });
const pendingIds = {};
for (const marker of ['V2-OK', 'V3-OK']) {
const built = await buildPackage(marker);
const version = await createVersion(
author,
gameId,
metadata,
built.bytes,
built.fileCount,
key(`ops-${marker}`),
);
const versionId = version.data?.versionId;
await uploadVersion(
author,
versionId,
built.bytes,
key(`ops-upload-${marker}`),
);
const submitted = await submitVersion(
author,
versionId,
1,
key(`ops-submit-${marker}`),
);
pendingIds[marker] = versionId;
check(
`${marker} 送审进入待审`,
submitted.status === 202,
`status=${submitted.status}`,
);
}
const v2Id = pendingIds['V2-OK'];
const v3Id = pendingIds['V3-OK'];
const v4Built = await buildPackage('V4-OK');
const v4Version = await createVersion(
author,
gameId,
metadata,
v4Built.bytes,
v4Built.fileCount,
key('ops-v4'),
);
const v4Id = v4Version.data?.versionId;
check('v4 只建版本记录(未上传,用于验证上传被开关拦住)', Boolean(v4Id));
section('关闭发布开关:关投稿、保在线');
const gateOff = await setPublishGate(admin, false, 0);
check(
'发布开关可关闭(紧急关闭投稿)',
gateOff.status === 200,
`status=${gateOff.status}`,
);
const blocked = [
[
'创建游戏',
await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-game') },
body: metadata,
}),
],
[
'创建版本',
await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-version') },
body: {
packageSha256: createHash('sha256')
.update(v4Built.bytes)
.digest('hex'),
packageBytes: v4Built.bytes.length,
packageFileCount: v4Built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
}),
],
[
'上传包',
await uploadVersion(
author,
v4Id,
v4Built.bytes,
key('ops-blocked-upload'),
),
],
['送审', await submitVersion(author, v4Id, 1, key('ops-blocked-submit'))],
[
'撤回',
await api(`/api/game-distribution/versions/${v4Id}/cancel`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-cancel') },
body: { expectedPublicationRevision: 1 },
}),
],
[
'作者下架',
await api(`/api/game-distribution/games/${gameId}/unpublish`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-unpublish') },
body: { expectedPublicationRevision: 1 },
}),
],
[
'管理员批准新版本',
await reviewVersion(
admin,
v3Id,
{ decision: 'approve', expectedPublicationRevision: 1 },
key('ops-blocked-approve'),
),
],
];
for (const [name, response] of blocked) {
check(
`开关关闭时「${name}」被拦(503 + 发布关闭码)`,
response.status === 503 && response.error?.code === PUBLISH_DISABLED_CODE,
`status=${response.status} code=${response.error?.code ?? ''}`,
);
}
const closedCatalog = await api('/api/game-distribution/games');
const closedDetail = await api(`/api/game-distribution/games/${gameId}`);
const closedMine = await api('/api/game-distribution/my-games', {
token: author,
});
const closedReviews = await api('/admin/api/game-distribution/reviews', {
token: admin,
});
const closedRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'开关关闭时读取全部可用(目录/详情/我的游戏/审核队列)',
closedCatalog.status === 200 &&
closedDetail.status === 200 &&
closedMine.status === 200 &&
closedReviews.status === 200,
`catalog=${closedCatalog.status} detail=${closedDetail.status} mine=${closedMine.status} reviews=${closedReviews.status}`,
);
check(
'开关关闭时当前公开版本不变(回滚窗口保留在线旧版)',
closedDetail.data?.publicationRevision === 1 &&
closedDetail.data?.currentVersion?.id === v1Id,
`revision=${closedDetail.data?.publicationRevision ?? ''} active=${closedDetail.data?.currentVersion?.id ?? ''}`,
);
check(
'开关关闭时已公开游戏仍可游玩(发行入口继续服务 v1)',
closedRelease.status === 200 && closedRelease.body.includes('V1-OK'),
`status=${closedRelease.status}`,
);
const rejected = await reviewVersion(
admin,
v2Id,
{
decision: 'reject',
expectedPublicationRevision: 1,
reviewReason: 'E2E 开关关闭仍可拒绝审核',
},
key('ops-reject-v2'),
);
check(
'开关关闭时拒绝审核始终可用',
rejected.status === 200,
`status=${rejected.status} code=${rejected.error?.code ?? ''}`,
);
const suspended = await api(
`/admin/api/game-distribution/games/${gameId}/suspend`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': key('ops-suspend') },
body: {
expectedPublicationRevision: 1,
reason: 'E2E 开关关闭仍可安全下架',
},
},
);
check(
'开关关闭时管理员安全下架始终可用',
suspended.status === 200,
`status=${suspended.status} code=${suspended.error?.code ?? ''}`,
);
const suspendedDetail = await api(`/api/game-distribution/games/${gameId}`);
const suspendedRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'下架后新的发行请求立刻被源站拒绝(不依赖 CDN purge)',
suspendedRelease.status === 404 && suspendedDetail.status === 404,
`release=${suspendedRelease.status} detail=${suspendedDetail.status}`,
);
const afterSuspend = await adminGameRow(admin, gameId);
const suspendedRevision = afterSuspend.game?.publicationRevision;
const restored = await api(
`/admin/api/game-distribution/games/${gameId}/restore`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': key('ops-restore') },
body: { expectedPublicationRevision: suspendedRevision },
},
);
const restoredRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'安全下架可恢复,恢复后发行入口重新服务同一公开版本',
restored.status === 200 &&
restoredRelease.status === 200 &&
restoredRelease.body.includes('V1-OK'),
`restore=${restored.status} release=${restoredRelease.status}`,
);
section('重新开放后换版:旧公开版本被保留为已撤回记录');
const reopened = await setPublishGate(admin, true, 100);
check(
'发布开关可重新开放',
reopened.status === 200,
`status=${reopened.status}`,
);
const beforeSwitch = await adminGameRow(admin, gameId);
const switchRevision = beforeSwitch.game?.publicationRevision;
const switched = await reviewVersion(
admin,
v3Id,
{ decision: 'approve', expectedPublicationRevision: switchRevision },
key('ops-approve-v3'),
);
check(
'开关恢复后管理员可以激活新版本',
switched.status === 200,
`status=${switched.status} code=${switched.error?.code ?? ''}`,
);
const switchedRelease = await release(
`/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'换版后发行入口改为新版本内容',
switchedRelease.status === 200 && switchedRelease.body.includes('V3-OK'),
`status=${switchedRelease.status} marker=${switchedRelease.body.includes('V3-OK')}`,
);
const mine = await api('/api/game-distribution/my-games', { token: author });
const mineGame = (mine.data?.games ?? []).find((game) => game.id === gameId);
const mineVersions = mineGame?.versions ?? [];
const v1Record = mineVersions.find((version) => version.versionId === v1Id);
check(
'换版后旧公开版本仍作为已撤回记录保留(可追溯、未删除)',
v1Record?.status === 'revoked',
`v1=${v1Record?.status ?? 'missing'} versions=${mineVersions.length}`,
);
section('日志脱敏与边缘日志格式');
await sleep(500);
const logFile = newestApiLog();
const logText = logFile ? readFileSync(logFile, 'utf8') : '';
check(
'运行期日志不出现访问令牌与刷新 Cookie',
Boolean(logFile) &&
!logText.includes(author) &&
(!refreshCookieValue || !logText.includes(refreshCookieValue)),
`log=${path.basename(logFile ?? '(none)')} tokenHit=${logText.includes(author)} cookieChecked=${Boolean(refreshCookieValue)}`,
);
check(
'日志脱敏正向对照:本轮的发行请求确实落在同一份日志里',
Boolean(logFile) && logText.includes(gameId),
`log=${path.basename(logFile ?? '(none)')} gameHit=${logText.includes(gameId)}`,
);
check(
'运行期日志不出现 OSS signed URL 凭据',
!logText.includes('OSSAccessKeyId') && !logText.includes('Signature='),
);
check(
'关闭投稿与安全下架在日志里按 operation 可观测',
logText.includes('publish_switch_blocked') &&
logText.includes('game_suspended'),
);
const edgeLeaks = EDGE_TEMPLATES.filter((template) => {
const source = readFileSync(template, 'utf8');
const match = source.match(/log_format\s+[\s\S]*?;/u);
const format = match ? match[0] : '';
return (
format.includes('$http_authorization') ||
format.includes('$http_cookie') ||
format.includes('$arg_')
);
});
check(
'三份边缘模板的 log_format 不记录请求头与查询参数',
edgeLeaks.length === 0,
`leaks=${edgeLeaks.join(',')}`,
);
const routeIssues = EDGE_TEMPLATES.filter((template) => {
const source = readFileSync(template, 'utf8');
return !(
source.includes(
'location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"',
) &&
source.includes('proxy_set_header Cookie ""') &&
source.includes(
'proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path',
)
);
});
check(
'三份边缘模板把 /games/<gameId>/ 映射到发行网关并清空 Cookie',
routeIssues.length === 0,
`issues=${routeIssues.join(',')}`,
);
const opsDoc = readFileSync(OPS_DOC_PATH, 'utf8');
check(
'运维文档与运行期缓存窗口一致(60 秒上限 + 已下载脚本不可远程抹除)',
opsDoc.includes(`max-age=${RELEASE_TTL_SECONDS}`) &&
opsDoc.includes(`${RELEASE_TTL_SECONDS} 秒`) &&
opsDoc.includes('无法远程抹除'),
);
}
async function run() {
try {
await main();
} finally {
// 无论通过与否都放开灰度,避免把本机后续验证卡在“关投稿”状态。
try {
const admin = await adminToken();
if (admin) await setPublishGate(admin, true, 100);
} catch (error) {
console.error(`恢复发布开关失败:${error}`);
}
console.log(failures === 0 ? '\n全部通过' : `\n${failures} 项失败`);
process.exit(failures === 0 ? 0 : 1);
}
}
await run();