Files
ark-stronghold-protocol/test/skland/client.test.js
T
herryyun 7c25c89992
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
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

325 lines
16 KiB
JavaScript

// 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;
});
});