用户系统(B,游戏零改动): - POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩 - 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听) - claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁 - PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET) - web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4) 键盘双端(A,游戏代码零改动): - gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault - settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始) - 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示 - live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言 回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过 P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
426 lines
21 KiB
JavaScript
426 lines
21 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)
|
||
*
|
||
* 运行: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, decideBucket, getCurrentProfile, migrateLegacyAuth,
|
||
nextLocalProfileName, readRegistry, 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. 受管桶清单覆盖 8 个可同步游戏 + _settings', () => {
|
||
const games = BUCKETS.map((b) => b.game).sort();
|
||
assert.deepEqual(games, ['_settings', 'dungeon', 'escape', 'radio', 'rhythm', 'rpg', 'simon', 'space']);
|
||
for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)');
|
||
});
|
||
|
||
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();
|
||
}
|
||
});
|