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
742 lines
24 KiB
JavaScript
742 lines
24 KiB
JavaScript
// 游戏分发「发布开关回滚窗口 + 发行网关缓存与响应头」真实栈检查。
|
||
//
|
||
// 对应里程碑阶段 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();
|