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
157 lines
11 KiB
JavaScript
157 lines
11 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { createHmac } from 'node:crypto';
|
|
import { WebSocket } from 'ws';
|
|
import { startServer } from '../../server/index.js';
|
|
import { ACCOUNT_COOKIE, LOGIN_TTL } from '../../server/skland/auth.js';
|
|
import { roleKey } from '../../shared/accountIdentity.js';
|
|
import { PROTOCOL_VERSION } from '../../shared/constants.js';
|
|
|
|
const role = (uid = '123', channelName = '官服', gameId = 1) => ({ uid, channelName, gameId, nickName: '同名博士' });
|
|
async function request(server, endpoint, { token, cookie, body, headers = {} } = {}) {
|
|
const response = await fetch(server.url + endpoint, { method: body === undefined ? 'GET' : 'POST',
|
|
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookie ? { cookie } : {}),
|
|
'content-type': 'application/json', ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
return { status: response.status, body: await response.json(), setCookie: response.headers.get('set-cookie') };
|
|
}
|
|
async function login(server, roles = [role()]) {
|
|
const session = server.registry.create('temporary');
|
|
server.skland._store.update('@session/' + session.playerId, { status: 'bound', nickname: roles[0].nickName,
|
|
binding: { list: [{ appCode: 'arknights', bindingList: roles }] } });
|
|
const result = await request(server, '/skland/session/status', { token: session.token });
|
|
assert.equal(result.status, 200);
|
|
return { token: session.token, cookie: result.setCookie.split(';')[0], state: result.body, setCookie: result.setCookie };
|
|
}
|
|
async function socket(server, account = {}) {
|
|
const ws = new WebSocket(server.url.replace('http:', 'ws:') + '/ws', { headers: account.cookie ? { cookie: account.cookie } : {} });
|
|
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
|
|
let rid = 0;
|
|
const send = (message) => new Promise((resolve, reject) => {
|
|
const id = ++rid;
|
|
const timer = setTimeout(() => { ws.off('message', receive); reject(new Error('socket reply timeout')); }, 3000);
|
|
const receive = (data) => { const reply = JSON.parse(data);
|
|
if (reply.rid === id) { clearTimeout(timer); ws.off('message', receive); resolve(reply); } };
|
|
ws.on('message', receive); ws.send(JSON.stringify({ ...message, rid: id }));
|
|
});
|
|
const entry = account.cookie && await request(server, '/skland/session/enter', { ...account, body: {} });
|
|
const welcome = await send({ t: 'hello', name: '伪造昵称', token: entry?.body.token || account.token, version: PROTOCOL_VERSION });
|
|
return { ws, send, welcome };
|
|
}
|
|
|
|
test('account HTTP rejects forged JWT/roles and cross-origin access; logout revokes replay and diagnostics are closed', async () => {
|
|
const authSecret = 'test-only-key-never-a-real-deployment-123456789';
|
|
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', authSecret } });
|
|
try {
|
|
const a = await login(server), b = await login(server, [role('456')]);
|
|
assert.match(a.setCookie, /HttpOnly/); assert.match(a.setCookie, /SameSite=Strict/); assert.match(a.setCookie, /Max-Age=2592000/);
|
|
assert.equal((await request(server, '/skland/session/status', { cookie: a.cookie })).status, 401);
|
|
assert.equal((await request(server, '/skland/session/status?token=' + a.token, { cookie: a.cookie })).status, 401);
|
|
assert.equal((await request(server, '/skland/session/status?player=' + encodeURIComponent(b.state.playerId), a)).body.playerId, a.state.playerId);
|
|
assert.equal((await request(server, '/skland/session/role/select', { ...a, body: { roleKey: roleKey(role('456')) } })).status, 403);
|
|
for (const headers of [{ origin: 'https://attacker.invalid' }, { 'sec-fetch-site': 'cross-site' }]) {
|
|
assert.equal((await request(server, '/skland/session/status', { ...a, headers })).status, 403);
|
|
}
|
|
for (const url of ['/skland/status?player=' + a.state.playerId, '/skland/roles?player=someone']) {
|
|
assert.equal((await request(server, url, a)).status, 403);
|
|
}
|
|
const jwt = a.cookie.slice(ACCOUNT_COOKIE.length + 1).split('.'), claims = JSON.parse(Buffer.from(jwt[1], 'base64url'));
|
|
assert.ok(!JSON.stringify(claims).includes('access_token'));
|
|
for (const forged of [
|
|
[jwt[0], Buffer.from(JSON.stringify({ ...claims, sub: b.state.playerId })).toString('base64url'), jwt[2]],
|
|
[Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'), jwt[1], jwt[2]],
|
|
]) assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${forged.join('.')}` })).status, 401);
|
|
for (const invalid of [{ iss: 'another-server' }, { aud: 'another-app' }, { iat: claims.exp + 1 }, { role: roleKey(role('456')) }]) {
|
|
const body = Buffer.from(JSON.stringify({ ...claims, ...invalid })).toString('base64url');
|
|
const input = jwt[0] + '.' + body;
|
|
const signed = input + '.' + createHmac('sha256', authSecret).update(input).digest('base64url');
|
|
assert.equal((await request(server, '/skland/session/status', { ...a, cookie: `${ACCOUNT_COOKIE}=${signed}` })).status, 401);
|
|
}
|
|
await request(server, '/skland/session/unbind', { ...b, body: { player: a.state.playerId } });
|
|
assert.equal((await request(server, '/skland/session/status', a)).body.bound, true, 'B only unbinds B');
|
|
await request(server, '/skland/session/unbind', { ...a, body: {} });
|
|
assert.equal((await request(server, '/skland/session/status', a)).status, 401);
|
|
assert.equal((await request(server, '/skland/session/status', { token: a.token })).body.bound, false, 'old transport cannot reissue JWT');
|
|
} finally { await server.close(); }
|
|
});
|
|
|
|
test('late provider replies cannot restore credentials after unbind or replace a newer scan', async () => {
|
|
let resolveScan, resolveBinding, announceBinding, scans = 0, tokenExchanges = 0;
|
|
const binding = { list: [{ appCode: 'arknights', bindingList: [role()] }] };
|
|
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:', client: {
|
|
genScan: async () => ({ scanId: String(++scans), scanUrl: 'test:qr' }),
|
|
checkScan: async () => new Promise((resolve) => { resolveScan = resolve; }),
|
|
tokenByScanCode: async () => { tokenExchanges++; return { token: 'TEST' }; },
|
|
tokenByPhonePassword: async () => ({ token: 'TEST' }),
|
|
getAccountBinding: async () => { announceBinding(); return new Promise((resolve) => { resolveBinding = resolve; }); },
|
|
} } });
|
|
try {
|
|
const service = server.skland;
|
|
for (const action of [() => service.unbind('pending'), () => service.startScan('pending')]) {
|
|
await service.startScan('pending');
|
|
const pending = service.pollScan('pending');
|
|
await action(); resolveScan({ state: 'confirmed', scanCode: 'OLD' });
|
|
assert.equal((await pending).state, 'none');
|
|
assert.equal(service.status('pending').bound, false);
|
|
}
|
|
assert.equal(tokenExchanges, 0);
|
|
const reachedBinding = new Promise((resolve) => { announceBinding = resolve; });
|
|
const password = service.loginByPhonePassword('pending', '13800000000', 'test');
|
|
await reachedBinding; service.unbind('pending'); resolveBinding(binding);
|
|
assert.equal((await password).ok, false);
|
|
assert.equal(service._store.get('pending').access_token, null);
|
|
assert.equal(service.status('pending').bound, false);
|
|
} finally { await server.close(); }
|
|
});
|
|
|
|
test('JWT and bindings survive restart, expire after 30 days, and credential files cannot be downloaded', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-account-'));
|
|
let at = Date.now(), server;
|
|
const options = { port: 0, host: '127.0.0.1', quiet: true, publicDir: dir,
|
|
skland: { storeFile: path.join(dir, 'private.db'), authNow: () => at } };
|
|
try {
|
|
server = await startServer(options); const a = await login(server);
|
|
for (const name of ['private.db', 'PRIVATE.DB', 'private.db-wal', 'private.db-shm']) assert.equal((await fetch(server.url + '/' + name)).status, 404);
|
|
try { fs.symlinkSync(path.join(dir, 'private.db'), path.join(dir, 'alias.txt'));
|
|
assert.equal((await fetch(server.url + '/alias.txt')).status, 404);
|
|
} catch (error) { if (!['EPERM', 'EACCES'].includes(error.code)) throw error; }
|
|
await server.close(); server = await startServer(options);
|
|
const fresh = server.registry.create('new transport');
|
|
const restored = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie });
|
|
assert.equal(restored.body.gamePlayerId, a.state.gamePlayerId); assert.equal(restored.body.playerId, a.state.playerId);
|
|
assert.equal(restored.body.bound, true);
|
|
at += (LOGIN_TTL + 1) * 1000;
|
|
const expired = await request(server, '/skland/session/status', { token: fresh.token, cookie: a.cookie });
|
|
assert.equal(expired.status, 401); assert.match(expired.setCookie, /Max-Age=0/);
|
|
} finally { await server?.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('game identity uses UID plus region, permits duplicate nicknames, and blocks anonymous game requests', async () => {
|
|
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, skland: { storeFile: ':memory:' } });
|
|
const sockets = [];
|
|
try {
|
|
const accounts = [await login(server), await login(server, [role('456')]), await login(server, [role('123', 'B服', 2)])];
|
|
for (const account of accounts) sockets.push(await socket(server, account));
|
|
const welcomes = sockets.map((client) => client.welcome);
|
|
assert.ok(welcomes.every((welcome) => welcome.name === '同名博士'));
|
|
assert.equal(new Set(welcomes.map((welcome) => welcome.playerId)).size, 3);
|
|
assert.equal((await sockets[0].send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'ok');
|
|
const anonymous = await socket(server, { token: welcomes[0].token }); sockets.push(anonymous);
|
|
assert.notEqual(anonymous.welcome.playerId, welcomes[0].playerId);
|
|
assert.equal((await anonymous.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error');
|
|
assert.equal((await request(server, '/skland/session/status', { token: welcomes[0].token, cookie: accounts[1].cookie })).status, 403);
|
|
const reconnect = await socket(server, accounts[1]); sockets.push(reconnect);
|
|
assert.equal(reconnect.welcome.playerId, welcomes[1].playerId);
|
|
await request(server, '/skland/session/unbind', { token: reconnect.welcome.token, cookie: accounts[1].cookie, body: {} });
|
|
assert.equal((await reconnect.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'error');
|
|
} finally { for (const client of sockets) client.ws.terminate(); await server.close(); }
|
|
});
|
|
|
|
test('a disabled server keeps the account surface off', async () => {
|
|
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
|
try { assert.equal((await fetch(server.url + '/skland/session/status')).status, 404); }
|
|
finally { await server.close(); }
|
|
});
|