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)
This commit is contained in:
xsl
2026-09-09 23:39:23 +08:00
parent 3704cd0792
commit ede522dda1
22 changed files with 2370 additions and 275 deletions
+425
View File
@@ -0,0 +1,425 @@
/**
* 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();
}
});