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
- 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 与「按可消费取证」两条口径
470 lines
15 KiB
JavaScript
470 lines
15 KiB
JavaScript
// 游戏分发「作者归属与越权隔离」真实链路检查(需要本地 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);
|
||
});
|