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