实现账号绑定、持久登录与跨设备对局恢复
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
This commit is contained in:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -141,3 +141,24 @@ test('live token ownership survives a duplicated tab, reload and reconnect', { t
|
||||
assert.equal(welcome.playerId, a.welcome.playerId);
|
||||
await assertOnline(h, [a2, b]);
|
||||
});
|
||||
|
||||
|
||||
test('explicit account tokens are tentative until welcome and do not overwrite the last established local identity', async () => {
|
||||
const old = '1'.repeat(32), selected = '2'.repeat(32), attached = '3'.repeat(32);
|
||||
const local = storage(), session = storage({ 'sp.token': old });
|
||||
const id = createIdentity({ local, session, channel: null });
|
||||
await id.init();
|
||||
id.saveToken(old);
|
||||
assert.equal(id.stageToken(selected), true);
|
||||
assert.equal(id.getToken(), selected);
|
||||
assert.deepEqual(id.getTokenClaim(), { noReplace: false });
|
||||
assert.equal(session.getItem('sp.token'), old);
|
||||
assert.deepEqual(JSON.parse(local.getItem('sp.tokens')), [old]);
|
||||
assert.equal(id.stageToken(''), false);
|
||||
assert.equal(id.getToken(), selected);
|
||||
id.saveToken(attached);
|
||||
assert.equal(session.getItem('sp.token'), attached);
|
||||
assert.equal(id.getToken(), attached);
|
||||
assert.ok(Number.isFinite(id.getTokenClaim().claimAt));
|
||||
assert.equal(JSON.parse(local.getItem('sp.tokens')).includes(selected), false, 'only the rotated welcome token is remembered');
|
||||
});
|
||||
+14
-1
@@ -1,6 +1,7 @@
|
||||
// .gitignore keeps per-machine state out of the repository (and so out of the release packages, which ship tracked
|
||||
// files only: tools/package.mjs). GitHub #258: a local npm cache (`npm_config_cache=./.npm-cache`, `npm ci --cache
|
||||
// .npm-cache`) must never be committed by any contributor.
|
||||
// .npm-cache`) must never be committed by any contributor. The opt-in Skland credential database (docs/skland.md) is
|
||||
// the other per-machine secret: `data/skland.db` and the Docker volume path `skland-data/`.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -29,3 +30,15 @@ test('GitHub #258: the local npm cache (.npm-cache/) is ignored, like node_modul
|
||||
}
|
||||
assert.notEqual(ignored('package.json'), true, 'a tracked file is not');
|
||||
});
|
||||
|
||||
test('the Skland credential database (docs/skland.md): the file, its WAL side files and the Docker volume path', () => {
|
||||
for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) {
|
||||
assert.ok(lines.includes(entry), `.gitignore lists ${entry}`);
|
||||
}
|
||||
// the store's default path, the two side files node:sqlite/WAL writes next to it, and the bind-mount form a
|
||||
// deployment may use instead of docker-compose.yml's named volume (docker.test.js pins the volume itself)
|
||||
for (const rel of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/skland.db', 'skland-data/skland.db-wal']) {
|
||||
const v = ignored(rel);
|
||||
if (v !== null) assert.equal(v, true, `${rel} is ignored`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBindingApi, ensureBindingSession, preferredRole, roleKey } from '../../public/js/skland/api.js';
|
||||
|
||||
test('the title client puts its game token only in an authorization header and never sends a player id', async () => {
|
||||
const calls = [];
|
||||
const request = createBindingApi({ token: 'private-game-token', fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ok: true }) };
|
||||
} });
|
||||
await request('status'); await request('password/login', { phone: '13800000000', password: 'local-input' });
|
||||
assert.equal(calls[0].url, '/skland/session/status');
|
||||
assert.equal(calls[0].init.headers.authorization, 'Bearer private-game-token');
|
||||
assert.equal(calls[0].init.method, 'GET');
|
||||
assert.equal(calls[1].init.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(calls[1].init.body), { phone: '13800000000', password: 'local-input' });
|
||||
assert.ok(calls.every(({ url, init }) => !url.includes('private-game-token') && !init.body?.includes('private-game-token')));
|
||||
});
|
||||
|
||||
test('the title client distinguishes disabled module, expired session, network and malformed responses', async () => {
|
||||
for (const [status, code] of [[404, 'off'], [401, 'session']]) {
|
||||
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status }) })('status'), { code });
|
||||
}
|
||||
await assert.rejects(createBindingApi()('status'), { code: 'session' });
|
||||
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => { throw new Error('offline'); } })('status'), { code: 'network' });
|
||||
await assert.rejects(createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 200, ok: true, json: async () => { throw new Error(); } }) })('status'), { code: 'response' });
|
||||
});
|
||||
|
||||
test('binding starts hello without entering the game, reuses an online identity, and cleans up on failure', async () => {
|
||||
const listeners = new Map();
|
||||
let current = null, connects = 0, sentName;
|
||||
const identity = { init: async () => {}, getToken: () => current };
|
||||
const net = {
|
||||
status: 'connected',
|
||||
on: (event, fn) => { listeners.set(event, fn); return () => listeners.delete(event); },
|
||||
setName: (name) => { sentName = name; },
|
||||
connect: () => { connects++; setTimeout(() => { current = 'T'; listeners.get('welcome')?.({ token: 'T' }); }, 0); },
|
||||
};
|
||||
assert.equal(await ensureBindingSession({ net, identity, name: '博士代号' }), 'T');
|
||||
assert.equal(sentName, '博士代号'); assert.equal(listeners.size, 0);
|
||||
net.status = 'online';
|
||||
assert.equal(await ensureBindingSession({ net, identity, name: '另一个名字' }), 'T');
|
||||
assert.equal(connects, 1);
|
||||
net.status = 'connected'; net.connect = () => {};
|
||||
await assert.rejects(ensureBindingSession({ net, identity, name: '博士', timeoutMs: 1 }), { code: 'network' });
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test('nickname selection uses the default role, preserves a selected channel, and supports no roles', () => {
|
||||
const a = { uid: '123', channelName: '官服', gameId: 1, nickName: '官服博士' };
|
||||
const b = { uid: '123', channelName: 'B服', gameId: 2, nickName: 'B服博士', isDefault: true };
|
||||
assert.equal(preferredRole([a, b]), b);
|
||||
assert.equal(preferredRole([a, b], roleKey(a)), a);
|
||||
assert.equal(preferredRole([a, b], 'removed-role'), b);
|
||||
assert.equal(preferredRole([a]), a);
|
||||
assert.equal(preferredRole([]), null);
|
||||
assert.equal(preferredRole([{ uid: 'missing-name', isDefault: true }, { ...b, nickName: ' ' }, a]), a);
|
||||
assert.equal(preferredRole([{ uid: 'missing-name' }]), null);
|
||||
assert.equal(preferredRole([{ uid: null, channelName: '官服', nickName: '缺少身份' }]), null);
|
||||
assert.equal(roleKey(a), roleKey({ ...a, uid: 123, gameId: null, nickName: '改名以后' }), 'nickname and optional gameId never change identity');
|
||||
});
|
||||
|
||||
|
||||
test('an expired recovery is distinguishable from a transport error and never becomes a successful entry', async () => {
|
||||
const request = createBindingApi({ token: 'T', fetchImpl: async () => ({ status: 409, ok: false,
|
||||
json: async () => ({ ok: false, code: 'resume_expired', error: 'expired' }),
|
||||
}) });
|
||||
await assert.rejects(request('resume', { id: 'stale' }), { code: 'resume_expired' });
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Provider contract tests use a fake widget and fake proof only; no real captcha or network request is performed.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import { solveCaptcha } from '../../public/js/skland/captcha.js';
|
||||
|
||||
function fakeProvider(proof) {
|
||||
const events = {};
|
||||
const calls = { shown: 0, destroyed: 0, options: null };
|
||||
const widget = {
|
||||
onReady(fn) { events.ready = fn; }, onSuccess(fn) { events.success = fn; },
|
||||
onClose(fn) { events.close = fn; }, onError(fn) { events.error = fn; },
|
||||
getValidate() { return proof; },
|
||||
showCaptcha() { calls.shown++; }, verify() { calls.shown++; },
|
||||
destroy() { calls.destroyed++; events.close?.(); },
|
||||
};
|
||||
const init = (options, ready) => { calls.options = options; ready(widget); };
|
||||
return { events, calls, scope: { initGeetest4: init, initGeetest: init }, load: async () => {} };
|
||||
}
|
||||
|
||||
test('captcha v4 opens the provider widget and preserves its proof plus the upstream challenge', async () => {
|
||||
const proof = { captcha_id: 'TEST', lot_number: 'TEST-LOT', captcha_output: 'TEST-PROOF', pass_token: 'TEST-PASS', gen_time: '1' };
|
||||
const provider = fakeProvider(proof);
|
||||
const pending = solveCaptcha({ geetestId: 'TEST', challenge: 'TEST-CHALLENGE', riskType: 'slide' }, provider);
|
||||
await setImmediate();
|
||||
assert.equal(provider.calls.shown, 0, 'initialization does not submit a proof');
|
||||
provider.events.ready();
|
||||
assert.equal(provider.calls.shown, 1);
|
||||
assert.deepEqual(provider.calls.options, { captchaId: 'TEST', riskType: 'slide', product: 'bind', language: 'zho' });
|
||||
provider.events.success();
|
||||
assert.deepEqual(await pending, { ...proof, challenge: 'TEST-CHALLENGE' });
|
||||
assert.equal(provider.calls.destroyed, 1);
|
||||
});
|
||||
|
||||
test('captcha v3 preserves the three provider fields for the SMS retry', async () => {
|
||||
const proof = { geetest_challenge: 'TEST-CH', geetest_validate: 'TEST-VALIDATE', geetest_seccode: 'TEST-SEC' };
|
||||
const provider = fakeProvider(proof);
|
||||
const pending = solveCaptcha({ gt: 'TEST', challenge: 'TEST-CH', new_captcha: true }, provider);
|
||||
await setImmediate();
|
||||
provider.events.ready();
|
||||
provider.events.success();
|
||||
assert.deepEqual(await pending, proof);
|
||||
assert.equal(provider.calls.options.https, true);
|
||||
});
|
||||
|
||||
test('captcha cancellation never yields a proof, including after a provider retry event', async () => {
|
||||
const provider = fakeProvider({ captcha_output: 'UNUSED-TEST-PROOF' });
|
||||
const pending = solveCaptcha({ geetestId: 'TEST' }, provider);
|
||||
await setImmediate();
|
||||
provider.events.error({ error_code: 'error_01' });
|
||||
provider.events.close();
|
||||
provider.events.success();
|
||||
assert.equal(await pending, null);
|
||||
assert.equal(provider.calls.destroyed, 1);
|
||||
});
|
||||
|
||||
test('unsupported or failed captchas are rejected so the UI can offer retry', async () => {
|
||||
await assert.rejects(() => solveCaptcha({}), /captcha_type/);
|
||||
await assert.rejects(() => solveCaptcha({ geetestId: 'TEST' }, { load: async () => { throw new Error('captcha_load'); } }), /captcha_load/);
|
||||
const provider = fakeProvider(null);
|
||||
const pending = solveCaptcha({ geetestId: 'TEST' }, provider);
|
||||
const rejected = assert.rejects(pending, /captcha_validate/);
|
||||
await setImmediate();
|
||||
provider.events.success();
|
||||
await rejected;
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
// The Skland login chains (server/skland/client.js): 扫码 / 验证码 / 密码, the 人机验证 hand-off, and the two
|
||||
// credential-refresh paths.
|
||||
//
|
||||
// Every case runs against a stub upstream whose answers are the shapes the community documentation records (the
|
||||
// 3mua.cn write-up and TNXG/skland-api), so the chain's *order* and the *fields on the wire* are pinned without
|
||||
// touching the network. The stub records each call, and the assertions read that record — a chain that skipped a step
|
||||
// or sent the wrong body fails here rather than in production.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from '../../server/skland/client.js';
|
||||
|
||||
/**
|
||||
* A stub upstream. `overrides` replaces individual answers by endpoint key.
|
||||
* Keys: genScan, scanStatus, scanToken, sendCode, phoneToken, passwordToken, grant, cred, binding, refresh.
|
||||
*/
|
||||
function stubUpstream(overrides = {}) {
|
||||
const calls = [];
|
||||
const j = (o) => ({ status: 200, json: async () => o });
|
||||
const answers = {
|
||||
genScan: () => j({ status: 0, msg: 'OK', data: { scanId: 'SCAN1', scanUrl: 'hypergryph://scan_login?scanId=SCAN1' } }),
|
||||
scanStatus: () => j({ status: 0, msg: 'OK', data: { scanCode: 'SCANCODE1' } }),
|
||||
scanToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-SCAN', hgId: 'HG1', deviceToken: 'DEV1' } }),
|
||||
sendCode: () => j({ status: 0, msg: 'OK' }),
|
||||
phoneToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-CODE', hgId: 'HG1', deviceToken: 'DEV1' } }),
|
||||
passwordToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-PW' } }),
|
||||
grant: ({ body }) => j({ status: 0, msg: 'OK', data: body.appCode === 'be36d44aa36bfb5b'
|
||||
? { token: 'BINDINGTOKEN' } : { uid: 'U1', code: 'GRANTCODE' } }),
|
||||
cred: () => j({ code: 0, data: { cred: 'CRED', token: 'CREDTOKEN' } }),
|
||||
binding: () => j({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] } }),
|
||||
accountBinding: () => j({ status: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', isDefault: true }] }] } }),
|
||||
refresh: () => j({ code: 0, data: { token: 'CREDTOKEN2' } }),
|
||||
};
|
||||
const routes = [
|
||||
['gen_scan/login', 'genScan'], ['scan_status', 'scanStatus'], ['token_by_scan_code', 'scanToken'],
|
||||
['send_phone_code', 'sendCode'], ['token_by_phone_code', 'phoneToken'],
|
||||
['token_by_phone_password', 'passwordToken'], ['oauth2/v2/grant', 'grant'],
|
||||
['generate_cred_by_code', 'cred'], ['auth/refresh', 'refresh'], ['player/binding', 'binding'],
|
||||
['account/binding/v1/binding_list', 'accountBinding'],
|
||||
];
|
||||
const fetchImpl = async (url, init = {}) => {
|
||||
const key = routes.find(([frag]) => url.includes(frag))?.[1];
|
||||
const answer = overrides[key] ?? answers[key];
|
||||
calls.push({
|
||||
key, url, method: init.method || 'GET',
|
||||
body: init.body ? JSON.parse(init.body) : null,
|
||||
headers: init.headers || {},
|
||||
});
|
||||
if (!answer) throw new Error(`stub: no route for ${url}`);
|
||||
return answer(calls.at(-1));
|
||||
};
|
||||
return { fetchImpl, calls, byKey: (key) => calls.filter((c) => c.key === key) };
|
||||
}
|
||||
|
||||
test('扫码登录: gen_scan → scan_status → token_by_scan_code → grant → cred', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
|
||||
const r = await client.loginByScan();
|
||||
|
||||
assert.equal(r.scanId, 'SCAN1');
|
||||
assert.equal(r.scanUrl, 'hypergryph://scan_login?scanId=SCAN1', 'the QR encodes scanUrl, not the scanId');
|
||||
assert.equal(r.token, 'T-SCAN');
|
||||
assert.equal(r.deviceToken, 'DEV1');
|
||||
assert.equal(r.cred, 'CRED');
|
||||
assert.equal(r.credToken, 'CREDTOKEN');
|
||||
|
||||
assert.deepEqual(up.calls.map((c) => c.key), [
|
||||
'genScan', 'scanStatus', 'scanToken', 'grant', 'cred',
|
||||
]);
|
||||
assert.deepEqual(up.byKey('genScan')[0].body, { appCode: '4ca99fa6b56cc2ba' });
|
||||
assert.deepEqual(up.byKey('scanToken')[0].body, { appCode: '4ca99fa6b56cc2ba', from: 0, scanCode: 'SCANCODE1' });
|
||||
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1', 'the grant carries the scan deviceToken');
|
||||
});
|
||||
|
||||
test('扫码登录: the QR session carries the device headers the upstream requires', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
|
||||
await client.loginByScan();
|
||||
const headers = up.byKey('scanToken')[0].headers;
|
||||
assert.match(headers['X-DeviceId'], /^[0-9a-f]{10}$/);
|
||||
assert.equal(headers['X-DeviceType'], '2');
|
||||
assert.ok(headers['X-DeviceModel']);
|
||||
});
|
||||
|
||||
test('扫码登录: 未扫码 (100) and 已扫码待确认 (101) keep polling until confirmed', async () => {
|
||||
const pending = [
|
||||
{ status: 100, msg: '未扫码' },
|
||||
{ status: 101, msg: '已扫码待确认' },
|
||||
{ status: 101, msg: '已扫码待确认' },
|
||||
];
|
||||
const up = stubUpstream({
|
||||
scanStatus: () => ({ status: 200, json: async () => pending.shift()
|
||||
?? { status: 0, msg: 'OK', data: { scanCode: 'SCANCODE1' } } }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1 });
|
||||
const onWait = [];
|
||||
const r = await client.loginByScan({ onWait: (s) => onWait.push(s) });
|
||||
assert.equal(r.token, 'T-SCAN');
|
||||
assert.equal(up.byKey('scanStatus').length, 4, 'polled through phone confirmation');
|
||||
assert.deepEqual(onWait, ['waiting', 'waiting', 'waiting']);
|
||||
assert.equal(up.byKey('scanToken').length, 1, 'the confirmed code is exchanged once');
|
||||
});
|
||||
|
||||
test('扫码登录: terminal and unknown statuses still fail instead of polling indefinitely', async () => {
|
||||
for (const status of [102, 999]) {
|
||||
const up = stubUpstream({
|
||||
scanStatus: () => ({ status: 200, json: async () => ({ status, msg: '扫码已失效' }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.loginByScan(), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.equal(e.code, status);
|
||||
assert.equal(e.step, 'checkScan');
|
||||
return true;
|
||||
});
|
||||
assert.equal(up.byKey('scanStatus').length, 1);
|
||||
assert.equal(up.byKey('scanToken').length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('扫码登录: a timeout stops the poll with a readable error', async () => {
|
||||
const up = stubUpstream({ scanStatus: () => ({ status: 200, json: async () => ({ status: 100, msg: '未扫码' }) }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl, pollIntervalMs: 1, pollTimeoutMs: 0 });
|
||||
await assert.rejects(() => client.loginByScan(), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.equal(e.step, 'pollScan');
|
||||
assert.match(e.message, /超时/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码登录: send_phone_code(type 2, LOGIN_OR_REGISTER) → token_by_phone_code → grant → cred', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
assert.deepEqual(await client.sendPhoneCode('13800000000'), { ok: true });
|
||||
const r = await client.loginByPhoneCode('13800000000', '123456');
|
||||
|
||||
assert.equal(r.token, 'T-CODE');
|
||||
assert.equal(r.hgId, 'HG1');
|
||||
assert.equal(r.deviceToken, 'DEV1');
|
||||
assert.equal(r.cred, 'CRED');
|
||||
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2 });
|
||||
assert.deepEqual(up.byKey('phoneToken')[0].body, { appCode: '4ca99fa6b56cc2ba', code: '123456', phone: '13800000000' });
|
||||
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1');
|
||||
assert.equal(up.byKey('phoneToken')[0].headers['X-DeviceType'], '2');
|
||||
});
|
||||
|
||||
test('验证码登录: 需要人机验证 surfaces the geetest challenge', async () => {
|
||||
const captcha = { gt: 'GT', challenge: 'CH', new_captcha: true };
|
||||
const up = stubUpstream({
|
||||
sendCode: () => ({ status: 200, json: async () => ({ status: 1, msg: '需要人机验证', type: 'A', data: { captcha } }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.sendPhoneCode('13800000000'), (e) => {
|
||||
assert.ok(e instanceof SklandCaptchaError);
|
||||
assert.equal(e.captcha.gt, 'GT');
|
||||
assert.equal(e.captcha.challenge, 'CH');
|
||||
assert.equal(e.captcha.new_captcha, true);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码登录: a solved captcha is echoed into the resend', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const solved = { geetest_challenge: 'a', geetest_validate: 'b', geetest_seccode: 'c|d' };
|
||||
await client.sendPhoneCode('13800000000', solved);
|
||||
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2, captcha: solved });
|
||||
});
|
||||
|
||||
test('验证码登录: 发送频率太高 (status 102) is a readable error', async () => {
|
||||
const up = stubUpstream({
|
||||
sendCode: () => ({ status: 200, json: async () => ({ status: 102, msg: '发送频率太高,请稍后再试', type: 'C1' }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.sendPhoneCode('13800000000'), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.equal(e.code, 102);
|
||||
assert.match(e.message, /频率太高/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('密码登录: password → account-service grant → official Arknights bindings without Skland', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.loginByPhonePassword('13800000000', 'pw');
|
||||
assert.equal(r.token, 'T-PW');
|
||||
assert.equal(r.binding.list[0].bindingList[0].nickName, '博士');
|
||||
assert.deepEqual(up.byKey('passwordToken')[0].body, { phone: '13800000000', password: 'pw' });
|
||||
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
|
||||
const url = new URL(up.byKey('accountBinding')[0].url);
|
||||
assert.equal(url.origin, 'https://binding-api-account-prod.hypergryph.com');
|
||||
assert.equal(url.pathname, '/account/binding/v1/binding_list');
|
||||
assert.equal(url.searchParams.get('token'), 'BINDINGTOKEN');
|
||||
assert.equal(url.searchParams.get('appCode'), 'arknights');
|
||||
assert.equal(up.byKey('cred').length, 0);
|
||||
assert.equal(up.byKey('binding').length, 0);
|
||||
});
|
||||
|
||||
test('密码登录: without a deviceToken the chain still attempts grant and succeeds', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.loginByPhonePassword('13800000000', 'pw');
|
||||
assert.equal(r.binding.list[0].bindingList.length, 1);
|
||||
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
|
||||
});
|
||||
|
||||
test('密码登录: only an upstream status 109 requires device verification', async () => {
|
||||
const up = stubUpstream({
|
||||
grant: () => ({ status: 200, json: async () => ({ status: 109, msg: '需要设备验证' }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.loginByPhonePassword('13800000000', 'pw'), (e) => {
|
||||
assert.ok(e instanceof SklandNeedDeviceError);
|
||||
assert.equal(e.code, 109);
|
||||
return true;
|
||||
});
|
||||
assert.equal(up.byKey('grant').length, 1, 'the upstream decides whether verification is needed');
|
||||
assert.equal(up.byKey('cred').length, 0, 'a rejected grant never advances to credentials');
|
||||
assert.equal(up.byKey('accountBinding').length, 0);
|
||||
});
|
||||
|
||||
test('account bindings: a grant missing its service token never queries with an undefined token', async () => {
|
||||
const up = stubUpstream({ grant: () => ({ status: 200, json: async () => ({ status: 0, data: {} }) }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.getAccountBinding('T'), (e) => e.code === 'no_token');
|
||||
assert.equal(up.byKey('accountBinding').length, 0);
|
||||
});
|
||||
|
||||
test('account bindings: upstream errors and malformed lists do not become empty successful bindings', async () => {
|
||||
for (const answer of [{ status: 3, msg: '登录已过期' }, { status: 0, data: {} }]) {
|
||||
const up = stubUpstream({ accountBinding: () => ({ status: 200, json: async () => answer }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.getAccountBinding('T'), SklandError);
|
||||
}
|
||||
});
|
||||
|
||||
test('grant: the deviceToken is sent only when the login path produced one', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await client.getGrantCode('T', null);
|
||||
assert.ok(!('deviceToken' in up.byKey('grant')[0].body));
|
||||
await client.getGrantCode('T', 'DEV');
|
||||
assert.equal(up.byKey('grant')[1].body.deviceToken, 'DEV');
|
||||
});
|
||||
|
||||
test('getBinding: the signed request carries cred, dId, timestamp, vName and a signature', async () => {
|
||||
const up = stubUpstream();
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.getBinding({ cred: 'CRED', credToken: 'CREDTOKEN', dId: 'DEV' });
|
||||
assert.equal(r.binding.list[0].appCode, 'arknights');
|
||||
const call = up.byKey('binding')[0];
|
||||
assert.equal(call.method, 'GET');
|
||||
assert.equal(call.headers.cred, 'CRED');
|
||||
assert.equal(call.headers.dId, 'DEV');
|
||||
assert.equal(call.headers.platform, '3');
|
||||
assert.equal(call.headers.vName, '1.0.0');
|
||||
assert.match(call.headers.sign, /^[0-9a-f]{32}$/, 'sign = md5(hmacSha256(...))');
|
||||
assert.ok(Number(call.headers.timestamp) > 0);
|
||||
});
|
||||
|
||||
test('getBinding: code 10000 refreshes the cred_token once and retries', async () => {
|
||||
let first = true;
|
||||
const up = stubUpstream({
|
||||
binding: () => {
|
||||
if (first) { first = false; return { status: 200, json: async () => ({ code: 10000, message: 'cred_token 过期' }) }; }
|
||||
return { status: 200, json: async () => ({ code: 0, data: { list: [] } }) };
|
||||
},
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.getBinding({ cred: 'CRED', credToken: 'OLD', dId: 'DEV' });
|
||||
assert.equal(r.credToken, 'CREDTOKEN2', 'the refreshed cred_token is returned to the caller');
|
||||
assert.equal(up.byKey('refresh').length, 1);
|
||||
assert.equal(up.byKey('binding').length, 2, 'retried once');
|
||||
});
|
||||
|
||||
test('getBinding: code 10002 rebuilds the cred from the stored access_token', async () => {
|
||||
let first = true;
|
||||
const up = stubUpstream({
|
||||
binding: () => {
|
||||
if (first) { first = false; return { status: 200, json: async () => ({ code: 10002, message: 'cred 过期' }) }; }
|
||||
return { status: 200, json: async () => ({ code: 0, data: { list: [] } }) };
|
||||
},
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
const r = await client.getBinding({ cred: 'OLD', credToken: 'OLD', dId: 'DEV', accessToken: 'AT', deviceToken: 'DEV1' });
|
||||
assert.equal(r.cred, 'CRED', 'the rebuilt cred is returned to the caller');
|
||||
assert.equal(r.credToken, 'CREDTOKEN');
|
||||
assert.equal(up.byKey('grant').length, 1);
|
||||
assert.equal(up.byKey('cred').length, 1);
|
||||
});
|
||||
|
||||
test('getBinding: 用户未登录 reads as an expired session', async () => {
|
||||
const up = stubUpstream({
|
||||
binding: () => ({ status: 200, json: async () => ({ code: 10002, message: '用户未登录' }) }),
|
||||
});
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.getBinding({ cred: 'C', credToken: 'CT', dId: 'D' }), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.match(e.message, /过期|未登录/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('an upstream failure carries the step that failed', async () => {
|
||||
const up = stubUpstream({ genScan: () => ({ status: 200, json: async () => ({ status: 1, msg: '系统繁忙' }) }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.genScan(), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.equal(e.message, '系统繁忙');
|
||||
assert.equal(e.step, '获取扫码会话');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('a non-JSON answer is reported as such instead of throwing a parse error', async () => {
|
||||
const up = stubUpstream({ genScan: () => ({ status: 502, json: async () => { throw new Error('bad json'); } }) });
|
||||
const client = createSklandClient({ fetchImpl: up.fetchImpl });
|
||||
await assert.rejects(() => client.genScan(), (e) => {
|
||||
assert.ok(e instanceof SklandError);
|
||||
assert.match(e.message, /响应不是 JSON/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// The Skland request signature (server/skland/crypto.js): the composition of the signed header set.
|
||||
//
|
||||
// The upstream's algorithm has two independent references in the community (the 3mua.cn write-up on the account login
|
||||
// surface and TNXG/skland-api's crypto module). This file pins the *composition* — which bytes go into the HMAC, and
|
||||
// in what order — because that is the part a refactor can silently break. The digest itself is checked against
|
||||
// node:crypto's own HMAC-SHA256 and MD5, so a change of either primitive shows up here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHmac, createHash } from 'node:crypto';
|
||||
import { signedHeaders, hmacSha256Hex, md5, compactJson, makeDeviceId, deviceHeaders } from '../../server/skland/crypto.js';
|
||||
|
||||
const ref = (key, msg) => createHash('md5').update(createHmac('sha256', key).update(msg, 'utf8').digest('hex'), 'utf8').digest('hex');
|
||||
|
||||
test('compactJson: no whitespace, insertion order kept (the upstream signs the exact body bytes)', () => {
|
||||
assert.equal(compactJson({ b: 1, a: 2 }), '{"b":1,"a":2}');
|
||||
assert.equal(compactJson({ gameId: 1, uid: '123' }), '{"gameId":1,"uid":"123"}');
|
||||
assert.equal(compactJson({}), '{}');
|
||||
});
|
||||
|
||||
test('hmacSha256Hex / md5 are the plain primitives', () => {
|
||||
assert.equal(hmacSha256Hex('key', 'msg'), createHmac('sha256', 'key').update('msg').digest('hex'));
|
||||
assert.equal(md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
|
||||
});
|
||||
|
||||
test('a GET sign input is path + query + timestamp + compact(header_ca)', () => {
|
||||
const { signInput, headers } = signedHeaders({
|
||||
cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'GET', path: '/api/v1/game/player/binding',
|
||||
query: 'a=1', timestampSeconds: 1700000000,
|
||||
});
|
||||
const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}';
|
||||
assert.equal(signInput, `/api/v1/game/player/bindinga=11700000000${ca}`);
|
||||
assert.equal(headers.sign, ref('CTOK', signInput));
|
||||
assert.equal(headers.cred, 'CRED');
|
||||
assert.equal(headers.platform, '3');
|
||||
assert.equal(headers.vName, '1.0.0');
|
||||
assert.equal(headers.dId, 'DEV');
|
||||
});
|
||||
|
||||
test('a GET without a query signs the bare path', () => {
|
||||
const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 });
|
||||
assert.equal(signInput, `/p1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`);
|
||||
});
|
||||
|
||||
test('a POST sign input is path + compact(body) + timestamp + compact(header_ca)', () => {
|
||||
const body = { gameId: 1, uid: '123' };
|
||||
const { signInput, headers } = signedHeaders({
|
||||
cred: 'CRED', credToken: 'CTOK', dId: 'DEV', method: 'POST', path: '/api/v1/game/attendance',
|
||||
body, timestampSeconds: 1700000000,
|
||||
});
|
||||
const ca = '{"platform":"3","timestamp":"1700000000","dId":"DEV","vName":"1.0.0"}';
|
||||
assert.equal(signInput, `/api/v1/game/attendance{"gameId":1,"uid":"123"}1700000000${ca}`);
|
||||
assert.equal(headers.sign, ref('CTOK', signInput));
|
||||
});
|
||||
|
||||
test('a POST without a body signs "{}"', () => {
|
||||
const { signInput } = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', timestampSeconds: 1 });
|
||||
assert.equal(signInput, `/p{}1{"platform":"3","timestamp":"1","dId":"d","vName":"1.0.0"}`);
|
||||
});
|
||||
|
||||
test('the signature is stable for equal inputs and moves with any single input', () => {
|
||||
const base = { cred: 'c', credToken: 'k', dId: 'd', method: 'POST', path: '/p', body: { x: 1 }, timestampSeconds: 5 };
|
||||
const a = signedHeaders(base).headers.sign;
|
||||
assert.equal(signedHeaders({ ...base }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, credToken: 'k2' }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, dId: 'd2' }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, timestampSeconds: 6 }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, body: { x: 2 } }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, path: '/q' }).headers.sign, a);
|
||||
assert.notEqual(signedHeaders({ ...base, method: 'GET', query: '' }).headers.sign, a);
|
||||
});
|
||||
|
||||
test('the mobile client identity headers the upstream expects', () => {
|
||||
const h = signedHeaders({ cred: 'c', credToken: 'k', dId: 'd', method: 'GET', path: '/p', timestampSeconds: 1 }).headers;
|
||||
assert.match(h['User-Agent'], /^Skland\//);
|
||||
assert.equal(h['X-Requested-With'], 'com.hypergryph.skland');
|
||||
assert.equal(h['Accept-Encoding'], 'gzip');
|
||||
});
|
||||
|
||||
test('makeDeviceId: stable shape, a fresh value each call', () => {
|
||||
const a = makeDeviceId();
|
||||
const b = makeDeviceId();
|
||||
assert.match(a, /^B[0-9a-f]{32}$/);
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test('deviceHeaders: the login device headers, cached for the process', () => {
|
||||
const h = deviceHeaders();
|
||||
assert.match(h['X-DeviceId'], /^[0-9a-f]{10}$/);
|
||||
assert.equal(h['X-DeviceType'], '2');
|
||||
assert.ok(h['X-DeviceModel'].length > 0);
|
||||
assert.equal(deviceHeaders(), h, 'one device per process (the upstream sees a stable device)');
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// The Docker packaging of the opt-in Skland account binding: docker-compose.yml, the Dockerfile's runtime stage and
|
||||
// .gitignore form one contract — the credentials live in one SQLite file on a named volume that the unprivileged
|
||||
// `node` user can write, and that file is never committed and never reachable over HTTP.
|
||||
//
|
||||
// No Docker daemon is needed (and CI has none): the files are read as text and the facts that make `docker compose up
|
||||
// -d` work are asserted. docker-compose.yml is parsed with a few lines of indentation-based reading rather than a YAML
|
||||
// dependency, so the assertions rest on the structure (which service, which key) instead of one loose regex.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
const linesOf = (text) => text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
|
||||
|
||||
/** The indentation of a line (a YAML block is defined by it). */
|
||||
const indentOf = (l) => l.length - l.replace(/^[ \t]*/, '').length;
|
||||
|
||||
/**
|
||||
* The lines of the block under `key:` at exactly `indent` — comments and blank lines dropped (they are prose, not
|
||||
* structure), a dedent ends it. Returns [] when the key is absent. Enough YAML to read this one file honestly.
|
||||
* @param {string[]} lines
|
||||
* @param {string} key
|
||||
* @param {number} indent
|
||||
*/
|
||||
function block(lines, key, indent) {
|
||||
const head = new RegExp(`^ {${indent}}${key}:`);
|
||||
const out = [];
|
||||
let inside = false;
|
||||
for (const l of lines) {
|
||||
if (!l.trim() || l.trim().startsWith('#')) continue;
|
||||
if (!inside) { if (indentOf(l) === indent && head.test(l)) inside = true; continue; }
|
||||
if (indentOf(l) <= indent) break;
|
||||
out.push(l);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The scalar of `key: value` inside a block, with surrounding quotes removed; null when unset. */
|
||||
function scalar(blockLines, key) {
|
||||
for (const l of blockLines) {
|
||||
const m = l.match(new RegExp(`^[ \\t]*${key}:[ \\t]*(.*)$`));
|
||||
if (m) return m[1].trim().replace(/^(["'])(.*)\1$/, '$2');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The `- item` entries of the list under `key:` inside a block (quotes removed). */
|
||||
function items(blockLines, key, indent) {
|
||||
return block(blockLines, key, indent)
|
||||
.map((l) => l.match(/^[ \t]*-[ \t]*(.*)$/)?.[1] ?? '')
|
||||
.filter(Boolean)
|
||||
.map((v) => v.trim().replace(/^(["'])(.*)\1$/, '$2'));
|
||||
}
|
||||
|
||||
const compose = linesOf(read('docker-compose.yml'));
|
||||
const services = block(compose, 'services', 0);
|
||||
const service = block(services, 'stronghold', 2);
|
||||
|
||||
test('docker-compose.yml: one stronghold service, built locally, tagged and restarted', () => {
|
||||
assert.deepEqual(services.filter((l) => indentOf(l) === 2 && /:$/.test(l.trim())).map((l) => l.trim()),
|
||||
['stronghold:'], 'exactly one service');
|
||||
const build = block(service, 'build', 4);
|
||||
assert.equal(scalar(build, 'context'), '.', 'builds the repository Dockerfile');
|
||||
assert.equal(scalar(service, 'image'), 'stronghold-protocol', 'tags the image');
|
||||
assert.equal(scalar(service, 'restart'), 'unless-stopped');
|
||||
// the art is opt-in: the compose file must not force a 550 MB download unless the operator uncomments it
|
||||
assert.equal(scalar(build, 'args'), null, 'FETCH_ASSETS stays the caller\'s choice');
|
||||
});
|
||||
|
||||
test('docker-compose.yml: publishes 3000:3000', () => {
|
||||
const ports = items(service, 'ports', 4);
|
||||
assert.ok(ports.includes('3000:3000'), `publishes 3000:3000 (got ${JSON.stringify(ports)})`);
|
||||
});
|
||||
|
||||
test('docker-compose.yml: SP_SKLAND=1 and SP_SKLAND_DB inside the mounted named volume', () => {
|
||||
const env = block(service, 'environment', 4);
|
||||
assert.equal(scalar(env, 'SP_SKLAND'), '1', 'the Skland module is on');
|
||||
const db = scalar(env, 'SP_SKLAND_DB');
|
||||
assert.ok(db, 'SP_SKLAND_DB is set');
|
||||
|
||||
// the database must sit inside a mount, and that mount must be the named volume (survives down / rebuilds)
|
||||
const mounts = items(service, 'volumes', 4);
|
||||
const named = mounts.find((m) => m.startsWith('skland-data:'));
|
||||
assert.ok(named, `the named volume skland-data is mounted (got ${JSON.stringify(mounts)})`);
|
||||
const target = named.slice(named.indexOf(':') + 1);
|
||||
assert.ok(target.startsWith('/'), `the volume has an absolute container path (${target})`);
|
||||
assert.ok(db.startsWith(`${target}/`), `SP_SKLAND_DB ${db} is inside the mounted volume ${target}`);
|
||||
// the volume is declared, so `docker compose up` creates it rather than failing on an unknown name
|
||||
assert.ok(block(compose, 'volumes', 0).some((l) => indentOf(l) === 2 && l.trim().startsWith('skland-data')),
|
||||
'the top-level volumes: section declares skland-data');
|
||||
});
|
||||
|
||||
test('docker-compose.yml: the credential file is outside every static mount (/public, /data)', () => {
|
||||
const db = scalar(block(service, 'environment', 4), 'SP_SKLAND_DB');
|
||||
for (const dir of ['/app/public', '/app/data', '/app/shared']) {
|
||||
assert.ok(!db.startsWith(`${dir}/`), `${db} is not under ${dir}: the static handler would serve it over HTTP`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Dockerfile: /app/skland-data exists and belongs to node before USER node', () => {
|
||||
const df = linesOf(read('Dockerfile'));
|
||||
const user = df.findIndex((l) => /^USER[ \t]+node[ \t]*$/.test(l));
|
||||
assert.ok(user > 0, 'the runtime stage still runs as USER node');
|
||||
const mkdir = df.findIndex((l) => /^RUN[^\n]*mkdir -p[^\n]*\/app\/skland-data\b/.test(l));
|
||||
assert.ok(mkdir >= 0, 'the database directory is created');
|
||||
assert.ok(mkdir < user, `mkdir (line ${mkdir + 1}) precedes USER node (line ${user + 1})`);
|
||||
assert.match(df[mkdir], /chown[ \t]+node:node[ \t]+\/app\/skland-data\b/, 'owned by node, so node can write it');
|
||||
// a VOLUME instruction would hand `docker run` an anonymous volume the host cannot find; the mount is explicit
|
||||
assert.ok(!df.some((l) => /^VOLUME\b/.test(l) && l.includes('/app/skland-data')),
|
||||
'no anonymous VOLUME for the credential directory');
|
||||
assert.match(read('Dockerfile'), /SP_SKLAND_DB/, 'the top comment documents the data path');
|
||||
});
|
||||
|
||||
test('the compose volume target is the directory the image prepares', () => {
|
||||
const named = items(service, 'volumes', 4).find((m) => m.startsWith('skland-data:'));
|
||||
const target = named.slice(named.indexOf(':') + 1);
|
||||
const df = linesOf(read('Dockerfile'));
|
||||
const mkdir = df.find((l) => l.includes('mkdir -p') && l.includes(target));
|
||||
assert.ok(mkdir, `the image creates ${target} before USER node, the directory compose mounts`);
|
||||
assert.match(mkdir, new RegExp(`mkdir -p[ \\t]+${target.replace(/\//g, '\\/')}\\b`));
|
||||
});
|
||||
|
||||
test('.gitignore: the runtime database and its SQLite side files are never committed', () => {
|
||||
const lines = linesOf(read('.gitignore')).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
||||
for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) {
|
||||
assert.ok(lines.includes(entry), `.gitignore lists ${entry}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('docs/DEPLOY.md: the Docker section documents SP_SKLAND, SP_SKLAND_DB, the volume and the secret warning', () => {
|
||||
const doc = read('docs/DEPLOY.md');
|
||||
const start = doc.indexOf('## 3. Docker');
|
||||
const end = doc.indexOf('## 4. ', start);
|
||||
assert.ok(start >= 0 && end > start, 'the Docker section is still there');
|
||||
const docker = doc.slice(start, end);
|
||||
assert.match(docker, /`SP_SKLAND`/, 'names SP_SKLAND');
|
||||
assert.match(docker, /`SP_SKLAND_DB`/, 'names SP_SKLAND_DB');
|
||||
assert.match(docker, /docker compose up -d/, 'the one-command path');
|
||||
assert.match(docker, /skland-data/, 'names the volume');
|
||||
assert.match(docker, /密钥|凭据/, 'warns that the database holds credentials');
|
||||
assert.match(docker, /chmod|权限/, 'tells the operator to restrict the file mode');
|
||||
assert.match(docker, /备份/, 'tells the operator to back it up carefully');
|
||||
assert.match(docker, /HTTP/, 'warns against exposing it over HTTP');
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
// Real title and modal, stub account provider. No real account, SMS, authorization or CAPTCHA is exercised.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { SklandCaptchaError } from '../../server/skland/client.js';
|
||||
|
||||
const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||
const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome);
|
||||
|
||||
test('title requires binding, reads the nickname, keeps channel choice and handles CAPTCHA, SMS and restored entry', {
|
||||
skip: enabled ? false : 'set SP_E2E=1 and CHROME_PATH (needs Chrome)', timeout: 60000,
|
||||
}, async () => {
|
||||
const binding = { list: [{ appCode: 'arknights', bindingList: [
|
||||
{ uid: '123', nickName: '官服博士', channelName: '官服', gameId: 1 },
|
||||
{ uid: '123', nickName: 'B服博士', channelName: 'B服', gameId: 2, isDefault: true },
|
||||
] }] };
|
||||
let polls = 0, scans = 0, passwordProof, smsSends = 0;
|
||||
const client = {
|
||||
genScan: async () => { scans++; return { scanId: `SCAN${scans}`, scanUrl: `hypergryph://scan_login?scanId=SCAN${scans}` }; },
|
||||
checkScan: async () => (++polls > 1 ? { state: 'confirmed', scanCode: 'CODE' } : { state: 'waiting', awaitingConfirmation: true }),
|
||||
tokenByScanCode: async () => ({ token: 'SCAN-TOKEN' }),
|
||||
getGrantCode: async () => 'GRANT',
|
||||
getCred: async () => ({ cred: 'CRED', credToken: 'SIGN' }),
|
||||
getBinding: async () => ({ binding, cred: 'CRED', credToken: 'SIGN' }),
|
||||
sendPhoneCode: async () => { smsSends++; },
|
||||
tokenByPhoneCode: async () => ({ token: 'SMS-TOKEN' }),
|
||||
tokenByPhonePassword: async (_phone, _password, captcha) => {
|
||||
if (!captcha) throw new SklandCaptchaError({ geetestId: 'FAKE', challenge: 'ORIGINAL' }, 'password');
|
||||
passwordProof = captcha; return { token: 'PASSWORD-TOKEN' };
|
||||
},
|
||||
getAccountBinding: async () => binding,
|
||||
};
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client } });
|
||||
let browser;
|
||||
try {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] });
|
||||
let page;
|
||||
const errors = [];
|
||||
const newPage = async () => {
|
||||
const tab = await browser.newPage();
|
||||
await tab.setViewport({ width: 1440, height: 1000 });
|
||||
tab.on('pageerror', (error) => errors.push(error.message));
|
||||
await tab.setRequestInterception(true);
|
||||
tab.on('request', (request) => {
|
||||
if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
return tab;
|
||||
};
|
||||
page = await newPage();
|
||||
await page.goto(server.url);
|
||||
await page.waitForSelector('[data-testid="binding-start"]');
|
||||
const clickText = async (text) => {
|
||||
await page.waitForFunction((value) => [...document.querySelectorAll('.sp-binding button')]
|
||||
.some((button) => button.textContent.trim() === value && !button.disabled), {}, text);
|
||||
await page.evaluate((value) => [...document.querySelectorAll('.sp-binding button')]
|
||||
.find((button) => button.textContent.trim() === value).click(), text);
|
||||
};
|
||||
const open = async () => {
|
||||
await page.click(await page.$('[data-testid="account-binding-entry"]') ? '[data-testid="account-binding-entry"]' : '[data-testid="binding-start"]');
|
||||
await page.waitForSelector('.sp-binding');
|
||||
await page.waitForFunction(() => window.__SP__?.net.status === 'online' && !document.querySelector('.sp-binding .btn[aria-busy="true"]'));
|
||||
};
|
||||
const callsign = () => page.evaluate(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent || '');
|
||||
const expectName = async (name) => {
|
||||
try {
|
||||
await page.waitForFunction((value) => (document.querySelector('[data-testid="bound-callsign"]')?.textContent || '') === value, { timeout: 12000 }, name);
|
||||
assert.equal(await page.$('.title-login input'), null, 'the callsign is never an input');
|
||||
assert.equal(!!await page.$('.sp-binding-callsign'), !!name, 'the callsign area only exists after binding');
|
||||
if (name) assert.ok(await page.$('.sp-binding-callsign__icon'), 'the nickname keeps its person icon');
|
||||
} catch (error) {
|
||||
console.error('Callsign state:', await page.evaluate(() => ({
|
||||
callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent,
|
||||
action: document.querySelector('[data-testid="binding-start"]')?.textContent,
|
||||
playerId: window.__SP__?.store.get().me.playerId, connection: window.__SP__?.net.status,
|
||||
alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent),
|
||||
})), 'page errors:', errors);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const expectAction = async (text) => page.waitForFunction((value) => document.querySelector('[data-testid="binding-start"]')?.textContent.trim() === value, {}, text);
|
||||
await expectAction('绑定账号');
|
||||
await expectName('');
|
||||
assert.equal(await page.$('.title-login label'), null);
|
||||
// Old manual callsigns and a cached entered flag must not bypass the server binding check.
|
||||
await page.evaluate(() => { localStorage.setItem('sp.name', '旧的手填代号'); sessionStorage.setItem('sp.entered', '1'); });
|
||||
await page.reload();
|
||||
await expectAction('绑定账号');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
await open();
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
await page.waitForSelector('.sp-binding__qr');
|
||||
assert.equal(scans, 1, 'opening the scan tab automatically fetches a QR');
|
||||
const firstQr = await page.$eval('.sp-binding__qr', (image) => image.src);
|
||||
await clickText('重新获取二维码');
|
||||
await page.waitForFunction((previous) => {
|
||||
const image = document.querySelector('.sp-binding__qr'); return image && image.src !== previous;
|
||||
}, {}, firstQr);
|
||||
assert.equal(scans, 2, 'manual QR refresh remains available');
|
||||
await page.waitForFunction(() => document.querySelector('.sp-binding__scan [role="status"]')?.textContent.includes('已扫码'));
|
||||
await expectName('B服博士');
|
||||
await expectAction('开始');
|
||||
assert.ok(polls >= 2);
|
||||
await clickText('官服博士官服 · UID 123');
|
||||
await expectName('官服博士');
|
||||
await clickText('刷新角色');
|
||||
await expectName('官服博士');
|
||||
await clickText('完成');
|
||||
await open();
|
||||
assert.equal(await callsign(), '官服博士');
|
||||
await clickText('解绑账号');
|
||||
await expectName('');
|
||||
await expectAction('绑定账号');
|
||||
await clickText('密码');
|
||||
await page.type('.sp-binding input[type="tel"]', '13800000000');
|
||||
await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD');
|
||||
await clickText('登录并绑定');
|
||||
await page.waitForSelector('.sp-binding__captcha');
|
||||
await page.evaluate(() => {
|
||||
window.initGeetest4 = (_options, callback) => {
|
||||
let success;
|
||||
callback({
|
||||
onSuccess(fn) { success = fn; }, onError() {}, onClose() {},
|
||||
onReady(fn) { setTimeout(fn, 0); }, showCaptcha() { success(); },
|
||||
getValidate() { return { captcha_id: 'FAKE', lot_number: 'TEST' }; }, destroy() {},
|
||||
});
|
||||
};
|
||||
});
|
||||
await clickText('开始人机验证');
|
||||
await expectName('B服博士');
|
||||
assert.equal(passwordProof.challenge, 'ORIGINAL');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false, 'binding never presses Start');
|
||||
fs.mkdirSync('.cache/skland-preview', { recursive: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await page.screenshot({ path: '.cache/skland-preview/bound.png' });
|
||||
await clickText('解绑账号');
|
||||
await clickText('验证码');
|
||||
await clickText('发送验证码');
|
||||
await page.waitForFunction(() => document.querySelector('.sp-binding__notice')?.textContent.includes('验证码已发送'));
|
||||
assert.equal(smsSends, 1);
|
||||
await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456');
|
||||
await clickText('登录并绑定');
|
||||
await expectName('B服博士');
|
||||
await clickText('完成');
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await page.screenshot({ path: '.cache/skland-preview/entry-bound.png' });
|
||||
await page.reload();
|
||||
await expectName('B服博士');
|
||||
const accountId = () => page.evaluate(async () => {
|
||||
const response = await fetch('/skland/session/status', { headers: { authorization: 'Bearer ' + window.__SP__.store.get().me.token } });
|
||||
return (await response.json()).gamePlayerId;
|
||||
});
|
||||
const previousId = await accountId();
|
||||
await page.close(); page = await newPage(); await page.goto(server.url);
|
||||
await expectName('B服博士'); await expectAction('开始');
|
||||
assert.equal(await accountId(), previousId, 'a fresh tab restores the JWT-owned role without logging in or entering');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
await open();
|
||||
// Keep the existing target alive while checking the landscape layout; device emulation can reload the app and
|
||||
// would test browser boot behavior rather than this feature's responsive CSS.
|
||||
await page.setViewport({ width: 844, height: 390 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await page.screenshot({ path: '.cache/skland-preview/mobile.png' });
|
||||
const fits = await page.$eval('.sp-binding', (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const overlay = element.parentElement?.getBoundingClientRect();
|
||||
return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, height: rect.height,
|
||||
overlay: overlay && { top: overlay.top, bottom: overlay.bottom, height: overlay.height }, viewport: [innerWidth, innerHeight] };
|
||||
});
|
||||
assert.equal(fits.left >= 0 && fits.right <= fits.viewport[0] && fits.top >= 0 && fits.bottom <= fits.viewport[1], true,
|
||||
`the modal fits a landscape phone: ${JSON.stringify(fits)}`);
|
||||
await clickText('解绑账号');
|
||||
await expectName('');
|
||||
await expectAction('绑定账号');
|
||||
await clickText('完成');
|
||||
await page.click('[data-testid="binding-start"]');
|
||||
await page.waitForSelector('.sp-binding');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
// A login without a named role still cannot start.
|
||||
const namedRoles = binding.list[0].bindingList;
|
||||
binding.list[0].bindingList = [];
|
||||
await clickText('验证码');
|
||||
await page.type('.sp-binding input[type="tel"]', '13800000000');
|
||||
await page.type('.sp-binding input[autocomplete="one-time-code"]', '123456');
|
||||
await clickText('登录并绑定');
|
||||
await page.waitForSelector('.sp-binding__roles');
|
||||
await expectAction('绑定账号');
|
||||
await expectName('');
|
||||
binding.list[0].bindingList = namedRoles;
|
||||
await clickText('刷新角色');
|
||||
await expectName('B服博士');
|
||||
await clickText('完成');
|
||||
await page.click('[data-testid="binding-start"]');
|
||||
await page.waitForSelector('.lobby-screen');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().me.name), 'B服博士');
|
||||
await page.reload();
|
||||
await page.waitForSelector('.lobby-screen');
|
||||
// Removing the binding on the server invalidates the cached entry on the next reload.
|
||||
await page.evaluate(async () => {
|
||||
await fetch('/skland/session/unbind', { method: 'POST', headers: {
|
||||
authorization: `Bearer ${window.__SP__.store.get().me.token}`, 'content-type': 'application/json',
|
||||
}, body: '{}' });
|
||||
});
|
||||
await page.reload();
|
||||
await expectAction('绑定账号');
|
||||
assert.equal(await page.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
// The /skland/* HTTP surface and its per-player orchestration (server/skland/service.js, server/skland/http.js).
|
||||
//
|
||||
// The server is started for real (startServer with `skland: { storeFile: ':memory:', fetchImpl }`), so the wiring —
|
||||
// routes.js handing /skland/* to the module before its GET/HEAD gate, the JSON bodies, the store — is what is under
|
||||
// test; only the upstream HTTP is a stub. Each case drives one login path the way the panel would.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../../server/index.js';
|
||||
|
||||
/** The stub upstream (the shapes the community documentation records). */
|
||||
function stubUpstream(overrides = {}) {
|
||||
const calls = [];
|
||||
const j = (o) => ({ status: 200, json: async () => o });
|
||||
const answers = {
|
||||
genScan: () => j({ status: 0, msg: 'OK', data: { scanId: 'SCAN1', scanUrl: 'hypergryph://scan_login?scanId=SCAN1' } }),
|
||||
scanStatus: () => j({ status: 100, msg: '未扫码' }),
|
||||
scanToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-SCAN', hgId: 'HG1', deviceToken: 'DEV1' } }),
|
||||
sendCode: () => j({ status: 0, msg: 'OK' }),
|
||||
phoneToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-CODE', hgId: 'HG1', deviceToken: 'DEV1' } }),
|
||||
passwordToken: () => j({ status: 0, msg: 'OK', data: { token: 'T-PW' } }),
|
||||
grant: ({ body }) => j({ status: 0, msg: 'OK', data: body.appCode === 'be36d44aa36bfb5b'
|
||||
? { token: 'BINDINGTOKEN' } : { uid: 'U1', code: 'GRANTCODE' } }),
|
||||
cred: () => j({ code: 0, data: { cred: 'CRED', token: 'CREDTOKEN' } }),
|
||||
binding: () => j({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', channelName: '官服', gameId: 1 }] }] } }),
|
||||
accountBinding: () => j({ status: 0, data: { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士', channelName: '官服', isDefault: true }] }] } }),
|
||||
refresh: () => j({ code: 0, data: { token: 'CREDTOKEN2' } }),
|
||||
};
|
||||
const routes = [
|
||||
['gen_scan/login', 'genScan'], ['scan_status', 'scanStatus'], ['token_by_scan_code', 'scanToken'],
|
||||
['send_phone_code', 'sendCode'], ['token_by_phone_code', 'phoneToken'],
|
||||
['token_by_phone_password', 'passwordToken'], ['oauth2/v2/grant', 'grant'],
|
||||
['generate_cred_by_code', 'cred'], ['auth/refresh', 'refresh'], ['player/binding', 'binding'],
|
||||
['account/binding/v1/binding_list', 'accountBinding'],
|
||||
];
|
||||
const fetchImpl = async (url, init = {}) => {
|
||||
const key = routes.find(([frag]) => url.includes(frag))?.[1];
|
||||
calls.push({ key, url, method: init.method || 'GET', body: init.body ? JSON.parse(init.body) : null });
|
||||
const answer = overrides[key] ?? answers[key];
|
||||
if (!answer) throw new Error(`stub: no route for ${url}`);
|
||||
return answer(calls.at(-1));
|
||||
};
|
||||
return { fetchImpl, calls, byKey: (key) => calls.filter((c) => c.key === key) };
|
||||
}
|
||||
|
||||
/** Start a server with the stubbed Skland module. */
|
||||
async function withServer(overrides, fn) {
|
||||
const up = stubUpstream(overrides);
|
||||
const srv = await startServer({
|
||||
port: 0, host: '127.0.0.1', quiet: true,
|
||||
skland: { storeFile: ':memory:', fetchImpl: up.fetchImpl, diagnosticKey: 'test-admin-only' },
|
||||
});
|
||||
const post = (path, body) => fetch(srv.url + path, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', 'x-sp-diagnostic-key': 'test-admin-only' }, body: JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
const get = (path) => fetch(srv.url + path, { headers: { 'x-sp-diagnostic-key': 'test-admin-only' } }).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
try { await fn({ srv, post, get, up }); } finally { await srv.close(); }
|
||||
}
|
||||
|
||||
test('the server exposes the module when skland is configured', async () => {
|
||||
await withServer({}, async ({ srv }) => {
|
||||
assert.ok(srv.skland, 'startServer returns the service');
|
||||
});
|
||||
});
|
||||
|
||||
test('the module is off by default, and /skland/* still answers one uniform JSON 404', async () => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try {
|
||||
assert.equal(srv.skland, null, 'no account store on an unconfigured server');
|
||||
const got = await fetch(srv.url + '/skland/status?player=p1');
|
||||
assert.equal(got.status, 404, 'GET /skland/* is not routed to a store');
|
||||
assert.equal((await got.json()).ok, false, 'the off-surface is JSON, not the HTML error page');
|
||||
|
||||
// A POST must not reach routes.js's GET/HEAD gate and answer "405 method not allowed" for a feature that is
|
||||
// simply off — the panel would show a cryptic method error instead of "not enabled".
|
||||
const posted = await fetch(srv.url + '/skland/scan/start', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ player: 'p1' }),
|
||||
});
|
||||
assert.equal(posted.status, 404, 'POST is 404 too, not 405');
|
||||
assert.match((await posted.json()).error, /SP_SKLAND|未启用/);
|
||||
} finally { await srv.close(); }
|
||||
});
|
||||
|
||||
test('GET /skland/status: an unknown player reads as unbound', async () => {
|
||||
await withServer({}, async ({ get }) => {
|
||||
const { status, body } = await get('/skland/status?player=p1');
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body, { playerId: 'p1', status: 'unbound', bound: false, nickname: null, hgId: null, roles: [] });
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /skland/status without a player is a JSON 400', async () => {
|
||||
await withServer({}, async ({ get }) => {
|
||||
const { status, body } = await get('/skland/status');
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.ok, false, 'a JSON client must never be handed the HTML error page');
|
||||
assert.match(body.error, /player/);
|
||||
});
|
||||
});
|
||||
|
||||
test('扫码: start → unscanned → awaiting phone confirmation → confirmed binds the same session', async () => {
|
||||
const pending = [
|
||||
{ status: 100, msg: '未扫码' },
|
||||
{ status: 101, msg: '已扫码待确认' },
|
||||
{ status: 101, msg: '已扫码待确认' },
|
||||
];
|
||||
await withServer({
|
||||
scanStatus: () => ({ status: 200, json: async () => pending.shift()
|
||||
?? { status: 0, msg: 'OK', data: { scanCode: 'SC1' } } }),
|
||||
binding: () => ({ status: 200, json: async () => ({ code: 0, data: { list: [{ appCode: 'arknights', bindingList: [
|
||||
{ uid: '123', nickName: '博士', channelName: '官服', gameId: 1 },
|
||||
{ uid: '456', nickName: 'B服博士', channelName: 'B服', gameId: 2 },
|
||||
] }] } }) }),
|
||||
}, async ({ post, get, up }) => {
|
||||
const start = await post('/skland/scan/start', { player: 'p1' });
|
||||
assert.equal(start.status, 200);
|
||||
assert.equal(start.body.ok, true);
|
||||
assert.equal(start.body.scanUrl, 'hypergryph://scan_login?scanId=SCAN1');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const waiting = await post('/skland/scan/poll', { player: 'p1' });
|
||||
assert.equal(waiting.body.ok, true);
|
||||
assert.equal(waiting.body.state, 'waiting');
|
||||
assert.equal(waiting.body.awaitingConfirmation, i > 0, 'status 101 is preserved for the longer QR refresh window');
|
||||
assert.equal(waiting.body.scanUrl, start.body.scanUrl, 'the QR URL survives every pending poll');
|
||||
const scanning = await get('/skland/status?player=p1');
|
||||
assert.equal(scanning.body.status, 'scanning');
|
||||
assert.equal(scanning.body.bound, false);
|
||||
assert.equal(up.byKey('scanToken').length, 0, 'no token exchange before confirmation');
|
||||
}
|
||||
|
||||
const done = await post('/skland/scan/poll', { player: 'p1' });
|
||||
assert.equal(done.body.state, 'confirmed');
|
||||
assert.equal(done.body.status, 'bound');
|
||||
|
||||
const status = await get('/skland/status?player=p1');
|
||||
assert.equal(status.body.bound, true);
|
||||
assert.equal(status.body.nickname, '博士');
|
||||
assert.deepEqual(status.body.roles, [
|
||||
{ uid: '123', nickName: '博士', channelName: '官服', isDefault: false, gameId: 1 },
|
||||
{ uid: '456', nickName: 'B服博士', channelName: 'B服', isDefault: false, gameId: 2 },
|
||||
]);
|
||||
assert.equal(up.byKey('scanToken')[0].body.scanCode, 'SC1');
|
||||
assert.equal(up.byKey('scanToken').length, 1);
|
||||
assert.equal(up.byKey('grant').length, 1);
|
||||
assert.equal(up.byKey('accountBinding').length, 0, 'QR login keeps the Skland binding provider');
|
||||
assert.equal(up.byKey('genScan').length, 1, 'no replacement QR is needed after status 101');
|
||||
});
|
||||
});
|
||||
|
||||
test('扫码: a poll without a session answers state none', async () => {
|
||||
await withServer({}, async ({ post }) => {
|
||||
const r = await post('/skland/scan/poll', { player: 'never-started' });
|
||||
assert.equal(r.body.ok, true);
|
||||
assert.equal(r.body.state, 'none');
|
||||
});
|
||||
});
|
||||
|
||||
test('扫码: a failing poll still names a state, so the client never reads `undefined`', async () => {
|
||||
// The panel showed "未知状态 · Unknown state: undefined" when the upstream died mid-scan: the answer was a bare
|
||||
// { ok: false } with no `state`, and a caller that only switches on `state` fell through to its unknown branch.
|
||||
await withServer({ scanStatus: () => { throw new Error('upstream down'); } }, async ({ post }) => {
|
||||
await post('/skland/scan/start', { player: 'p1' });
|
||||
const { body } = await post('/skland/scan/poll', { player: 'p1' });
|
||||
assert.equal(body.ok, false);
|
||||
assert.equal(body.state, 'error', 'every poll answer carries a terminal state');
|
||||
assert.match(body.error, /upstream down/);
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码: send → login binds the player', async () => {
|
||||
await withServer({}, async ({ post, get, up }) => {
|
||||
const sent = await post('/skland/phone/send', { player: 'p1', phone: '13800000000' });
|
||||
assert.equal(sent.body.ok, true);
|
||||
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2 });
|
||||
|
||||
const login = await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
|
||||
assert.equal(login.body.ok, true);
|
||||
assert.equal(login.body.nickname, '博士');
|
||||
assert.equal(login.body.roles.length, 1);
|
||||
|
||||
const status = await get('/skland/status?player=p1');
|
||||
assert.equal(status.body.bound, true);
|
||||
assert.equal(status.body.hgId, 'HG1', 'the hgId from the login is kept');
|
||||
assert.equal(up.byKey('grant').length, 1, 'exactly one grant per login');
|
||||
assert.equal(up.byKey('grant')[0].body.deviceToken, 'DEV1');
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码: 需要人机验证 is passed through with the geetest challenge', async () => {
|
||||
await withServer({
|
||||
sendCode: () => ({ status: 200, json: async () => ({ status: 1, msg: '需要人机验证', data: { captcha: { gt: 'GT', challenge: 'CH', new_captcha: true } } }) }),
|
||||
}, async ({ post }) => {
|
||||
const r = await post('/skland/phone/send', { player: 'p1', phone: '13800000000' });
|
||||
assert.equal(r.body.ok, false);
|
||||
assert.equal(r.body.needCaptcha, true);
|
||||
assert.deepEqual(r.body.captcha, { gt: 'GT', challenge: 'CH', new_captcha: true });
|
||||
});
|
||||
});
|
||||
|
||||
test('验证码: a solved captcha travels back to the upstream', async () => {
|
||||
await withServer({}, async ({ post, up }) => {
|
||||
const solved = { geetest_challenge: 'a', geetest_validate: 'b', geetest_seccode: 'c|d' };
|
||||
await post('/skland/phone/send', { player: 'p1', phone: '13800000000', captcha: solved });
|
||||
assert.deepEqual(up.byKey('sendCode')[0].body, { phone: '13800000000', type: 2, captcha: solved });
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: a fresh player can bind without a previous 验证码/扫码 login', async () => {
|
||||
await withServer({}, async ({ post, get, up }) => {
|
||||
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
|
||||
assert.equal(r.body.ok, true);
|
||||
assert.equal(r.body.nickname, '博士');
|
||||
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
|
||||
assert.equal(up.byKey('phoneToken').length, 0);
|
||||
assert.equal(up.byKey('scanToken').length, 0);
|
||||
assert.deepEqual(up.byKey('grant')[0].body, { token: 'T-PW', appCode: 'be36d44aa36bfb5b', type: 1 });
|
||||
assert.equal(r.body.roles[0].isDefault, true, 'official isDefault is preserved');
|
||||
assert.equal(up.byKey('cred').length, 0, 'no Skland credentials are acquired');
|
||||
assert.equal(up.byKey('binding').length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: upstream device verification (109) is explained after attempting login', async () => {
|
||||
await withServer({
|
||||
grant: () => ({ status: 200, json: async () => ({ status: 109, msg: '需要设备验证' }) }),
|
||||
}, async ({ post, up, srv }) => {
|
||||
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
|
||||
assert.equal(r.body.ok, false);
|
||||
assert.equal(r.body.needDevice, true);
|
||||
assert.match(r.body.error, /验证码|扫码/);
|
||||
assert.equal(up.byKey('passwordToken').length, 1);
|
||||
assert.equal(up.byKey('grant').length, 1);
|
||||
assert.equal(up.byKey('cred').length, 0);
|
||||
assert.equal(srv.skland._store.get('p1').access_token, null, 'failed login credentials are not persisted');
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: an incorrect password stays a password error without requiring a prior login', async () => {
|
||||
await withServer({
|
||||
passwordToken: () => ({ status: 200, json: async () => ({ status: 100, msg: '手机号或密码错误' }) }),
|
||||
}, async ({ post, up }) => {
|
||||
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'wrong' });
|
||||
assert.equal(r.body.ok, false);
|
||||
assert.equal(r.body.needDevice, undefined);
|
||||
assert.equal(r.body.error, '手机号或密码错误');
|
||||
assert.equal(up.byKey('passwordToken').length, 1);
|
||||
assert.equal(up.byKey('grant').length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: interactive captcha proofs resume the password and account-grant steps separately', async () => {
|
||||
const challenge = { geetestId: 'TEST-ID', challenge: 'TEST-CHALLENGE', riskType: 'slide' };
|
||||
const passwordProof = { captcha_output: 'TEST-PASSWORD-PROOF', challenge: 'TEST-CHALLENGE' };
|
||||
const grantProof = { captcha_output: 'TEST-GRANT-PROOF', challenge: 'TEST-CHALLENGE' };
|
||||
const answer = (data) => ({ status: 200, json: async () => data });
|
||||
await withServer({
|
||||
passwordToken: ({ body }) => answer(body.captcha
|
||||
? { status: 0, data: { token: 'T-AFTER-CAPTCHA' } } : { status: 1, data: { captcha: challenge } }),
|
||||
grant: ({ body }) => answer(body.captcha
|
||||
? { status: 0, data: { token: 'BINDINGTOKEN' } } : { status: 1, data: { captcha: challenge } }),
|
||||
}, async ({ post, up, srv }) => {
|
||||
const login = { player: 'p1', phone: '13800000000', password: 'pw' };
|
||||
const first = await post('/skland/password/login', login);
|
||||
assert.equal(first.body.needCaptcha, true);
|
||||
assert.equal(first.body.captchaStep, 'password');
|
||||
assert.deepEqual(first.body.captcha, challenge);
|
||||
assert.equal(up.byKey('grant').length, 0);
|
||||
const second = await post('/skland/password/login', { ...login, captcha: passwordProof, captchaStep: 'password' });
|
||||
assert.equal(second.body.needCaptcha, true);
|
||||
assert.equal(second.body.captchaStep, 'accountGrant');
|
||||
assert.deepEqual(up.byKey('passwordToken')[1].body.captcha, passwordProof);
|
||||
assert.equal(srv.skland._store.get('p1'), null, 'pending credentials are not persisted');
|
||||
const third = await post('/skland/password/login', { ...login, captcha: grantProof, captchaStep: 'accountGrant' });
|
||||
assert.equal(third.body.ok, true);
|
||||
assert.equal(third.body.roles.length, 1);
|
||||
assert.equal(up.byKey('passwordToken').length, 2, 'the successful password exchange is not repeated');
|
||||
assert.deepEqual(up.byKey('grant').map((c) => c.body.token), ['T-AFTER-CAPTCHA', 'T-AFTER-CAPTCHA']);
|
||||
assert.deepEqual(up.byKey('grant')[1].body.captcha, grantProof);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: unbind discards a pending grant captcha and cannot resurrect its login', async () => {
|
||||
await withServer({
|
||||
grant: () => ({ status: 200, json: async () => ({ status: 1, data: { captcha: { geetestId: 'TEST-ID' } } }) }),
|
||||
}, async ({ post, up }) => {
|
||||
const login = { player: 'p1', phone: '13800000000', password: 'pw' };
|
||||
assert.equal((await post('/skland/password/login', login)).body.captchaStep, 'accountGrant');
|
||||
await post('/skland/unbind', { player: 'p1' });
|
||||
const retry = await post('/skland/password/login', { ...login, captcha: { captcha_output: 'TEST' }, captchaStep: 'accountGrant' });
|
||||
assert.equal(retry.body.ok, false);
|
||||
assert.match(retry.body.error, /过期/);
|
||||
assert.equal(up.byKey('grant').length, 1);
|
||||
assert.equal(up.byKey('accountBinding').length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: a previous Skland binding switches to official account lookup without reusing its deviceToken', async () => {
|
||||
await withServer({}, async ({ post, up, srv }) => {
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
|
||||
const r = await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
|
||||
assert.equal(r.body.ok, true);
|
||||
assert.equal(r.body.nickname, '博士');
|
||||
assert.deepEqual(up.byKey('passwordToken')[0].body, { phone: '13800000000', password: 'pw' },
|
||||
'the password endpoint takes phone + password only');
|
||||
const grant = up.byKey('grant').at(-1);
|
||||
assert.equal(grant.body.deviceToken, undefined, 'official account lookup does not need the Skland deviceToken');
|
||||
assert.equal(grant.body.appCode, 'be36d44aa36bfb5b');
|
||||
assert.equal(grant.body.type, 1);
|
||||
assert.equal(grant.body.token, 'T-PW');
|
||||
const row = srv.skland._store.get('p1');
|
||||
assert.equal(row.binding_source, 'hypergryph');
|
||||
assert.equal(row.cred, null);
|
||||
assert.equal(row.cred_token, null);
|
||||
assert.equal(row.device_token, null);
|
||||
});
|
||||
});
|
||||
|
||||
test('密码: role refresh uses official account bindings and rebind via SMS restores the Skland source', async () => {
|
||||
await withServer({}, async ({ post, get, up, srv }) => {
|
||||
await post('/skland/password/login', { player: 'p1', phone: '13800000000', password: 'pw' });
|
||||
assert.equal((await get('/skland/roles?player=p1')).body.roles.length, 1);
|
||||
assert.equal(up.byKey('accountBinding').length, 1, 'cached lookup does not call upstream');
|
||||
const refreshed = await get('/skland/roles?player=p1&refresh=1');
|
||||
assert.equal(refreshed.body.ok, true);
|
||||
assert.equal(refreshed.body.roles[0].isDefault, true);
|
||||
assert.equal(up.byKey('accountBinding').length, 2);
|
||||
assert.equal(up.byKey('cred').length, 0);
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '13800000000', code: '123456' });
|
||||
assert.equal(srv.skland._store.get('p1').binding_source, 'skland');
|
||||
await get('/skland/roles?player=p1&refresh=1');
|
||||
assert.equal(up.byKey('binding').length, 2);
|
||||
assert.equal(up.byKey('accountBinding').length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('解绑 clears the credentials and the status reads unbound again', async () => {
|
||||
await withServer({}, async ({ post, get }) => {
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
|
||||
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
|
||||
assert.equal((await post('/skland/unbind', { player: 'p1' })).body.ok, true);
|
||||
const after = await get('/skland/status?player=p1');
|
||||
assert.equal(after.body.bound, false);
|
||||
assert.equal(after.body.status, 'unbound');
|
||||
assert.deepEqual(after.body.roles, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('players are isolated: one binding never leaks into another', async () => {
|
||||
await withServer({}, async ({ post, get }) => {
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
|
||||
assert.equal((await get('/skland/status?player=p1')).body.bound, true);
|
||||
assert.equal((await get('/skland/status?player=p2')).body.bound, false, 'p2 stays unbound');
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /skland/roles returns the cached binding, and ?refresh=1 re-queries upstream', async () => {
|
||||
await withServer({}, async ({ post, get, up }) => {
|
||||
await post('/skland/phone/login', { player: 'p1', phone: '138', code: '1' });
|
||||
const cached = await get('/skland/roles?player=p1');
|
||||
assert.equal(cached.body.ok, true);
|
||||
assert.equal(cached.body.roles.length, 1);
|
||||
assert.equal(up.byKey('binding').length, 1, 'the cached read does not hit the upstream');
|
||||
|
||||
const refreshed = await get('/skland/roles?player=p1&refresh=1');
|
||||
assert.equal(refreshed.body.ok, true);
|
||||
assert.equal(up.byKey('binding').length, 2, 'refresh re-queries');
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed 验证码 login records the error on the status', async () => {
|
||||
await withServer({
|
||||
phoneToken: () => ({ status: 200, json: async () => ({ status: 1, msg: '验证码错误' }) }),
|
||||
}, async ({ post, get }) => {
|
||||
const r = await post('/skland/phone/login', { player: 'p1', phone: '138', code: '000000' });
|
||||
assert.equal(r.body.ok, false);
|
||||
assert.equal(r.body.error, '验证码错误');
|
||||
const status = await get('/skland/status?player=p1');
|
||||
assert.equal(status.body.status, 'error');
|
||||
assert.equal(status.body.error, '验证码错误');
|
||||
assert.equal(status.body.bound, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('an unknown /skland endpoint is a JSON 404 and a bad method a JSON 405', async () => {
|
||||
await withServer({}, async ({ post, srv }) => {
|
||||
const unknown = await post('/skland/nope', { player: 'p1' });
|
||||
assert.equal(unknown.status, 404);
|
||||
assert.equal(unknown.body.ok, false);
|
||||
|
||||
const del = await fetch(srv.url + '/skland/status?player=p1', { method: 'DELETE', headers: { 'x-sp-diagnostic-key': 'test-admin-only' } });
|
||||
assert.equal(del.status, 405);
|
||||
const body = await del.json();
|
||||
assert.equal(body.ok, false, 'the method error is JSON too');
|
||||
});
|
||||
});
|
||||
|
||||
test('a malformed JSON body is a 400, not a crash', async () => {
|
||||
await withServer({}, async ({ srv }) => {
|
||||
const res = await fetch(srv.url + '/skland/phone/login', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', 'x-sp-diagnostic-key': 'test-admin-only' }, body: '{not json',
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('the rest of the server is unaffected: /healthz and / still answer', async () => {
|
||||
await withServer({}, async ({ srv }) => {
|
||||
const health = await fetch(srv.url + '/healthz');
|
||||
assert.equal(health.status, 200);
|
||||
assert.equal((await health.json()).ok, true);
|
||||
const home = await fetch(srv.url + '/');
|
||||
assert.ok([200, 404].includes(home.status), 'the static mount still owns /');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// test/skland/panel.test.js — public/skland.html, the standalone Skland binding panel (scan / SMS / password).
|
||||
//
|
||||
// The panel is one self-contained HTML file with an inline module script (no build step, no browser test), so this
|
||||
// test reads it as TEXT and pins only the contract facts that would silently rot: the endpoints it calls, that the QR
|
||||
// encodes the scanUrl deep link (NOT the scan session id), that captcha challenges are handled, that a 404 reads as
|
||||
// "the module is off", and that the 2 s polling loop always gets cleared.
|
||||
// String checks like these are deliberately few and each one names the failure it prevents.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const HTML_PATH = path.join(ROOT, 'public', 'skland.html');
|
||||
|
||||
describe('the Skland binding panel (public/skland.html)', () => {
|
||||
assert.ok(existsSync(HTML_PATH), 'the panel is a real file the static server can serve');
|
||||
const html = readFileSync(HTML_PATH, 'utf8');
|
||||
|
||||
test('is one whole document: a doctype, an inline module script and a closed html tag', () => {
|
||||
// A stray `</script>` or an unclosed tag is exactly the kind of damage a text-only test can catch.
|
||||
assert.match(html, /^<!doctype html>/i);
|
||||
assert.equal((html.match(/<script\b/g) || []).length, (html.match(/<\/script>/g) || []).length, 'every script opens and closes once');
|
||||
assert.match(html, /<script(?![^>]*\bsrc=)[^>]*type="module"/i, 'the panel logic is an inline module (no build step)');
|
||||
assert.match(html, /<\/body>\s*<\/html>\s*$/, 'the document ends where it should');
|
||||
});
|
||||
|
||||
test('every endpoint the panel needs is referenced', () => {
|
||||
// Each is a distinct login/status step of server/skland/http.js; dropping one breaks that path silently.
|
||||
for (const endpoint of [
|
||||
'/skland/status', '/skland/scan/start', '/skland/scan/poll', '/skland/phone/send',
|
||||
'/skland/phone/login', '/skland/password/login', '/skland/unbind', '/skland/roles',
|
||||
]) {
|
||||
assert.ok(html.includes(endpoint), `the panel calls ${endpoint}`);
|
||||
}
|
||||
assert.match(html, /\/skland\/roles\?player=.*refresh=1/, 'a bound refresh re-queries the roles');
|
||||
});
|
||||
|
||||
test('the QR code encodes scanUrl — never the scanId', () => {
|
||||
// The upstream deep link is `hypergryph://scan_login?…` (server/skland/service.js startScan). Encoding the opaque
|
||||
// scanId would render a QR that no phone can act on, and nothing else in the flow would fail loudly.
|
||||
assert.ok(html.includes('/vendor/qrcode.js'), 'the vendored library is loaded as a classic script');
|
||||
const addData = html.match(/qr\.addData\(\s*([^)]*)\)/);
|
||||
assert.ok(addData, 'the generator is fed one payload');
|
||||
assert.match(addData[1], /scanUrl/, 'the payload is the scanUrl deep link');
|
||||
assert.doesNotMatch(addData[1], /scanId/, 'never the scan session id');
|
||||
assert.match(html, /renderQr\(\s*r\.scanUrl\s*\)/, 'the value comes from the scan/start answer');
|
||||
assert.match(html, /typeof qrcode !== 'function'/, 'a missing vendor file is detected…');
|
||||
assert.match(html, /createSvgTag|createImgTag/, '…and the present library renders a tag');
|
||||
assert.match(html, /textContent\s*=\s*String\(scanUrl\)|value\s*=\s*String\(scanUrl\)/, '…with the deep link as selectable text');
|
||||
});
|
||||
|
||||
test('the QR frame step is named exactly as the server sends it', () => {
|
||||
// service.js pollScan answers state ∈ none|waiting|confirmed|expired; 'none' and 'expired' mean start over.
|
||||
for (const state of ["'waiting'", "'confirmed'", "'expired'", "'none'"]) {
|
||||
assert.ok(html.includes(`state === ${state}`), `the poll handles state ${state}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 404 (or the 405 a POST gets) on /skland/* reads as "the module is off", not as a broken page', () => {
|
||||
// The module is opt-in (SP_SKLAND=1). With it off /skland/* is not routed at all: a GET falls through to the
|
||||
// static server's 404 page, but a POST hits routes.js's GET/HEAD gate first and gets a 405 HTML page. Both are
|
||||
// the bilingual error page, while an enabled module always answers JSON (errors included) — so the panel keys on
|
||||
// the HTML content type as well, and only a plain 404 check would show a confusing "method not allowed".
|
||||
assert.match(html, /res\.status === 404/);
|
||||
assert.match(html, /text\/html/, 'an HTML answer is recognised as the wrong server…');
|
||||
assert.match(html, /该功能未启用\s*·\s*the module is off/);
|
||||
assert.ok(html.includes('id="module-off"'), 'the notice has a place to render');
|
||||
assert.match(html, /setModuleOff\(\)/, 'the notice is raised, not just logged');
|
||||
});
|
||||
|
||||
test('needCaptcha is handled', () => {
|
||||
// needCaptcha: send → geetest challenge, then resend with the solved fields (service.js sendPhoneCode).
|
||||
assert.ok(html.includes('needCaptcha'));
|
||||
assert.match(html, /needCaptcha[\s\S]{0,200}renderCaptcha\(/, 'the challenge data is displayed');
|
||||
assert.ok(html.includes('/js/skland/captcha.js'), 'the shared interactive widget supplies the proof');
|
||||
assert.ok(html.includes('pw-captcha-box'), 'password login has its own challenge control');
|
||||
});
|
||||
|
||||
test('a poll answer is never read as `undefined`: ok:false is honoured and `state` is always named', () => {
|
||||
// The panel once showed "未知状态 · Unknown state: undefined" because the server answered a failed poll with a
|
||||
// bare { ok: false } and pollOnce only switched on `state`. Both halves are pinned here.
|
||||
assert.match(html, /r\.ok === false/, 'pollOnce checks ok:false before it reads state');
|
||||
assert.match(html, /r\.state === 'error'/, "the server's terminal error state is handled");
|
||||
assert.match(html, /未知状态[\s\S]{0,160}brief\(JSON\.stringify\(r\)\)/, 'the unknown branch shows the real body');
|
||||
});
|
||||
|
||||
test('the poll runs about every 2 s and its timer is always cleared', () => {
|
||||
// A leaked interval keeps asking the server after the tab is hidden or the section is left (service.js deletes the
|
||||
// scan session on the first non-waiting answer, so a leaked poll can only ever report state 'none' — noise).
|
||||
const ms = html.match(/POLL_MS\s*=\s*(\d+)/);
|
||||
assert.ok(ms, 'the interval is a named constant');
|
||||
assert.equal(Number(ms[1]), 2000, 'the documented ~2 s');
|
||||
assert.match(html, /setInterval\([\s\S]{0,80}?POLL_MS\s*\)/, 'the timer uses that constant');
|
||||
assert.match(html, /clearInterval\(pollTimer\)/, 'the single cleanup path clears the interval');
|
||||
assert.match(html, /document\.hidden\)\s*return/, 'the poll never starts on a hidden tab');
|
||||
assert.match(html, /document\.hidden\)\s*pollStop\(\)/, 'a tab that goes hidden stops the poll');
|
||||
assert.ok(html.includes("addEventListener('visibilitychange'"), 'visibility changes are watched');
|
||||
assert.ok(html.includes("addEventListener('pagehide'"), 'leaving the page stops the poll too');
|
||||
});
|
||||
|
||||
test('the identity shown is the ?player= / localStorage one, labelled as a placeholder', () => {
|
||||
assert.ok(html.includes('URLSearchParams'), 'the player id is read from the URL');
|
||||
assert.ok(html.includes('sp.skland.player'), 'and kept in localStorage under a clear key');
|
||||
assert.match(html, /placeholder/i, 'the page says the id is a placeholder until the session provides one');
|
||||
});
|
||||
|
||||
test('no external network calls and no inline secrets', () => {
|
||||
// The page must work on an offline LAN box: only same-origin /vendor and /skland URLs are allowed. No tokens,
|
||||
// passwords or phone numbers are baked in either.
|
||||
assert.doesNotMatch(html, /https?:\/\//, 'no absolute http(s) URL anywhere');
|
||||
assert.doesNotMatch(html, /\b(?:token|secret|password)\s*[:=]\s*['"][A-Za-z0-9]{8,}['"]/i, 'no hardcoded credential');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Two isolated browser contexts exercise real title/login/recovery UI against an entirely stubbed provider.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { startServer } from '../../server/index.js';
|
||||
|
||||
const chrome = process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
|
||||
const enabled = process.env.SP_E2E === '1' && fs.existsSync(chrome);
|
||||
|
||||
test('a separately logged-in device restores the match without local saves, and stale or duplicated tabs do not take it back',
|
||||
{ skip: !enabled, timeout: 90_000 }, async () => {
|
||||
const binding = { list: [{ appCode: 'arknights', bindingList: [
|
||||
{ uid: '123', channelName: '官服', nickName: '跨设备博士', isDefault: true },
|
||||
] }] };
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false,
|
||||
skland: { storeFile: ':memory:', client: {
|
||||
genScan: async () => ({ scanId: 'TEST', scanUrl: 'hypergryph://scan_login?scanId=TEST' }),
|
||||
checkScan: async () => ({ state: 'waiting' }),
|
||||
tokenByPhonePassword: async () => ({ token: 'FAKE' }),
|
||||
getAccountBinding: async () => binding,
|
||||
} } });
|
||||
let browser;
|
||||
const errors = [];
|
||||
try {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-first-run', '--no-sandbox'] });
|
||||
const firstContext = await browser.createBrowserContext(), secondContext = await browser.createBrowserContext();
|
||||
const pageIn = async (context) => {
|
||||
const page = await context.newPage();
|
||||
await page.setViewport({ width: 1440, height: 1000 });
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (/^https?:/.test(request.url()) && !request.url().startsWith(server.url)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
return page;
|
||||
};
|
||||
const clickDialog = async (page, label) => {
|
||||
// QR loading can disable the tabs between separate readiness and click calls. Check/click atomically.
|
||||
await page.waitForFunction((text) => {
|
||||
const button = [...document.querySelectorAll('.sp-binding button')]
|
||||
.find((element) => element.textContent.trim() === text && !element.disabled);
|
||||
if (!button) return false;
|
||||
button.click(); return true;
|
||||
}, {}, label);
|
||||
};
|
||||
const login = async (page) => {
|
||||
await page.goto(server.url);
|
||||
await page.waitForSelector('[data-testid="binding-start"]');
|
||||
await page.click('[data-testid="binding-start"]');
|
||||
await clickDialog(page, '密码');
|
||||
await page.waitForSelector('.sp-binding input[type="tel"]:not([disabled])');
|
||||
await page.type('.sp-binding input[type="tel"]', '13800000000');
|
||||
await page.type('.sp-binding input[type="password"]', 'FAKE-PASSWORD');
|
||||
await clickDialog(page, '登录并绑定');
|
||||
await page.waitForFunction(() => document.querySelector('[data-testid="bound-callsign"]')?.textContent === '跨设备博士');
|
||||
await clickDialog(page, '完成');
|
||||
};
|
||||
const a = await pageIn(firstContext), b = await pageIn(secondContext);
|
||||
await login(a);
|
||||
await a.click('[data-testid="resume-account-match"]');
|
||||
await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="status"]')?.textContent === '当前角色没有可恢复的对局。');
|
||||
await a.keyboard.press('Escape');
|
||||
await a.waitForSelector('.sp-account-resume', { hidden: true });
|
||||
await a.click('[data-testid="binding-start"]');
|
||||
await a.waitForSelector('.lobby-screen');
|
||||
await a.evaluate(async () => {
|
||||
await window.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
||||
await window.__SP__.net.request('room.start');
|
||||
});
|
||||
await a.waitForFunction(() => window.__SP__.store.get().match.private && window.__SP__.store.get().room?.inMatch);
|
||||
const previous = await a.evaluate(() => {
|
||||
const s = window.__SP__.store.get();
|
||||
return { code: s.room.code, playerId: s.me.playerId, token: s.me.token, private: s.match.private };
|
||||
});
|
||||
await login(b);
|
||||
assert.equal(await b.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('sp.matches') || '{}')).length), 0);
|
||||
assert.equal(await b.evaluate(() => window.__SP__.store.get().match.private), null);
|
||||
assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online');
|
||||
assert.equal(await b.$('[data-testid="resume-local-match"]'), null, 'account UI no longer offers a local-token recovery');
|
||||
await b.click('[data-testid="resume-account-match"]');
|
||||
await b.waitForSelector('[data-testid="confirm-account-resume"]');
|
||||
assert.match(await b.$eval('[data-testid="confirm-account-resume"]', (el) => el.textContent), new RegExp(previous.code));
|
||||
assert.equal(await a.evaluate(() => window.__SP__.net.status), 'online', 'opening the picker does not take over');
|
||||
fs.mkdirSync('.cache/skland-preview', { recursive: true });
|
||||
await b.$eval('.modal', (element) => Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)));
|
||||
await b.screenshot({ path: '.cache/skland-preview/account-recovery.png' });
|
||||
await b.setViewport({ width: 844, height: 390 });
|
||||
const fits = await b.$eval('.sp-account-resume', (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight;
|
||||
});
|
||||
assert.equal(fits, true, 'the recovery picker fits a landscape phone');
|
||||
const readable = await b.$eval('.sp-account-resume', (element) => ({
|
||||
font: parseFloat(getComputedStyle(element.querySelector('.modal__body')).fontSize),
|
||||
hit: element.querySelector('[data-testid="confirm-account-resume"]').getBoundingClientRect().height,
|
||||
}));
|
||||
assert.ok(readable.font >= 14 && readable.hit >= 44, 'small-screen recovery stays readable and tappable');
|
||||
await b.screenshot({ path: '.cache/skland-preview/account-recovery-mobile.png' });
|
||||
await b.setViewport({ width: 1440, height: 1000 });
|
||||
await b.click('[data-testid="confirm-account-resume"]');
|
||||
await b.waitForFunction((code) => {
|
||||
const s = window.__SP__.store.get();
|
||||
return s.session.entered && s.room?.code === code && !!s.match.private;
|
||||
}, {}, previous.code);
|
||||
const restored = await b.evaluate(() => window.__SP__.store.get());
|
||||
assert.equal(restored.me.playerId, previous.playerId);
|
||||
assert.notEqual(restored.me.token, previous.token);
|
||||
for (const key of ['funds', 'shop', 'hand', 'board']) assert.deepEqual(restored.match.private[key], previous.private[key]);
|
||||
await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED' && window.__SP__.net.status === 'closed');
|
||||
const continueHere = async (page) => {
|
||||
await page.waitForFunction(() => {
|
||||
const button = [...document.querySelectorAll('.conn-banner button')]
|
||||
.find((element) => element.textContent.trim() === '在此页面继续' && !element.disabled);
|
||||
if (!button) return false;
|
||||
button.click(); return true;
|
||||
});
|
||||
await page.waitForFunction((code) => {
|
||||
const s = window.__SP__.store.get();
|
||||
return window.__SP__.net.status === 'online' && s.session.entered && s.room?.code === code;
|
||||
}, {}, previous.code);
|
||||
};
|
||||
await continueHere(a);
|
||||
await b.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED');
|
||||
await continueHere(b);
|
||||
const heldToken = await b.evaluate(() => window.__SP__.store.get().me.token);
|
||||
await a.waitForFunction(() => window.__SP__.net.lastError?.code === 'REPLACED');
|
||||
await a.reload();
|
||||
await a.waitForSelector('[data-testid="bound-callsign"]');
|
||||
assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false, 'an old saved entry cannot silently retake the role');
|
||||
assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online');
|
||||
const duplicate = await pageIn(secondContext);
|
||||
await duplicate.evaluateOnNewDocument((token) => {
|
||||
sessionStorage.setItem('sp.token', token); sessionStorage.setItem('sp.entered', '1');
|
||||
}, heldToken);
|
||||
await duplicate.goto(server.url);
|
||||
await duplicate.waitForSelector('[data-testid="bound-callsign"]');
|
||||
assert.equal(await duplicate.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.equal(await b.evaluate(() => window.__SP__.net.status), 'online', 'a duplicated tab does not take over either');
|
||||
// The match can end while the other device has its recovery dialog open.
|
||||
await a.click('[data-testid="resume-account-match"]');
|
||||
await a.waitForSelector('[data-testid="confirm-account-resume"]');
|
||||
await b.evaluate(() => window.__SP__.net.request('g.leave'));
|
||||
await a.click('[data-testid="confirm-account-resume"]');
|
||||
await a.waitForFunction(() => document.querySelector('.sp-account-resume [role="alert"]')?.textContent === '此对局已结束或过期,请刷新后重试。');
|
||||
assert.equal(await a.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.equal(await a.$('[data-testid="confirm-account-resume"]'), null);
|
||||
// An active page that loses its server session must not stay in an anonymous, unusable lobby.
|
||||
const current = server.registry.byToken(heldToken);
|
||||
server.registry.remove(current);
|
||||
await b.evaluate(() => window.__SP__.net.reconnectNow());
|
||||
await b.waitForSelector('[data-testid="bound-callsign"]');
|
||||
assert.equal(await b.evaluate(() => window.__SP__.store.get().session.entered), false);
|
||||
assert.deepEqual(errors, []);
|
||||
} catch (error) {
|
||||
const pages = browser ? await browser.pages() : [];
|
||||
console.error('Recovery browser state:', await Promise.all(pages.map((page) => page.evaluate(() => ({
|
||||
status: window.__SP__?.net.status, entered: window.__SP__?.store.get().session.entered,
|
||||
playerId: window.__SP__?.store.get().me.playerId,
|
||||
callsign: document.querySelector('[data-testid="bound-callsign"]')?.textContent,
|
||||
alerts: [...document.querySelectorAll('[role="alert"]')].map((element) => element.textContent),
|
||||
})).catch(() => null))), 'page errors:', errors);
|
||||
throw error;
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// Independent devices have independent login cookies and no shared browser storage. No provider network calls.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
import { roleKey } from '../../shared/accountIdentity.js';
|
||||
import { validateC2S } from '../../shared/protocol.js';
|
||||
import { accountPlayerId } from '../../server/skland/auth.js';
|
||||
|
||||
const role = (uid = '123', channelName = '官服', extra = {}) => ({ uid, channelName, nickName: '同名博士', ...extra });
|
||||
|
||||
async function fixture(t) {
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false,
|
||||
skland: { storeFile: ':memory:' } });
|
||||
const clients = [];
|
||||
t.after(async () => { await Promise.all(clients.map((client) => client.close())); await server.close(); });
|
||||
const connect = async (cookie, token) => {
|
||||
const client = await TestClient.connect(server.url.replace('http:', 'ws:') + '/ws', {
|
||||
wsOptions: { headers: cookie ? { cookie } : {} },
|
||||
});
|
||||
clients.push(client);
|
||||
const welcome = await client.hello('Transport', token);
|
||||
return { client, token: welcome.token, welcome };
|
||||
};
|
||||
const request = async (device, endpoint, body, headers = {}) => {
|
||||
const response = await fetch(server.url + '/skland/session/' + endpoint, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { authorization: 'Bearer ' + device.token, ...(device.cookie ? { cookie: device.cookie } : {}),
|
||||
'content-type': 'application/json', ...headers },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
if (cookie) device.cookie = cookie.split(';')[0];
|
||||
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
|
||||
};
|
||||
const device = async (roles = [role()]) => {
|
||||
const d = await connect();
|
||||
server.skland._store.update('@session/' + d.welcome.playerId, { status: 'bound', nickname: roles[0].nickName,
|
||||
binding: { list: [{ appCode: 'arknights', bindingList: roles }] } });
|
||||
const result = await request(d, 'status');
|
||||
assert.equal(result.status, 200);
|
||||
d.state = result.body;
|
||||
return d;
|
||||
};
|
||||
const enter = async (d, id) => {
|
||||
const result = await request(d, id ? 'resume' : 'enter', id ? { id } : {});
|
||||
assert.equal(result.status, 200, JSON.stringify(result.body));
|
||||
await d.client.close();
|
||||
Object.assign(d, await connect(d.cookie, result.body.token));
|
||||
assert.equal(d.welcome.playerId, result.body.gamePlayerId);
|
||||
assert.notEqual(d.token, result.body.token, 'the accepted socket rotates the account token');
|
||||
return d;
|
||||
};
|
||||
const start = async (d, mode = 'solo') => {
|
||||
await enter(d);
|
||||
assert.equal((await d.client.request({ t: 'room.create', mode, difficulty: 'NORMAL' })).t, 'ok');
|
||||
const state = await d.client.waitFor('room.state');
|
||||
assert.equal((await d.client.request({ t: 'room.start' })).t, 'ok');
|
||||
const room = server.lobby.rooms.get(state.code);
|
||||
assert.ok(room.match, 'uses the real Match implementation');
|
||||
await d.client.waitFor('m.public');
|
||||
return room;
|
||||
};
|
||||
const disconnect = async (d) => {
|
||||
const session = server.registry.byToken(d.token);
|
||||
const closed = once(session.ws, 'close');
|
||||
await d.client.close();
|
||||
await closed;
|
||||
};
|
||||
return { server, connect, request, device, enter, start, disconnect };
|
||||
}
|
||||
|
||||
test('a fresh device queries the selected role and explicitly resumes the same live match and private state', async (t) => {
|
||||
const h = await fixture(t), a = await h.device();
|
||||
const room = await h.start(a), match = room.match, player = match.players.get(a.welcome.playerId);
|
||||
player.funds = 37;
|
||||
player.shop.freeRefreshes = 2;
|
||||
const before = player.privateView(), oldToken = a.token;
|
||||
const b = await h.device([role(123, '官服', { nickName: '已改名', gameId: null })]);
|
||||
assert.notEqual(a.cookie, b.cookie, 'the device logs in independently, not by copying a JWT');
|
||||
assert.equal(a.state.gamePlayerId, b.state.gamePlayerId, 'nickname and optional metadata do not select another identity');
|
||||
assert.equal(a.client.isOpen, true, 'login alone does not take over the active device');
|
||||
assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false);
|
||||
const result = await h.request(b, 'recovery');
|
||||
const item = result.body.recovery;
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.cache, /no-store/);
|
||||
assert.equal(item.code, room.code); assert.equal(item.inMatch, true); assert.equal(item.connected, true);
|
||||
assert.equal(Object.hasOwn(item, 'token'), false, 'the read-only summary exposes no reconnect credential');
|
||||
assert.equal(a.client.isOpen, true, 'querying a match is not resuming it');
|
||||
await h.enter(b, item.id);
|
||||
assert.equal((await a.client.closed).code, 4001, 'only successful explicit recovery replaces the old device');
|
||||
assert.equal(b.welcome.playerId, a.welcome.playerId);
|
||||
assert.equal(room.match, match);
|
||||
assert.equal((await b.client.waitFor('room.state')).code, room.code);
|
||||
assert.deepEqual(await b.client.waitFor('m.private'), before);
|
||||
assert.equal(h.server.registry.byToken(oldToken), null, 'the old device token was revoked');
|
||||
const stale = await h.connect(a.cookie, oldToken);
|
||||
assert.notEqual(stale.welcome.playerId, b.welcome.playerId, 'an old device cannot silently take the role back');
|
||||
assert.equal(b.client.isOpen, true);
|
||||
assert.equal((await stale.client.request({ t: 'g.ready' })).t, 'error');
|
||||
});
|
||||
|
||||
test('disconnected solo and co-op matches use the existing account-independent reconnect windows', async (t) => {
|
||||
for (const mode of ['solo', 'coop']) await t.test(mode, async (t) => {
|
||||
const h = await fixture(t), a = await h.device();
|
||||
const room = await h.start(a, mode), session = h.server.registry.byId(a.welcome.playerId);
|
||||
await h.disconnect(a);
|
||||
const at = session.disconnectedAt;
|
||||
assert.ok(at != null);
|
||||
assert.equal(h.server.registry.windowOf(session), mode === 'solo' ? 86_400_000 : 600_000);
|
||||
const b = await h.device();
|
||||
const item = (await h.request(b, 'recovery')).body.recovery;
|
||||
assert.equal(item.connected, false);
|
||||
assert.equal(session.disconnectedAt, at, 'looking at a recovery never renews its deadline');
|
||||
await h.enter(b, item.id);
|
||||
assert.equal((await b.client.waitFor('room.state')).code, room.code);
|
||||
assert.equal(session.disconnectedAt, null);
|
||||
assert.equal(session.connected, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('recovery never follows a supplied player, nickname, UID, region or foreign recovery selector', async (t) => {
|
||||
const h = await fixture(t), a = await h.device(), room = await h.start(a);
|
||||
const item = (await h.request(a, 'recovery')).body.recovery;
|
||||
const strangers = [await h.device([role('456')]), await h.device([role('123', 'B服')]),
|
||||
await h.device([role('123'), role('456', '官服', { isDefault: true })])];
|
||||
for (const b of strangers) {
|
||||
assert.equal((await h.request(b, 'recovery?player=' + a.welcome.playerId)).body.recovery, null);
|
||||
const result = await h.request(b, 'resume', { id: item.id, player: a.welcome.playerId,
|
||||
uid: '123', channelName: '官服', name: a.welcome.name });
|
||||
assert.equal(result.status, 409); assert.equal(result.body.code, 'resume_expired');
|
||||
const stolen = await h.connect(b.cookie, a.token);
|
||||
assert.notEqual(stolen.welcome.playerId, a.welcome.playerId);
|
||||
assert.equal(stolen.client.log.some((frame) => frame.t === 'm.private'), false);
|
||||
}
|
||||
const multi = strangers[2];
|
||||
assert.equal((await h.request(multi, 'role/select', { roleKey: roleKey(role('123')) })).status, 200);
|
||||
assert.equal((await h.request(multi, 'recovery')).body.recovery.code, room.code);
|
||||
const anonymous = await h.connect();
|
||||
assert.equal((await h.request(anonymous, 'recovery')).status, 401);
|
||||
assert.equal((await h.request(anonymous, 'resume', { id: item.id })).status, 401);
|
||||
for (const endpoint of ['enter', 'resume']) {
|
||||
assert.equal((await h.request(multi, endpoint, { id: item.id }, { origin: 'https://attacker.invalid' })).status, 403);
|
||||
}
|
||||
assert.equal(a.client.isOpen, true);
|
||||
});
|
||||
|
||||
test('a stale or expired recovery is refused without creating a session; later entry disposes the old seat first', async (t) => {
|
||||
const h = await fixture(t), a = await h.device(), room = await h.start(a);
|
||||
const session = h.server.registry.byId(a.welcome.playerId), item = (await h.request(a, 'recovery')).body.recovery;
|
||||
await h.disconnect(a);
|
||||
const at = session.disconnectedAt + h.server.registry.windowOf(session) + 1;
|
||||
h.server.registry.now = () => at; h.server.network.now = () => at;
|
||||
const b = await h.device();
|
||||
assert.equal((await h.request(b, 'recovery')).body.recovery, null);
|
||||
const stale = await h.request(b, 'resume', { id: item.id });
|
||||
assert.equal(stale.status, 409); assert.equal(stale.body.code, 'resume_expired');
|
||||
assert.equal(h.server.registry.byId(session.playerId), session, 'a failed restore does not mint a fresh game');
|
||||
await h.enter(b);
|
||||
assert.notEqual(h.server.registry.byId(session.playerId), session);
|
||||
assert.equal(session.roomCode, null);
|
||||
assert.equal(room.disposed, true, 'the expired solo match is abandoned, not orphaned under the stable playerId');
|
||||
assert.equal((await h.request(b, 'recovery')).body.recovery, null);
|
||||
assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false);
|
||||
});
|
||||
|
||||
test('a room that starts a later match invalidates the old selector without stealing its connected seat', async (t) => {
|
||||
const h = await fixture(t), a = await h.device(), b = await h.device();
|
||||
await h.enter(a);
|
||||
assert.equal((await a.client.request({ t: 'room.create', mode: 'solo', difficulty: 'NORMAL' })).t, 'ok');
|
||||
const first = (await h.request(b, 'recovery')).body.recovery;
|
||||
assert.equal(first.inMatch, false);
|
||||
assert.equal((await a.client.request({ t: 'room.start' })).t, 'ok');
|
||||
assert.equal((await h.request(b, 'resume', { id: first.id })).status, 409);
|
||||
const current = (await h.request(b, 'recovery')).body.recovery;
|
||||
assert.notEqual(current.id, first.id); assert.equal(current.code, first.code);
|
||||
assert.equal(a.client.isOpen, true);
|
||||
await h.enter(b, current.id);
|
||||
assert.equal((await a.client.closed).code, 4001);
|
||||
});
|
||||
|
||||
|
||||
test('stable account identities and normal fields fit every existing client message limit', () => {
|
||||
const playerId = accountPlayerId(role()), fieldId = 'n:' + playerId;
|
||||
const perPlayer = { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, unitsEnd: [] };
|
||||
for (const message of [
|
||||
{ t: 'room.kick', seat: 1, playerId },
|
||||
{ t: 'room.removeSpectator', playerId },
|
||||
{ t: 'g.watch', fieldId, playerId },
|
||||
{ t: 'b.result', battleId: 'match.1', result: { reason: 'cleared', time: 1, perPlayer: { [playerId]: perPlayer } } },
|
||||
]) assert.equal(validateC2S(message), null, message.t + ' accepts the account identity');
|
||||
assert.equal(playerId, accountPlayerId(role(123, ' 官服 ', { nickName: '新名字', gameId: 2 })));
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createQrLogin } from '../../public/js/skland/scan.js';
|
||||
|
||||
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
||||
function fakeClock() {
|
||||
let time = 0, id = 0;
|
||||
const tasks = new Map();
|
||||
return {
|
||||
now: () => time,
|
||||
timers: {
|
||||
setTimeout: (fn, ms) => { tasks.set(++id, { fn, at: time + ms }); return id; },
|
||||
clearTimeout: (key) => tasks.delete(key),
|
||||
},
|
||||
count: () => tasks.size,
|
||||
async advance(ms) {
|
||||
const target = time + ms;
|
||||
for (;;) {
|
||||
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next || next[1].at > target) break;
|
||||
time = next[1].at; tasks.delete(next[0]); next[1].fn(); await flush();
|
||||
}
|
||||
time = target; await flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture(poll = () => ({ ok: true, state: 'waiting' }), onConfirmed) {
|
||||
const clock = fakeClock(), calls = [], images = [], pending = [], errors = [];
|
||||
let confirmed = 0, starts = 0;
|
||||
const scan = createQrLogin({ ...clock,
|
||||
request: async (path) => {
|
||||
calls.push({ path, at: clock.now() });
|
||||
return path === 'scan/start' ? { ok: true, scanUrl: `QR${++starts}` } : poll();
|
||||
},
|
||||
encode: async (url) => `image:${url}`, onQr: (value) => images.push(value),
|
||||
onPending: (value) => pending.push(value), onError: (value) => { if (value) errors.push(value); },
|
||||
onBusy: () => {}, onConfirmed: async () => { confirmed++; await onConfirmed?.(); },
|
||||
});
|
||||
return { scan, clock, calls, images, pending, errors, starts: () => starts, confirmed: () => confirmed };
|
||||
}
|
||||
|
||||
test('QR is fetched automatically, polled every 2 s and replaced every 10 s', async () => {
|
||||
const f = fixture(); f.scan.setActive(true); await f.clock.advance(0);
|
||||
assert.deepEqual(f.images, ['image:QR1']);
|
||||
await f.clock.advance(9999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
assert.deepEqual(f.calls.filter((c) => c.path === 'scan/poll').map((c) => c.at), [2000, 4000, 6000, 8000, 10000]);
|
||||
await f.clock.advance(10000); assert.equal(f.starts(), 3);
|
||||
f.scan.stop(); assert.equal(f.clock.count(), 0);
|
||||
});
|
||||
|
||||
test('a failed status fetch after confirmation retries completion without replacing the authenticated QR', async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture(() => ({ ok: true, state: 'confirmed' }), async () => {
|
||||
if (++attempts === 1) throw new Error('status offline');
|
||||
});
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
assert.equal(f.confirmed(), 1); assert.equal(f.errors[0].message, 'status offline');
|
||||
await f.clock.advance(10000);
|
||||
assert.equal(f.confirmed(), 2); assert.equal(f.starts(), 1); assert.equal(f.clock.count(), 0);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('the first awaiting-confirmation event starts a 60 s window; repeated polls do not extend it', async () => {
|
||||
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
assert.equal(f.pending.at(-1), true);
|
||||
await f.clock.advance(59999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
assert.equal(f.calls.filter((c) => c.path === 'scan/start')[1].at, 62000);
|
||||
assert.equal(f.pending.at(-1), false, 'a new QR returns to the ordinary interval');
|
||||
f.scan.refresh(); await f.clock.advance(0); assert.equal(f.starts(), 3, 'manual refresh is immediate too');
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('a scan detected at the 10 s refresh boundary keeps the QR for confirmation', async () => {
|
||||
let f;
|
||||
f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: f.clock.now() >= 10000 }));
|
||||
f.scan.setActive(true); await f.clock.advance(10000);
|
||||
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
|
||||
await f.clock.advance(59999); assert.equal(f.starts(), 1);
|
||||
await f.clock.advance(1); assert.equal(f.starts(), 2);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('pausing stops requests and resuming checks confirmation before replacing an overdue QR', async () => {
|
||||
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
|
||||
f.scan.setActive(true); await f.clock.advance(0);
|
||||
f.scan.setActive(false); assert.equal(f.clock.count(), 0);
|
||||
await f.clock.advance(50000); assert.equal(f.calls.length, 1);
|
||||
f.scan.setActive(true); await f.clock.advance(0);
|
||||
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
|
||||
f.scan.stop(); await f.clock.advance(120000); assert.equal(f.calls.length, 2);
|
||||
});
|
||||
|
||||
test('confirmation wins over a manual refresh queued during an in-flight poll', async () => {
|
||||
let resolvePoll;
|
||||
const f = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
|
||||
f.scan.setActive(true); await f.clock.advance(2000);
|
||||
f.scan.refresh(); assert.equal(f.starts(), 1, 'a refresh never overlaps its poll');
|
||||
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
|
||||
assert.equal(f.confirmed(), 1); assert.equal(f.clock.count(), 0);
|
||||
await f.clock.advance(120000); assert.equal(f.starts(), 1);
|
||||
f.scan.stop();
|
||||
});
|
||||
|
||||
test('stopping ignores late replies, and a failed poll can recover with a fresh QR', async () => {
|
||||
let resolvePoll;
|
||||
const stopped = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
|
||||
stopped.scan.setActive(true); await stopped.clock.advance(2000); stopped.scan.stop();
|
||||
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
|
||||
assert.equal(stopped.confirmed(), 0); assert.equal(stopped.clock.count(), 0);
|
||||
|
||||
const retry = fixture(() => { throw new Error('offline'); });
|
||||
retry.scan.setActive(true); await retry.clock.advance(2000);
|
||||
assert.equal(retry.errors[0].message, 'offline');
|
||||
await retry.clock.advance(9999); assert.equal(retry.starts(), 1);
|
||||
await retry.clock.advance(1); assert.equal(retry.starts(), 2);
|
||||
retry.scan.stop();
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { WebSocket } from 'ws';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { ACCOUNT_COOKIE, LOGIN_TTL } from '../../server/skland/auth.js';
|
||||
import { roleKey } from '../../shared/accountIdentity.js';
|
||||
import { PROTOCOL_VERSION } from '../../shared/constants.js';
|
||||
|
||||
const role = (uid = '123', channelName = '官服', gameId = 1) => ({ uid, channelName, gameId, nickName: '同名博士' });
|
||||
async function request(server, endpoint, { token, cookie, body, headers = {} } = {}) {
|
||||
const response = await fetch(server.url + endpoint, { method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookie ? { cookie } : {}),
|
||||
'content-type': 'application/json', ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { status: response.status, body: await response.json(), setCookie: response.headers.get('set-cookie') };
|
||||
}
|
||||
async function login(server, roles = [role()]) {
|
||||
const session = server.registry.create('temporary');
|
||||
server.skland._store.update('@session/' + session.playerId, { status: 'bound', nickname: roles[0].nickName,
|
||||
binding: { list: [{ appCode: 'arknights', bindingList: roles }] } });
|
||||
const result = await request(server, '/skland/session/status', { token: session.token });
|
||||
assert.equal(result.status, 200);
|
||||
return { token: session.token, cookie: result.setCookie.split(';')[0], state: result.body, setCookie: result.setCookie };
|
||||
}
|
||||
async function socket(server, account = {}) {
|
||||
const ws = new WebSocket(server.url.replace('http:', 'ws:') + '/ws', { headers: account.cookie ? { cookie: account.cookie } : {} });
|
||||
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
|
||||
let rid = 0;
|
||||
const send = (message) => new Promise((resolve, reject) => {
|
||||
const id = ++rid;
|
||||
const timer = setTimeout(() => { ws.off('message', receive); reject(new Error('socket reply timeout')); }, 3000);
|
||||
const receive = (data) => { const reply = JSON.parse(data);
|
||||
if (reply.rid === id) { clearTimeout(timer); ws.off('message', receive); resolve(reply); } };
|
||||
ws.on('message', receive); ws.send(JSON.stringify({ ...message, rid: id }));
|
||||
});
|
||||
const entry = account.cookie && await request(server, '/skland/session/enter', { ...account, body: {} });
|
||||
const welcome = await send({ t: 'hello', name: '伪造昵称', token: entry?.body.token || account.token, version: PROTOCOL_VERSION });
|
||||
return { ws, send, welcome };
|
||||
}
|
||||
|
||||
test('account HTTP rejects forged JWT/roles and cross-origin access; logout revokes replay and diagnostics are closed', async () => {
|
||||
const authSecret = 'test-only-key-never-a-real-deployment-123456789';
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', authSecret } });
|
||||
try {
|
||||
const a = await login(server), b = await login(server, [role('456')]);
|
||||
assert.match(a.setCookie, /HttpOnly/); assert.match(a.setCookie, /SameSite=Strict/); assert.match(a.setCookie, /Max-Age=2592000/);
|
||||
assert.equal((await request(server, '/skland/session/status', { cookie: a.cookie })).status, 401);
|
||||
assert.equal((await request(server, '/skland/session/status?token=' + a.token, { cookie: a.cookie })).status, 401);
|
||||
assert.equal((await request(server, '/skland/session/status?player=' + encodeURIComponent(b.state.playerId), a)).body.playerId, a.state.playerId);
|
||||
assert.equal((await request(server, '/skland/session/role/select', { ...a, body: { roleKey: roleKey(role('456')) } })).status, 403);
|
||||
for (const headers of [{ origin: 'https://attacker.invalid' }, { 'sec-fetch-site': 'cross-site' }]) {
|
||||
assert.equal((await request(server, '/skland/session/status', { ...a, headers })).status, 403);
|
||||
}
|
||||
for (const url of ['/skland/status?player=' + a.state.playerId, '/skland/roles?player=someone']) {
|
||||
assert.equal((await request(server, url, a)).status, 403);
|
||||
}
|
||||
const jwt = a.cookie.slice(ACCOUNT_COOKIE.length + 1).split('.'), claims = JSON.parse(Buffer.from(jwt[1], 'base64url'));
|
||||
assert.ok(!JSON.stringify(claims).includes('access_token'));
|
||||
for (const forged of [
|
||||
[jwt[0], Buffer.from(JSON.stringify({ ...claims, sub: b.state.playerId })).toString('base64url'), jwt[2]],
|
||||
[Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'), jwt[1], jwt[2]],
|
||||
]) assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${forged.join('.')}` })).status, 401);
|
||||
for (const invalid of [{ iss: 'another-server' }, { aud: 'another-app' }, { iat: claims.exp + 1 }, { role: roleKey(role('456')) }]) {
|
||||
const body = Buffer.from(JSON.stringify({ ...claims, ...invalid })).toString('base64url');
|
||||
const input = jwt[0] + '.' + body;
|
||||
const signed = input + '.' + createHmac('sha256', authSecret).update(input).digest('base64url');
|
||||
assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${signed}` })).status, 401);
|
||||
}
|
||||
await request(server, '/skland/session/unbind', { ...b, body: { player: a.state.playerId } });
|
||||
assert.equal((await request(server, '/skland/session/status', a)).body.bound, true, 'B only unbinds B');
|
||||
await request(server, '/skland/session/unbind', { ...a, body: {} });
|
||||
assert.equal((await request(server, '/skland/session/status', a)).status, 401);
|
||||
assert.equal((await request(server, '/skland/session/status', { token: a.token })).body.bound, false, 'old transport cannot reissue JWT');
|
||||
} finally { await server.close(); }
|
||||
});
|
||||
|
||||
test('late provider replies cannot restore credentials after unbind or replace a newer scan', async () => {
|
||||
let resolveScan, resolveBinding, announceBinding, scans = 0, tokenExchanges = 0;
|
||||
const binding = { list: [{ appCode: 'arknights', bindingList: [role()] }] };
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client: {
|
||||
genScan: async () => ({ scanId: String(++scans), scanUrl: 'test:qr' }),
|
||||
checkScan: async () => new Promise((resolve) => { resolveScan = resolve; }),
|
||||
tokenByScanCode: async () => { tokenExchanges++; return { token: 'TEST' }; },
|
||||
tokenByPhonePassword: async () => ({ token: 'TEST' }),
|
||||
getAccountBinding: async () => { announceBinding(); return new Promise((resolve) => { resolveBinding = resolve; }); },
|
||||
} } });
|
||||
try {
|
||||
const service = server.skland;
|
||||
for (const action of [() => service.unbind('pending'), () => service.startScan('pending')]) {
|
||||
await service.startScan('pending');
|
||||
const pending = service.pollScan('pending');
|
||||
await action(); resolveScan({ state: 'confirmed', scanCode: 'OLD' });
|
||||
assert.equal((await pending).state, 'none');
|
||||
assert.equal(service.status('pending').bound, false);
|
||||
}
|
||||
assert.equal(tokenExchanges, 0);
|
||||
const reachedBinding = new Promise((resolve) => { announceBinding = resolve; });
|
||||
const password = service.loginByPhonePassword('pending', '13800000000', 'test');
|
||||
await reachedBinding; service.unbind('pending'); resolveBinding(binding);
|
||||
assert.equal((await password).ok, false);
|
||||
assert.equal(service._store.get('pending').access_token, null);
|
||||
assert.equal(service.status('pending').bound, false);
|
||||
} finally { await server.close(); }
|
||||
});
|
||||
|
||||
test('JWT and bindings survive restart, expire after 30 days, and credential files cannot be downloaded', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-account-'));
|
||||
let at = Date.now(), server;
|
||||
const options = { port: 0, host: '127.0.0.1', quiet: true, publicDir: dir,
|
||||
skland: { storeFile: path.join(dir, 'private.db'), authNow: () => at } };
|
||||
try {
|
||||
server = await startServer(options); const a = await login(server);
|
||||
for (const name of ['private.db', 'PRIVATE.DB', 'private.db-wal', 'private.db-shm']) assert.equal((await fetch(server.url + '/' + name)).status, 404);
|
||||
try { fs.symlinkSync(path.join(dir, 'private.db'), path.join(dir, 'alias.txt'));
|
||||
assert.equal((await fetch(server.url + '/alias.txt')).status, 404);
|
||||
} catch (error) { if (!['EPERM', 'EACCES'].includes(error.code)) throw error; }
|
||||
await server.close(); server = await startServer(options);
|
||||
const fresh = server.registry.create('new transport');
|
||||
const restored = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie });
|
||||
assert.equal(restored.body.gamePlayerId, a.state.gamePlayerId); assert.equal(restored.body.playerId, a.state.playerId);
|
||||
assert.equal(restored.body.bound, true);
|
||||
at += (LOGIN_TTL + 1) * 1000;
|
||||
const expired = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie });
|
||||
assert.equal(expired.status, 401); assert.match(expired.setCookie, /Max-Age=0/);
|
||||
} finally { await server?.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('game identity uses UID plus region, permits duplicate nicknames, and blocks anonymous game requests', async () => {
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:' } });
|
||||
const sockets = [];
|
||||
try {
|
||||
const accounts = [await login(server), await login(server, [role('456')]), await login(server, [role('123', 'B服', 2)])];
|
||||
for (const account of accounts) sockets.push(await socket(server, account));
|
||||
const welcomes = sockets.map((client) => client.welcome);
|
||||
assert.ok(welcomes.every((welcome) => welcome.name === '同名博士'));
|
||||
assert.equal(new Set(welcomes.map((welcome) => welcome.playerId)).size, 3);
|
||||
assert.equal((await sockets[0].send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'ok');
|
||||
const anonymous = await socket(server, { token: welcomes[0].token }); sockets.push(anonymous);
|
||||
assert.notEqual(anonymous.welcome.playerId, welcomes[0].playerId);
|
||||
assert.equal((await anonymous.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error');
|
||||
assert.equal((await request(server, '/skland/session/status', { token: welcomes[0].token, cookie: accounts[1].cookie })).status, 403);
|
||||
const reconnect = await socket(server, accounts[1]); sockets.push(reconnect);
|
||||
assert.equal(reconnect.welcome.playerId, welcomes[1].playerId);
|
||||
await request(server, '/skland/session/unbind', { token: reconnect.welcome.token, cookie: accounts[1].cookie, body: {} });
|
||||
assert.equal((await reconnect.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error');
|
||||
} finally { for (const client of sockets) client.ws.terminate(); await server.close(); }
|
||||
});
|
||||
|
||||
test('a disabled server keeps the account surface off', async () => {
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try { assert.equal((await fetch(server.url + '/skland/session/status')).status, 404); }
|
||||
finally { await server.close(); }
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// The per-player credential store (server/skland/store.js): node:sqlite in WAL mode, one row per player.
|
||||
//
|
||||
// The store is what survives a restart, so the round-trip of every column and the 解绑 semantics are pinned here.
|
||||
// The file-backed cases also assert the WAL side files exist, which is what makes the SQLite path a "high-performance
|
||||
// local database" rather than a map that happens to be persisted.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createSklandStore } from '../../server/skland/store.js';
|
||||
|
||||
const tempDir = () => mkdtempSync(join(tmpdir(), 'sp-skland-'));
|
||||
|
||||
test('a player row starts unbound and ensure() is idempotent', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.ensure('p1');
|
||||
store.ensure('p1');
|
||||
const row = store.get('p1');
|
||||
assert.equal(store.count(), 1);
|
||||
assert.equal(row.player_id, 'p1');
|
||||
assert.equal(row.status, 'unbound');
|
||||
assert.equal(row.cred, null);
|
||||
assert.equal(row.binding, null);
|
||||
assert.ok(row.created_at > 0);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('get() of an unknown player is null; update() creates the row', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
assert.equal(store.get('nobody'), null);
|
||||
store.update('p2', { status: 'bound', cred: 'C', cred_token: 'CT' });
|
||||
assert.equal(store.get('p2').status, 'bound');
|
||||
assert.equal(store.count(), 1, 'update() creates exactly one row');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('the whole login chain round-trips, including the binding JSON', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
const binding = { list: [{ appCode: 'arknights', bindingList: [{ uid: '123', nickName: '博士' }] }] };
|
||||
store.update('p1', {
|
||||
access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT',
|
||||
user_id: 'U', d_id: 'D', nickname: '博士', binding, status: 'bound',
|
||||
});
|
||||
const row = store.get('p1');
|
||||
assert.equal(row.access_token, 'AT');
|
||||
assert.equal(row.device_token, 'DT');
|
||||
assert.equal(row.hg_id, 'HG');
|
||||
assert.equal(row.cred, 'C');
|
||||
assert.equal(row.cred_token, 'CT');
|
||||
assert.equal(row.d_id, 'D');
|
||||
assert.equal(row.nickname, '博士');
|
||||
assert.deepEqual(row.binding, binding, 'binding is stored as JSON and parsed back');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('update() merges only the named fields', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.update('p1', { cred: 'C', status: 'bound', nickname: 'N' });
|
||||
store.update('p1', { cred_token: 'CT' });
|
||||
const row = store.get('p1');
|
||||
assert.equal(row.cred, 'C');
|
||||
assert.equal(row.nickname, 'N');
|
||||
assert.equal(row.status, 'bound');
|
||||
assert.equal(row.cred_token, 'CT');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('unbind() forgets every credential and the binding source but keeps the row', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.update('p1', {
|
||||
access_token: 'AT', device_token: 'DT', hg_id: 'HG', cred: 'C', cred_token: 'CT',
|
||||
user_id: 'U', d_id: 'D', nickname: 'N', binding: { list: [] }, binding_source: 'hypergryph', status: 'bound',
|
||||
});
|
||||
store.unbind('p1');
|
||||
const row = store.get('p1');
|
||||
assert.equal(row.status, 'unbound');
|
||||
for (const field of ['access_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname']) {
|
||||
assert.equal(row[field], null, `${field} is cleared`);
|
||||
}
|
||||
assert.equal(row.binding, null);
|
||||
assert.equal(row.device_token, null, 'the device token is cleared with the rest of the credentials');
|
||||
assert.equal(row.binding_source, 'skland', 'unbound rows return to the legacy default');
|
||||
assert.equal(row.player_id, 'p1', 'the row stays so a rebind is cheap');
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a legacy database migrates without losing QR bindings and persists the official account source', () => {
|
||||
const dir = tempDir();
|
||||
const file = join(dir, 'legacy.db');
|
||||
try {
|
||||
const initial = createSklandStore({ file });
|
||||
initial.update('old-player', { cred: 'OLD-CRED', device_token: 'OLD-DEVICE', status: 'bound', binding: { list: [] } });
|
||||
initial.close();
|
||||
const legacy = new DatabaseSync(file);
|
||||
legacy.exec('ALTER TABLE skland_players DROP COLUMN binding_source');
|
||||
legacy.close();
|
||||
|
||||
const migrated = createSklandStore({ file });
|
||||
assert.equal(migrated.get('old-player').cred, 'OLD-CRED');
|
||||
assert.equal(migrated.get('old-player').device_token, 'OLD-DEVICE');
|
||||
assert.equal(migrated.get('old-player').binding_source, 'skland');
|
||||
migrated.update('password-player', { access_token: 'HG-TOKEN', binding_source: 'hypergryph', status: 'bound', binding: { list: [] } });
|
||||
migrated.close();
|
||||
const reopened = createSklandStore({ file });
|
||||
assert.equal(reopened.get('password-player').binding_source, 'hypergryph');
|
||||
assert.equal(reopened.get('password-player').access_token, 'HG-TOKEN');
|
||||
assert.equal(reopened.get('password-player').cred, null);
|
||||
assert.equal(reopened.get('old-player').status, 'bound');
|
||||
reopened.close();
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('remove() drops the row entirely', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.update('p1', { status: 'bound' });
|
||||
store.remove('p1');
|
||||
assert.equal(store.get('p1'), null);
|
||||
assert.equal(store.count(), 0);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('list() returns every row, newest update first', () => {
|
||||
const store = createSklandStore({ file: ':memory:' });
|
||||
try {
|
||||
store.update('a', { status: 'bound' });
|
||||
store.update('b', { status: 'unbound' });
|
||||
const ids = store.list().map((r) => r.player_id).sort();
|
||||
assert.deepEqual(ids, ['a', 'b']);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a file-backed store persists across instances and uses WAL', () => {
|
||||
const dir = tempDir();
|
||||
const file = join(dir, 'skland.db');
|
||||
try {
|
||||
const first = createSklandStore({ file });
|
||||
first.update('p1', { cred: 'C', status: 'bound', binding: { list: [{ appCode: 'arknights', bindingList: [] }] } });
|
||||
assert.equal(first.journalMode(), 'wal', 'WAL mode is on (readers do not block the writer)');
|
||||
first.close();
|
||||
assert.ok(existsSync(file), 'the database file exists on disk');
|
||||
|
||||
const second = createSklandStore({ file });
|
||||
const row = second.get('p1');
|
||||
assert.equal(row.cred, 'C', 'the credentials survive a restart');
|
||||
assert.equal(row.status, 'bound');
|
||||
assert.deepEqual(row.binding.list[0].appCode, 'arknights');
|
||||
second.close();
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('the parent directory of the database file is created', () => {
|
||||
const dir = tempDir();
|
||||
const file = join(dir, 'nested', 'deeper', 'skland.db');
|
||||
try {
|
||||
const store = createSklandStore({ file });
|
||||
store.update('p1', { status: 'bound' });
|
||||
assert.ok(existsSync(file));
|
||||
store.close();
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in new issue
Block a user