// 游戏分发「工程源包(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),
};
}
// ---------- 对抗用例用的裸 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;
});