- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决 - 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏 - 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言 - 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步 - 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿 - 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描) - 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目 验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
676 lines
34 KiB
JavaScript
676 lines
34 KiB
JavaScript
/**
|
||
* api-smoke.mjs — 无感用户系统(P0-B)零依赖冒烟测试。
|
||
*
|
||
* 覆盖(完成标准第 1 条):
|
||
* A. POST /api/device 匿名建档(自动名玩家一/二…)与 token 重附着
|
||
* B. 进度 PUT-GET-LWW:legacy 放行、baseUpdatedAt 守卫 409、全桶 GET
|
||
* C. claim 命名/设 PIN、改名、PIN-only 认领
|
||
* D. claim-merge(换设备登录):旧档案与新设备档案按桶 LWW 合并、token 改绑
|
||
* E. 旧客户端 login/register 兼容 + logout 吊销
|
||
* F. v1 迁移:users.json/sessions.json/<username>.json → profiles.json/tokens.json/
|
||
* profiles/<pid>.json(PIN 明文即刻 scrypt 哈希、旧 token 继续有效)
|
||
* G. per-IP 匿名建档频控(429)与 /server/ 静态目录封禁(数据不外泄)
|
||
* H. 大厅 localStorage 分桶逻辑单测(web/profiles.js 纯函数,Map 版 storage)
|
||
* I. 配对码跨设备(P1):start(6 位/10min/同档案 1 码/频控)→ join 按桶 LWW 合并
|
||
* + token 改绑 → 单次使用作废 + join 5 连错封锁 + 已设 PIN joiner 拒绝 + 日志不落码
|
||
* J. token 180 天滑动过期:到期 401(大厅「档案已过期」引导的服务器侧路径)
|
||
*
|
||
* 运行:tools/node/bin/node web/server/test/api-smoke.mjs(或任意 node ≥18)
|
||
* 全部通过退出码 0;任一失败抛异常退出非 0。测试数据全部写进临时目录。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { spawn } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import {
|
||
BUCKETS, SETTINGS_KEY,
|
||
applyAuth, cnNum, decideBucket, digitsToCn, getCurrentProfile, migrateLegacyAuth,
|
||
nextLocalProfileName, readRegistry, removeProfile, restoreSnapshot, setCurrentProfile,
|
||
snapshotKeyOf, stashSnapshot, syncKeyOf, writeRegistry,
|
||
} from '../../profiles.js';
|
||
|
||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
const SERVER = path.join(HERE, '..', 'server.js');
|
||
const NODE = process.execPath;
|
||
|
||
// ---------------- 服务器实例管理 ----------------
|
||
|
||
function tmpDataDir() {
|
||
return fs.mkdtempSync(path.join(os.tmpdir(), 'soundgame-smoke-'));
|
||
}
|
||
|
||
async function startServer({ dataDir = tmpDataDir(), env = {} } = {}) {
|
||
const port = 20000 + Math.floor(Math.random() * 20000);
|
||
const child = spawn(NODE, [SERVER], {
|
||
env: { ...process.env, PORT: String(port), SG_DATA_DIR: dataDir, ...env },
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
const logs = [];
|
||
child.stdout.on('data', (c) => logs.push(String(c)));
|
||
child.stderr.on('data', (c) => logs.push(String(c)));
|
||
const base = `http://127.0.0.1:${port}`;
|
||
const deadline = Date.now() + 15000;
|
||
for (;;) {
|
||
if (child.exitCode !== null) throw new Error(`服务器提前退出:\n${logs.join('')}`);
|
||
if (Date.now() > deadline) throw new Error('服务器启动超时');
|
||
try {
|
||
const r = await fetch(base + '/');
|
||
if (r.ok) break;
|
||
} catch (_) { /* 尚未监听 */ }
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
}
|
||
return {
|
||
base,
|
||
dataDir,
|
||
logs,
|
||
stop() {
|
||
return new Promise((resolve) => {
|
||
child.on('exit', resolve);
|
||
child.kill('SIGTERM');
|
||
setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} resolve(); }, 2000);
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
async function call(base, method, apiPath, { token, body } = {}) {
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||
const resp = await fetch(base + apiPath, {
|
||
method,
|
||
headers,
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
const json = await resp.json().catch(() => ({}));
|
||
return { status: resp.status, json };
|
||
}
|
||
|
||
// ---------------- H. 大厅分桶逻辑单测(纯函数,无需服务器) ----------------
|
||
|
||
test('H1. 快照回灌/倒灌:玩家一/玩家二进度互不污染', () => {
|
||
const storage = mockStorage();
|
||
// 玩家一在线:rpg 进度 + 设置
|
||
storage.setItem('soundgame.progress.rpg', JSON.stringify({ schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 }));
|
||
storage.setItem(SETTINGS_KEY, JSON.stringify({ voice: 'male', speed: 1.7 }));
|
||
storage.setItem('soundgame.rpg.tutIntroSeen', '1'); // 教学标记:不入桶(跨档案共享,01 §4.6)
|
||
writeRegistry(storage, [
|
||
{ id: 'p_aaa', name: '玩家一', token: 't1' },
|
||
{ id: 'p_bbb', name: '玩家二', token: 't2' },
|
||
]);
|
||
setCurrentProfile(storage, 'p_aaa');
|
||
|
||
// 切走:回灌玩家一
|
||
stashSnapshot(storage, 'p_aaa');
|
||
assert.equal(storage.getItem('soundgame.progress.rpg'), JSON.stringify({ schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 }));
|
||
assert.ok(!JSON.parse(storage.getItem(snapshotKeyOf('p_aaa'))).hasOwnProperty('soundgame.rpg.tutIntroSeen'), '教学标记不应入快照');
|
||
|
||
// 切入玩家二:倒灌前先清空活动 key → 无残留串档
|
||
restoreSnapshot(storage, 'p_bbb');
|
||
assert.equal(storage.getItem('soundgame.progress.rpg'), null, '切到空档案必须清掉玩家一的 rpg 存档');
|
||
assert.equal(storage.getItem(SETTINGS_KEY), null, '设置桶同样隔离');
|
||
assert.equal(storage.getItem('soundgame.rpg.tutIntroSeen'), '1', '教学标记保留(跨档案共享是既定行为)');
|
||
|
||
// 玩家二游玩后切回玩家一:进度原样回归
|
||
storage.setItem('soundgame.progress.rpg', JSON.stringify({ schema: 1, updatedAt: '2026-09-09T11:00:00Z', summary: '新手', lv: 1 }));
|
||
stashSnapshot(storage, 'p_bbb');
|
||
restoreSnapshot(storage, 'p_aaa');
|
||
assert.deepEqual(JSON.parse(storage.getItem('soundgame.progress.rpg')), { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章', lv: 7 });
|
||
});
|
||
|
||
test('H2. decideBucket 桶级 LWW 裁决', () => {
|
||
const newer = { updatedAt: '2026-09-09T12:00:00Z', rev: 3, data: { updatedAt: '2026-09-09T12:00:00Z', summary: 'new' } };
|
||
const older = { updatedAt: '2026-09-09T11:00:00Z', rev: 2, data: { updatedAt: '2026-09-09T11:00:00Z', summary: 'old' } };
|
||
// 服务器缺 → 本地上云
|
||
assert.equal(decideBucket('{"updatedAt":"2026-09-09T10:00:00Z"}', null), 'push-local');
|
||
assert.equal(decideBucket(null, null), 'keep');
|
||
// 本地缺 → 服务器倒灌(修「7/8 游戏只 PUT 不 GET」由大厅统一做)
|
||
assert.equal(decideBucket(null, older), 'take-server');
|
||
// 两边都有:data.updatedAt 新者胜
|
||
assert.equal(decideBucket('{"updatedAt":"2026-09-09T12:00:00Z"}', older), 'push-local');
|
||
assert.equal(decideBucket('{"updatedAt":"2026-09-09T10:00:00Z"}', older), 'take-server');
|
||
assert.equal(decideBucket('{"updatedAt":"2026-09-09T11:00:00Z"}', older), 'keep');
|
||
// 无时间戳(settings / simon 最高分):内容等价 keep,否则服务器胜(对齐旧「登录拉设置」行为)
|
||
assert.equal(decideBucket('{"voice":"male"}', { updatedAt: 'x', rev: 1, data: { voice: 'male' } }), 'keep');
|
||
assert.equal(decideBucket('{"voice":"male"}', { updatedAt: 'x', rev: 1, data: { voice: 'female' } }), 'take-server');
|
||
assert.equal(decideBucket('5', { updatedAt: 'x', rev: 1, data: 5 }), 'keep'); // simon.best 标量
|
||
// 本地损坏 → 服务器兜底
|
||
assert.equal(decideBucket('{broken', older), 'take-server');
|
||
});
|
||
|
||
test('H3. 注册表 / 旧登录态收养 / 本地自动名 / 游戏侧 auth 视图', () => {
|
||
const storage = mockStorage();
|
||
assert.deepEqual(readRegistry(storage), []);
|
||
assert.equal(getCurrentProfile(storage), null);
|
||
assert.equal(nextLocalProfileName([]), '玩家一');
|
||
assert.equal(nextLocalProfileName([{ id: 'a', name: 'x' }, { id: 'b', name: 'y' }]), '玩家三');
|
||
|
||
// v1 旧登录态识别
|
||
storage.setItem('soundgame.auth', JSON.stringify({ username: 'uuu', token: 'ab'.repeat(24) }));
|
||
const legacy = migrateLegacyAuth(storage);
|
||
assert.equal(legacy.username, 'uuu');
|
||
assert.equal(migrateLegacyAuth(mockStorage()), null);
|
||
|
||
// 有 token → 游戏兼容视图 {username, token};无 token(纯本地档案)→ 移除
|
||
applyAuth(storage, { id: 'p1', name: '玩家一', token: 'tk' });
|
||
assert.deepEqual(JSON.parse(storage.getItem('soundgame.auth')), { username: '玩家一', token: 'tk' });
|
||
applyAuth(storage, { id: 'L1', name: '玩家二', token: null });
|
||
assert.equal(storage.getItem('soundgame.auth'), null);
|
||
});
|
||
|
||
test('H4. 受管桶清单覆盖 11 个可同步游戏 + _settings', () => {
|
||
const games = BUCKETS.map((b) => b.game).sort();
|
||
// hunt 为第 13 个游戏(《围猎》联机切片,23-hunt.md §12 进度桶)
|
||
assert.deepEqual(games, ['_settings', 'abyss', 'detective', 'dungeon', 'escape', 'hunt', 'radio', 'rhythm', 'rpg', 'simon', 'space']);
|
||
for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)');
|
||
});
|
||
|
||
test('H5. 配对码逐位中文读法(0 读「零」非字母 O;speechSynthesis 前置纯函数)', () => {
|
||
assert.equal(digitsToCn('048920'), '零 四 八 九 二 零');
|
||
assert.equal(digitsToCn('000000'), '零 零 零 零 零 零');
|
||
assert.equal(digitsToCn('123456'), '一 二 三 四 五 六');
|
||
assert.equal(digitsToCn(''), '');
|
||
assert.equal(digitsToCn('9'), '九');
|
||
// 自动名词表与 server.js cnNum 同源(玩家一~九可听)
|
||
assert.equal(cnNum(1), '一');
|
||
assert.equal(cnNum(10), '十');
|
||
assert.equal(cnNum(21), '二十一');
|
||
});
|
||
|
||
test('H6. 从本机移除:清快照/同步表/注册表项;当前档案连带 CURRENT/AUTH,非当前不动 AUTH', () => {
|
||
const storage = mockStorage();
|
||
writeRegistry(storage, [
|
||
{ id: 'p_aaa', name: '玩家一', token: 't1' },
|
||
{ id: 'p_bbb', name: '玩家二', token: 't2' },
|
||
]);
|
||
setCurrentProfile(storage, 'p_bbb');
|
||
storage.setItem(snapshotKeyOf('p_aaa'), JSON.stringify({ 'soundgame.progress.rpg': '{}' }));
|
||
storage.setItem(syncKeyOf('p_aaa'), JSON.stringify({ rpg: { updatedAt: 'x', rev: 1 } }));
|
||
applyAuth(storage, { id: 'p_bbb', name: '玩家二', token: 't2' });
|
||
|
||
// 移除非当前档案:不清 CURRENT/AUTH
|
||
let list = removeProfile(storage, 'p_aaa');
|
||
assert.equal(list.length, 1);
|
||
assert.equal(storage.getItem(snapshotKeyOf('p_aaa')), null);
|
||
assert.equal(storage.getItem(syncKeyOf('p_aaa')), null);
|
||
assert.equal(storage.getItem('soundgame.profile.current'), 'p_bbb');
|
||
assert.ok(storage.getItem('soundgame.auth'), '非当前档案移除不应动 AUTH');
|
||
|
||
// 移除当前档案:连带清 CURRENT/AUTH(调用方负责新建/切换)
|
||
storage.setItem(snapshotKeyOf('p_bbb'), '{}');
|
||
list = removeProfile(storage, 'p_bbb');
|
||
assert.deepEqual(list, []);
|
||
assert.equal(storage.getItem('soundgame.profile.current'), null);
|
||
assert.equal(storage.getItem('soundgame.auth'), null);
|
||
assert.equal(storage.getItem(snapshotKeyOf('p_bbb')), null);
|
||
assert.equal(getCurrentProfile(storage), null);
|
||
});
|
||
|
||
function mockStorage() {
|
||
const m = new Map();
|
||
return {
|
||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||
setItem: (k, v) => m.set(k, String(v)),
|
||
removeItem: (k) => m.delete(k),
|
||
_map: m,
|
||
};
|
||
}
|
||
|
||
// ---------------- A-G. API 冒烟(共享一个服务器实例的顺序场景) ----------------
|
||
|
||
test('A-G. API 全链路(device → progress LWW → claim → 合并 → 兼容 → 防护)', async () => {
|
||
const srv = await startServer({ env: { SG_DEVICE_LIMIT: '100' } });
|
||
try {
|
||
// ---- A. 匿名建档 ----
|
||
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
assert.equal(r.status, 201);
|
||
const A = { profileId: r.json.profileId, token: r.json.token };
|
||
assert.match(A.profileId, /^p_[0-9a-f]{12}$/);
|
||
assert.match(A.token, /^[0-9a-f]{48}$/);
|
||
assert.equal(r.json.name, '玩家一');
|
||
assert.equal(r.json.claimed, false);
|
||
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const B = { profileId: r.json.profileId, token: r.json.token };
|
||
assert.equal(r.json.name, '玩家二');
|
||
|
||
// token 重附着(幂等,不新建档案、不消耗 seq)
|
||
r = await call(srv.base, 'POST', '/api/device', { body: { token: A.token } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.attached, true);
|
||
assert.equal(r.json.profileId, A.profileId);
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
assert.equal(r.json.name, '玩家三');
|
||
|
||
// ---- B. 进度 PUT-GET-LWW ----
|
||
const rpgSave = { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 };
|
||
// legacy PUT(无 baseUpdatedAt,存量游戏路径)→ 放行
|
||
r = await call(srv.base, 'PUT', '/api/progress', { token: A.token, body: { game: 'rpg', data: rpgSave } });
|
||
assert.equal(r.status, 200);
|
||
assert.ok(r.json.updatedAt && r.json.rev === 1);
|
||
|
||
// 带 baseUpdatedAt 的守卫写
|
||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||
token: A.token,
|
||
body: { game: 'rpg', data: { ...rpgSave, lv: 8 }, baseUpdatedAt: r.json.updatedAt },
|
||
});
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.rev, 2);
|
||
const aRpgAt = r.json.updatedAt;
|
||
|
||
// 过期基线 → 409 + 服务器桶(客户端择优重试用)
|
||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||
token: A.token,
|
||
body: { game: 'rpg', data: { ...rpgSave, lv: 1 }, baseUpdatedAt: '2000-01-01T00:00:00.000Z' },
|
||
});
|
||
assert.equal(r.status, 409);
|
||
assert.equal(r.json.server.data.lv, 8, '409 应携带服务器当前桶');
|
||
assert.equal(r.json.server.updatedAt, aRpgAt);
|
||
|
||
// 单桶 GET:形状兼容旧客户端(radio 直接读 j.data)
|
||
r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: A.token });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.game, 'rpg');
|
||
assert.equal(r.json.data.lv, 8);
|
||
assert.equal(r.json.updatedAt, aRpgAt);
|
||
|
||
// 全桶 GET(大厅一次拉回)
|
||
r = await call(srv.base, 'GET', '/api/progress', { token: A.token });
|
||
assert.equal(r.status, 200);
|
||
assert.ok(r.json.games.rpg && r.json.games.rpg.data.lv === 8);
|
||
assert.ok(r.json.games._settings === undefined || r.json.games._settings.data !== undefined);
|
||
// 未写过 radio → GET 单桶 data 为 null(radio 新设备开局路径)
|
||
r = await call(srv.base, 'GET', '/api/progress?game=radio', { token: A.token });
|
||
assert.equal(r.json.data, null);
|
||
|
||
// 未授权
|
||
r = await call(srv.base, 'GET', '/api/progress?game=rpg');
|
||
assert.equal(r.status, 401);
|
||
|
||
// ---- C. claim:命名 + 设 PIN ----
|
||
r = await call(srv.base, 'POST', '/api/claim', { token: A.token, body: { name: '爸爸', pin: '1234' } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.profileId, A.profileId);
|
||
assert.equal(r.json.name, '爸爸');
|
||
assert.equal(r.json.claimed, true);
|
||
|
||
r = await call(srv.base, 'GET', '/api/profile', { token: A.token });
|
||
assert.equal(r.json.name, '爸爸');
|
||
assert.equal(r.json.claimed, true);
|
||
|
||
// 改名(PUT profile)
|
||
r = await call(srv.base, 'PUT', '/api/profile', { token: A.token, body: { name: '妈妈' } });
|
||
assert.equal(r.status, 200);
|
||
// 已认领名字被抢注 → 409
|
||
r = await call(srv.base, 'PUT', '/api/profile', { token: B.token, body: { name: '妈妈' } });
|
||
assert.equal(r.status, 409);
|
||
|
||
// PIN-only 认领(给当前档案补设 PIN,不改名)
|
||
r = await call(srv.base, 'POST', '/api/claim', { token: B.token, body: { pin: '5678' } });
|
||
assert.equal(r.status, 200);
|
||
r = await call(srv.base, 'GET', '/api/profile', { token: B.token });
|
||
assert.equal(r.json.claimed, true);
|
||
|
||
// ---- D. claim-merge:换设备登录,按桶 LWW 合并 ----
|
||
// 设备 C(新设备匿名档案):dungeon 桶 + 比妈妈更新的 rpg 桶
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const C = { profileId: r.json.profileId, token: r.json.token };
|
||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||
token: C.token,
|
||
body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } },
|
||
});
|
||
assert.equal(r.status, 200);
|
||
const cDungeonAt = r.json.updatedAt;
|
||
await new Promise((res) => setTimeout(res, 20)); // 服务器时钟至少推进
|
||
const cRpg = { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 };
|
||
r = await call(srv.base, 'PUT', '/api/progress', { token: C.token, body: { game: 'rpg', data: cRpg } });
|
||
assert.equal(r.status, 200);
|
||
|
||
// 用「妈妈 + 1234」在设备 C 登录 → 匿名档案 C 合并进妈妈的档案,token 改绑
|
||
r = await call(srv.base, 'POST', '/api/claim', { token: C.token, body: { name: '妈妈', pin: '1234' } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.merged, true);
|
||
assert.equal(r.json.profileId, A.profileId, 'C 的 token 应改绑到妈妈的档案');
|
||
const CTOKEN = C.token; // token 不变,指向已变
|
||
|
||
r = await call(srv.base, 'GET', '/api/progress', { token: CTOKEN });
|
||
assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:C 的 rpg 更新,应胜出');
|
||
assert.equal(r.json.games.dungeon.data.floor, 3, '合并应带入 C 独有的 dungeon 桶');
|
||
|
||
// 旧 token(妈妈原设备 A)不受影响,且两端看到同一档案
|
||
r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: A.token });
|
||
assert.equal(r.json.data.floor, 3);
|
||
|
||
// 错误 PIN → 401
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const D = { token: r.json.token };
|
||
r = await call(srv.base, 'POST', '/api/claim', { token: D.token, body: { name: '妈妈', pin: '0000' } });
|
||
assert.equal(r.status, 401);
|
||
// 无 PIN 直接抢已认领名 → 401
|
||
r = await call(srv.base, 'POST', '/api/claim', { token: D.token, body: { name: '妈妈' } });
|
||
assert.equal(r.status, 401);
|
||
|
||
// ---- E. 旧客户端兼容:register / login / logout ----
|
||
r = await call(srv.base, 'POST', '/api/register', { body: { username: '老王', pin: '4321' } });
|
||
assert.equal(r.status, 201);
|
||
r = await call(srv.base, 'POST', '/api/register', { body: { username: '老王', pin: '4321' } });
|
||
assert.equal(r.status, 409);
|
||
r = await call(srv.base, 'POST', '/api/login', { body: { username: '老王', pin: '9999' } });
|
||
assert.equal(r.status, 401);
|
||
r = await call(srv.base, 'POST', '/api/login', { body: { username: '老王', pin: '4321' } });
|
||
assert.equal(r.status, 200);
|
||
const oldToken = r.json.token;
|
||
assert.equal(r.json.username, '老王');
|
||
r = await call(srv.base, 'GET', '/api/profile', { token: oldToken });
|
||
assert.equal(r.json.name, '老王');
|
||
// logout 吊销
|
||
r = await call(srv.base, 'POST', '/api/logout', { token: oldToken });
|
||
assert.equal(r.status, 200);
|
||
r = await call(srv.base, 'GET', '/api/profile', { token: oldToken });
|
||
assert.equal(r.status, 401);
|
||
|
||
// ---- G1. /server/ 数据目录静态封禁 ----
|
||
let resp = await fetch(srv.base + '/server/data/profiles.json');
|
||
assert.equal(resp.status, 404, 'profiles.json 不应可被静态读取');
|
||
resp = await fetch(srv.base + '/x/../server/data/tokens.json');
|
||
assert.ok(resp.status === 404 || resp.status === 403, '穿越变体也应被拦');
|
||
resp = await fetch(srv.base + '/');
|
||
assert.equal(resp.status, 200, '大厅不受影响');
|
||
|
||
// 落盘检查:PIN 哈希而非明文
|
||
const idx = JSON.parse(fs.readFileSync(path.join(srv.dataDir, 'profiles.json'), 'utf8'));
|
||
for (const p of Object.values(idx.profiles)) {
|
||
assert.ok(p.pinHash === null || p.pinHash.startsWith('scrypt$'), 'PIN 必须以 scrypt$ 哈希存储');
|
||
}
|
||
assert.ok(!fs.readFileSync(path.join(srv.dataDir, 'profiles.json'), 'utf8').includes('"1234"'));
|
||
} finally {
|
||
await srv.stop();
|
||
}
|
||
});
|
||
|
||
test('F. v1 迁移:users.json/sessions.json/<username>.json → 档案分桶(旧用户无感)', async () => {
|
||
const dataDir = tmpDataDir();
|
||
const oldToken = '7c20d0f3534a31893157529e1f71c7e2c28e26fd07c8d8fc'; // 现网实测形状
|
||
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ uuu: '0858' }, null, 2));
|
||
fs.writeFileSync(path.join(dataDir, 'sessions.json'), JSON.stringify({ [oldToken]: 'uuu' }, null, 2));
|
||
fs.writeFileSync(path.join(dataDir, 'uuu.json'), JSON.stringify({
|
||
_settings: { voice: 'male', speed: 1, bgm: 1, guide: 'full' },
|
||
rpg: { schema: 1, summary: '第一章·Lv3', lv: 3 },
|
||
}, null, 2));
|
||
|
||
const srv = await startServer({ dataDir });
|
||
try {
|
||
// 旧 token 继续有效(迁移进 tokens.json,补 180 天过期)
|
||
let r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: oldToken });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.data.lv, 3, '旧进度数据应完整保留(桶包装、data 透传)');
|
||
r = await call(srv.base, 'GET', '/api/progress?game=_settings', { token: oldToken });
|
||
assert.equal(r.json.data.voice, 'male');
|
||
|
||
// 旧客户端 用户名+PIN 登录兼容(映射到档案)
|
||
r = await call(srv.base, 'POST', '/api/login', { body: { username: 'uuu', pin: '0000' } });
|
||
assert.equal(r.status, 401);
|
||
r = await call(srv.base, 'POST', '/api/login', { body: { username: 'uuu', pin: '0858' } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.profileId, 'uuu', 'pid 沿用旧用户名(进度文件零改名)');
|
||
const t2 = r.json.token;
|
||
r = await call(srv.base, 'GET', '/api/progress', { token: t2 });
|
||
assert.equal(r.json.games.rpg.data.lv, 3);
|
||
|
||
// 新设备用 claim 登录旧档案 → 合并路径可用(匿名档案进度并入 uuu)
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const dev = r.json;
|
||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||
token: dev.token,
|
||
body: { game: 'dungeon', data: { schema: 1, summary: '第 2 层', floor: 2 } },
|
||
});
|
||
assert.equal(r.status, 200);
|
||
r = await call(srv.base, 'POST', '/api/claim', { token: dev.token, body: { name: 'uuu', pin: '0858' } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.merged, true);
|
||
r = await call(srv.base, 'GET', '/api/progress', { token: dev.token });
|
||
assert.equal(r.json.games.rpg.data.lv, 3, 'uuu 旧进度在合并后保留');
|
||
assert.equal(r.json.games.dungeon.data.floor, 2, '新设备进度并入');
|
||
|
||
// 迁移落盘断言:PIN 已哈希、原 v1 文件保留不动
|
||
const idxRaw = fs.readFileSync(path.join(dataDir, 'profiles.json'), 'utf8');
|
||
assert.ok(idxRaw.includes('scrypt$'), '迁移后 PIN 应为 scrypt 哈希');
|
||
assert.ok(!idxRaw.includes('0858'), '明文 PIN 不应出现在 profiles.json');
|
||
assert.equal(JSON.parse(fs.readFileSync(path.join(dataDir, 'users.json'), 'utf8')).uuu, '0858', 'v1 原文件保留');
|
||
assert.ok(fs.existsSync(path.join(dataDir, 'profiles', 'uuu.json')), '进度迁至 profiles/uuu.json');
|
||
const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8'));
|
||
assert.ok(tokensDisk[oldToken] && tokensDisk[oldToken].profileId === 'uuu');
|
||
assert.ok(Date.parse(tokensDisk[oldToken].expiresAt) > Date.now(), '旧 token 补 180 天过期');
|
||
} finally {
|
||
await srv.stop();
|
||
}
|
||
});
|
||
|
||
test('G2. per-IP 匿名建档频控', async () => {
|
||
const srv = await startServer({ env: { SG_DEVICE_LIMIT: '2' } });
|
||
try {
|
||
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
assert.equal(r.status, 201);
|
||
const firstToken = r.json.token;
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
assert.equal(r.status, 201);
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
assert.equal(r.status, 429);
|
||
// 重附着(带有效 token)不受频控影响
|
||
r = await call(srv.base, 'POST', '/api/device', { body: { token: firstToken } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.attached, true);
|
||
} finally {
|
||
await srv.stop();
|
||
}
|
||
});
|
||
|
||
// ---------------- I. 配对码跨设备(P1) ----------------
|
||
|
||
test('I1. 配对码全流程:start(6 位/10min/同档案重生成顶替)→ join 按桶 LWW 合并 → 单次作废', async () => {
|
||
const srv = await startServer({});
|
||
try {
|
||
// 老设备:建档 + rpg 进度
|
||
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const oldDev = { profileId: r.json.profileId, token: r.json.token, name: r.json.name };
|
||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||
token: oldDev.token,
|
||
body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 } },
|
||
});
|
||
assert.equal(r.status, 200);
|
||
|
||
// start:6 位数字、10 分钟有效期、需鉴权
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||
assert.equal(r.status, 200);
|
||
assert.ok(r.json.ok);
|
||
assert.match(r.json.code, /^\d{6}$/);
|
||
const firstCode = r.json.code;
|
||
const ttl = Date.parse(r.json.expiresAt) - Date.now();
|
||
assert.ok(ttl > 9 * 60 * 1000 && ttl <= 10 * 60 * 1000, `expiresAt 应为 ~10 分钟(实际 ${ttl}ms)`);
|
||
r = await call(srv.base, 'POST', '/api/pair/start');
|
||
assert.equal(r.status, 401, 'pair/start 必须鉴权');
|
||
|
||
// 同档案重新生成:旧码立即作废(同档案同时仅 1 个有效码)
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||
const code2 = r.json.code;
|
||
let code = code2;
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const stranger = { token: r.json.token };
|
||
// 被顶替的旧码 → 404(计 1 次错误尝试,未到封锁阈值不影响后续)
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: firstCode } });
|
||
assert.equal(r.status, 404, '被重新生成顶替的旧码必须立即作废');
|
||
// 非 6 位数字 → 400(格式校验在先,不计入连错计数)
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: '000001-not-real' } });
|
||
assert.equal(r.status, 400, '非 6 位数字 → 400');
|
||
|
||
// 新设备:匿名档案 + dungeon 进度 + 更新的 rpg 进度
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const newDev = { profileId: r.json.profileId, token: r.json.token };
|
||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||
token: newDev.token,
|
||
body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } },
|
||
});
|
||
assert.equal(r.status, 200);
|
||
r = await call(srv.base, 'PUT', '/api/progress', {
|
||
token: newDev.token,
|
||
body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 } },
|
||
});
|
||
assert.equal(r.status, 200);
|
||
|
||
// join:新设备档案按桶 LWW 并入老档案,token 改绑(与 claim-merge 同语义)
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: newDev.token, body: { code } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.ok, true);
|
||
assert.equal(r.json.merged, true);
|
||
assert.equal(r.json.profileId, oldDev.profileId, '新设备 token 应改绑到码对应的老档案');
|
||
assert.equal(r.json.name, oldDev.name);
|
||
assert.equal(r.json.token, newDev.token, 'join 复用新设备 token(改绑,不发新)');
|
||
assert.ok(r.json.mergedGames >= 2, `至少 rpg+dungeon 两桶更新(实际 ${r.json.mergedGames})`);
|
||
|
||
// 合并结果:LWW 裁决(rpg 取新设备的第 12 级;dungeon 是新设备独有桶)
|
||
r = await call(srv.base, 'GET', '/api/progress', { token: newDev.token });
|
||
assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:新设备 rpg 更新,应胜出');
|
||
assert.equal(r.json.games.dungeon.data.floor, 3);
|
||
// 老设备 token 不受影响,且看到同一份合并后的档案
|
||
r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: oldDev.token });
|
||
assert.equal(r.json.data.floor, 3);
|
||
|
||
// 单次使用:同一码再用 → 失效(404)
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const third = { token: r.json.token };
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: third.token, body: { code } });
|
||
assert.equal(r.status, 404, 'join 成功后旧码必须立即失效');
|
||
|
||
// 无 token 新设备(全新浏览器)join:直接为老档案签发新 token
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||
code = r.json.code;
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { body: { code } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.merged, false);
|
||
assert.equal(r.json.profileId, oldDev.profileId);
|
||
assert.match(r.json.token, /^[0-9a-f]{48}$/, '无档案设备应拿到老档案的新 token');
|
||
r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: r.json.token });
|
||
assert.equal(r.json.data.lv, 12, '无档案 join 也能拉到合并后的进度');
|
||
|
||
// joiner 已设 PIN(已认领档案)→ 409,防误合并
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const claimed = { token: r.json.token };
|
||
r = await call(srv.base, 'POST', '/api/claim', { token: claimed.token, body: { pin: '1111' } });
|
||
assert.equal(r.status, 200);
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||
code = r.json.code;
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: claimed.token, body: { code } });
|
||
assert.equal(r.status, 409, '已设 PIN 的档案不允许作为 join 源被合并');
|
||
|
||
// joiner 已是该档案(同设备重复转入)→ 幂等成功,消费码
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||
code = r.json.code;
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: oldDev.token, body: { code } });
|
||
assert.equal(r.status, 200);
|
||
assert.equal(r.json.merged, false);
|
||
assert.equal(r.json.token, oldDev.token);
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code } });
|
||
assert.equal(r.status, 404, '幂等转入同样消费码(单次)');
|
||
|
||
// 日志不落配对码明文(与 PIN 同待遇):本会话所有生成过的码逐一比对
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token });
|
||
const logged = srv.logs.join('');
|
||
for (const c of [code, code2, r.json.code]) {
|
||
assert.ok(!logged.includes(c), `服务器日志不得包含配对码明文 ${c}`);
|
||
}
|
||
} finally {
|
||
await srv.stop();
|
||
}
|
||
});
|
||
|
||
test('I2. 配对码防护:join 5 连错封锁 10 分钟、start per-IP 频控、过期码失败计数', async () => {
|
||
const srv = await startServer({ env: { SG_PAIR_LIMIT: '3' } });
|
||
try {
|
||
let r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const dev = { token: r.json.token };
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const guesser = { token: r.json.token };
|
||
|
||
// 5 次错误码 → 全部 404,第 6 次起 429(封锁)
|
||
for (let i = 1; i <= 5; i++) {
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: guesser.token, body: { code: '00000' + i } });
|
||
assert.equal(r.status, 404, `第 ${i} 次错误码应 404`);
|
||
}
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const legit = { token: r.json.token };
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
|
||
const code = r.json.code;
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: legit.token, body: { code } });
|
||
assert.equal(r.status, 429, '连错 5 次后同 IP 一切 join 被封锁(含正确码)');
|
||
assert.ok(r.json.error.includes('10 分钟'), '封锁提示应引导等待与重新生成');
|
||
|
||
// start per-IP 频控:SG_PAIR_LIMIT=3 → 第 4 次 429
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
|
||
assert.equal(r.status, 200); // 2
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
|
||
assert.equal(r.status, 200); // 3
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token });
|
||
assert.equal(r.status, 429, 'pair/start 应受 per-IP 频控');
|
||
} finally {
|
||
await srv.stop();
|
||
}
|
||
});
|
||
|
||
// ---------------- J. token 180 天滑动过期(大厅「档案已过期」引导的服务器侧路径) ----------------
|
||
|
||
test('J1. 过期 token → 401;pair/join 对过期 token 视作无档案设备(引导可自愈)', async () => {
|
||
const dataDir = tmpDataDir();
|
||
// 手工落一份「token 已过期」的数据:1 个档案 + 1 条 179 天前过期的 token
|
||
const expiredToken = 'ab'.repeat(24);
|
||
fs.mkdirSync(path.join(dataDir, 'profiles'), { recursive: true });
|
||
fs.writeFileSync(path.join(dataDir, 'profiles.json'), JSON.stringify({
|
||
seq: 1,
|
||
profiles: { p_dead: { name: '玩家一', pinHash: null, createdAt: '2025-01-01T00:00:00Z' } },
|
||
}, null, 2));
|
||
fs.writeFileSync(path.join(dataDir, 'tokens.json'), JSON.stringify({
|
||
[expiredToken]: {
|
||
profileId: 'p_dead', createdAt: '2025-01-01T00:00:00Z',
|
||
lastSeen: '2025-01-01T00:00:00Z', expiresAt: '2025-06-01T00:00:00Z', // 早已过期
|
||
},
|
||
}, null, 2));
|
||
|
||
const srv = await startServer({ dataDir });
|
||
try {
|
||
// 全部鉴权端点对过期 token 一律 401(大厅据此弹「档案已过期,重新登录或新建」)
|
||
let r = await call(srv.base, 'GET', '/api/profile', { token: expiredToken });
|
||
assert.equal(r.status, 401);
|
||
assert.ok(r.json.error.includes('未授权'), '错误文案供大厅 isAuthExpired 判定');
|
||
r = await call(srv.base, 'GET', '/api/progress', { token: expiredToken });
|
||
assert.equal(r.status, 401);
|
||
r = await call(srv.base, 'PUT', '/api/progress', { token: expiredToken, body: { game: 'rpg', data: {} } });
|
||
assert.equal(r.status, 401);
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: expiredToken });
|
||
assert.equal(r.status, 401);
|
||
// 过期 token 已被惰性清扫出 tokens.json
|
||
const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8'));
|
||
assert.ok(!tokensDisk[expiredToken], '过期 token 应被惰性清扫');
|
||
|
||
// 恢复路径 A(大厅「新玩家」):匿名建档照常可用
|
||
r = await call(srv.base, 'POST', '/api/device', { body: {} });
|
||
const helper = { token: r.json.token, profileId: r.json.profileId };
|
||
assert.notEqual(helper.profileId, 'p_dead');
|
||
r = await call(srv.base, 'GET', '/api/profile', { token: helper.token });
|
||
assert.equal(r.status, 200);
|
||
|
||
// 恢复路径 B(配对转入):pair/join 带过期 token 不 401,视作无档案设备直发新 token
|
||
// (服务器端对 join 的宽容 = 大厅引导文案「从其他设备转入」可以无感自愈)
|
||
r = await call(srv.base, 'POST', '/api/pair/start', { token: helper.token });
|
||
const code = r.json.code;
|
||
r = await call(srv.base, 'POST', '/api/pair/join', { token: expiredToken, body: { code } });
|
||
assert.equal(r.status, 200, 'join 对过期 token 应按无档案处理而非 401');
|
||
assert.match(r.json.token, /^[0-9a-f]{48}$/);
|
||
assert.equal(r.json.profileId, helper.profileId);
|
||
} finally {
|
||
await srv.stop();
|
||
}
|
||
});
|