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
325 lines
16 KiB
JavaScript
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;
|
|
});
|
|
});
|