Files
Genarrative/scripts/check-game-distribution-ops-rollback-e2e.mjs
T
k88936 1f239a96ce 游戏分发 E2E 抽出共享 multipart 发布 helper
- 新增 scripts/game-distribution-e2e-helpers.mjs:COVER_PNG、imagePart、coverImagePart、publishFormData、resolveApiRequest
- 11 个 E2E 脚本删除各自的重复实现,改为 import;api() 的 body 分支收敛到 resolveApiRequest
- 各脚本的封面文件名前缀、envelope 与鉴权头合并顺序、返回形状保持不变
2026-10-07 11:42:45 +08:00

794 lines
26 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 http from 'node:http';
import path from 'node:path';
import JSZip from 'jszip';
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
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,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
const request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: request.headers,
body: request.body,
});
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 };
}
/// 原样请求(不自动解压):压缩与条件请求必须看真实线字节,不能让 fetch 帮忙解码。
function releaseRaw(pathname, { acceptEncoding, ifNoneMatch } = {}) {
return new Promise((resolve, reject) => {
const target = new URL(`${API}${pathname}`);
const headers = {};
if (acceptEncoding) headers['accept-encoding'] = acceptEncoding;
if (ifNoneMatch) headers['if-none-match'] = ifNoneMatch;
const request = http.request(
{
hostname: target.hostname,
port: target.port,
path: `${target.pathname}${target.search}`,
headers,
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () =>
resolve({
status: response.statusCode,
headers: response.headers,
body: Buffer.concat(chunks),
}),
);
},
);
request.on('error', reject);
request.end();
});
}
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;
}
const coverImagePart = (id) =>
imagePart('cover', id, { fileName: `ops-${id}.png` });
function gameMetadata(title, coverObjectKey) {
return {
title,
summary: '发布开关回滚窗口 E2E',
description: '',
category: '休闲',
tags: ['ops'],
coverObjectKey,
screenshots: [],
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 },
formData: publishFormData({
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 cover = coverImagePart(stamp);
const title = `发布回滚 ${String(stamp).slice(-6)}`;
const metadata = gameMetadata(title, null);
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-game') },
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
check('创建游戏成功', created.status === 200 && Boolean(gameId));
if (!gameId) process.exit(1);
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
const resolvedMetadata = {
...metadata,
coverObjectKey: created.data.coverObjectKey,
};
section('基线:v1 公开、v2/v3 待审、v4 只有版本记录');
const v1 = await buildPackage('V1-OK');
const v1Version = await createVersion(
author,
gameId,
resolvedMetadata,
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 });
// 传输与条件请求契约:引擎包动辄 1 MiB 以上,文本资源必须按 Accept-Encoding 压缩,
// 并在 60 秒缓存窗口之后靠 ETag 重验证(304)而不是重下整包。
const releasePath = `/api/game-distribution/releases/${gameId}/index.html`;
const compressedHtml = await releaseRaw(releasePath, {
acceptEncoding: 'gzip',
});
check(
'发行文本资源按 Accept-Encoding 压缩并声明 Vary',
compressedHtml.status === 200 &&
compressedHtml.headers['content-encoding'] === 'gzip' &&
String(compressedHtml.headers.vary ?? '').includes('accept-encoding') &&
compressedHtml.body.length < Buffer.byteLength(baselineRelease.body),
`encoding=${compressedHtml.headers['content-encoding'] ?? ''} vary=${compressedHtml.headers.vary ?? ''} bytes=${compressedHtml.body.length}/${Buffer.byteLength(baselineRelease.body)}`,
);
const releaseEtag = baselineRelease.headers.get('etag') ?? '';
check(
'发行资源下发强 ETag',
/^"[0-9a-f]{32}"$/u.test(releaseEtag),
`etag=${releaseEtag}`,
);
const revalidated = await releaseRaw(releasePath, {
ifNoneMatch: releaseEtag,
});
check(
'If-None-Match 命中时返回 304 且不带正文',
revalidated.status === 304 &&
revalidated.body.length === 0 &&
revalidated.headers['cache-control'] === RELEASE_CACHE_CONTROL &&
revalidated.headers.etag === releaseEtag,
`status=${revalidated.status} body=${revalidated.body.length} cache-control=${revalidated.headers['cache-control'] ?? ''}`,
);
const identityHtml = await releaseRaw(releasePath, {
acceptEncoding: 'identity',
});
check(
'客户端不接受 gzip 时不下发压缩体',
identityHtml.status === 200 &&
identityHtml.headers['content-encoding'] === undefined,
`encoding=${identityHtml.headers['content-encoding'] ?? ''}`,
);
const pendingIds = {};
for (const marker of ['V2-OK', 'V3-OK']) {
const built = await buildPackage(marker);
const version = await createVersion(
author,
gameId,
resolvedMetadata,
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,
resolvedMetadata,
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') },
// 「新图」语义:带二进制 cover 时 metadata 的 coverObjectKey 必须是 null,别同时给
// 一个可复用 objectKey,否则 payload 含义含糊,网关检查顺序变化时结论会漂。
formData: publishFormData(
{ ...resolvedMetadata, coverObjectKey: null },
{ cover },
),
}),
],
[
'创建版本',
await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': key('ops-blocked-version') },
formData: publishFormData({
packageSha256: createHash('sha256')
.update(v4Built.bytes)
.digest('hex'),
packageBytes: v4Built.bytes.length,
packageFileCount: v4Built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: resolvedMetadata,
}),
}),
],
[
'上传包',
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();