// 游戏分发「作者归属与越权隔离」真实链路检查(需要本地 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); });