From 360cb9830ab0174d02773f41e28b0ab7a9e1561c Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 16:01:15 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=B7=A5=E7=A8=8B=E6=BA=90=E5=8C=85=EF=BC=88?= =?UTF-8?q?M2b=EF=BC=89=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- package.json | 1 + ...k-game-distribution-project-bundle-e2e.mjs | 958 ++++++++++++++++++ 2 files changed, 959 insertions(+) create mode 100644 scripts/check-game-distribution-project-bundle-e2e.mjs diff --git a/package.json b/package.json index e8edf38ac..51ceb5ff3 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "check:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs", "check:game-distribution-fork-authorization-e2e": "node scripts/check-game-distribution-fork-authorization-e2e.mjs", "check:game-distribution-lineage-e2e": "node scripts/check-game-distribution-lineage-e2e.mjs", + "check:game-distribution-project-bundle-e2e": "node scripts/check-game-distribution-project-bundle-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-project-bundle-e2e.mjs b/scripts/check-game-distribution-project-bundle-e2e.mjs new file mode 100644 index 000000000..a1090ca91 --- /dev/null +++ b/scripts/check-game-distribution-project-bundle-e2e.mjs @@ -0,0 +1,958 @@ +// 游戏分发「工程源包(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', + `${marker}

${marker}

`, + ); + 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', + `${marker}` + + '
', + ); + 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; +});