Files
Genarrative/scripts/check-game-distribution-purchase-e2e.mjs
T
suzmii 1e13563a5e
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
新增游戏买断制泥点付费真实本地栈 E2E 检查脚本
- 新增 scripts/check-game-distribution-purchase-e2e.mjs:覆盖付费游戏发布与审核、未购买用户无可播放入口且直连 404、购买扣泥点与幂等重放、播放会话可玩与重复进入不扣费、免费游戏回归、余额不足失败无副作用
- 脚本端口只读 .app/dev-stack.json 的实际地址,E2E_START_STACK=1 时自行拉起并收束本地 dev 栈
- 追加播放会话网关取证:伪造令牌 404、带平台 refresh Cookie 直连 403、经 dev 代理按前缀清空 Cookie 后仍可播放
- package.json 增加 check:game-distribution-purchase-e2e 别名
2026-10-05 18:08:35 +08:00

968 lines
36 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.
// 游戏买断制泥点付费与播放鉴权「真实本地栈」端到端检查。
//
// 用法:
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-purchase-e2e
// E2E_START_STACK=1 时脚本自行在仓库根执行 `npm run dev --no-interactive`,
// 等 `.app/dev-stack.json` 里的 api-server `/healthz` 就绪后跑用例,进程结束时
// 用 taskkill / SIGTERM 收掉自己拉起的整棵 dev 栈,不依赖人工清理。
// 默认不自行起服务:直接从仓库根 `.app/dev-stack.json` 读取实际端口(api-server 与
// 主站 Vite),端口漂移后仍以文件为准,不臆断 3000 / 8082 这类默认值。
// E2E_API_BASE / E2E_WEB_BASE 可显式覆盖两个地址;E2E_WEB_BASE 缺省且本地没有
// 主站 Vite 时,平台同源路径 `/games/<gameId>/` 只做 WARN,不误判为通过。
// E2E_PRICE_MUD_POINTS 覆盖付费游戏定价(默认 30)。
// E2E_ADMIN_USER / E2E_ADMIN_PASSWORD 必填,脚本不读取仓库内任何凭据文件。
//
// 覆盖:
// 1. 作者发布付费游戏(priceMudPoints=N)→ 管理员审核通过。
// 2. 未购买账号:公开详情有价格但 purchased=false 且无 entryUrl;直连发行网关与
// 平台同源 `/games/<gameId>/` 都是 404;请求播放会话 403。
// 3. 购买前记录钱包余额;购买成功扣 N 泥点、账单出现 game_purchase 流水且购买记录唯一。
// 4. 同 Idempotency-Key 重放与换 key 重复购买都只扣一次。
// 5. 购买后播放会话返回 playUrl,入口 200 且是同一发行包内容;重复进入不再扣费。
// 6. 播放会话网关来源约束:伪造令牌 404;带平台 refresh Cookie 直连 api-server 被 403
// 拒绝,但经 dev 代理(或生产 nginx `^~ /api/game-distribution/play-sessions/`)
// 按前缀清空 Cookie 后仍能播放;同时取证 refresh Cookie 的 Path 属性。
// 7. 免费游戏回归:公开入口直接可玩,播放会话不签发令牌。
// 8. (附加)余额不足时购买失败,余额、账单与购买记录都不变。
import { spawn, spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import JSZip from 'jszip';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DEV_STACK_PATH = resolve(REPO_ROOT, '.app/dev-stack.json');
const STACK_LOG_PATH = resolve(REPO_ROOT, 'logs/e2e-game-distribution-purchase.log');
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:请用已配置管理员账号的环境变量运行,' +
'本地栈可先以 GENARRATIVE_ADMIN_USERNAME / GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
);
process.exit(2);
}
const PRICE_MUD_POINTS = Number.parseInt(
(process.env.E2E_PRICE_MUD_POINTS ?? '').trim() || '30',
10,
);
if (!Number.isInteger(PRICE_MUD_POINTS) || PRICE_MUD_POINTS <= 0) {
console.error('E2E_PRICE_MUD_POINTS 必须是正整数');
process.exit(2);
}
const START_STACK = (process.env.E2E_START_STACK ?? '').trim() === '1';
const GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE = 'game_purchase';
/** 1x1 透明 PNG:仅用于满足发布必填封面,内容不影响本用例断言。 */
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`);
}
function warn(name, detail = '') {
console.log(`WARN ${name}${detail ? ` :: ${detail}` : ''}`);
}
function readDevStack() {
try {
return JSON.parse(readFileSync(DEV_STACK_PATH, 'utf8'));
} catch {
return null;
}
}
function normalizeBase(value) {
return String(value ?? '').trim().replace(/\/+$/u, '');
}
// api-server / 主站 Vite 的实际地址只认显式覆盖或 .app/dev-stack.json,不用默认端口兜底。
// 自起 dev 栈时端口由 dev 脚本探测决定,因此这两个地址要在就绪后重新解析一次。
let devStack = readDevStack();
let API = normalizeBase(
process.env.E2E_API_BASE ?? devStack?.services?.['api-server']?.url,
);
let WEB = '';
/**
* 解析主站 Vite 地址:显式覆盖优先,其次 dev-stack.json 里记录的 web 地址。
*
* 不能只看 `status`:dev.mjs 记录的 web 状态偶发滞后(进程实际在跑但字段仍是 failed),
* 因此这里以「该地址能不能响应 HTTP」为准,探不通才按未启动处理。
*/
async function resolveWebBase() {
const candidate = normalizeBase(
process.env.E2E_WEB_BASE ?? devStack?.services?.web?.url,
);
if (!candidate) return '';
// 自起 dev 栈时 Vite 在 api-server 之后启动,给它一个就绪窗口。
const deadline = Date.now() + (START_STACK ? 60_000 : 0);
for (;;) {
try {
await fetch(`${candidate}/`);
return candidate;
} catch {
if (Date.now() >= deadline) return '';
await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000));
}
}
}
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,
headers: response.headers,
};
}
function stamp() {
return `${Date.now()}${Math.floor(Math.random() * 100000)}`;
}
/** 每次运行都用新手机号注册,避免复用账号把购买记录的「唯一性」断言做假。 */
async function registerAccount(prefix) {
const phone = `${prefix}${String(Date.now() + Math.floor(Math.random() * 1000)).slice(-8)}`;
const entry = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: 'GenE2e123!' },
});
return { status: entry.status, token: entry.data?.token ?? '', phone };
}
async function uploadCover(token, id) {
const fileName = `cover-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'cover', 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-cover',
},
});
if (confirm.status !== 200) {
throw new Error(
`confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
/**
* 生成最小可玩发行包:入口页里带唯一标记,用来证明播放会话读到的是同一份包内容。
*/
async function buildPackage(marker) {
const entry = `<!doctype html><html><head><meta charset="utf-8"><title>E2E ${marker}</title><script src="assets/app.js"></script></head><body><h1>${marker}</h1></body></html>`;
const asset = `document.documentElement.dataset.e2e="${marker}";`;
const zip = new JSZip();
zip.file('index.html', entry);
zip.file('assets/app.js', asset);
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
const crypto = await import('node:crypto');
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
return {
bytes,
sha256,
entry,
asset,
assetPath: 'assets/app.js',
fileCount: 2,
marker,
};
}
function gameMetadata(title, coverAssetId) {
return {
title,
summary: '买断制泥点付费真实链路验证',
description: 'E2E:定价 → 审核 → 购买 → 播放会话',
category: '益智',
tags: ['E2E'],
deviceSupport: { desktop: true, mobile: true, touch: true },
inputModes: ['keyboard', 'mouse', 'touch'],
orientation: 'responsive',
coverAssetId,
screenshots: [],
};
}
/** 走完「建游戏 → 建版本(冻结价格)→ 传包 → 送审 → 管理员通过」的真实发布链路。 */
async function publishGame({ authorToken, adminToken, coverAssetId, price, marker, title }) {
const id = stamp();
const metadata = gameMetadata(title, coverAssetId);
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: authorToken,
headers: { 'Idempotency-Key': `e2e-purchase-game-${id}` },
body: metadata,
});
if (created.status !== 200 || !created.data?.id) {
throw new Error(`创建游戏失败 ${created.status} ${created.text.slice(0, 300)}`);
}
const gameId = created.data.id;
const pkg = await buildPackage(marker);
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: authorToken,
headers: { 'Idempotency-Key': `e2e-purchase-version-${id}` },
body: {
priceMudPoints: price,
packageSha256: pkg.sha256,
packageBytes: pkg.bytes.length,
packageFileCount: pkg.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
if (version.status !== 200 || !version.data?.versionId) {
throw new Error(`创建版本失败 ${version.status} ${version.text.slice(0, 300)}`);
}
const versionId = version.data.versionId;
const upload = await api(`/api/game-distribution/versions/${versionId}/package`, {
method: 'PUT',
token: authorToken,
headers: {
'Idempotency-Key': `e2e-purchase-upload-${id}`,
'Content-Type': 'application/zip',
},
binary: pkg.bytes,
});
if (upload.status !== 200 || upload.data?.status !== 'uploaded') {
throw new Error(`上传发行包失败 ${upload.status} ${upload.text.slice(0, 300)}`);
}
const submitted = await api(`/api/game-distribution/versions/${versionId}/submit`, {
method: 'POST',
token: authorToken,
headers: { 'Idempotency-Key': `e2e-purchase-submit-${id}` },
body: { expectedPublicationRevision: created.data.publicationRevision ?? 0 },
});
if (submitted.status !== 202) {
throw new Error(`送审失败 ${submitted.status} ${submitted.text.slice(0, 300)}`);
}
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
token: authorToken,
});
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: adminToken,
headers: { 'Idempotency-Key': `e2e-purchase-approve-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: readback.data?.version?.publicationRevision ?? 0,
},
},
);
if (approved.status !== 200) {
throw new Error(`审核通过失败 ${approved.status} ${approved.text.slice(0, 300)}`);
}
return { gameId, versionId, pkg };
}
async function walletBalance(token) {
const dashboard = await api('/api/profile/dashboard', { token });
return dashboard.data?.walletBalance;
}
async function walletLedgerEntries(token) {
const ledger = await api('/api/profile/wallet-ledger', { token });
return ledger.data?.entries ?? [];
}
/** 平台同源发行入口:只有主站 Vite 在跑时才能真实走 `/games/<gameId>/`。 */
async function fetchPlatformEntry(gameId, assetPath = '') {
if (!WEB) return null;
const response = await fetch(`${WEB}/games/${gameId}/${assetPath}`);
const body = await response.text();
return { status: response.status, body };
}
/**
* 取一份真实的平台 refresh Cookie(含服务端下发的 Path 属性)。
*
* 登录请求通过 `base` 发出:`base` 传主站 Vite 时走的是和浏览器完全相同的 dev 代理链路。
*/
async function loginWithRefreshCookie(base) {
const phone = `136${String(Date.now() + Math.floor(Math.random() * 1000)).slice(-8)}`;
const response = await fetch(`${base}/api/auth/entry`, {
method: 'POST',
headers: { ...ENVELOPE, 'Content-Type': 'application/json' },
body: JSON.stringify({ purePhoneNumber: phone, password: 'GenE2e123!' }),
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
const setCookies =
typeof response.headers.getSetCookie === 'function'
? response.headers.getSetCookie()
: [];
return {
status: response.status,
token: json?.data?.token ?? '',
setCookies,
cookieHeader: setCookies.map((entry) => entry.split(';')[0]).join('; '),
};
}
/** 平台 refresh Cookie 的 Path 属性决定浏览器会不会把它带到播放会话前缀上。 */
function refreshCookiePath(setCookie) {
const matched = /;\s*path=([^;]+)/iu.exec(setCookie);
return matched ? matched[1].trim() : '';
}
async function waitForStackReady(deadlineMs = 900_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < deadlineMs) {
devStack = readDevStack();
const apiService = devStack?.services?.['api-server'];
const candidate = normalizeBase(
process.env.E2E_API_BASE ?? apiService?.url,
);
if (candidate) {
try {
const response = await fetch(`${candidate}/healthz`);
if (response.ok) {
return candidate;
}
} catch {
// 端口还没起来:继续等。
}
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000));
}
throw new Error('等待 API /healthz 就绪超时');
}
let stackChild = null;
let stackDumpLogs = null;
function startStack() {
const isWindows = process.platform === 'win32';
stackChild = spawn('npm', ['run', 'dev', '--', '--no-interactive'], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
shell: isWindows,
detached: !isWindows,
env: process.env,
});
const logChunks = [];
const capture = (chunk) => {
logChunks.push(chunk);
if (logChunks.length > 400) logChunks.shift();
};
stackChild.stdout?.on('data', capture);
stackChild.stderr?.on('data', capture);
const dump = () => {
try {
writeFileSync(STACK_LOG_PATH, Buffer.concat(logChunks));
} catch {
// 日志落盘失败不影响用例结论。
}
};
stackChild.on('exit', dump);
stackDumpLogs = dump;
return dump;
}
/** 只收自己拉起的 dev 栈;attach 模式不动别人已经在跑的服务。 */
function stopStack(dumpLogs) {
if (!stackChild?.pid) return;
try {
dumpLogs?.();
} catch {
// 忽略日志落盘失败。
}
const isWindows = process.platform === 'win32';
// dev.mjs 记录的 pid 才是 SpacetimeDB / api-server / bgfilter-worker 的真实进程;
// 只杀 npm 外壳会在 Windows 上留下孤儿进程,所以两者都要按实例声明回收。
const recorded = readDevStack();
const pids = new Set([String(stackChild.pid)]);
const instanceId = recorded?.instanceId;
if (instanceId) {
for (const service of Object.values(recorded?.services ?? {})) {
if (service?.instanceId === instanceId && Number.isInteger(service?.pid)) {
pids.add(String(service.pid));
}
}
}
if (isWindows) {
for (const pid of pids) {
spawnSync('taskkill', ['/PID', pid, '/T', '/F'], { stdio: 'ignore' });
}
return;
}
try {
process.kill(-stackChild.pid, 'SIGTERM');
} catch {
stackChild.kill('SIGTERM');
}
for (const pid of pids) {
if (pid === String(stackChild.pid)) continue;
try {
process.kill(Number(pid), 'SIGTERM');
} catch {
// 进程已经退出。
}
}
}
async function main() {
if (START_STACK) {
console.log('[e2e] E2E_START_STACK=1:自行拉起本地 dev 栈');
const dumpLogs = startStack();
try {
const resolved = await waitForStackReady();
devStack = readDevStack();
API = normalizeBase(process.env.E2E_API_BASE ?? resolved);
console.log(`[e2e] dev 栈就绪: api-server=${API}`);
} catch (error) {
dumpLogs();
throw error;
}
}
WEB = await resolveWebBase();
if (!API) {
console.error(
'缺少 api-server 地址:请设置 E2E_API_BASE,或用 E2E_START_STACK=1 由脚本自起 dev 栈。',
);
return 2;
}
console.log(`[e2e] api-server: ${API}`);
if (WEB) {
console.log(`[e2e] 主站 Vite: ${WEB}`);
} else {
warn(
'未发现主站 Vite,跳过平台同源 /games/<gameId>/ 断言',
'可设置 E2E_WEB_BASE 或 E2E_START_STACK=1 覆盖',
);
}
// 1. 账号与管理员
const author = await registerAccount('137');
check('作者账号注册拿到 token', author.status === 200 && Boolean(author.token), `status=${author.status}`);
const buyer = await registerAccount('138');
check('买家账号注册拿到 token', buyer.status === 200 && Boolean(buyer.token), `status=${buyer.status}`);
const stranger = await registerAccount('139');
check('未购买账号注册拿到 token', stranger.status === 200 && Boolean(stranger.token), `status=${stranger.status}`);
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const adminToken = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check('管理员登录成功', adminLogin.status === 200 && Boolean(adminToken), `status=${adminLogin.status}`);
if (!author.token || !buyer.token || !stranger.token || !adminToken) {
console.error('[e2e] 缺少必要身份,无法继续');
return 1;
}
// 发布灰度是写入闸门:显式开到 100%,避免被运营收紧状态误伤。
const gateOpen = await api('/admin/api/feature-gates', {
method: 'PUT',
token: adminToken,
body: {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 买断制付费发布灰度',
},
});
check('发布灰度可开启并放量', gateOpen.status === 200, `status=${gateOpen.status}`);
const coverAssetId = await uploadCover(author.token, stamp());
check('作者封面素材直传并 confirm', Boolean(coverAssetId), String(coverAssetId).slice(0, 24));
// 2. 付费游戏:发布 → 审核通过
const paidMarker = `E2E-PAID-OK-${stamp()}`;
const paid = await publishGame({
authorToken: author.token,
adminToken,
coverAssetId,
price: PRICE_MUD_POINTS,
marker: paidMarker,
title: `买断制支付验证 ${stamp().slice(-6)}`,
});
check('付费游戏发布并审核通过', Boolean(paid.gameId && paid.versionId), `gameId=${paid.gameId}`);
// 3. 未购买用户:看得到资料与价格,但拿不到任何可播放入口
const anonymousDetail = await api(`/api/game-distribution/games/${paid.gameId}`);
check(
'匿名公开详情展示价格',
anonymousDetail.status === 200 && anonymousDetail.data?.priceMudPoints === PRICE_MUD_POINTS,
`status=${anonymousDetail.status} price=${anonymousDetail.data?.priceMudPoints}`,
);
check(
'匿名公开详情 purchased=false 且无发行入口',
anonymousDetail.data?.purchased === false &&
anonymousDetail.data?.currentVersion?.entryUrl === null,
`purchased=${anonymousDetail.data?.purchased} entryUrl=${String(anonymousDetail.data?.currentVersion?.entryUrl)}`,
);
const strangerDetail = await api(`/api/game-distribution/games/${paid.gameId}`, {
token: stranger.token,
});
check(
'未购买账号详情 purchased=false 且无发行入口',
strangerDetail.status === 200 &&
strangerDetail.data?.purchased === false &&
strangerDetail.data?.currentVersion?.entryUrl === null,
`status=${strangerDetail.status} purchased=${strangerDetail.data?.purchased} entryUrl=${String(strangerDetail.data?.currentVersion?.entryUrl)}`,
);
const paidGateway = await fetch(`${API}/api/game-distribution/releases/${paid.gameId}`);
const paidGatewayBody = await paidGateway.text();
check(
'付费作品直连发行网关入口 404',
paidGateway.status === 404,
`status=${paidGateway.status} bytes=${paidGatewayBody.length}`,
);
const paidGatewayAsset = await fetch(
`${API}/api/game-distribution/releases/${paid.gameId}/${paid.pkg.assetPath}`,
);
check(
'付费作品直连发行网关包内资源 404',
paidGatewayAsset.status === 404,
`status=${paidGatewayAsset.status}`,
);
const paidPlatformEntry = await fetchPlatformEntry(paid.gameId);
if (paidPlatformEntry) {
check(
'付费作品平台同源 /games/<gameId>/ 404',
paidPlatformEntry.status === 404,
`status=${paidPlatformEntry.status}`,
);
}
const strangerSession = await api(
`/api/game-distribution/games/${paid.gameId}/play-session`,
{ method: 'POST', token: stranger.token },
);
check(
'未购买账号请求播放会话 403',
strangerSession.status === 403,
`status=${strangerSession.status} code=${strangerSession.error?.code ?? ''}`,
);
const anonymousSession = await api(
`/api/game-distribution/games/${paid.gameId}/play-session`,
{ method: 'POST' },
);
check(
'未登录请求付费播放会话 401',
anonymousSession.status === 401,
`status=${anonymousSession.status}`,
);
// 4. 购买:余额、账单与幂等
const buyerBefore = await walletBalance(buyer.token);
check(
'买家钱包有足够余额完成购买',
Number.isFinite(buyerBefore) && buyerBefore >= PRICE_MUD_POINTS,
`balance=${buyerBefore} price=${PRICE_MUD_POINTS}`,
);
const purchaseKey = `e2e-purchase-${stamp()}`;
const purchase = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, {
method: 'POST',
token: buyer.token,
headers: { 'Idempotency-Key': purchaseKey },
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
});
check(
'购买成功并扣费',
purchase.status === 200 &&
purchase.data?.purchase?.priceMudPoints === PRICE_MUD_POINTS &&
purchase.data?.replayed === false &&
purchase.data?.walletBalance === buyerBefore - PRICE_MUD_POINTS,
`status=${purchase.status} replayed=${purchase.data?.replayed} balance=${purchase.data?.walletBalance} before=${buyerBefore}`,
);
const afterPurchaseBalance = purchase.data?.walletBalance;
const purchaseId = purchase.data?.purchase?.purchaseId;
const ledgerAfterPurchase = await walletLedgerEntries(buyer.token);
const purchaseLedger = ledgerAfterPurchase.filter(
(entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE,
);
check(
'账单出现唯一的 game_purchase 扣费流水',
purchaseLedger.length === 1 &&
purchaseLedger[0]?.amountDelta === -PRICE_MUD_POINTS &&
purchaseLedger[0]?.balanceAfter === afterPurchaseBalance,
`count=${purchaseLedger.length} amountDelta=${purchaseLedger[0]?.amountDelta} balanceAfter=${purchaseLedger[0]?.balanceAfter}`,
);
// 同 key 重放:必须回放既有购买,不再扣费
const replay = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, {
method: 'POST',
token: buyer.token,
headers: { 'Idempotency-Key': purchaseKey },
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
});
check(
'同 Idempotency-Key 重放只扣一次',
replay.status === 200 &&
replay.data?.replayed === true &&
replay.data?.walletBalance === afterPurchaseBalance &&
replay.data?.purchase?.purchaseId === purchaseId,
`status=${replay.status} replayed=${replay.data?.replayed} balance=${replay.data?.walletBalance}`,
);
// 换 key 重复购买:已有所有权,同样不再扣费
const duplicate = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, {
method: 'POST',
token: buyer.token,
headers: { 'Idempotency-Key': `e2e-purchase-${stamp()}` },
body: { expectedPriceMudPoints: PRICE_MUD_POINTS },
});
check(
'换 key 重复购买只扣一次',
duplicate.status === 200 &&
duplicate.data?.replayed === true &&
duplicate.data?.walletBalance === afterPurchaseBalance,
`status=${duplicate.status} replayed=${duplicate.data?.replayed} balance=${duplicate.data?.walletBalance}`,
);
const ledgerAfterDuplicate = await walletLedgerEntries(buyer.token);
check(
'重复购买未新增 game_purchase 流水',
ledgerAfterDuplicate.filter(
(entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE,
).length === 1,
`count=${ledgerAfterDuplicate.filter((entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE).length}`,
);
const buyerDetail = await api(`/api/game-distribution/games/${paid.gameId}`, {
token: buyer.token,
});
check(
'购买后详情 purchased=true 且下发发行入口',
buyerDetail.data?.purchased === true &&
buyerDetail.data?.currentVersion?.entryUrl === `/games/${paid.gameId}/`,
`purchased=${buyerDetail.data?.purchased} entryUrl=${String(buyerDetail.data?.currentVersion?.entryUrl)}`,
);
// 5. 播放会话:签发令牌、可玩、重复进入不再扣费
const playSession = await api(
`/api/game-distribution/games/${paid.gameId}/play-session`,
{ method: 'POST', token: buyer.token },
);
const playUrl = playSession.data?.playUrl ?? '';
check(
'购买后签发播放会话 playUrl',
playSession.status === 200 &&
playUrl.startsWith('/api/game-distribution/play-sessions/'),
`status=${playSession.status} playUrl=${playUrl.slice(0, 48)}…`,
);
const sessionEntry = await fetch(`${API}${playUrl}`);
const sessionEntryBody = await sessionEntry.text();
check(
'播放会话入口 200 且是同一发行包内容',
sessionEntry.status === 200 &&
sessionEntryBody.includes(paid.pkg.marker) &&
/<html|<!doctype html/iu.test(sessionEntryBody),
`status=${sessionEntry.status} marker=${sessionEntryBody.includes(paid.pkg.marker)}`,
);
check(
'播放会话入口带 nosniff',
sessionEntry.headers.get('x-content-type-options') === 'nosniff',
String(sessionEntry.headers.get('x-content-type-options')),
);
const sessionAsset = await fetch(`${API}${playUrl}${paid.pkg.assetPath}`);
const sessionAssetBody = await sessionAsset.text();
check(
'播放会话包内资源与上传内容逐字节一致',
sessionAsset.status === 200 && sessionAssetBody === paid.pkg.asset,
`status=${sessionAsset.status} bytes=${sessionAssetBody.length}`,
);
const secondSession = await api(
`/api/game-distribution/games/${paid.gameId}/play-session`,
{ method: 'POST', token: buyer.token },
);
const balanceAfterSecondEntry = await walletBalance(buyer.token);
check(
'重复进入不再扣费',
secondSession.status === 200 &&
balanceAfterSecondEntry === afterPurchaseBalance,
`status=${secondSession.status} balance=${balanceAfterSecondEntry} expected=${afterPurchaseBalance}`,
);
// 6. 播放会话网关的来源约束与伪造令牌
// (1) 直连 api-server 且不带 Cookie:上面已断言 200(这是播放会话的正常读取姿态)。
// (2) 伪造令牌:拿不到 token 的未购买账号即使猜 URL 也只能拿到 404。
const forgedToken = `forged${stamp()}`;
const forgedEntry = await fetch(
`${API}/api/game-distribution/play-sessions/${forgedToken}/`,
);
check(
'伪造播放会话令牌请求入口 404',
forgedEntry.status === 404,
`status=${forgedEntry.status}`,
);
const forgedAsset = await fetch(
`${API}/api/game-distribution/play-sessions/${forgedToken}/${paid.pkg.assetPath}`,
);
check(
'伪造播放会话令牌请求包内资源 404',
forgedAsset.status === 404,
`status=${forgedAsset.status}`,
);
// (3) 带平台 refresh Cookie:网关按「播放会话必须在独立来源读取」的约束直接 403。
// 浏览器侧是否真的会带上这个 Cookie 取决于服务端下发的 Path,因此一起取证。
const cookieSource = WEB || API;
const cookieLogin = await loginWithRefreshCookie(cookieSource);
const refreshSetCookie = cookieLogin.setCookies.find((entry) =>
/refresh|session/iu.test(entry),
);
const cookiePath = refreshSetCookie ? refreshCookiePath(refreshSetCookie) : '';
check(
'取到平台 refresh Cookie 且 Path 限定非 /api/game-distribution',
cookieLogin.status === 200 &&
Boolean(cookieLogin.cookieHeader) &&
Boolean(cookiePath) &&
!cookiePath.startsWith('/api/game-distribution'),
`path=${cookiePath} cookies=${cookieLogin.setCookies.length} via=${cookieSource}`,
);
const cookieInspect = await fetch(`${API}/_internal/auth/refresh-cookie`, {
headers: { ...ENVELOPE, Cookie: cookieLogin.cookieHeader },
});
const cookieInspectBody = await cookieInspect.json().catch(() => null);
const cookiePresent =
cookieInspectBody?.data?.present ?? cookieInspectBody?.present;
check(
'网关能解析这份 Cookie(确认是有效平台会话)',
cookieInspect.status === 200 && cookiePresent === true,
`status=${cookieInspect.status} present=${cookiePresent}`,
);
const cookieEntryDirect = await fetch(`${API}${playUrl}`, {
headers: { Cookie: cookieLogin.cookieHeader },
});
const cookieEntryDirectBody = await cookieEntryDirect.text();
check(
'带平台 Cookie 直连播放会话入口被 403 拒绝(网关来源约束)',
cookieEntryDirect.status === 403,
`status=${cookieEntryDirect.status} bytes=${cookieEntryDirectBody.length}`,
);
if (WEB) {
// dev 代理对播放会话前缀有专门规则清空 Cookie(vite.config.ts 里排在通用
// `/api/game-distribution` 之前;生产 nginx 用 `location ^~ .../play-sessions/` 做同一件事),
// 所以带 Cookie 经代理访问仍然可玩。这就是 iframe 在真实浏览器里可用的边缘证据。
const cookieEntryViaProxy = await fetch(`${WEB}${playUrl}`, {
headers: { Cookie: cookieLogin.cookieHeader },
});
const cookieEntryViaProxyBody = await cookieEntryViaProxy.text();
check(
'带平台 Cookie 经 dev 代理仍能播放(代理按前缀清空 Cookie)',
cookieEntryViaProxy.status === 200 &&
cookieEntryViaProxyBody.includes(paid.pkg.marker),
`status=${cookieEntryViaProxy.status} marker=${cookieEntryViaProxyBody.includes(paid.pkg.marker)} bytes=${cookieEntryViaProxyBody.length}`,
);
const forgedViaProxy = await fetch(
`${WEB}/api/game-distribution/play-sessions/${forgedToken}/`,
{ headers: { Cookie: cookieLogin.cookieHeader } },
);
check(
'伪造令牌经 dev 代理请求播放会话入口 404',
forgedViaProxy.status === 404,
`status=${forgedViaProxy.status}`,
);
} else {
warn('跳过 dev 代理带 Cookie 取证', '未发现主站 Vite');
}
// 7. 免费游戏回归:公开入口直接可玩,播放会话不签发令牌
const freeMarker = `E2E-FREE-OK-${stamp()}`;
const free = await publishGame({
authorToken: author.token,
adminToken,
coverAssetId,
price: 0,
marker: freeMarker,
title: `免费游戏回归 ${stamp().slice(-6)}`,
});
check('免费游戏发布并审核通过', Boolean(free.gameId && free.versionId), `gameId=${free.gameId}`);
const freeDetail = await api(`/api/game-distribution/games/${free.gameId}`);
check(
'免费游戏公开详情保留发行入口',
freeDetail.status === 200 &&
freeDetail.data?.priceMudPoints === 0 &&
freeDetail.data?.currentVersion?.entryUrl === `/games/${free.gameId}/`,
`status=${freeDetail.status} price=${freeDetail.data?.priceMudPoints} entryUrl=${String(freeDetail.data?.currentVersion?.entryUrl)}`,
);
const freeGateway = await fetch(`${API}/api/game-distribution/releases/${free.gameId}/`);
const freeGatewayBody = await freeGateway.text();
check(
'免费游戏发行网关直接可玩',
freeGateway.status === 200 && freeGatewayBody.includes(freeMarker),
`status=${freeGateway.status} marker=${freeGatewayBody.includes(freeMarker)}`,
);
const freePlatformEntry = await fetchPlatformEntry(free.gameId);
if (freePlatformEntry) {
check(
'免费游戏平台同源 /games/<gameId>/ 直接可玩',
freePlatformEntry.status === 200 && freePlatformEntry.body.includes(freeMarker),
`status=${freePlatformEntry.status} marker=${freePlatformEntry.body.includes(freeMarker)}`,
);
}
const freeSession = await api(
`/api/game-distribution/games/${free.gameId}/play-session`,
{ method: 'POST' },
);
check(
'免费游戏播放会话不签发令牌、直接回公开入口',
freeSession.status === 200 &&
freeSession.data?.playUrl === `/games/${free.gameId}/`,
`status=${freeSession.status} playUrl=${String(freeSession.data?.playUrl)}`,
);
// 8. 附加:余额不足时购买失败且不产生任何副作用
if (Number.isFinite(buyerBefore) && buyerBefore < 1_000_000) {
const priceyMarker = `E2E-PRICEY-OK-${stamp()}`;
const pricey = await publishGame({
authorToken: author.token,
adminToken,
coverAssetId,
price: 1_000_000,
marker: priceyMarker,
title: `余额不足验证 ${stamp().slice(-6)}`,
});
const balanceBeforeFailure = await walletBalance(buyer.token);
const ledgerBeforeFailure = (await walletLedgerEntries(buyer.token)).length;
const insufficient = await api(
`/api/game-distribution/games/${pricey.gameId}/purchase`,
{
method: 'POST',
token: buyer.token,
headers: { 'Idempotency-Key': `e2e-purchase-insufficient-${stamp()}` },
body: { expectedPriceMudPoints: 1_000_000 },
},
);
const balanceAfterFailure = await walletBalance(buyer.token);
const ledgerAfterFailure = (await walletLedgerEntries(buyer.token)).length;
check(
'余额不足购买失败(400 INSUFFICIENT_MUD_POINTS)',
insufficient.status === 400 &&
(insufficient.error?.code === 'INSUFFICIENT_MUD_POINTS' ||
String(insufficient.error?.message ?? '').includes('泥点')),
`status=${insufficient.status} code=${insufficient.error?.code ?? ''} msg=${insufficient.error?.message ?? ''}`,
);
check(
'余额不足时余额、账单与购买记录都不变',
balanceAfterFailure === balanceBeforeFailure &&
ledgerAfterFailure === ledgerBeforeFailure,
`balance=${balanceAfterFailure}/${balanceBeforeFailure} ledger=${ledgerAfterFailure}/${ledgerBeforeFailure}`,
);
} else {
warn('跳过余额不足用例', `买家余额 ${buyerBefore} 已达上限价,无法构造不足场景`);
}
console.log(
`\n[e2e] 断言汇总:${failures === 0 ? '全部通过' : `${failures} 条失败`}`,
);
return failures === 0 ? 0 : 1;
}
let exitCode = 1;
try {
exitCode = await main();
} catch (error) {
console.error(`[e2e] 执行失败: ${error?.message ?? error}`);
exitCode = 1;
} finally {
stopStack(stackDumpLogs);
}
process.exit(exitCode);