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