/** * 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) * * 运行: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/.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(); } });