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