d0c0ca5033
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
- scripts/check-game-distribution-project-bundle-e2e.mjs 新增 A7 对抗段(脚本内自带裸 ZIP 写手, 可逐字节控制条目名、unix mode 与 central directory 声明值),逐条断言 422 + details.reason: · A7-1 路径穿越:foo/../bar、/etc/passwd、C:/evil.txt、a\..\b、./x、a//b → InvalidPath · A7-2 路径形状:a/secret.、a/secret(尾随空格)、src/a?.ts、a*b.ts → InvalidPath · A7-3 大小写变体:Node_Modules/… → DependencyDirectoryNotAllowed;.GIT/HEAD → VersionControlDirectoryNotAllowed;.AGENT/x → LocalStateDirectoryNotAllowed · A7-4 符号链接条目(external attrs=0o120777)→ SymlinkNotAllowed · A7-5 嵌套包改名 deps.dat(zip magic)→ NestedArchiveNotAllowed · A7-6 凭据:.aws/credentials、.ssh/id_ecdsa → CredentialDirectoryNotAllowed;.htpasswd、service-account-prod.json、terraform.tfstate、app.p8 → SensitiveFileNotAllowed · A7-7 内容嗅探:无扩展名文件里的 PEM 私钥块 → SecretContentDetected · A7-8 声明说谎:STORE 条目声明 1 字节、实际 4096 字节 → ReadFailed(失败关闭;非内存量测) · A7 收尾:全部拒绝后该版本仍 bytes=0/sha256 空;对照包(.dat/文本/无扩展名/PNG)200 不被过度拦截 - 修正一处我此前的错误判断:`./x`/`a//b` 并非「被 zip crate 归一化后放行」,而是被校验器自身的 路径形状检查(空段/`.`)拒为 InvalidPath(zip-2.4.2 types.rs:537-555 只拒 NUL/根/`..` 逃逸, 且返回未归一化原串);脚本 NOTE 已按实测与源码改正,两条也改为严格 422 断言 - 实测:本地 dev 栈(SpacetimeDB 3110 / api-server 8188)72 项 72 PASS / 0 FAIL / 0 SKIP; 同栈回归 lineage-e2e 99/99(真实发行包上传,覆盖 package.rs 读取封顶改动)与 fork-authorization-e2e 48/48
1265 lines
43 KiB
JavaScript
1265 lines
43 KiB
JavaScript
// 游戏分发「工程源包(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),
|
||
};
|
||
}
|
||
|
||
// ---------- 对抗用例用的裸 ZIP 写手 ----------
|
||
//
|
||
// JSZip 会规范化条目名、且不便于构造「符号链接条目」「伪造声明大小」这类畸形包,
|
||
// 对抗用例需要一个能逐字节控制 local header / central directory 的写手。
|
||
// 只用 STORE(method=0、无 data descriptor),格式见 PKWARE APPNOTE:
|
||
// local header 0x04034b50 / central 0x02014b50 / EOCD 0x06054b50。
|
||
|
||
const CRC32_TABLE = (() => {
|
||
const table = new Uint32Array(256);
|
||
for (let index = 0; index < 256; index += 1) {
|
||
let value = index;
|
||
for (let bit = 0; bit < 8; bit += 1) {
|
||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||
}
|
||
table[index] = value >>> 0;
|
||
}
|
||
return table;
|
||
})();
|
||
|
||
function crc32(buffer) {
|
||
let crc = 0xffffffff;
|
||
for (const byte of buffer) {
|
||
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||
}
|
||
return (crc ^ 0xffffffff) >>> 0;
|
||
}
|
||
|
||
/// entries: `{ name, data, mode?, versionMadeBy?, declaredUncompressedSize? }`
|
||
/// - `mode`:unix mode,写进 central directory 的高 16 位(0o120777 = 符号链接)。
|
||
/// - `declaredUncompressedSize`:故意与真实字节数不一致,用于「声明说谎」用例。
|
||
function buildRawZip(entries) {
|
||
const locals = [];
|
||
const centrals = [];
|
||
let offset = 0;
|
||
for (const entry of entries) {
|
||
const nameBytes = Buffer.from(entry.name, 'utf8');
|
||
const data = Buffer.from(entry.data ?? '');
|
||
const crc = crc32(data);
|
||
const declared = entry.declaredUncompressedSize ?? data.length;
|
||
const versionMadeBy = entry.versionMadeBy ?? 0x031e; // 3.0 / unix
|
||
const externalAttrs = ((entry.mode ?? 0o100644) & 0xffff) << 16;
|
||
|
||
const local = Buffer.alloc(30);
|
||
local.writeUInt32LE(0x04034b50, 0);
|
||
local.writeUInt16LE(20, 4); // version needed
|
||
local.writeUInt16LE(0, 6); // flags
|
||
local.writeUInt16LE(0, 8); // method: STORE
|
||
local.writeUInt16LE(0, 10); // time
|
||
local.writeUInt16LE(0, 12); // date
|
||
local.writeUInt32LE(crc, 14);
|
||
local.writeUInt32LE(data.length, 18); // compressed size
|
||
local.writeUInt32LE(declared, 22); // uncompressed size (可被伪造)
|
||
local.writeUInt16LE(nameBytes.length, 26);
|
||
local.writeUInt16LE(0, 28); // extra length
|
||
locals.push(local, nameBytes, data);
|
||
|
||
const central = Buffer.alloc(46);
|
||
central.writeUInt32LE(0x02014b50, 0);
|
||
central.writeUInt16LE(versionMadeBy, 4);
|
||
central.writeUInt16LE(20, 6);
|
||
central.writeUInt16LE(0, 8);
|
||
central.writeUInt16LE(0, 10);
|
||
central.writeUInt16LE(0, 12);
|
||
central.writeUInt16LE(0, 14);
|
||
central.writeUInt32LE(crc, 16);
|
||
central.writeUInt32LE(data.length, 20);
|
||
central.writeUInt32LE(declared, 24);
|
||
central.writeUInt16LE(nameBytes.length, 28);
|
||
central.writeUInt16LE(0, 30); // extra
|
||
central.writeUInt16LE(0, 32); // comment
|
||
central.writeUInt16LE(0, 34); // disk
|
||
central.writeUInt16LE(0, 36); // internal attrs
|
||
central.writeUInt32LE(externalAttrs >>> 0, 38);
|
||
central.writeUInt32LE(offset, 42);
|
||
centrals.push(central, nameBytes);
|
||
|
||
offset += local.length + nameBytes.length + data.length;
|
||
}
|
||
const centralSize = centrals.reduce((sum, part) => sum + part.length, 0);
|
||
const eocd = Buffer.alloc(22);
|
||
eocd.writeUInt32LE(0x06054b50, 0);
|
||
eocd.writeUInt16LE(0, 4);
|
||
eocd.writeUInt16LE(0, 6);
|
||
eocd.writeUInt16LE(entries.length, 8);
|
||
eocd.writeUInt16LE(entries.length, 10);
|
||
eocd.writeUInt32LE(centralSize, 12);
|
||
eocd.writeUInt32LE(offset, 16);
|
||
eocd.writeUInt16LE(0, 20);
|
||
const bytes = Buffer.concat([...locals, ...centrals, eocd]);
|
||
return {
|
||
bytes,
|
||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||
};
|
||
}
|
||
|
||
/// 对抗用例的每次上传都只带一个「合法基线条目 + 一个可疑条目」,
|
||
/// 这样 422 只能归因于可疑条目本身。
|
||
function adversarialZip(caseName, data = Buffer.from('x'), extra = {}) {
|
||
return buildRawZip([
|
||
{ name: 'package.json', data: Buffer.from('{"name":"adversarial-e2e"}') },
|
||
{ name: caseName, data, ...extra },
|
||
]);
|
||
}
|
||
|
||
/// 422 断言:错误码必须是 PROJECT_BUNDLE_VALIDATION_FAILED,并把 details.reason 打出来。
|
||
async function expectBundleRejected(label, versionId, bytes, expectedReasons) {
|
||
// HTTP 头必须是 ByteString:把中文标签折成 ASCII 键片段。
|
||
const keyTag = label.replace(/[^A-Za-z0-9]+/gu, '-').slice(0, 48);
|
||
const response = await putProjectBundle(versionId, bytes, {
|
||
token: authorTokenRef.token,
|
||
key: `pb-adv-${keyTag}-${stampRef.value}`,
|
||
});
|
||
const reason = response.error?.details?.reason ?? '';
|
||
const codeMatches =
|
||
response.status === 422 &&
|
||
(response.error?.code ?? '') === 'PROJECT_BUNDLE_VALIDATION_FAILED';
|
||
const reasonMatches = expectedReasons.includes(reason);
|
||
check(
|
||
`${label} → 422 PROJECT_BUNDLE_VALIDATION_FAILED(reason=${reason || '∅'})`,
|
||
codeMatches && reasonMatches,
|
||
`${brief(response)} expectedReason∈[${expectedReasons.join(',')}]`,
|
||
);
|
||
return response;
|
||
}
|
||
|
||
// 供上面的 helper 使用(main 里赋值,避免把 token/stamp 一路透传)。
|
||
const authorTokenRef = { token: '' };
|
||
const stampRef = { value: 0 };
|
||
|
||
// ---------- 上行 ----------
|
||
|
||
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);
|
||
stampRef.value = stamp;
|
||
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);
|
||
authorTokenRef.token = author.token;
|
||
|
||
// ---------- 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)}`,
|
||
);
|
||
|
||
// ---------- A7:对抗用例(校验器补强后的拒绝清单) ----------
|
||
// 预期形状读自实现:module-game-distribution/src/project_bundle.rs(目录/凭据/嵌套包
|
||
// 拒绝清单 :235-295、magic 嗅探 :326-345、凭据内容嗅探 :360-400)与共享路径规范化
|
||
// server-rs/crates/module-game-distribution/src/package.rs:158-182。
|
||
const vAdv = await createVersion({
|
||
token: author.token,
|
||
gameId,
|
||
metadata,
|
||
zip: await buildReleaseZip(`vadv-${suffix}`),
|
||
stamp,
|
||
tag: 'vadv',
|
||
});
|
||
const vAdvId = vAdv.data?.versionId;
|
||
check(
|
||
'A7 对抗用例版本创建成功(awaiting_upload)',
|
||
vAdv.status === 200 && Boolean(vAdvId),
|
||
`status=${vAdv.status} versionId=${vAdvId ?? ''}`,
|
||
);
|
||
if (!vAdvId) process.exit(1);
|
||
|
||
// A7-1 路径穿越/畸形路径(全部 422 InvalidPath)
|
||
for (const name of [
|
||
'foo/../bar',
|
||
'/etc/passwd',
|
||
'C:/evil.txt',
|
||
'a\\..\\b',
|
||
'./x',
|
||
'a//b',
|
||
]) {
|
||
await expectBundleRejected(
|
||
`A7 路径穿越 ${name}`,
|
||
vAdvId,
|
||
adversarialZip(name).bytes,
|
||
['InvalidPath'],
|
||
);
|
||
}
|
||
note(
|
||
'两层防线共同覆盖这些形状:zip crate 的 enclosed_name() 只拒 NUL / 根路径 / `..` 逃逸' +
|
||
'(zip-2.4.2 src/types.rs:537-555,且返回的是**未归一化**的原始条目名);' +
|
||
'`a//b`、`./x`、结尾点/空格、`:`/`*`/`?`、反斜杠等由校验器自己的路径形状检查逐段拦下' +
|
||
'(module-game-distribution/src/package.rs:158-182:空段 / `.` / `..` / 尾随空格或点 / 禁止字符 / 反斜杠)。' +
|
||
'实测这六种形状全部 422 + reason=InvalidPath。',
|
||
);
|
||
|
||
// A7-2 结尾点/空格与禁止字符
|
||
for (const name of ['a/secret.', 'a/secret ', 'src/a?.ts', 'a*b.ts']) {
|
||
await expectBundleRejected(
|
||
`A7 路径形状 ${name}`,
|
||
vAdvId,
|
||
adversarialZip(name).bytes,
|
||
['InvalidPath'],
|
||
);
|
||
}
|
||
|
||
// A7-3 大小写变体目录(拒绝清单按大小写折叠比对)
|
||
await expectBundleRejected(
|
||
'A7 大小写变体 Node_Modules/lodash/x.js',
|
||
vAdvId,
|
||
adversarialZip('Node_Modules/lodash/x.js').bytes,
|
||
['DependencyDirectoryNotAllowed'],
|
||
);
|
||
await expectBundleRejected(
|
||
'A7 大小写变体 .GIT/HEAD',
|
||
vAdvId,
|
||
adversarialZip('.GIT/HEAD').bytes,
|
||
['VersionControlDirectoryNotAllowed'],
|
||
);
|
||
await expectBundleRejected(
|
||
'A7 大小写变体 .AGENT/x',
|
||
vAdvId,
|
||
adversarialZip('.AGENT/x').bytes,
|
||
['LocalStateDirectoryNotAllowed'],
|
||
);
|
||
|
||
// A7-4 符号链接条目(central directory 高 16 位 = 0o120777)
|
||
await expectBundleRejected(
|
||
'A7 符号链接条目',
|
||
vAdvId,
|
||
adversarialZip('link-outside', Buffer.from('/etc/passwd'), {
|
||
mode: 0o120777,
|
||
}).bytes,
|
||
['SymlinkNotAllowed'],
|
||
);
|
||
|
||
// A7-5 嵌套包:改名成 deps.dat,只能靠 magic bytes 拦住
|
||
const innerZip = buildRawZip([
|
||
{ name: 'node_modules/x/index.js', data: Buffer.from('x') },
|
||
]);
|
||
await expectBundleRejected(
|
||
'A7 嵌套 zip 改名 deps.dat',
|
||
vAdvId,
|
||
adversarialZip('deps.dat', innerZip.bytes).bytes,
|
||
['NestedArchiveNotAllowed'],
|
||
);
|
||
|
||
// A7-6 凭据类(补强新增的目录/文件名规则)
|
||
const credentialCases = [
|
||
[
|
||
'.aws/credentials',
|
||
['CredentialDirectoryNotAllowed', 'SensitiveFileNotAllowed'],
|
||
],
|
||
[
|
||
'.ssh/id_ecdsa',
|
||
['CredentialDirectoryNotAllowed', 'SensitiveFileNotAllowed'],
|
||
],
|
||
['.htpasswd', ['SensitiveFileNotAllowed']],
|
||
['service-account-prod.json', ['SensitiveFileNotAllowed']],
|
||
['terraform.tfstate', ['SensitiveFileNotAllowed']],
|
||
['app.p8', ['SensitiveFileNotAllowed']],
|
||
];
|
||
for (const [name, reasons] of credentialCases) {
|
||
await expectBundleRejected(
|
||
`A7 凭据 ${name}`,
|
||
vAdvId,
|
||
adversarialZip(name).bytes,
|
||
reasons,
|
||
);
|
||
}
|
||
|
||
// A7-7 凭据内容嗅探:无扩展名文件里放 PEM 私钥块
|
||
await expectBundleRejected(
|
||
'A7 内容嗅探(无扩展名 PEM 私钥)',
|
||
vAdvId,
|
||
adversarialZip(
|
||
'secrets',
|
||
Buffer.from('-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n'),
|
||
).bytes,
|
||
['SecretContentDetected'],
|
||
);
|
||
|
||
// A7-8 声明说谎:STORE 条目把解锁声明写成 1 字节、实际 4096 字节
|
||
await expectBundleRejected(
|
||
'A7 声明说谎(声明 1 字节 / 实际 4096 字节)',
|
||
vAdvId,
|
||
adversarialZip('liar.bin', Buffer.alloc(4096, 0x41), {
|
||
declaredUncompressedSize: 1,
|
||
}).bytes,
|
||
['ReadFailed', 'InvalidArchive'],
|
||
);
|
||
note(
|
||
'声明说谎用例断言的是「失败关闭」而不是内存量测:服务端按声明大小 take(declared+1) 读取' +
|
||
'(package.rs:115-135),多读 1 字节即判 ReadFailed,因此不会出现「声明 1 KiB、实际解压数 GiB」的放大;' +
|
||
'服务端真实内存占用本脚本无法观测(未量测 RSS)。',
|
||
);
|
||
|
||
// 全部拒绝用例之后:该版本仍未落库
|
||
const vAdvAfter = await ownerVersion(author.token, vAdvId);
|
||
check(
|
||
'A7 全部拒绝用例后该版本仍 bytes=0 / sha256 空',
|
||
(vAdvAfter.version?.projectBundleBytes ?? -1) === 0 &&
|
||
!vAdvAfter.version?.projectBundleSha256,
|
||
`bytes=${vAdvAfter.version?.projectBundleBytes} sha256=${JSON.stringify(vAdvAfter.version?.projectBundleSha256 ?? null)}`,
|
||
);
|
||
|
||
// A7-9 过度拦截对照:普通 .dat / 文本 / 无扩展名 / 二进制条目必须放行
|
||
const controlBundle = await buildProjectBundle({
|
||
marker: `adv-control-${suffix}`,
|
||
extraFiles: {
|
||
'notes.dat': 'plain text payload\n',
|
||
'docs/readme.md': '# readme\n',
|
||
'notes.txt': 'notes\n',
|
||
LICENSE: 'MIT\n',
|
||
'assets/logo.png': COVER_PNG,
|
||
},
|
||
});
|
||
const controlPut = await putProjectBundle(vAdvId, controlBundle.bytes, {
|
||
token: author.token,
|
||
key: `pb-adv-control-${stamp}`,
|
||
});
|
||
check(
|
||
'A7 对照:普通 .dat/文本/无扩展名/二进制条目不被过度拦截(200)',
|
||
controlPut.status === 200,
|
||
brief(controlPut),
|
||
);
|
||
|
||
// ---------- 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;
|
||
});
|