// 游戏分发「作品级共创授权(Fork authorization)」链路真实行为验收。 // 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server [+ web])+ 管理员账号。 // // 用法(Windows + bash 均可;只依赖 Node 内置模块 + 仓库已有依赖): // E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ // node scripts/check-game-distribution-fork-authorization-e2e.mjs // SKIP_BROWSER=1 只跑 HTTP 契约部分(跳过 Playwright 视觉步骤) // E2E_API_BASE / E2E_WEB_BASE 覆盖地址;默认从 CWD 的 .app/dev-stack.json 读取(不写死端口) // E2E_PLAYWRIGHT_DIR 指向临时装了 playwright 的目录(仓库 devDependencies 不含 playwright) // E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器) // E2E_FORK_ENTRY_LABEL / E2E_FORK_LABEL_FORBIDDEN / E2E_FORK_LABEL_NON_COMMERCIAL / E2E_FORK_LABEL_FULL // 覆盖前端文案(前端尚未落地,默认值见下方常量) // // 参考的仓库既有脚本与实现(本脚本按同一风格写成,未猜测既有契约): // [1] admin 登录 / 缺凭据退出码 2 / 灰度开关:scripts/check-game-distribution-web-e2e.mjs:22-31,150-156,176-187 // [2] 作者注册(/api/auth/entry,dev 自动注册):scripts/check-game-distribution-web-e2e.mjs:24,161-166 // [3] 从 .app/dev-stack.json 取地址:scripts/check-game-distribution-ratings-e2e.mjs:15-27 // [4] 封面直传 + 确认("发布游戏必须提供封面"):scripts/check-game-distribution-owner-isolation.mjs:97-150 // [5] 建游戏 payload 与响应取值:scripts/check-game-distribution-owner-isolation.mjs:53-64,201-212 // [6] my-games 明细形状(data.game):server-rs/crates/api-server/src/modules/game_distribution.rs:1151-1200,2991-3016 // [7] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs:51-57,74-90,148-159 // // 契约假设(⚠ 只读核查确认 master 尚未落地,脚本按目标契约断言): // A. PUT /api/game-distribution/games/{game_id}/fork-authorization 在 master 不存在: // 全仓库 grep `fork-authorization|forkAuthorization|fork_authorization` 无匹配; // 受保护路由全量枚举见 modules/game_distribution.rs:296-355。 // B. game 载荷当前没有 forkAuthorization 字段:modules/game_distribution.rs:2952-2977。 // C. 因此步骤 5/6 在 master 上会 404 / 字段缺失并 FAIL —— 设计预期(功能落地后再跑)。 // D. 前端 /games/mine 的「共创授权」入口与三态文案同样未落地,故做成可覆盖常量。 // E. 响应包装:本脚本同时接受 `data.game` 与 `data` 两种形态,并同时接受 // `data.replayed` 与 `data.game.replayed`(既有写接口把 `replayed` 放在 data 顶层, // 例:modules/game_distribution.rs:682-700 的评价管理响应)。 import { readFileSync } from 'node:fs'; import { mkdtemp } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import path from 'node:path'; const COVER_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); 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'; const SKIP_BROWSER = (process.env.SKIP_BROWSER ?? '').trim() === '1'; // 三态档位的线上取值(契约值,不随文案变化)。 const FORK_TIERS = ['forbidden', 'nonCommercial', 'full']; // 前端文案(对齐 packages/shared/src/contracts/gameDistribution.ts:130-148),可用环境变量覆盖。 const FORK_ENTRY_LABEL = process.env.E2E_FORK_ENTRY_LABEL ?? '共创授权'; const FORK_TIER_LABELS = { forbidden: process.env.E2E_FORK_LABEL_FORBIDDEN ?? '禁止共创', nonCommercial: process.env.E2E_FORK_LABEL_NON_COMMERCIAL ?? '允许非商用共创', full: process.env.E2E_FORK_LABEL_FULL ?? '允许全开放共创', }; const FORK_TIER_SHORT_LABELS = { forbidden: process.env.E2E_FORK_SHORT_FORBIDDEN ?? '禁止', nonCommercial: process.env.E2E_FORK_SHORT_NON_COMMERCIAL ?? '非商用', full: process.env.E2E_FORK_SHORT_FULL ?? '全开放', }; const FORK_FULL_BADGE = process.env.E2E_FORK_FULL_BADGE ?? '共创授权已达上限'; 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); } // 地址一律从 CWD 的 dev 栈状态文件读取,不写死端口(对齐 ratings-e2e.mjs:15-27)。 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, ''); const WEB = ( process.env.E2E_WEB_BASE ?? devStack.services?.web?.url ?? 'http://127.0.0.1:3000' ).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; let skipped = 0; function check(name, ok, detail = '') { checks += 1; if (!ok) failures += 1; console.log( `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, ); } function skip(name, detail = '') { skipped += 1; console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`); } // ---------- HTTP helpers(对齐 owner-isolation.mjs:36-76) ---------- async function api(pathname, options = {}) { const { method = 'GET', token, body, headers = {} } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; 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(30_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, 200)}`; } /// 兼容 `data.game` 与扁平 `data` 两种响应形态,并抽出 `replayed`。 function forkState(body) { const data = body?.data ?? null; const game = data?.game ?? data ?? null; const replayed = data?.replayed ?? data?.game?.replayed ?? game?.replayed ?? null; return { game, replayed, forkAuthorization: game?.forkAuthorization ?? null }; } /// 断言某个响应里的 game 载荷带有合法的 forkAuthorization,并等于期望档位。 function checkForkTier(name, body, expected, { expectReplayed } = {}) { const { game, replayed, forkAuthorization } = forkState(body); const shapeOk = game !== null && typeof game === 'object' && Object.hasOwn(game, 'forkAuthorization'); const allowed = FORK_TIERS.includes(forkAuthorization); const detail = `status=${body?.status} forkAuthorization=${JSON.stringify(forkAuthorization)} ` + `replayed=${JSON.stringify(replayed)} shapeOk=${shapeOk} allowed=${allowed}`; check( `${name}(契约形状:game.forkAuthorization 存在且取值合法)`, shapeOk && allowed, detail, ); check( `${name}(档位 === ${expected})`, forkAuthorization === expected, detail, ); if (expectReplayed !== undefined) { check( `${name}(replayed === ${expectReplayed})`, replayed === expectReplayed, detail, ); } return { game, replayed, forkAuthorization }; } 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) { return { title, summary: '共创授权验收临时游戏', description: '', category: '休闲', tags: ['e2e'], deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', }; } // 建游戏必须有封面(modules/game_distribution.rs:2806-2814),走现役直传 + 确认链路 // (对齐 owner-isolation.mjs:97-150;只往 dev bucket 写一个 67 字节 PNG)。 async function uploadCover(token, id) { const fileName = `fork-authorization-${id}.png`; const ticket = await api('/api/assets/direct-upload-tickets', { method: 'POST', token, body: { legacyPrefix: 'generated-character-drafts', pathSegments: ['game-distribution', 'fork-authorization', 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-fork-authorization', }, }); if (confirm.status !== 200) { throw new Error( `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, ); } return confirm.data.assetObject.assetObjectId; } // ---------- Playwright helpers(对齐 web-publish-e2e.mjs:51-57,74-90,148-159) ---------- async function loadPlaywright() { const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim(); if (!dir) return import('playwright'); const requireFromDir = createRequire(path.join(dir, 'noop.js')); return requireFromDir('playwright'); } async function loginThroughWebUi(page, phone) { await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 }); await page.waitForSelector('.platform-account-entry', { timeout: 120_000 }); await page.locator('.platform-account-entry').first().click(); await page.getByRole('tab', { name: '密码登录' }).first().click(); await page.waitForTimeout(600); await page .getByLabel('同意法律协议') .first() .check({ force: true }) .catch(() => {}); await page .locator('input[placeholder="13800000000"]:visible') .first() .fill(phone); await page .locator('input[placeholder="输入密码"]:visible') .first() .fill(DEV_PASSWORD); await page.getByRole('button', { name: '登录', exact: true }).first().click(); await page .locator('.platform-account-entry') .first() .filter({ hasText: phone.slice(-4) }) .waitFor({ state: 'visible', timeout: 30_000 }); } /// 三态选项的 DOM 契约尚未落地:按常见可交互语义逐个尝试定位。 async function findTierOption(page, label) { const candidates = [ page.getByRole('radio', { name: label }), page.getByRole('menuitemradio', { name: label }), page.getByRole('button', { name: label }), page.locator('[role="option"]').filter({ hasText: label }), page.locator('label').filter({ hasText: label }), page.getByText(label, { exact: false }), ]; for (const locator of candidates) { const first = locator.first(); const count = await first.count().catch(() => 0); if (count > 0 && (await first.isVisible().catch(() => false))) { return first; } } return null; } /// `isDisabled()` 覆盖 button/input/select/[aria-disabled];取不到时退回 aria-disabled 属性。 async function isTierDisabled(locator) { const disabled = await locator.isDisabled().catch(() => null); if (disabled !== null) return { disabled, source: 'isDisabled' }; const aria = await locator.getAttribute('aria-disabled').catch(() => null); if (aria !== null) return { disabled: aria === 'true', source: 'aria-disabled' }; return { disabled: null, source: 'unknown' }; } // ---------- 浏览器视觉步骤 ---------- async function runBrowserStep({ phone, title, draftTitle }) { if (SKIP_BROWSER) { skip('浏览器视觉:/games/mine 共创授权入口与三态', 'SKIP_BROWSER=1'); return; } let chromium; try { ({ chromium } = await loadPlaywright()); } catch (error) { check( '浏览器视觉:Playwright 可用', false, `仓库 devDependencies 不含 playwright(package.json:255-282),请先执行 ` + `\`npm install --prefix %TEMP%\\genarrative-pw --no-save --no-package-lock playwright\` ` + `并设置 E2E_PLAYWRIGHT_DIR=%TEMP%\\genarrative-pw(写法见 web-publish-e2e.mjs:4-5);` + `或设置 SKIP_BROWSER=1 只跑 HTTP 部分。原始错误:${error?.message ?? error}`, ); return; } const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim(); const browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}), }); const screenshotDir = await mkdtemp( path.join(tmpdir(), 'genarrative-fork-e2e-'), ); try { const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, }); await loginThroughWebUi(page, phone); check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`); await page.goto(`${WEB}/games/mine`, { waitUntil: 'commit', timeout: 120_000, }); // A. HTTP 步骤已把作品提升到 full:卡片只读展示档位,不给入口。 const fullCard = page.getByText(title, { exact: false }).first(); const fullCardVisible = await fullCard .waitFor({ state: 'visible', timeout: 60_000 }) .then(() => true) .catch(() => false); check( '浏览器视觉:/games/mine 出现 full 档作品卡片', fullCardVisible, `title=${title}`, ); check( '浏览器视觉:full 档卡片显示档位文案', await page .getByText(`共创:${FORK_TIER_LABELS.full}`) .first() .isVisible() .catch(() => false), `label=共创:${FORK_TIER_LABELS.full}`, ); const fullCardElement = page .locator('article.my-game-card') .filter({ hasText: title }) .first(); check( '浏览器视觉:full 档卡片显示只读上限且不提供入口', (await fullCardElement .getByText(FORK_FULL_BADGE) .first() .isVisible() .catch(() => false)) && (await fullCardElement .getByRole('button', { name: FORK_ENTRY_LABEL, exact: true }) .count()) === 0, `badge=${FORK_FULL_BADGE} card=article.my-game-card`, ); // B. 全新草稿作品(forbidden):卡片有「共创授权」入口,点开后三态可选、当前档位不可点。 const draftCard = page.getByText(draftTitle, { exact: false }).first(); const draftCardVisible = await draftCard .waitFor({ state: 'visible', timeout: 60_000 }) .then(() => true) .catch(() => false); check( '浏览器视觉:/games/mine 出现草稿作品卡片', draftCardVisible, `title=${draftTitle}`, ); check( '浏览器视觉:草稿卡片档位文案为禁止共创', await page .getByText(`共创:${FORK_TIER_LABELS.forbidden}`) .first() .isVisible() .catch(() => false), `label=共创:${FORK_TIER_LABELS.forbidden}`, ); const entry = page .getByRole('button', { name: FORK_ENTRY_LABEL, exact: true }) .first(); const entryVisible = await entry .waitFor({ state: 'visible', timeout: 30_000 }) .then(() => true) .catch(() => false); check( '浏览器视觉:草稿卡片上可见「共创授权」入口', entryVisible, `label=${FORK_ENTRY_LABEL}`, ); if (entryVisible) { await entry.click(); await page.waitForTimeout(600); const options = {}; for (const tier of FORK_TIERS) { options[tier] = await findTierOption( page, FORK_TIER_SHORT_LABELS[tier], ); check( `浏览器视觉:三态选项可见(${tier} = ${FORK_TIER_SHORT_LABELS[tier]})`, options[tier] !== null, `label=${FORK_TIER_SHORT_LABELS[tier]}`, ); } // 当前档位 forbidden、无更低档位:当前档位必须不可点,更高档位必须可点。 for (const tier of FORK_TIERS) { const locator = options[tier]; if (!locator) { check( `浏览器视觉:${tier} 档位禁用态`, false, `未定位到 ${FORK_TIER_SHORT_LABELS[tier]} 选项元素,无法判定禁用态`, ); continue; } const { disabled, source } = await isTierDisabled(locator); const shouldBeDisabled = tier === 'forbidden'; check( `浏览器视觉:${tier} 档位${shouldBeDisabled ? '不可点(当前档位)' : '可点(更高档位)'}`, disabled === shouldBeDisabled, disabled === null ? `label=${FORK_TIER_SHORT_LABELS[tier]} 无法判定禁用态(尝试过 isDisabled 与 aria-disabled)` : `label=${FORK_TIER_SHORT_LABELS[tier]} source=${source} disabled=${disabled}`, ); } const panelShot = path.join( screenshotDir, 'fork-authorization-panel.png', ); await page.screenshot({ path: panelShot, fullPage: true }); check('浏览器视觉:三态面板已截图', true, `截图路径=${panelShot}`); // C. 走一次真实提升(禁止 → 非商用):确认面板 + 卡片文案回读。 const promote = options.nonCommercial; if (promote) { await promote.click(); await page.waitForTimeout(300); check( '浏览器视觉:点击非商用后出现确认提升提示', await page .getByText( new RegExp(`确认提升为「${FORK_TIER_LABELS.nonCommercial}」`), ) .first() .isVisible() .catch(() => false), `期望包含 确认提升为「${FORK_TIER_LABELS.nonCommercial}」`, ); const confirmButton = page .getByRole('button', { name: '确认提升', exact: true }) .first(); const confirmVisible = await confirmButton .isVisible() .catch(() => false); check( '浏览器视觉:确认提升按钮可见', confirmVisible, 'button=确认提升', ); if (confirmVisible) { await confirmButton.click(); check( '浏览器视觉:网页提升到非商用后卡片文案更新', await page .getByText(`共创:${FORK_TIER_LABELS.nonCommercial}`) .first() .waitFor({ state: 'visible', timeout: 30_000 }) .then(() => true) .catch(() => false), `label=共创:${FORK_TIER_LABELS.nonCommercial}`, ); } } else { check('浏览器视觉:确认提升按钮可见', false, '未定位到非商用选项'); } const afterShot = path.join( screenshotDir, 'fork-authorization-after-promote.png', ); await page.screenshot({ path: afterShot, fullPage: true }); check('浏览器视觉:提升后已截图', true, `截图路径=${afterShot}`); } const screenshotPath = path.join( screenshotDir, 'fork-authorization-panel.png', ); await page.screenshot({ path: screenshotPath, fullPage: true }); check('浏览器视觉:已截图保存', true, `截图路径=${screenshotPath}`); } finally { await browser.close().catch(() => {}); console.log(`[fork-e2e] 截图目录:${screenshotDir}`); } } // ---------- 主流程 ---------- async function main() { console.log( `[fork-e2e] api-server=${API} web=${WEB} database=${devStack.database}`, ); // 1) 管理员登录(对齐 web-e2e.mjs:150-156) 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); // 2) 开灰度(对齐 web-e2e.mjs:176-187) 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}`); // 3) 注册两个作者(对齐 web-e2e.mjs:161-166) const stamp = Date.now(); 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); // 4) 创建作品(建游戏必须有封面:modules/game_distribution.rs:2806-2814) 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': `fork-e2e-game-${stamp}` }, body: { ...gameMetadata(title), coverAssetId }, }); const gameId = created.data?.id; check( '作者创建作品成功并取到 game.id', created.status === 200 && Boolean(gameId), `status=${created.status} id=${gameId ?? ''} msg=${created.error?.message ?? ''}`, ); if (!gameId) process.exit(1); const forkPath = `/api/game-distribution/games/${gameId}/fork-authorization`; const forkBody = (expected, next) => ({ expectedForkAuthorization: expected, forkAuthorization: next, }); // 幂等键:除"重放"用例复用同一把键外,每一步都是独立键。 const keyNonCommercial = `fork-e2e-nc-${stamp}`; const keyFull = `fork-e2e-full-${stamp}`; const bodyNonCommercial = forkBody('forbidden', 'nonCommercial'); const bodyFull = forkBody('nonCommercial', 'full'); // 5a) 初始档位必须是 forbidden(默认值) const initial = await api(`/api/game-distribution/my-games/${gameId}`, { token: author.token, }); const initialGame = initial.data?.game ?? null; check( '初始 GET my-games/{game_id} 的 game 载荷存在', initial.status === 200 && initialGame !== null, `status=${initial.status}`, ); checkForkTier('初始档位', initial, 'forbidden'); // 5b) forbidden → nonCommercial const promoted = await api(forkPath, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': keyNonCommercial }, body: bodyNonCommercial, }); check( '提升到 nonCommercial 返回 200', promoted.status === 200, `status=${promoted.status} msg=${promoted.error?.message ?? ''}`, ); checkForkTier('nonCommercial', promoted, 'nonCommercial', { expectReplayed: false, }); // 5c) nonCommercial → full const upgraded = await api(forkPath, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': keyFull }, body: bodyFull, }); check( '提升到 full 返回 200', upgraded.status === 200, `status=${upgraded.status} msg=${upgraded.error?.message ?? ''}`, ); checkForkTier('full', upgraded, 'full', { expectReplayed: false }); // 5d) 降级必须被拒(full → forbidden)→ 409 const downgrade = await api(forkPath, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': `fork-e2e-downgrade-${stamp}` }, body: forkBody('full', 'forbidden'), }); check( '降级 full → forbidden 被拒(409)', downgrade.status === 409, brief(downgrade), ); // 5e) 过期 CAS 必须被拒(当前已是 full,却声明 expected=forbidden)→ 409 const staleCas = await api(forkPath, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': `fork-e2e-stale-${stamp}` }, body: forkBody('forbidden', 'full'), }); check( '过期 CAS(expected=forbidden,实际 full)被拒(409)', staleCas.status === 409, brief(staleCas), ); // 5f) 未知档位必须被拒(allowed 不是三态之一)→ 400 const unknownTier = await api(forkPath, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': `fork-e2e-unknown-${stamp}` }, body: forkBody('full', 'allowed'), }); check( '未知档位 allowed 被拒(400)', unknownTier.status === 400, brief(unknownTier), ); // 5g) 幂等重放:复用 nonCommercial → full 的同一把键与同一 body → 200 且 replayed=true const replay = await api(forkPath, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': keyFull }, body: bodyFull, }); check( '幂等重放返回 200', replay.status === 200, `status=${replay.status} msg=${replay.error?.message ?? ''}`, ); checkForkTier('幂等重放', replay, 'full', { expectReplayed: true }); // 5h) 非作者(第二个账号)必须被拒 → 403 const foreign = await api(forkPath, { method: 'PUT', token: other.token, headers: { 'Idempotency-Key': `fork-e2e-foreign-${stamp}` }, body: forkBody('full', 'forbidden'), }); check('非作者发起变更被拒(403)', foreign.status === 403, brief(foreign)); // 5i) 未带 Bearer 必须被拒 → 401 const anonymous = await api(forkPath, { method: 'PUT', headers: { 'Idempotency-Key': `fork-e2e-anon-${stamp}` }, body: forkBody('full', 'forbidden'), }); check( '未带 Authorization 被拒(401)', anonymous.status === 401, brief(anonymous), ); // 6) 回读确认最终档位仍是 full(越权与非法请求都不得有副作用) const finalRead = await api(`/api/game-distribution/my-games/${gameId}`, { token: author.token, }); check( '最终 GET my-games/{game_id} 仍为 200', finalRead.status === 200, `status=${finalRead.status}`, ); checkForkTier('最终档位(非法请求无副作用)', finalRead, 'full'); // 7) 浏览器视觉:需要一张 full 档作品(只读展示)与一张全新草稿作品(三态编辑器)。 const draftTitle = `共创授权草稿 ${String(stamp).slice(-6)}`; const draftCoverAssetId = await uploadCover(author.token, `${stamp}-draft`); const draftCreated = await api('/api/game-distribution/games', { method: 'POST', token: author.token, headers: { 'Idempotency-Key': `fork-e2e-draft-${stamp}` }, body: { ...gameMetadata(draftTitle), coverAssetId: draftCoverAssetId }, }); check( '草稿作品创建成功(浏览器三态用例)', draftCreated.status === 200 && Boolean(draftCreated.data?.id), `status=${draftCreated.status} id=${draftCreated.data?.id ?? ''}`, ); await runBrowserStep({ phone: author.phone, title, draftTitle }); console.log( `[fork-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); if (failures > 0) { process.exitCode = 1; } } main().catch((error) => { console.error(`[fork-e2e] 未捕获异常:${error?.stack ?? error}`); process.exitCode = 1; });