Files
SoundGame/web/server/test/api-smoke.mjs
T
xsl ede522dda1 feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)
用户系统(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)
2026-09-09 23:39:23 +08:00

426 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
});