Files
Genarrative/scripts/check-game-distribution-media-e2e.mjs
T
k88936 009a772ebd 媒体验证 E2E 去掉对服务端存储前缀的硬编码断言
- 「公开投影只暴露服务端派生的媒体 objectKey」改为结构断言:非空去空白、含路径分隔符、不是客户端 part 文件名
- 不再断言 agc/project-snapshots/v1/game-distribution/media/ 这一内部布局常量
- 与服务端返回键的一致性仍由相邻的 owner/公开投影相等断言覆盖
2026-10-07 11:43:17 +08:00

1309 lines
44 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.
// 游戏分发「封面 + 截图」真实链路检查(需要本地 dev 栈 + 真实 OSS 配置)。
//
// 用法:
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-media-e2e
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。
// E2E_PACKAGE_ZIP 指向一个已经构建好的发行包(根目录含 index.html),例如真实
// Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。
// E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。
//
// 覆盖:本机准备图片 → 创建游戏(multipart 直传媒体)→ 创建版本(资料冻结)→ 送审 →
// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影
// 暴露 objectKey → 匿名走新 media/read-url 换签读封面与截图 → 发行网关可直接游玩。
import { readFile } from 'node:fs/promises';
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:12401';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:请用已配置管理员账号的环境变量运行,' +
'本地栈可先以 GENARRATIVE_ADMIN_USERNAME / GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
);
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
/**
* 探测对象是否被匿名直取。
*
* 这一条验的是「bucket / 对象 ACL」而不是接口行为:本地环境若还是公共读,默认只报 WARN,
* 设 E2E_REQUIRE_PRIVATE_BUCKET=1 时按失败处理,用于上线前的私有化复验。
*/
async function probeAnonymousObjectAccess(url, label) {
const response = await fetch(url);
if (response.status >= 400) {
check(`匿名直取${label}被拒绝`, true, `status=${response.status}`);
return;
}
if ((process.env.E2E_REQUIRE_PRIVATE_BUCKET ?? '').trim() === '1') {
check(`匿名直取${label}被拒绝`, false, `status=${response.status}`);
return;
}
console.log(
`WARN 匿名直取${label}返回 status=${response.status}:当前 bucket/对象 ACL 不是私有(可用 E2E_REQUIRE_PRIVATE_BUCKET=1 复验)。`,
);
}
async function api(path, 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}${path}`, {
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,
json,
text,
data: json?.data,
error: json?.error,
};
}
function stamp() {
return `${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
function gameMetadata(overrides = {}) {
return {
title: `分发媒体验证 ${stamp().slice(-6)}`,
summary: '真实链路验证封面与截图冻结',
description: 'E2E:真实素材直传 + 冻结 + 审核生效',
category: '益智',
tags: ['E2E'],
deviceSupport: { desktop: true, mobile: true, touch: true },
inputModes: ['keyboard', 'mouse', 'touch'],
orientation: 'responsive',
...overrides,
};
}
const externalPackageZip = (process.env.E2E_PACKAGE_ZIP ?? '').trim();
const gameTitleOverride = (process.env.E2E_GAME_TITLE ?? '').trim();
/** 返回待发布的发行包字节与条目数:优先使用调用方真实构建产物,否则用内置 fixture。 */
async function buildZip() {
if (externalPackageZip) {
const bytes = await readFile(externalPackageZip);
const archive = new JSZip();
const parsed = await archive.loadAsync(bytes);
const entryNames = Object.keys(parsed.files).filter(
(name) => !parsed.files[name].dir,
);
if (!entryNames.includes('index.html')) {
throw new Error(
`E2E_PACKAGE_ZIP 根目录缺少 index.html:${externalPackageZip}`,
);
}
// 真实构建产物(Phaser/Vite 等)资源名带哈希:从包内派生一个资源路径做网关断言。
const assetPath =
entryNames.find((name) => /^assets\/.+\.js$/u.test(name)) ??
entryNames.find((name) => name.endsWith('.js'));
if (!assetPath) {
throw new Error(
`E2E_PACKAGE_ZIP 内没有可断言的 JS 资源:${externalPackageZip}`,
);
}
return {
bytes: Buffer.from(bytes),
fileCount: entryNames.length,
assetPath,
entryMarker: null,
};
}
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>E2E 媒体验证</title><script src="assets/app.js"></script></head><body><h1>E2E-MEDIA-OK</h1></body></html>',
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";');
const bytes = await zip.generateAsync({ type: 'uint8array' });
return {
bytes: Buffer.from(bytes),
fileCount: 2,
assetPath: 'assets/app.js',
entryMarker: 'E2E-MEDIA-OK',
};
}
/** 更新版本用的小包:入口里带标记,用来证明在线旧版没有被待审/被拒的新版本替换。 */
async function buildZipWithMarker(marker) {
const zip = new JSZip();
zip.file(
'index.html',
`<!doctype html><html><head><meta charset="utf-8"><title>E2E ${marker}</title><script src="assets/app.js"></script></head><body><h1>${marker}</h1></body></html>`,
);
zip.file(
'assets/app.js',
`document.documentElement.dataset.e2e="${marker}";`,
);
const bytes = await zip.generateAsync({ type: 'uint8array' });
return {
bytes: Buffer.from(bytes),
fileCount: 2,
assetPath: 'assets/app.js',
entryMarker: marker,
};
}
async function main() {
// 1. 作者注册
const phone = `137${String(Date.now()).slice(-8)}`;
const entry = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: 'GenE2e123!' },
});
check(
'作者注册拿到 token',
entry.status === 200 && Boolean(entry.data?.token),
`status=${entry.status}`,
);
const author = entry.data.token;
const otherEntry = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `138${String(Date.now() + 7).slice(-8)}`,
password: 'GenE2e123!',
},
});
const another = otherEntry.data.token;
// 1.1 管理员登录:发布灰度默认关闭,脚本先验证关闭态再为本轮验证开启。
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
check(
'管理员登录成功',
adminLogin.status === 200 &&
Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken),
`status=${adminLogin.status}`,
);
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
const setPublishGate = (enabled, rolloutPercent) =>
api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发布灰度',
},
});
const gateClosed = await setPublishGate(false, 0);
check(
'发布灰度可配置为关闭',
gateClosed.status === 200,
`status=${gateClosed.status}`,
);
const closedAvailability = await api('/api/runtime/frontend-config', {
token: author,
});
check(
'灰度关闭时作者拿不到发布入口',
closedAvailability.data?.gameDistributionPublishEnabled === false,
`value=${closedAvailability.data?.gameDistributionPublishEnabled}`,
);
const closedPublish = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-gate-closed-${Date.now()}` },
formData: publishFormData(
gameMetadata({
title: `灰度关闭验证 ${Date.now()}`,
coverObjectKey: null,
screenshots: [],
}),
),
});
check(
'灰度关闭时写入口 503',
closedPublish.status === 503,
`status=${closedPublish.status} code=${closedPublish.error?.code ?? ''}`,
);
const gateOpen = await setPublishGate(true, 100);
check(
'发布灰度可开启并放量',
gateOpen.status === 200,
`status=${gateOpen.status}`,
);
const openAvailability = await api('/api/runtime/frontend-config', {
token: author,
});
check(
'灰度开启后作者拿到发布入口',
openAvailability.data?.gameDistributionPublishEnabled === true,
`value=${openAvailability.data?.gameDistributionPublishEnabled}`,
);
// 2. 本机准备发布图片:新合同由服务端把 part 直写项目快照桶,不再走素材库登记。
const id = stamp();
const cover = imagePart('cover', id);
const shot1 = imagePart('screenshot', `${id}-1`);
const shot2 = imagePart('screenshot', `${id}-2`);
check(
'封面图片 part 准备完成',
cover.bytes.length > 0 && cover.contentType === 'image/png',
`${cover.fileName} bytes=${cover.bytes.length}`,
);
check(
'两张截图图片 part 准备完成',
shot1.bytes.length > 0 && shot2.bytes.length > 0,
);
// 3. 服务端校验:缺封面 / 超 6 张 / 沿用不属于该作品的 objectKey / 槽位数量不匹配
const noCover = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-nocover-${id}` },
formData: publishFormData(
gameMetadata({
title: '缺封面验证',
coverObjectKey: null,
screenshots: [],
}),
),
});
check(
'缺少封面被拒(400)',
noCover.status === 400 && (noCover.error?.message ?? '').includes('封面'),
`status=${noCover.status} msg=${noCover.error?.message ?? ''}`,
);
const tooMany = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-many-${id}` },
formData: publishFormData(
gameMetadata({
coverObjectKey: null,
screenshots: Array.from({ length: 7 }, () => null),
}),
{ cover, screenshots: Array.from({ length: 7 }, () => shot1) },
),
});
check(
'截图超过 6 张被拒(400)',
tooMany.status === 400 &&
(tooMany.error?.message ?? '').includes('最多 6 张'),
`status=${tooMany.status} msg=${tooMany.error?.message ?? ''}`,
);
const foreignObjectKey = `agc/project-snapshots/v1/game-distribution/media/not-this-game/cover-${id}.png`;
const ghost = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghost-${id}` },
formData: publishFormData(
gameMetadata({
coverObjectKey: foreignObjectKey,
screenshots: [],
}),
),
});
check(
'沿用不属于该作品的封面 objectKey 被拒(400)',
ghost.status === 400 && (ghost.error?.message ?? '').includes('沿用封面'),
`status=${ghost.status} msg=${ghost.error?.message ?? ''}`,
);
const slotMismatch = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-slot-mismatch-${id}` },
formData: publishFormData(
gameMetadata({ coverObjectKey: null, screenshots: [null, null] }),
{ cover, screenshots: [shot1] },
),
});
check(
'null 截图槽位与 screenshot part 数量不匹配被拒(400)',
slotMismatch.status === 400 &&
(slotMismatch.error?.message ?? '').includes('数量与槽位不匹配'),
`status=${slotMismatch.status} msg=${slotMismatch.error?.message ?? ''}`,
);
// 4. 创建游戏 + 版本(冻结资料)
const createMetadata = gameMetadata({
title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`,
coverObjectKey: null,
screenshots: [null, null],
});
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-game-${id}` },
formData: publishFormData(createMetadata, {
cover,
screenshots: [shot1, shot2],
}),
});
check(
'创建游戏成功',
created.status === 200 && Boolean(created.data?.id),
`status=${created.status} ${created.text.slice(0, 200)}`,
);
const gameId = created.data.id;
// 服务端把新图写成 objectKey 并落在游戏行上;后续建版/沿用槽位按 string objectKey 引用。
const resolvedMetadata = {
...createMetadata,
coverObjectKey: created.data.coverObjectKey,
screenshots: created.data.screenshots,
};
// 版本级校验用例需要真实的游戏行:用合法封面建一个只用于负面校验的游戏。
const ghostGame = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghostgame-${id}` },
formData: publishFormData(
gameMetadata({
title: `版本级素材校验 ${id.slice(-6)}`,
coverObjectKey: null,
screenshots: [],
}),
{ cover },
),
});
const gameIdForGhost = ghostGame.data?.id ?? gameId;
const ghostCoverObjectKey =
ghostGame.data?.coverObjectKey ?? created.data.coverObjectKey;
const ghostVersion = await api(
`/api/game-distribution/games/${gameIdForGhost}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghost-version-${id}` },
formData: publishFormData({
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
packageFileCount: 1,
packageEntryPath: 'index.html',
// 有 null 截图槽位却没有对应 screenshot part:按新语义必须被拒。
gameMetadata: gameMetadata({
coverObjectKey: ghostCoverObjectKey,
screenshots: [null],
}),
}),
},
);
check(
'版本冻结时同样拒绝截图槽位与 part 数量不匹配',
ghostVersion.status === 400 &&
(ghostVersion.error?.message ?? '').includes('数量与槽位不匹配'),
`status=${ghostVersion.status} msg=${ghostVersion.error?.message ?? ''}`,
);
const built = await buildZip();
const zipBytes = built.bytes;
const crypto = await import('node:crypto');
const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex');
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-version-${id}` },
formData: publishFormData({
packageSha256: sha256,
packageBytes: zipBytes.length,
packageFileCount: built.fileCount,
packageEntryPath: 'index.html',
// 建版沿用创建时服务端派生的封面/截图 objectKey。
gameMetadata: resolvedMetadata,
}),
});
check(
'创建版本成功',
version.status === 200 && Boolean(version.data?.versionId),
`status=${version.status} ${version.text.slice(0, 200)}`,
);
const versionId = version.data.versionId;
const uploadPackage = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `e2e-upload-${id}`,
'Content-Type': 'application/zip',
},
binary: zipBytes,
},
);
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': `e2e-submit-${id}` },
body: {
expectedPublicationRevision: created.data.publicationRevision ?? 0,
},
},
);
check(
'送审成功(202 + pending_review)',
submitted.status === 202 &&
submitted.data?.version?.status === 'pending_review',
`status=${submitted.status} ${submitted.text.slice(0, 200)}`,
);
// 版本冻结前仍要复核媒体归属:沿用别人游戏的封面 objectKey 必须被拒。
const foreign = await api('/api/game-distribution/games', {
method: 'POST',
token: another,
headers: { 'Idempotency-Key': `e2e-foreign-game-${id}` },
formData: publishFormData(
gameMetadata({
title: `他人媒体沿用验证 ${id.slice(-6)}`,
coverObjectKey: created.data.coverObjectKey,
screenshots: [],
}),
),
});
check(
'沿用他人作品封面 objectKey 创建游戏被拒(400)',
foreign.status === 400 &&
(foreign.error?.message ?? '').includes('沿用封面'),
`status=${foreign.status} msg=${foreign.error?.message ?? ''}`,
);
// 5. 作者回读版本:冻结资料带回 objectKey
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
token: author,
});
const frozen = readback.data?.version?.frozenMetadata;
check(
'作者回读拿到 frozenMetadata',
Boolean(frozen),
`status=${readback.status}`,
);
check(
'冻结资料保留封面 objectKey',
frozen?.coverObjectKey === created.data.coverObjectKey,
String(frozen?.coverObjectKey),
);
check(
'冻结资料保留截图 objectKey 顺序',
Array.isArray(frozen?.screenshots) &&
frozen.screenshots[0] === created.data.screenshots[0] &&
frozen.screenshots[1] === created.data.screenshots[1],
JSON.stringify(frozen?.screenshots ?? []),
);
// 6. 待审期间:公开目录不可见,匿名读封面被拒
const catalogBefore = await api('/api/game-distribution/games');
check(
'待审期间公开目录不含该游戏',
!(catalogBefore.data?.games ?? []).some((game) => game.id === gameId),
);
const readBefore = await api(
`/api/game-distribution/media/read-url?objectKey=${encodeURIComponent(created.data.coverObjectKey)}`,
);
check(
'未公开游戏的封面没有匿名读授权',
readBefore.status >= 400,
`status=${readBefore.status}`,
);
// 6b. owner 作用域:作者本人在待审期间就能预览自己作品的封面
const ownerRead = await api(
`/api/game-distribution/my-games/${gameId}/media/read-url?objectKey=${encodeURIComponent(created.data.coverObjectKey)}`,
{ token: author },
);
check(
'作者本人可经 owner 路由预览未公开作品封面',
ownerRead.status === 200 &&
Boolean(ownerRead.data?.read?.signedUrl ?? ownerRead.data?.signedUrl),
`status=${ownerRead.status} ${ownerRead.text.slice(0, 200)}`,
);
// 6c. owner 路由必须带令牌:匿名走 owner 路由被拒,不会退化成公开读
const ownerReadAnonymous = await api(
`/api/game-distribution/my-games/${gameId}/media/read-url?objectKey=${encodeURIComponent(created.data.coverObjectKey)}`,
);
check(
'owner 路由不带令牌被拒',
ownerReadAnonymous.status === 401,
`status=${ownerReadAnonymous.status}`,
);
// 6d. owner 路由只认自己作品当前行的媒体:同作者拿别人的 objectKey 换不出来
const ownerReadForeignKey = await api(
`/api/game-distribution/my-games/${gameId}/media/read-url?objectKey=${encodeURIComponent('agc/project-snapshots/v1/game-distribution/media/other/cover-other.png')}`,
{ token: author },
);
check(
'owner 路由拒绝非本作品媒体的 objectKey',
ownerReadForeignKey.status === 404,
`status=${ownerReadForeignKey.status}`,
);
// 7. 管理员审核通过(发行入口由服务端按部署模板与 gameId 派生;管理员 token 在步骤 1.1 已取得)
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-approve-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
},
},
);
check(
'管理员审核通过',
approved.status === 200,
`status=${approved.status} ${approved.text.slice(0, 250)}`,
);
// 8. 公开目录:封面/截图对象键生效
const catalogAfter = await api('/api/game-distribution/games');
const publishedGame = (catalogAfter.data?.games ?? []).find(
(game) => game.id === gameId,
);
check('公开目录返回该游戏', Boolean(publishedGame));
check(
'审核通过后发行入口由服务端派生为平台同源路径',
publishedGame?.currentVersion?.entryUrl === `/games/${gameId}/`,
String(publishedGame?.currentVersion?.entryUrl),
);
check(
'公开投影带封面对象键',
publishedGame?.coverObjectKey === created.data.coverObjectKey,
String(publishedGame?.coverObjectKey),
);
check(
'公开投影带截图对象键',
Array.isArray(publishedGame?.screenshots) &&
publishedGame.screenshots[0] === created.data.screenshots[0],
JSON.stringify(publishedGame?.screenshots ?? []),
);
// 只证明 objectKey 是服务端派生的对象路径、不是客户端直传的文件名;刻意不断言服务端内部
// 的具体 bucket/前缀布局,避免把存储路径写死进 E2E。
const clientPartNames = new Set([
cover.fileName,
shot1.fileName,
shot2.fileName,
]);
const isServerDerivedMediaKey = (key) =>
typeof key === 'string' &&
key.length > 0 &&
key.trim() === key &&
key.includes('/') &&
!/^(?:blob|https?):/iu.test(key) &&
!clientPartNames.has(key);
check(
'公开投影只暴露服务端派生的媒体 objectKey',
isServerDerivedMediaKey(publishedGame?.coverObjectKey) &&
Array.isArray(publishedGame?.screenshots) &&
publishedGame.screenshots.every(isServerDerivedMediaKey),
);
// 9. 匿名读授权:封面与截图都走新的游戏媒体读路由换签名地址
const coverRead = await api(
`/api/game-distribution/media/read-url?objectKey=${encodeURIComponent(created.data.coverObjectKey)}`,
);
check(
'匿名可读已公开游戏封面',
coverRead.status === 200 &&
Boolean(coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl),
`status=${coverRead.status}`,
);
const signedCoverUrl =
coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl;
if (signedCoverUrl) {
const coverHost = new URL(signedCoverUrl).host;
await probeAnonymousObjectAccess(
`https://${coverHost}/${created.data.coverObjectKey}`,
'服务端直写的私有封面对象',
);
}
const shotRead = await api(
`/api/game-distribution/media/read-url?objectKey=${encodeURIComponent(created.data.screenshots[0])}`,
);
check(
'匿名可读已公开游戏截图',
shotRead.status === 200 &&
Boolean(shotRead.data?.read?.signedUrl ?? shotRead.data?.signedUrl),
`status=${shotRead.status}`,
);
// 9b. 匿名同源字节读取:read-bytes 直接回图片字节,不经过素材库换签。
const coverBytesResponse = await fetch(
`${API}/api/game-distribution/media/read-bytes?objectKey=${encodeURIComponent(created.data.coverObjectKey)}`,
);
const coverBytesBody = await coverBytesResponse.arrayBuffer();
check(
'匿名可经 media/read-bytes 取到封面字节',
coverBytesResponse.status === 200 && coverBytesBody.byteLength > 0,
`status=${coverBytesResponse.status} bytes=${coverBytesBody.byteLength}`,
);
// 10. 发行网关可直接玩
const release = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
const releaseBody = await release.text();
const entryOk =
release.status === 200 &&
/<html|<!doctype html/iu.test(releaseBody) &&
(built.entryMarker === null || releaseBody.includes(built.entryMarker));
check(
'发行网关返回游戏入口',
entryOk,
`status=${release.status} bytes=${releaseBody.length}`,
);
check(
'发行入口带 nosniff',
release.headers.get('x-content-type-options') === 'nosniff',
);
const releaseAsset = await fetch(
`${API}/api/game-distribution/releases/${gameId}/${built.assetPath}`,
);
const assetBody = await releaseAsset.arrayBuffer();
check(
'发行网关返回包内资源',
releaseAsset.status === 200 && assetBody.byteLength > 0,
`status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`,
);
// 11. 审核治理:普通作者不能提交审核动作;更新待审 / 更新被拒都不改变在线旧版
const authorReview = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-author-review-${id}` },
body: { decision: 'approve', expectedPublicationRevision: 0 },
},
);
check(
'普通作者不能提交审核动作',
authorReview.status === 401 || authorReview.status === 403,
`status=${authorReview.status} code=${authorReview.error?.code ?? ''}`,
);
const builtV2 = await buildZipWithMarker('E2E-V2-OK');
const sha256V2 = crypto
.createHash('sha256')
.update(builtV2.bytes)
.digest('hex');
const versionV2 = await api(
`/api/game-distribution/games/${gameId}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-v2-version-${id}` },
formData: publishFormData({
packageSha256: sha256V2,
packageBytes: builtV2.bytes.length,
packageFileCount: builtV2.fileCount,
packageEntryPath: 'index.html',
gameMetadata: resolvedMetadata,
}),
},
);
const versionIdV2 = versionV2.data?.versionId;
check(
'已公开游戏可以创建更新版本',
versionV2.status === 200 && Boolean(versionIdV2),
`status=${versionV2.status}`,
);
const uploadV2 = await api(
`/api/game-distribution/versions/${versionIdV2}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `e2e-v2-upload-${id}`,
'Content-Type': 'application/zip',
},
binary: builtV2.bytes,
},
);
const submitV2 = await api(
`/api/game-distribution/versions/${versionIdV2}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-v2-submit-${id}` },
body: {
expectedPublicationRevision: publishedGame?.publicationRevision ?? 0,
},
},
);
check(
'更新版本上传并送审后进入待审',
uploadV2.status === 200 && submitV2.status === 202,
`upload=${uploadV2.status} submit=${submitV2.status}`,
);
const reviewQueue = await api('/admin/api/game-distribution/reviews', {
token: admin,
});
const pendingEntry = (reviewQueue.data?.entries ?? []).find(
(entry) => entry.versionId === versionIdV2,
);
check(
'管理员能看到真实待审条目',
pendingEntry?.status === 'pending_review',
`status=${pendingEntry?.status ?? 'missing'}`,
);
const detailWhilePending = await api(
`/api/game-distribution/games/${gameId}`,
);
check(
'更新待审期间公开详情仍指向在线旧版',
detailWhilePending.data?.currentVersion?.sha256 === sha256,
`sha=${String(detailWhilePending.data?.currentVersion?.sha256).slice(0, 12)} v1=${sha256.slice(0, 12)}`,
);
const releaseWhilePending = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
const releaseWhilePendingBody = await releaseWhilePending.text();
check(
'更新待审期间发行网关仍服务旧版内容',
releaseWhilePending.status === 200 &&
!releaseWhilePendingBody.includes('E2E-V2-OK') &&
(built.entryMarker === null ||
releaseWhilePendingBody.includes(built.entryMarker)),
`status=${releaseWhilePending.status}`,
);
const rejectedV2 = await api(
`/admin/api/game-distribution/versions/${versionIdV2}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-v2-reject-${id}` },
body: {
decision: 'reject',
expectedPublicationRevision:
detailWhilePending.data?.publicationRevision ?? 0,
reviewReason: 'E2E 拒绝原因',
},
},
);
check(
'管理员可以拒绝更新版本并留下原因',
rejectedV2.status === 200 &&
rejectedV2.data?.version?.status === 'rejected' &&
rejectedV2.data?.version?.reviewReason === 'E2E 拒绝原因',
`status=${rejectedV2.status} versionStatus=${rejectedV2.data?.version?.status ?? ''} reason=${rejectedV2.data?.version?.reviewReason ?? ''}`,
);
const detailAfterReject = await api(`/api/game-distribution/games/${gameId}`);
const releaseAfterReject = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
const releaseAfterRejectBody = await releaseAfterReject.text();
check(
'更新被拒后公开 URL 与可玩内容仍是旧版',
detailAfterReject.data?.currentVersion?.sha256 === sha256 &&
releaseAfterReject.status === 200 &&
!releaseAfterRejectBody.includes('E2E-V2-OK'),
`status=${releaseAfterReject.status} sha=${String(detailAfterReject.data?.currentVersion?.sha256).slice(0, 12)}`,
);
const adminGamesAfterReject = await api(
'/admin/api/game-distribution/games',
{ token: admin },
);
const adminGameAfterReject = (adminGamesAfterReject.data?.games ?? []).find(
(game) => game.gameId === gameId,
);
const adminVersionAfterReject = (adminGameAfterReject?.versions ?? []).find(
(version) => version.versionId === versionIdV2,
);
check(
'审核结论可在后台追溯(status / reviewReason / reviewedAt 都在版本行上)',
adminVersionAfterReject?.status === 'rejected' &&
adminVersionAfterReject?.reviewReason === 'E2E 拒绝原因' &&
Boolean(adminVersionAfterReject?.reviewedAt),
`status=${adminVersionAfterReject?.status ?? 'missing'} reviewedAt=${adminVersionAfterReject?.reviewedAt ?? ''}`,
);
// 12. publicationRevision CAS 与下架后的可见性
const revisionOfPublicGame = async () =>
(await api(`/api/game-distribution/games/${gameId}`)).data
?.publicationRevision;
const adminRevisionOfGame = async () => {
const list = await api('/admin/api/game-distribution/games', {
token: admin,
});
return (list.data?.games ?? []).find((game) => game.gameId === gameId)
?.publicationRevision;
};
const staleApprove = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-stale-approve-${id}` },
body: { decision: 'approve', expectedPublicationRevision: 0 },
},
);
check(
'过期 revision 的审核被 CAS 拒绝',
staleApprove.status === 409 &&
staleApprove.text.includes('PUBLICATION_CONFLICT'),
`status=${staleApprove.status} body=${staleApprove.text.slice(0, 160)}`,
);
const replayedApprove = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-approve-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
},
},
);
check(
'同 key 重放批准返回 replayed=true 且不产生第二次激活',
replayedApprove.status === 200 && replayedApprove.data?.replayed === true,
`status=${replayedApprove.status} replayed=${replayedApprove.data?.replayed}`,
);
const builtV3 = await buildZipWithMarker('E2E-V3-OK');
const sha256V3 = crypto
.createHash('sha256')
.update(builtV3.bytes)
.digest('hex');
const versionV3 = await api(
`/api/game-distribution/games/${gameId}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-v3-version-${id}` },
formData: publishFormData({
packageSha256: sha256V3,
packageBytes: builtV3.bytes.length,
packageFileCount: builtV3.fileCount,
packageEntryPath: 'index.html',
gameMetadata: resolvedMetadata,
}),
},
);
const versionIdV3 = versionV3.data?.versionId;
const uploadV3 = await api(
`/api/game-distribution/versions/${versionIdV3}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `e2e-v3-upload-${id}`,
'Content-Type': 'application/zip',
},
binary: builtV3.bytes,
},
);
const revisionBeforeActivation = await revisionOfPublicGame();
const submitV3 = await api(
`/api/game-distribution/versions/${versionIdV3}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-v3-submit-${id}` },
body: { expectedPublicationRevision: revisionBeforeActivation },
},
);
check(
'在线游戏的第三个版本上传并送审成功',
uploadV3.status === 200 && submitV3.status === 202,
`upload=${uploadV3.status} submit=${submitV3.status}`,
);
const activationAttempts = await Promise.all([
api(`/admin/api/game-distribution/versions/${versionIdV3}/review`, {
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-v3-approve-a-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: revisionBeforeActivation,
},
}),
api(`/admin/api/game-distribution/versions/${versionIdV3}/review`, {
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-v3-approve-b-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: revisionBeforeActivation,
},
}),
]);
const activationWins = activationAttempts.filter(
(item) => item.status === 200,
);
const activationConflicts = activationAttempts.filter(
(item) => item.status === 409 && item.text.includes('PUBLICATION_CONFLICT'),
);
check(
'并发激活只有一次成功、另一次被 CAS 拒绝',
activationWins.length === 1 && activationConflicts.length === 1,
activationAttempts
.map(
(item) =>
`${item.status}${item.error?.code ? `/${item.error.code}` : ''}`,
)
.join(' '),
);
const detailAfterActivation = await api(
`/api/game-distribution/games/${gameId}`,
);
check(
'并发激活后公开详情指向新版本',
detailAfterActivation.data?.currentVersion?.sha256 === sha256V3,
`sha=${String(detailAfterActivation.data?.currentVersion?.sha256).slice(0, 12)} v3=${sha256V3.slice(0, 12)}`,
);
const revisionBeforeUnpublish =
detailAfterActivation.data?.publicationRevision ?? 0;
const unpublished = await api(
`/api/game-distribution/games/${gameId}/unpublish`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-unpublish-${id}` },
body: { expectedPublicationRevision: revisionBeforeUnpublish },
},
);
check(
'作者下架成功',
unpublished.status === 200 &&
unpublished.data?.game?.status === 'unpublished',
`status=${unpublished.status} gameStatus=${unpublished.data?.game?.status ?? ''}`,
);
const catalogAfterUnpublish = await api('/api/game-distribution/games');
check(
'下架后公开目录不含该游戏',
!(catalogAfterUnpublish.data?.games ?? []).some(
(game) => game.id === gameId,
),
);
const detailAfterUnpublish = await api(
`/api/game-distribution/games/${gameId}`,
);
check(
'下架后公开详情不可见',
detailAfterUnpublish.status === 404,
`status=${detailAfterUnpublish.status}`,
);
const releaseAfterUnpublish = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'下架后发行网关不再服务该游戏',
releaseAfterUnpublish.status === 404,
`status=${releaseAfterUnpublish.status}`,
);
check(
'下架后的目录与详情不返回对象存储地址',
!/aliyuncs\.com|oss-cn|Signature=/iu.test(
catalogAfterUnpublish.text + detailAfterUnpublish.text,
),
);
const staleUnpublish = await api(
`/api/game-distribution/games/${gameId}/unpublish`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-stale-unpublish-${id}` },
body: {
expectedPublicationRevision: revisionBeforeUnpublish - 1,
},
},
);
check(
'过期 revision 的下架被 CAS 拒绝',
staleUnpublish.status === 409 &&
staleUnpublish.text.includes('PUBLICATION_CONFLICT'),
`status=${staleUnpublish.status} body=${staleUnpublish.text.slice(0, 160)}`,
);
const builtV4 = await buildZipWithMarker('E2E-V4-OK');
const sha256V4 = crypto
.createHash('sha256')
.update(builtV4.bytes)
.digest('hex');
const versionV4 = await api(
`/api/game-distribution/games/${gameId}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-v4-version-${id}` },
formData: publishFormData({
packageSha256: sha256V4,
packageBytes: builtV4.bytes.length,
packageFileCount: builtV4.fileCount,
packageEntryPath: 'index.html',
gameMetadata: resolvedMetadata,
}),
},
);
const versionIdV4 = versionV4.data?.versionId;
const uploadV4 = await api(
`/api/game-distribution/versions/${versionIdV4}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `e2e-v4-upload-${id}`,
'Content-Type': 'application/zip',
},
binary: builtV4.bytes,
},
);
const revisionAfterUnpublish = await adminRevisionOfGame();
const submitV4 = await api(
`/api/game-distribution/versions/${versionIdV4}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-v4-submit-${id}` },
body: { expectedPublicationRevision: revisionAfterUnpublish },
},
);
check(
'下架后仍可准备新的待审版本',
uploadV4.status === 200 && submitV4.status === 202,
`upload=${uploadV4.status} submit=${submitV4.status}`,
);
const reviveAttempt = await api(
`/admin/api/game-distribution/versions/${versionIdV4}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-revive-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: revisionBeforeUnpublish,
},
},
);
check(
'下架前的 revision 不能批准新版本(不会复活已下架游戏)',
reviveAttempt.status === 409 &&
reviveAttempt.text.includes('PUBLICATION_CONFLICT'),
`status=${reviveAttempt.status} body=${reviveAttempt.text.slice(0, 160)}`,
);
const catalogAfterReviveAttempt = await api('/api/game-distribution/games');
check(
'陈旧审核之后游戏仍未公开',
!(catalogAfterReviveAttempt.data?.games ?? []).some(
(game) => game.id === gameId,
),
);
// 13. 管理员安全下架 / 恢复,以及匿名直取发行包对象
const revisionForV4 = await adminRevisionOfGame();
const approveV4 = await api(
`/admin/api/game-distribution/versions/${versionIdV4}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-v4-approve-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: revisionForV4,
},
},
);
const detailAfterV4 = await api(`/api/game-distribution/games/${gameId}`);
check(
'用当前 revision 批准 v4 后新版本上线',
approveV4.status === 200 &&
detailAfterV4.data?.currentVersion?.sha256 === sha256V4,
`status=${approveV4.status} sha=${String(detailAfterV4.data?.currentVersion?.sha256).slice(0, 12)}`,
);
const revisionBeforeSuspend = detailAfterV4.data?.publicationRevision ?? 0;
const suspended = await api(
`/admin/api/game-distribution/games/${gameId}/suspend`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-suspend-${id}` },
body: {
expectedPublicationRevision: revisionBeforeSuspend,
reason: 'E2E 安全下架',
},
},
);
check(
'管理员安全下架成功',
suspended.status === 200 && suspended.data?.game?.status !== 'published',
`status=${suspended.status} gameStatus=${suspended.data?.game?.status ?? ''}`,
);
const catalogAfterSuspend = await api('/api/game-distribution/games');
const detailAfterSuspend = await api(
`/api/game-distribution/games/${gameId}`,
);
const releaseAfterSuspend = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'安全下架后目录 / 详情 / 发行读取全部关闭',
!(catalogAfterSuspend.data?.games ?? []).some(
(game) => game.id === gameId,
) &&
detailAfterSuspend.status === 404 &&
releaseAfterSuspend.status === 404,
`detail=${detailAfterSuspend.status} gateway=${releaseAfterSuspend.status}`,
);
const staleSuspend = await api(
`/admin/api/game-distribution/games/${gameId}/suspend`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-stale-suspend-${id}` },
body: {
expectedPublicationRevision: revisionBeforeSuspend - 1,
reason: 'E2E 过期安全下架',
},
},
);
check(
'过期 revision 的安全下架被 CAS 拒绝',
staleSuspend.status === 409 &&
staleSuspend.text.includes('PUBLICATION_CONFLICT'),
`status=${staleSuspend.status} body=${staleSuspend.text.slice(0, 160)}`,
);
const restoreRevision = await adminRevisionOfGame();
const restored = await api(
`/admin/api/game-distribution/games/${gameId}/restore`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-restore-${id}` },
body: { expectedPublicationRevision: restoreRevision },
},
);
const catalogAfterRestore = await api('/api/game-distribution/games');
const releaseAfterRestore = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
check(
'管理员恢复后游戏重新公开且可玩',
restored.status === 200 &&
(catalogAfterRestore.data?.games ?? []).some(
(game) => game.id === gameId,
) &&
releaseAfterRestore.status === 200,
`status=${restored.status} gateway=${releaseAfterRestore.status}`,
);
// 发行包落在 api-server 的快照 bucket(默认 agc-dev,见 config.rs 的默认值),
// 公开读取必须走网关;这里直接用对象键构造匿名请求,验证私有 bucket 不给直取。
const ossBucket = (
process.env.GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET ?? 'agc-dev'
).trim();
const ossEndpoint = (
process.env.GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT ??
'oss-rg-china-mainland.aliyuncs.com'
).trim();
const objectKey = `agc/project-snapshots/v1/game-distribution/${gameId}/${versionIdV4}.zip`;
const directObject = await fetch(
`https://${ossBucket}.${ossEndpoint}/${objectKey}`,
);
if (directObject.status >= 400) {
check(
'匿名直取私有 bucket 里的发行包对象被拒绝',
true,
`status=${directObject.status} bucket=${ossBucket}`,
);
} else if ((process.env.E2E_REQUIRE_PRIVATE_BUCKET ?? '').trim() === '1') {
check(
'匿名直取私有 bucket 里的发行包对象被拒绝',
false,
`status=${directObject.status} bucket=${ossBucket}`,
);
} else {
console.log(
`WARN 匿名直取发行包对象返回 status=${directObject.status}(bucket=${ossBucket}):` +
'本地 dev bucket 允许匿名读,生产上线前必须确认 bucket 与对象 ACL 都是私有(可用 E2E_REQUIRE_PRIVATE_BUCKET=1 复验)。',
);
}
console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exitCode = failures === 0 ? 0 : 1;
}
main().catch((error) => {
console.error('E2E 脚本异常:', error);
process.exitCode = 1;
});