Files
Genarrative/scripts/check-game-distribution-project-bundle-e2e.mjs
T
suzmii ff763abe55
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
test(游戏共创): 新增工程源包(M2b)端到端验收脚本
- 新增 scripts/check-game-distribution-project-bundle-e2e.mjs:真实 HTTP 验收 M2b 上行族与下行优先
  · A1 合法工程源包整包 PUT → 200;版本私有 payload 的 projectBundleBytes/projectBundleSha256 与上传一致且不含对象键
  · A2 >8 MiB 不可压缩负载两片续传:chunkBytes=8388608,偏移 8388608→9438176,upload-state 权威偏移逐片一致,complete 后 bytes/sha256 一致
  · A3 含 .env → 422 PROJECT_BUNDLE_VALIDATION_FAILED,且该版本仍 bytes=0 / sha256 空
  · A4 二次上传 → 409 PROJECT_BUNDLE_ALREADY_EXISTS
  · A5 已公开且已有工程包再传 → 409 ALREADY_EXISTS(阶段门先判已存在);A5b 已公开但无工程包补传 → 409 PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED
  · A6 未带 Bearer → 401;另一作者 token → 404(实现按不存在处理,脚本按实现断言并标注与工单 403 的差异)
  · B7/B8 发布后 fork-source source=project、sha256/bytes 一致、downloadPath 指向 project,下载校验 zip/length/sha256/解压条目
  · B9 对照作品(无工程包)source 回落 package,/fork-source/project → 409
- package.json:新增 npm script check:game-distribution-project-bundle-e2e
- 复用 lineage 脚本的发布链路与 helper(脚本头注释标注出处),未抽公共模块以免改动其它脚本
- 实测:本地 dev 栈 46 项 46 PASS / 0 FAIL / 0 SKIP
2026-10-05 16:01:15 +08:00

959 lines
32 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.
// 游戏分发「工程源包(M2b)」上行 + 下行优先链路真实行为验收。
//
// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;本脚本**不需要浏览器**。
//
// 用法:
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
// node scripts/check-game-distribution-project-bundle-e2e.mjs
// E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口)
//
// 契约来源(全部读实现确认,未按描述猜):
// [1] 上行路由族(Bearer + 发布灰度):api-server/src/modules/game_distribution.rs:363-385
// [2] 阶段门(已确认 → 409 ALREADY_EXISTS;仅 awaiting_upload/upload_failed 可写 → 409 UPLOAD_NOT_ALLOWED):
// api-server/...:2246-2274(ensure_project_bundle_uploadable)
// [3] 整包 PUT(要求 application/octet-stream):api-server/...:2285-2390;校验失败 → 422 PROJECT_BUNDLE_VALIDATION_FAILED
// (api-server/...:2277-2283 map_project_bundle_error)
// [4] 分片:api-server/...:2473-2597(x-genarrative-upload-offset 头 api-server/...:96、8 MiB 上限 :88、
// 偏移/超限错误码 :2492/:2501/:2525)、upload-state :2445-2470(chunkBytes/receivedBytes)
// [5] complete:api-server/...:2599-2700(未开始 → 409 UPLOAD_NOT_STARTED;校验失败删半包并 422)
// [6] 校验器:module-game-distribution/src/project_bundle.rs:66-152,拒绝清单含 node_modules / .env:
// :156-224(reject_forbidden_path / is_sensitive_file_name)
// [7] 版本私有 payload 暴露 projectBundleBytes / projectBundleSha256 且不含对象键:api-server/...:3421-3436
// [8] 下行优先:api-server/...:3110-3160(有工程包 → source=Project,回落 package)
// + /fork-source/project 无工程包时 409 FORK_SOURCE_NOT_AVAILABLE:api-server/...:3258-3270
// + downloadPath 按资产拼接:api-server/...:3174-3188
// [9] 对象键前缀 agc/project-snapshots/v1/game-distribution/(响应里绝不能出现):api-server/...:106,2757-2763
//
// 复用/照抄的 helper(本脚本与其同源,注释里标了出处):
// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs:115-133(源自 ratings-e2e.mjs:15-27)
// - check/brief/api/register/gameMetadata/uploadCover/createGame/ownerGame:lineage 脚本 :139-318
// (uploadCover 又源自 owner-isolation.mjs:97-150)
// - publishToPublic(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本 :320-407
// (其顺序与请求体源自 media-e2e.mjs:431-495,523-527,561-573)
// - downloadBinary(按字节校验下载):lineage 脚本 :418-433
// - 对象键/私有字段泄漏判定模式:lineage 脚本 :445-452 的同类写法
//
// 已知与工单描述不一致处(读实现后按实现断言,并在报告里单独标注):
// - 工单说「另一个作者的 token → 403」,实现是 **404**:api-server/...:4224-4240
// (load_owner_version_or_404:非 owner 按「不存在」处理,与发行包上行族同口径)。
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import JSZip from 'jszip';
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';
// 工程源包对象键前缀(api-server/...:106):响应里出现它等于泄漏了对象键。
const OBJECT_KEY_PREFIX = 'agc/project-snapshots/v1/game-distribution/';
const PRIVATE_OBJECT_PATTERN =
/agc\/project-snapshots|\.project\.zip|project_bundle_object_key/iu;
const CHUNK_HEADER = 'x-genarrative-upload-offset';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
'game-distribution:publish 写入口并走完整发布链路;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' +
'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
);
process.exit(2);
}
const devStack = JSON.parse(
readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
);
const API = (
process.env.E2E_API_BASE ??
devStack.services?.['api-server']?.url ??
''
).replace(/\/+$/u, '');
if (!API) {
console.error(
'无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' +
'或用 E2E_API_BASE 显式指定。',
);
process.exit(2);
}
let checks = 0;
let failures = 0;
const skipped = 0;
function check(name, ok, detail = '') {
checks += 1;
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
function note(message) {
console.log(`NOTE ${message}`);
}
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
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,
signal: AbortSignal.timeout(120_000),
});
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,
};
}
function brief(body) {
const code = body?.error?.code ?? body?.json?.error?.code ?? '';
return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 220)}`;
}
async function register(prefix) {
const phone = `${prefix}${String(Date.now()).slice(-8)}`;
const response = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
return { phone, response, token: response.data?.token };
}
function gameMetadata({ title, coverAssetId }) {
return {
title,
summary: '工程源包验收临时作品',
description: '',
category: '休闲',
tags: ['e2e'],
coverAssetId,
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
async function uploadCover(token, id) {
const fileName = `project-bundle-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'project-bundle', 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} ${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([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-project-bundle',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
async function createGame({ token, metadata, idemKey }) {
return api('/api/game-distribution/games', {
method: 'POST',
token,
headers: { 'Idempotency-Key': idemKey },
body: metadata,
});
}
/// 发行包(可玩成品)ZIP:与工程源包是两份不同资产,本脚本两者都要传。
async function buildReleaseZip(marker) {
const zip = new JSZip();
zip.file(
'index.html',
`<!doctype html><html><head><meta charset="utf-8"><title>${marker}</title></head><body><h1>${marker}</h1></body></html>`,
);
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
return {
bytes,
fileCount: 1,
sha256: createHash('sha256').update(bytes).digest('hex'),
};
}
/// 合法工程源包:包内条目覆盖 index.html / package.json / vite.config.js / src/main.js。
/// `extraFiles` 用于按用例注入禁项(如 .env);`randomBytesCount` 用于造 >8 MiB 的不可压缩负载。
async function buildProjectBundle({
marker,
extraFiles = {},
randomBytesCount = 0,
}) {
const zip = new JSZip();
zip.file(
'index.html',
`<!doctype html><html><head><meta charset="utf-8"><title>${marker}</title></head>` +
'<body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>',
);
zip.file(
'package.json',
JSON.stringify({ name: marker, version: '0.0.0' }, null, 2),
);
zip.file('vite.config.js', 'export default { build: { outDir: "dist" } };\n');
zip.file('src/main.js', `console.log(${JSON.stringify(marker)});\n`);
if (randomBytesCount > 0) {
// STORE:随机字节不可压缩,保证包体真的越过 8 MiB 分片边界。
zip.file('assets/blob.bin', randomBytes(randomBytesCount), {
compression: 'STORE',
});
}
for (const [filePath, content] of Object.entries(extraFiles)) {
zip.file(filePath, content);
}
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
return {
bytes,
sha256: createHash('sha256').update(bytes).digest('hex'),
entries: Object.keys(zip.files).filter((name) => !zip.files[name].dir),
};
}
// ---------- 上行 ----------
async function putProjectBundle(versionId, bytes, { token, key }) {
return api(`/api/game-distribution/versions/${versionId}/project-bundle`, {
method: 'PUT',
token,
headers: {
'Idempotency-Key': key,
'Content-Type': 'application/octet-stream',
},
binary: bytes,
});
}
async function putProjectBundleChunk(versionId, chunk, offset, { token, key }) {
return api(
`/api/game-distribution/versions/${versionId}/project-bundle/chunk`,
{
method: 'PUT',
token,
headers: {
'Idempotency-Key': key,
'Content-Type': 'application/octet-stream',
[CHUNK_HEADER]: String(offset),
},
binary: chunk,
},
);
}
async function projectBundleUploadState(versionId, token) {
return api(
`/api/game-distribution/versions/${versionId}/project-bundle/upload-state`,
{
token,
},
);
}
async function ownerVersion(token, versionId) {
const response = await api(`/api/game-distribution/versions/${versionId}`, {
token,
});
return { response, version: response.data?.version ?? null };
}
async function downloadBinary(pathname, token) {
const headers = { ...ENVELOPE };
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${API}${pathname}`, {
headers,
signal: AbortSignal.timeout(120_000),
});
const bytes = Buffer.from(await response.arrayBuffer());
return {
status: response.status,
contentType: response.headers.get('content-type') ?? '',
contentLength: response.headers.get('content-length') ?? '',
bytes,
};
}
// ---------- 发布链路(照抄 lineage 脚本 :320-407 的 helper,抽成共享模块会动到其它脚本,故按工单要求照抄并注明) ----------
async function createVersion({ token, gameId, metadata, zip, stamp, tag }) {
return api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token,
headers: { 'Idempotency-Key': `pb-version-${tag}-${stamp}` },
body: {
packageSha256: zip.sha256,
packageBytes: zip.bytes.length,
packageFileCount: zip.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
}
async function publishToPublic({
token,
admin,
gameId,
gameRevision,
metadata,
stamp,
tag,
label,
}) {
const zip = await buildReleaseZip(`release-${tag}-${stamp}`);
const created = await createVersion({
token,
gameId,
metadata,
zip,
stamp,
tag,
});
const versionId = created.data?.versionId;
check(
`${label} 版本创建成功`,
created.status === 200 && Boolean(versionId),
`status=${created.status} ${created.text.slice(0, 160)}`,
);
if (!versionId) return { versionId: null };
const upload = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token,
headers: {
'Idempotency-Key': `pb-upload-${tag}-${stamp}`,
'Content-Type': 'application/zip',
},
binary: zip.bytes,
},
);
check(
`${label} 发行包上传成功`,
upload.status === 200,
`status=${upload.status}`,
);
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': `pb-submit-${tag}-${stamp}` },
body: { expectedPublicationRevision: gameRevision },
},
);
check(
`${label} 送审成功(202)`,
submitted.status === 202,
`status=${submitted.status} ${submitted.text.slice(0, 140)}`,
);
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
token,
});
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `pb-approve-${tag}-${stamp}` },
body: {
decision: 'approve',
expectedPublicationRevision:
readback.data?.version?.publicationRevision ?? gameRevision,
},
},
);
check(
`${label} 管理员审核通过并公开`,
approved.status === 200,
`status=${approved.status} ${approved.text.slice(0, 140)}`,
);
return { versionId };
}
// ---------- 主流程 ----------
async function main() {
console.log(
`[project-bundle-e2e] api-server=${API} database=${devStack.database}`,
);
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
const gate = await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: GATE_KEY,
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 工程源包链路',
},
});
check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
const stamp = Date.now();
const suffix = String(stamp).slice(-6);
const author = await register('132');
const other = await register('133');
check(
'作者注册拿到 token',
author.response.status === 200 && Boolean(author.token),
`status=${author.response.status} phone=${author.phone}`,
);
check(
'另一个作者注册拿到 token',
other.response.status === 200 && Boolean(other.token),
`status=${other.response.status} phone=${other.phone}`,
);
if (!author.token || !other.token) process.exit(1);
// ---------- fixture:作品 A ----------
const gameTitle = `工程源包 ${suffix}`;
const coverAssetId = await uploadCover(author.token, stamp);
const metadata = gameMetadata({ title: gameTitle, coverAssetId });
const created = await createGame({
token: author.token,
metadata,
idemKey: `pb-game-${stamp}`,
});
const gameId = created.data?.id;
const gameRevision = created.data?.publicationRevision ?? 0;
check(
'作品创建成功',
created.status === 200 && Boolean(gameId),
`status=${created.status} id=${gameId ?? ''}`,
);
if (!gameId) process.exit(1);
// ---------- A1:草稿版本上传合法工程源包 ----------
const validBundle = await buildProjectBundle({ marker: `proj-${suffix}` });
const v1ReleaseZip = await buildReleaseZip(`v1-${suffix}`);
const v1 = await createVersion({
token: author.token,
gameId,
metadata,
zip: v1ReleaseZip,
stamp,
tag: 'v1',
});
const v1Id = v1.data?.versionId;
check(
'v1 版本创建成功且处于可写档位(awaiting_upload)',
v1.status === 200 && Boolean(v1Id) && v1.data?.status === 'awaiting_upload',
`status=${v1.status} versionId=${v1Id ?? ''} versionStatus=${v1.data?.status ?? ''}`,
);
if (!v1Id) process.exit(1);
const put1 = await putProjectBundle(v1Id, validBundle.bytes, {
token: author.token,
key: `pb-put-v1-${stamp}`,
});
check(
'A1 合法工程源包整包上传成功',
put1.status === 200 && put1.data?.versionId === v1Id,
brief(put1),
);
const v1After = await ownerVersion(author.token, v1Id);
check(
'A1 版本私有 payload 的 projectBundleBytes / projectBundleSha256 与上传一致',
v1After.version?.projectBundleBytes === validBundle.bytes.length &&
v1After.version?.projectBundleSha256 === validBundle.sha256,
`bytes=${v1After.version?.projectBundleBytes} 期望=${validBundle.bytes.length} ` +
`sha256=${v1After.version?.projectBundleSha256 ?? ''} 期望=${validBundle.sha256}`,
);
check(
'A1 版本私有 payload 不下发对象键',
!PRIVATE_OBJECT_PATTERN.test(v1After.response.text) &&
!v1After.response.text.includes(OBJECT_KEY_PREFIX),
`objectKeyPrefix=${OBJECT_KEY_PREFIX}`,
);
// ---------- A3:含禁项 → 422,且不落库 ----------
const forbiddenBundle = await buildProjectBundle({
marker: `bad-${suffix}`,
extraFiles: { '.env': 'SECRET=1\n' },
});
const v2 = await createVersion({
token: author.token,
gameId,
metadata,
zip: await buildReleaseZip(`v2-${suffix}`),
stamp,
tag: 'v2',
});
const v2Id = v2.data?.versionId;
check(
'v2 版本创建成功(用于禁项用例)',
v2.status === 200 && Boolean(v2Id),
`status=${v2.status} versionId=${v2Id ?? ''}`,
);
if (!v2Id) process.exit(1);
const putBad = await putProjectBundle(v2Id, forbiddenBundle.bytes, {
token: author.token,
key: `pb-put-v2-${stamp}`,
});
check(
'A3 含 .env 的工程源包被拒(422 PROJECT_BUNDLE_VALIDATION_FAILED)',
putBad.status === 422 &&
(putBad.error?.code ?? '') === 'PROJECT_BUNDLE_VALIDATION_FAILED',
brief(putBad),
);
const v2After = await ownerVersion(author.token, v2Id);
check(
'A3 被拒后该版本仍未落库工程源包(bytes=0 / sha256 空)',
(v2After.version?.projectBundleBytes ?? -1) === 0 &&
!v2After.version?.projectBundleSha256,
`bytes=${v2After.version?.projectBundleBytes} sha256=${JSON.stringify(v2After.version?.projectBundleSha256 ?? null)}`,
);
// ---------- A2:>8 MiB 不可压缩负载走两片 ----------
const bigRandomBytes = 9 * 1024 * 1024; // 9 MiB 随机字节:压缩后仍 >8 MiB
const bigBundle = await buildProjectBundle({
marker: `big-${suffix}`,
randomBytesCount: bigRandomBytes,
});
const v3 = await createVersion({
token: author.token,
gameId,
metadata,
zip: await buildReleaseZip(`v3-${suffix}`),
stamp,
tag: 'v3',
});
const v3Id = v3.data?.versionId;
check(
'v3 版本创建成功(用于多分片用例)',
v3.status === 200 && Boolean(v3Id),
`status=${v3.status} versionId=${v3Id ?? ''}`,
);
if (!v3Id) process.exit(1);
const state0 = await projectBundleUploadState(v3Id, author.token);
const chunkBytes = state0.data?.chunkBytes ?? 0;
check(
'A2 upload-state 下发权威分片大小与服务端已收字节',
state0.status === 200 && chunkBytes > 0 && state0.data?.receivedBytes === 0,
`chunkBytes=${chunkBytes} receivedBytes=${state0.data?.receivedBytes}`,
);
check(
'A2 构造的不可压缩负载确实超过一个分片(> chunkBytes)',
bigBundle.bytes.length > chunkBytes && chunkBytes > 0,
`bundleBytes=${bigBundle.bytes.length} chunkBytes=${chunkBytes} entries=${bigBundle.entries.length}`,
);
const firstChunk = bigBundle.bytes.subarray(0, chunkBytes);
const secondChunk = bigBundle.bytes.subarray(chunkBytes);
const chunk1 = await putProjectBundleChunk(v3Id, firstChunk, 0, {
token: author.token,
key: `pb-chunk1-${stamp}`,
});
check('A2 第一片(offset=0)写入成功', chunk1.status === 200, brief(chunk1));
const state1 = await projectBundleUploadState(v3Id, author.token);
check(
'A2 upload-state 权威偏移等于第一片字节数',
state1.data?.receivedBytes === firstChunk.length,
`receivedBytes=${state1.data?.receivedBytes} 期望=${firstChunk.length}`,
);
const chunk2 = await putProjectBundleChunk(
v3Id,
secondChunk,
firstChunk.length,
{
token: author.token,
key: `pb-chunk2-${stamp}`,
},
);
check('A2 第二片(续传偏移)写入成功', chunk2.status === 200, brief(chunk2));
const state2 = await projectBundleUploadState(v3Id, author.token);
check(
'A2 upload-state 权威偏移等于整包字节数',
state2.data?.receivedBytes === bigBundle.bytes.length,
`receivedBytes=${state2.data?.receivedBytes} 期望=${bigBundle.bytes.length}`,
);
const complete3 = await api(
`/api/game-distribution/versions/${v3Id}/project-bundle/complete`,
{
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `pb-complete-v3-${stamp}` },
},
);
check(
'A2 分片收齐后 complete 成功',
complete3.status === 200 && complete3.data?.versionId === v3Id,
brief(complete3),
);
const v3After = await ownerVersion(author.token, v3Id);
check(
'A2 分片上传确认后的 bytes / sha256 与本地构造一致',
v3After.version?.projectBundleBytes === bigBundle.bytes.length &&
v3After.version?.projectBundleSha256 === bigBundle.sha256,
`bytes=${v3After.version?.projectBundleBytes} 期望=${bigBundle.bytes.length} ` +
`sha256=${v3After.version?.projectBundleSha256 ?? ''} 期望=${bigBundle.sha256}`,
);
// ---------- A4:重复确认 ----------
const putAgain = await putProjectBundle(v1Id, validBundle.bytes, {
token: author.token,
key: `pb-put-v1-again-${stamp}`,
});
check(
'A4 同一版本二次上传被拒(409 PROJECT_BUNDLE_ALREADY_EXISTS)',
putAgain.status === 409 &&
(putAgain.error?.code ?? '') === 'PROJECT_BUNDLE_ALREADY_EXISTS',
brief(putAgain),
);
// ---------- A6:鉴权 ----------
const anonPut = await api(
`/api/game-distribution/versions/${v1Id}/project-bundle`,
{
method: 'PUT',
headers: {
'Idempotency-Key': `pb-anon-${stamp}`,
'Content-Type': 'application/octet-stream',
},
binary: validBundle.bytes,
},
);
check('A6 未带 Bearer → 401', anonPut.status === 401, brief(anonPut));
const foreignPut = await putProjectBundle(v1Id, validBundle.bytes, {
token: other.token,
key: `pb-foreign-${stamp}`,
});
check(
'A6 另一个作者的 token → 404(实现:非 owner 按不存在处理)',
foreignPut.status === 404 &&
!String(foreignPut.text).includes(OBJECT_KEY_PREFIX),
brief(foreignPut),
);
note(
'工单描述该用例为 403;实现是 404(api-server/...:4224-4240 load_owner_version_or_404,' +
'与发行包上行族同口径:不区分「别人的版本」与「不存在」)。本脚本按实现断言 404,并在此标注差异。',
);
// ---------- 发布 v1(带工程源包)→ A5 阶段门 ----------
const releaseZipV1 = v1ReleaseZip;
const uploadPackageV1 = await api(
`/api/game-distribution/versions/${v1Id}/package`,
{
method: 'PUT',
token: author.token,
headers: {
'Idempotency-Key': `pb-release-v1-${stamp}`,
'Content-Type': 'application/zip',
},
binary: releaseZipV1.bytes,
},
);
check(
'v1 发行包(成品)上传成功',
uploadPackageV1.status === 200,
brief(uploadPackageV1),
);
const submitV1 = await api(`/api/game-distribution/versions/${v1Id}/submit`, {
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `pb-submit-v1-${stamp}` },
body: { expectedPublicationRevision: gameRevision },
});
check('v1 送审成功(202)', submitV1.status === 202, brief(submitV1));
const v1Readback = await ownerVersion(author.token, v1Id);
const approveV1 = await api(
`/admin/api/game-distribution/versions/${v1Id}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `pb-approve-v1-${stamp}` },
body: {
decision: 'approve',
expectedPublicationRevision:
v1Readback.version?.publicationRevision ?? gameRevision,
},
},
);
check('v1 管理员审核通过并公开', approveV1.status === 200, brief(approveV1));
const putAfterPublish = await putProjectBundle(v1Id, validBundle.bytes, {
token: author.token,
key: `pb-put-after-publish-${stamp}`,
});
check(
'A5 已公开且已有工程包的版本再传 → 409 ALREADY_EXISTS(阶段门先判「已存在」)',
putAfterPublish.status === 409 &&
(putAfterPublish.error?.code ?? '') === 'PROJECT_BUNDLE_ALREADY_EXISTS',
brief(putAfterPublish),
);
note(
'实现里「已确认过工程包」先于「版本档位」判定(api-server/...:2246-2274 的注释与顺序):' +
'已有工程包的已公开版本因此返回 ALREADY_EXISTS;「已公开但无工程包」的补传用例在 A5b/B9 段。',
);
// 取件通道要求作品共创授权非禁止(api-server/...:3110-3145):发布后再提升授权。
const promoteFork = await api(
`/api/game-distribution/games/${gameId}/fork-authorization`,
{
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': `pb-promote-${stamp}` },
body: {
expectedForkAuthorization: 'forbidden',
forkAuthorization: 'nonCommercial',
},
},
);
check(
'作品 A 共创授权提升为 nonCommercial(取件通道前提)',
promoteFork.status === 200 &&
(promoteFork.data?.game?.forkAuthorization ?? null) === 'nonCommercial',
brief(promoteFork),
);
// ---------- B7:下行优先 ----------
const forkSource = await api(
`/api/game-distribution/games/${gameId}/fork-source`,
{
token: other.token,
},
);
const source = forkSource.data?.forkSource ?? null;
check(
'B7 带工程包的公开作品 fork-source 200 且 source=project',
forkSource.status === 200 && source?.source === 'project',
brief(forkSource),
);
check(
'B7 sha256 / bytes 与上传的工程源包一致',
source?.sha256 === validBundle.sha256 &&
source?.bytes === validBundle.bytes.length,
`sha256=${source?.sha256 ?? ''} 期望=${validBundle.sha256} bytes=${source?.bytes} 期望=${validBundle.bytes.length}`,
);
check(
'B7 downloadPath 指向 project 资产',
typeof source?.downloadPath === 'string' &&
source.downloadPath.endsWith('/fork-source/project') &&
!source.downloadPath.includes('://'),
`downloadPath=${source?.downloadPath ?? ''}`,
);
check(
'B7 取件元数据不含对象键',
!PRIVATE_OBJECT_PATTERN.test(forkSource.text) &&
!forkSource.text.includes(OBJECT_KEY_PREFIX),
`objectKeyPrefix=${OBJECT_KEY_PREFIX}`,
);
// ---------- B8:按 downloadPath 下载并逐字节校验 ----------
const projectDownload = await downloadBinary(
source?.downloadPath ?? '',
other.token,
);
check(
'B8 工程源包下载 200 + application/zip',
projectDownload.status === 200 &&
projectDownload.contentType.includes('application/zip'),
`status=${projectDownload.status} content-type=${projectDownload.contentType}`,
);
check(
'B8 content-length 与实际字节数一致',
Number(projectDownload.contentLength) === projectDownload.bytes.length,
`content-length=${projectDownload.contentLength} actual=${projectDownload.bytes.length}`,
);
const projectDownloadSha = createHash('sha256')
.update(projectDownload.bytes)
.digest('hex');
check(
'B8 下载字节 sha256 与元数据一致',
projectDownloadSha === source?.sha256,
`download=${projectDownloadSha} meta=${source?.sha256 ?? ''}`,
);
let unzippedEntries = [];
try {
const archive = await JSZip.loadAsync(projectDownload.bytes);
unzippedEntries = Object.keys(archive.files).filter(
(name) => !archive.files[name].dir,
);
} catch (error) {
unzippedEntries = [];
note(`B8 解压失败:${error?.message ?? error}`);
}
check(
'B8 下载内容可解压且包含上传的工程条目',
['index.html', 'package.json', 'vite.config.js', 'src/main.js'].every(
(entry) => unzippedEntries.includes(entry),
),
`entries=${unzippedEntries.join(',')}`,
);
// ---------- B9:对照(无工程包的公开作品回落成品包) ----------
const controlTitle = `工程源包对照 ${suffix}`;
const controlCover = await uploadCover(author.token, `${stamp}-control`);
const controlMeta = gameMetadata({
title: controlTitle,
coverAssetId: controlCover,
});
const controlCreated = await createGame({
token: author.token,
metadata: controlMeta,
idemKey: `pb-control-game-${stamp}`,
});
const controlGameId = controlCreated.data?.id;
check(
'B9 对照作品创建成功',
controlCreated.status === 200 && Boolean(controlGameId),
`status=${controlCreated.status} id=${controlGameId ?? ''}`,
);
if (!controlGameId) process.exit(1);
const controlPublish = await publishToPublic({
token: author.token,
admin,
gameId: controlGameId,
gameRevision: controlCreated.data?.publicationRevision ?? 0,
metadata: controlMeta,
stamp,
tag: 'control',
label: '对照',
});
// A5b:已公开且**没有**工程包的版本不允许补传(此时阶段门才落到档位判定)。
const controlVersionId = controlPublish.versionId;
if (controlVersionId) {
const controlPut = await putProjectBundle(
controlVersionId,
validBundle.bytes,
{ token: author.token, key: `pb-control-put-${stamp}` },
);
check(
'A5b 已公开但无工程包的版本不允许补传 → 409 PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED',
controlPut.status === 409 &&
(controlPut.error?.code ?? '') === 'PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED',
brief(controlPut),
);
}
const controlPromote = await api(
`/api/game-distribution/games/${controlGameId}/fork-authorization`,
{
method: 'PUT',
token: author.token,
headers: { 'Idempotency-Key': `pb-control-promote-${stamp}` },
body: {
expectedForkAuthorization: 'forbidden',
forkAuthorization: 'nonCommercial',
},
},
);
check(
'B9 对照作品共创授权提升为 nonCommercial',
controlPromote.status === 200,
brief(controlPromote),
);
const controlSource = await api(
`/api/game-distribution/games/${controlGameId}/fork-source`,
{ token: other.token },
);
check(
'B9 无工程包的作品 source 回落为 package',
controlSource.status === 200 &&
controlSource.data?.forkSource?.source === 'package',
brief(controlSource),
);
const controlProjectDownload = await downloadBinary(
`/api/game-distribution/games/${controlGameId}/fork-source/project`,
other.token,
);
check(
'B9 无工程包时 /fork-source/project → 409(不静默回落成品包)',
controlProjectDownload.status === 409,
`status=${controlProjectDownload.status}`,
);
console.log(
`[project-bundle-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`,
);
if (failures > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(`[project-bundle-e2e] 未捕获异常:${error?.stack ?? error}`);
process.exitCode = 1;
});