diff --git a/package.json b/package.json index 1a9caaae3..b3a27df3c 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "check:game-distribution-package-limits-e2e": "node scripts/check-game-distribution-package-limits-e2e.mjs", "check:game-distribution-validation-restart-e2e": "node scripts/check-game-distribution-validation-restart-e2e.mjs", "check:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs", + "check:game-distribution-fork-authorization-e2e": "node scripts/check-game-distribution-fork-authorization-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-fork-authorization-e2e.mjs b/scripts/check-game-distribution-fork-authorization-e2e.mjs new file mode 100644 index 000000000..a34d54fd3 --- /dev/null +++ b/scripts/check-game-distribution-fork-authorization-e2e.mjs @@ -0,0 +1,789 @@ +// 游戏分发「作品级共创授权(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; +});