/** * 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/.json → profiles.json/tokens.json/ * profiles/.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. 受管桶清单覆盖 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 名(游戏零改动)'); }); 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/.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(); } });