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
- 新增 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 别名
968 lines
36 KiB
JavaScript
968 lines
36 KiB
JavaScript
// 游戏买断制泥点付费与播放鉴权「真实本地栈」端到端检查。
|
||
//
|
||
// 用法:
|
||
// 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);
|