25a992468d
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m52s
Project CI / Backend tests (push) Successful in 4m55s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m4s
Project CI / Native shell tests (push) Successful in 6m20s
Project CI / Frontend tests (push) Successful in 2m11s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m6s
Project CI / AI game creator shell web tests (push) Successful in 1m40s
Project CI / Repository checks (push) Successful in 2m12s
- platform-oss 的内部 PUT 与分片追加显式下发 x-oss-object-acl(Private 为 private、Public 为 public-read),OssAppendInternalObjectRequest 增加 access 字段 - 直传 policy 增加 x-oss-object-acl 条件,表单字段与 shared-contracts 的 DirectUploadTicketFormFields 同步新增并透传到 ticket 响应 - 媒体链路 E2E 增加匿名直取直传封面与发行包两条探测:默认 WARN,E2E_REQUIRE_PRIVATE_BUCKET=1 时按失败处理 - 里程碑第 7 条、pitfalls 与 decision-log 记录根因、修复与复验(严格模式 65 项 PASS,两处匿名直取都 403)
1243 lines
41 KiB
JavaScript
1243 lines
41 KiB
JavaScript
// 游戏分发「封面 + 截图」真实链路检查(需要本地 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 可覆盖游戏标题,便于在广场里认出这次验证。
|
||
//
|
||
// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 →
|
||
// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影
|
||
// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。
|
||
import { readFile } from 'node:fs/promises';
|
||
|
||
import JSZip from 'jszip';
|
||
|
||
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);
|
||
}
|
||
const COVER_PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||
'base64',
|
||
);
|
||
|
||
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, 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}${path}`, {
|
||
method,
|
||
headers: finalHeaders,
|
||
body: finalBody,
|
||
});
|
||
const text = await response.text();
|
||
let json = null;
|
||
try {
|
||
json = JSON.parse(text);
|
||
} catch {
|
||
json = null;
|
||
}
|
||
return {
|
||
status: response.status,
|
||
json,
|
||
text,
|
||
data: json?.data,
|
||
error: json?.error,
|
||
};
|
||
}
|
||
|
||
function stamp() {
|
||
return `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||
}
|
||
|
||
async function uploadImage(token, kind, id) {
|
||
const bytes = COVER_PNG;
|
||
const fileName = `${kind}-${id}.png`;
|
||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||
method: 'POST',
|
||
token,
|
||
body: {
|
||
legacyPrefix: 'generated-character-drafts',
|
||
pathSegments: ['game-distribution', kind, id],
|
||
fileName,
|
||
contentType: 'image/png',
|
||
access: 'private',
|
||
maxSizeBytes: bytes.length,
|
||
metadata: { asset_kind: `game_distribution_${kind}` },
|
||
},
|
||
});
|
||
if (ticket.status !== 200) {
|
||
throw new Error(
|
||
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
const upload = ticket.data.upload;
|
||
const form = new FormData();
|
||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||
}
|
||
form.append('file', new Blob([bytes], { 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: bytes.length,
|
||
assetKind: `game_distribution_${kind}`,
|
||
accessPolicy: 'private',
|
||
entityId: `game-distribution-${kind}`,
|
||
},
|
||
});
|
||
if (confirm.status !== 200) {
|
||
throw new Error(
|
||
`confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
return {
|
||
assetObjectId: confirm.data.assetObject.assetObjectId,
|
||
objectKey: confirm.data.assetObject.objectKey,
|
||
};
|
||
}
|
||
|
||
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()}` },
|
||
body: gameMetadata({ title: `灰度关闭验证 ${Date.now()}` }),
|
||
});
|
||
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. 真实素材直传
|
||
const id = stamp();
|
||
const cover = await uploadImage(author, 'cover', id);
|
||
const shot1 = await uploadImage(author, 'screenshot', `${id}-1`);
|
||
const shot2 = await uploadImage(author, 'screenshot', `${id}-2`);
|
||
check(
|
||
'封面素材直传并 confirm',
|
||
Boolean(cover.assetObjectId) &&
|
||
cover.objectKey.includes('game-distribution/cover'),
|
||
cover.objectKey,
|
||
);
|
||
check(
|
||
'截图素材直传并 confirm',
|
||
Boolean(shot1.assetObjectId) && Boolean(shot2.assetObjectId),
|
||
);
|
||
|
||
// 3. 服务端校验:缺封面 / 超 6 张 / 素材不存在
|
||
const noCover = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: author,
|
||
headers: { 'Idempotency-Key': `e2e-nocover-${id}` },
|
||
body: gameMetadata({ title: '缺封面验证' }),
|
||
});
|
||
check(
|
||
'缺少封面被拒(400)',
|
||
noCover.status === 400,
|
||
`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}` },
|
||
body: gameMetadata({
|
||
coverAssetId: cover.assetObjectId,
|
||
screenshots: Array.from({ length: 7 }, () => shot1.assetObjectId),
|
||
}),
|
||
});
|
||
check(
|
||
'截图超过 6 张被拒(400)',
|
||
tooMany.status === 400,
|
||
`status=${tooMany.status} msg=${tooMany.error?.message ?? ''}`,
|
||
);
|
||
|
||
const ghost = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: author,
|
||
headers: { 'Idempotency-Key': `e2e-ghost-${id}` },
|
||
body: gameMetadata({ coverAssetId: 'asset_not_exists', screenshots: [] }),
|
||
});
|
||
check(
|
||
'不存在的封面素材被拒',
|
||
ghost.status === 400,
|
||
`status=${ghost.status} msg=${ghost.error?.message ?? ''}`,
|
||
);
|
||
|
||
// 4. 创建游戏 + 版本(冻结资料)
|
||
const metadata = gameMetadata({
|
||
title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`,
|
||
coverAssetId: cover.assetObjectId,
|
||
screenshots: [shot1.assetObjectId, shot2.assetObjectId],
|
||
});
|
||
const created = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: author,
|
||
headers: { 'Idempotency-Key': `e2e-game-${id}` },
|
||
body: metadata,
|
||
});
|
||
check(
|
||
'创建游戏成功',
|
||
created.status === 200 && Boolean(created.data?.id),
|
||
`status=${created.status} ${created.text.slice(0, 200)}`,
|
||
);
|
||
const gameId = created.data.id;
|
||
|
||
// 版本级校验用例需要真实的游戏行:用合法素材建一个只用于负面校验的游戏。
|
||
const ghostGame = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: author,
|
||
headers: { 'Idempotency-Key': `e2e-ghostgame-${id}` },
|
||
body: gameMetadata({
|
||
title: `版本级素材校验 ${id.slice(-6)}`,
|
||
coverAssetId: cover.assetObjectId,
|
||
screenshots: [],
|
||
}),
|
||
});
|
||
const gameIdForGhost = ghostGame.data?.id ?? gameId;
|
||
|
||
const ghostVersion = await api(
|
||
`/api/game-distribution/games/${gameIdForGhost}/versions`,
|
||
{
|
||
method: 'POST',
|
||
token: author,
|
||
headers: { 'Idempotency-Key': `e2e-ghost-version-${id}` },
|
||
body: {
|
||
packageSha256: 'a'.repeat(64),
|
||
packageBytes: 1024,
|
||
packageFileCount: 1,
|
||
packageEntryPath: 'index.html',
|
||
gameMetadata: gameMetadata({
|
||
coverAssetId: 'asset_not_exists',
|
||
screenshots: [],
|
||
}),
|
||
},
|
||
},
|
||
);
|
||
check(
|
||
'版本冻结时同样拒绝不存在的素材',
|
||
ghostVersion.status >= 400,
|
||
`status=${ghostVersion.status}`,
|
||
);
|
||
|
||
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}` },
|
||
body: {
|
||
packageSha256: sha256,
|
||
packageBytes: zipBytes.length,
|
||
packageFileCount: built.fileCount,
|
||
packageEntryPath: 'index.html',
|
||
gameMetadata: metadata,
|
||
},
|
||
});
|
||
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)}`,
|
||
);
|
||
|
||
// 版本冻结前仍要复核素材归属:换成别人的封面素材必须被拒。
|
||
const foreign = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: another,
|
||
headers: { 'Idempotency-Key': `e2e-foreign-game-${id}` },
|
||
body: gameMetadata({
|
||
title: `他人素材验证 ${id.slice(-6)}`,
|
||
coverAssetId: cover.assetObjectId,
|
||
screenshots: [],
|
||
}),
|
||
});
|
||
check(
|
||
'借用他人封面素材创建游戏被拒',
|
||
foreign.status === 403 || foreign.status === 400,
|
||
`status=${foreign.status} msg=${foreign.error?.message ?? ''}`,
|
||
);
|
||
|
||
// 5. 作者回读版本:冻结资料带回素材 ID
|
||
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(
|
||
'冻结资料保留封面素材 ID',
|
||
frozen?.coverAssetId === cover.assetObjectId,
|
||
String(frozen?.coverAssetId),
|
||
);
|
||
check(
|
||
'冻结资料保留截图素材 ID 顺序',
|
||
Array.isArray(frozen?.screenshots) &&
|
||
frozen.screenshots[0]?.assetId === shot1.assetObjectId &&
|
||
frozen.screenshots[1]?.assetId === shot2.assetObjectId,
|
||
);
|
||
check(
|
||
'冻结资料对象键由服务端派生',
|
||
frozen?.coverObjectKey === cover.objectKey,
|
||
String(frozen?.coverObjectKey),
|
||
);
|
||
|
||
// 6. 待审期间:公开目录不可见,匿名读封面被拒
|
||
const catalogBefore = await api('/api/game-distribution/games');
|
||
check(
|
||
'待审期间公开目录不含该游戏',
|
||
!(catalogBefore.data?.games ?? []).some((game) => game.id === gameId),
|
||
);
|
||
const readBefore = await api(
|
||
`/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`,
|
||
);
|
||
check(
|
||
'未公开游戏的封面没有匿名读授权',
|
||
readBefore.status >= 400,
|
||
`status=${readBefore.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 === cover.objectKey,
|
||
String(publishedGame?.coverObjectKey),
|
||
);
|
||
check(
|
||
'公开投影带截图对象键',
|
||
Array.isArray(publishedGame?.screenshots) &&
|
||
publishedGame.screenshots[0] === shot1.objectKey,
|
||
JSON.stringify(publishedGame?.screenshots ?? []),
|
||
);
|
||
check(
|
||
'公开投影不泄露素材 ID',
|
||
!JSON.stringify(publishedGame ?? {}).includes(cover.assetObjectId),
|
||
);
|
||
|
||
// 9. 匿名读授权:封面与截图都能换签名地址
|
||
const coverRead = await api(
|
||
`/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`,
|
||
);
|
||
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}/${cover.objectKey}`,
|
||
'直传上传的私有封面对象',
|
||
);
|
||
}
|
||
const shotRead = await api(
|
||
`/api/assets/read-url?objectKey=${encodeURIComponent(shot1.objectKey)}`,
|
||
);
|
||
check(
|
||
'匿名可读已公开游戏截图',
|
||
shotRead.status === 200 &&
|
||
Boolean(shotRead.data?.read?.signedUrl ?? shotRead.data?.signedUrl),
|
||
`status=${shotRead.status}`,
|
||
);
|
||
|
||
// 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}` },
|
||
body: {
|
||
packageSha256: sha256V2,
|
||
packageBytes: builtV2.bytes.length,
|
||
packageFileCount: builtV2.fileCount,
|
||
packageEntryPath: 'index.html',
|
||
gameMetadata: metadata,
|
||
},
|
||
},
|
||
);
|
||
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}` },
|
||
body: {
|
||
packageSha256: sha256V3,
|
||
packageBytes: builtV3.bytes.length,
|
||
packageFileCount: builtV3.fileCount,
|
||
packageEntryPath: 'index.html',
|
||
gameMetadata: metadata,
|
||
},
|
||
},
|
||
);
|
||
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}` },
|
||
body: {
|
||
packageSha256: sha256V4,
|
||
packageBytes: builtV4.bytes.length,
|
||
packageFileCount: builtV4.fileCount,
|
||
packageEntryPath: 'index.html',
|
||
gameMetadata: metadata,
|
||
},
|
||
},
|
||
);
|
||
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;
|
||
});
|