Files
Genarrative/scripts/check-game-distribution-owner-isolation.mjs
T
kdletters 253f99d921
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m59s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 5m26s
Project CI / Backend tests (push) Successful in 4m13s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
补齐错误 envelope 的请求上下文并扩展游戏分发审核取证
- api-server 的错误响应改用 attach_request_context 建立的 CURRENT_REQUEST_CONTEXT(tokio task_local),错误 envelope 与成功 envelope 一样带 meta.requestId / meta.operation,脱离请求任务时退回原形状
- 越权隔离脚本新增成功/失败 envelope 断言:修复前错误响应 requestId 为空让断言变红,修复后 23 项 PASS
- 媒体链路脚本扩展审核治理段:普通作者不能提交审核动作、更新待审与被拒都不改变在线旧版 URL 与可玩内容、审核结论可在后台追溯,44 项 PASS
- 游戏分发里程碑阶段 A 第 6 条、阶段 B 第 1/2 条改为已勾选并写入取证,阶段 B 其余条目列出具体缺口
- decision-log 补记错误 envelope 同一份 meta 与「按可消费取证」两条口径
2026-09-28 18:58:38 +08:00

470 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏分发「作者归属与越权隔离」真实链路检查(需要本地 dev 栈:SpacetimeDB standalone + api-server)。
//
// 用法:
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-owner-isolation
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:4198)。
//
// 覆盖:两个真实账号注册 → 作者建游戏与版本 → 另一个账号读私有版本、读上传状态、写分片、
// 确认分包、送审、撤回全部按「不可见」处理 → 未认证读私有版本 401 → 越权写没有副作用
// (作者侧已收字节仍为 0)→ 请求体里伪造 owner 不生效(游戏仍归请求者、不出现在被冒名账号
// 的 my-games)→ 作者撤回自己的版本成功。整条链路只在本机 dev 数据库落行;唯一的存储写入
// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(dev bucket,走现役直传 + 确认链路),
// 发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
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!';
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);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(path, options = {}) {
const { method = 'GET', token, body, headers = {}, binary } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return {
status: response.status,
json,
text,
data: json?.data,
error: json?.error,
meta: json?.meta,
ok: json?.ok,
};
}
function gameMetadata(title) {
return {
title,
summary: '越权隔离用例的临时游戏',
description: '',
category: '休闲',
tags: ['e2e'],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
async function uploadCover(token, id) {
const fileName = `owner-isolation-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'owner-isolation', 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-owner-isolation',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
async function register(prefix) {
const response = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `${prefix}${String(Date.now()).slice(-8)}`,
password: DEV_PASSWORD,
},
});
return { response, token: response.data?.token };
}
async function main() {
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 author = await register('137');
const intruder = await register('138');
check(
'作者注册拿到 token',
author.response.status === 200 && Boolean(author.token),
`status=${author.response.status}`,
);
check(
'另一个账号注册拿到 token',
intruder.response.status === 200 && Boolean(intruder.token),
`status=${intruder.response.status}`,
);
if (!author.token || !intruder.token) process.exit(1);
const setGate = (enabled, rolloutPercent) =>
api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 作者归属隔离',
},
});
const gateOpen = await setGate(true, 100);
check(
'发布灰度可开启并放量',
gateOpen.status === 200,
`status=${gateOpen.status}`,
);
const stamp = Date.now();
const title = `越权隔离 ${String(stamp).slice(-6)}`;
const coverAssetId = await uploadCover(author.token, stamp);
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `iso-game-${stamp}` },
body: { ...gameMetadata(title), coverAssetId },
});
const gameId = created.data?.id;
check(
'作者创建游戏成功',
created.status === 200 && Boolean(gameId),
`status=${created.status} msg=${created.error?.message ?? ''}`,
);
if (!gameId) process.exit(1);
const versionResponse = await api(
`/api/game-distribution/games/${gameId}/versions`,
{
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `iso-version-${stamp}` },
body: {
packageSha256: 'a'.repeat(64),
packageBytes: 2048,
packageFileCount: 2,
packageEntryPath: 'index.html',
gameMetadata: { ...gameMetadata(title), coverAssetId },
},
},
);
const versionId = versionResponse.data?.versionId;
const publicationRevision = versionResponse.data?.publicationRevision ?? 0;
check(
'作者创建版本成功(awaiting_upload)',
versionResponse.status === 200 &&
Boolean(versionId) &&
versionResponse.data?.status === 'awaiting_upload',
`status=${versionResponse.status} versionStatus=${versionResponse.data?.status}`,
);
if (!versionId) process.exit(1);
const ownRead = await api(`/api/game-distribution/versions/${versionId}`, {
token: author.token,
});
check(
'作者读到自己的私有版本',
ownRead.status === 200 && ownRead.data?.version?.versionId === versionId,
`status=${ownRead.status}`,
);
const anonymousRead = await api(
`/api/game-distribution/versions/${versionId}`,
);
check(
'未认证读私有版本 401',
anonymousRead.status === 401,
`status=${anonymousRead.status}`,
);
const foreignRead = await api(
`/api/game-distribution/versions/${versionId}`,
{ token: intruder.token },
);
check(
'他人读私有版本按不存在处理(404)',
foreignRead.status === 404,
`status=${foreignRead.status} code=${foreignRead.error?.code ?? ''}`,
);
// 成功 / 失败 envelope 的形状:TS 侧 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts`
// 的运行时守卫读的就是这几个字段(data / error.code / meta.apiVersion / meta.requestId)。
check(
'成功响应 envelope 带 ok/data 与 meta.apiVersion / meta.requestId',
created.status === 200 &&
created.data !== undefined &&
created.ok === true &&
created.error === null &&
typeof created.meta?.apiVersion === 'string' &&
created.meta.apiVersion.length > 0 &&
typeof created.meta?.requestId === 'string' &&
created.meta.requestId.length > 0,
`apiVersion=${created.meta?.apiVersion ?? ''} requestId=${created.meta?.requestId ?? ''}`,
);
check(
'失败响应 envelope 带 ok=false/error.code 与 meta.requestId',
foreignRead.status === 404 &&
foreignRead.ok === false &&
typeof foreignRead.error?.code === 'string' &&
foreignRead.error.code.length > 0 &&
foreignRead.data === null &&
typeof foreignRead.meta?.requestId === 'string' &&
foreignRead.meta.requestId.length > 0,
`code=${foreignRead.error?.code ?? ''} requestId=${foreignRead.meta?.requestId ?? ''}`,
);
const foreignState = await api(
`/api/game-distribution/versions/${versionId}/package/upload-state`,
{ token: intruder.token },
);
check(
'他人读上传状态 404',
foreignState.status === 404,
`status=${foreignState.status}`,
);
const foreignChunk = await api(
`/api/game-distribution/versions/${versionId}/package/chunk`,
{
method: 'PUT',
token: intruder.token,
headers: {
'Content-Type': 'application/octet-stream',
'Idempotency-Key': `iso-chunk-${stamp}`,
'x-genarrative-upload-offset': '0',
},
binary: Buffer.alloc(16, 7),
},
);
check(
'他人写发行包分片 404',
foreignChunk.status === 404,
`status=${foreignChunk.status}`,
);
const foreignComplete = await api(
`/api/game-distribution/versions/${versionId}/package/complete`,
{
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-complete-${stamp}` },
},
);
check(
'他人确认分包 404',
foreignComplete.status === 404,
`status=${foreignComplete.status}`,
);
const foreignSubmit = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-submit-${stamp}` },
body: { expectedPublicationRevision: publicationRevision },
},
);
check(
'他人送审 404',
foreignSubmit.status === 404,
`status=${foreignSubmit.status} code=${foreignSubmit.error?.code ?? ''} msg=${foreignSubmit.error?.message ?? ''}`,
);
const nonexistentSubmit = await api(
`/api/game-distribution/versions/gamever_missing_${stamp}/submit`,
{
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-missing-submit-${stamp}` },
body: { expectedPublicationRevision: 0 },
},
);
check(
'他人送审不存在的版本 404(对照)',
nonexistentSubmit.status === 404,
`status=${nonexistentSubmit.status} code=${nonexistentSubmit.error?.code ?? ''}`,
);
const foreignCancel = await api(
`/api/game-distribution/versions/${versionId}/cancel`,
{
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-cancel-${stamp}` },
body: { expectedPublicationRevision: publicationRevision },
},
);
check(
'他人撤回 404',
foreignCancel.status === 404,
`status=${foreignCancel.status}`,
);
const authorState = await api(
`/api/game-distribution/versions/${versionId}/package/upload-state`,
{ token: author.token },
);
check(
'越权写没有落副作用(作者侧已收字节仍为 0)',
authorState.status === 200 && authorState.data?.receivedBytes === 0,
`status=${authorState.status} receivedBytes=${authorState.data?.receivedBytes}`,
);
const intruderCoverAssetId = await uploadCover(
intruder.token,
`${stamp}-intruder`,
);
const forged = await api('/api/game-distribution/games', {
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-forged-${stamp}` },
body: {
...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`),
coverAssetId: intruderCoverAssetId,
ownerUserId: 'user_forged',
owner_user_id: 'user_forged',
},
});
const forgedGameId = forged.data?.id;
check(
'请求体伪造 owner 不影响创建成功',
forged.status === 200 && Boolean(forgedGameId),
`status=${forged.status} msg=${forged.error?.message ?? ''}`,
);
const intruderGames = await api('/api/game-distribution/my-games', {
token: intruder.token,
});
const forgedStaysWithRequester =
Array.isArray(intruderGames.data?.games) &&
intruderGames.data.games.some((game) => game.id === forgedGameId);
check(
'伪造 owner 的游戏仍归请求者(在请求者 my-games 里)',
forgedStaysWithRequester,
`games=${intruderGames.data?.games?.length ?? 'n/a'}`,
);
const authorGames = await api('/api/game-distribution/my-games', {
token: author.token,
});
const forgedLeakedToAuthor =
Array.isArray(authorGames.data?.games) &&
authorGames.data.games.some((game) => game.id === forgedGameId);
check(
'伪造 owner 的游戏不出现在被冒名账号的 my-games',
!forgedLeakedToAuthor,
`authorGames=${authorGames.data?.games?.length ?? 'n/a'}`,
);
const ownCancel = await api(
`/api/game-distribution/versions/${versionId}/cancel`,
{
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `iso-own-cancel-${stamp}` },
body: { expectedPublicationRevision: publicationRevision },
},
);
check(
'作者撤回自己的版本成功(cancelled)',
ownCancel.status === 200 && ownCancel.data?.version?.status === 'cancelled',
`status=${ownCancel.status} versionStatus=${ownCancel.data?.version?.status}`,
);
const gateClosed = await setGate(false, 0);
check(
'发布灰度恢复关闭',
gateClosed.status === 200,
`status=${gateClosed.status}`,
);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(`[check:game-distribution-owner-isolation] 运行失败:${error}`);
process.exit(1);
});