Files
ark-stronghold-protocol/test/skland/http.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

416 lines
22 KiB
JavaScript

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