// Independent devices have independent login cookies and no shared browser storage. No provider network calls. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { once } from 'node:events'; import { startServer } from '../../server/index.js'; import { TestClient } from '../helpers/wsClient.js'; import { roleKey } from '../../shared/accountIdentity.js'; import { validateC2S } from '../../shared/protocol.js'; import { accountPlayerId } from '../../server/skland/auth.js'; const role = (uid = '123', channelName = '官服', extra = {}) => ({ uid, channelName, nickName: '同名博士', ...extra }); async function fixture(t) { const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true, assetIndex: false, skland: { storeFile: ':memory:' } }); const clients = []; t.after(async () => { await Promise.all(clients.map((client) => client.close())); await server.close(); }); const connect = async (cookie, token) => { const client = await TestClient.connect(server.url.replace('http:', 'ws:') + '/ws', { wsOptions: { headers: cookie ? { cookie } : {} }, }); clients.push(client); const welcome = await client.hello('Transport', token); return { client, token: welcome.token, welcome }; }; const request = async (device, endpoint, body, headers = {}) => { const response = await fetch(server.url + '/skland/session/' + endpoint, { method: body === undefined ? 'GET' : 'POST', headers: { authorization: 'Bearer ' + device.token, ...(device.cookie ? { cookie: device.cookie } : {}), 'content-type': 'application/json', ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const cookie = response.headers.get('set-cookie'); if (cookie) device.cookie = cookie.split(';')[0]; return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') }; }; const device = async (roles = [role()]) => { const d = await connect(); server.skland._store.update('@session/' + d.welcome.playerId, { status: 'bound', nickname: roles[0].nickName, binding: { list: [{ appCode: 'arknights', bindingList: roles }] } }); const result = await request(d, 'status'); assert.equal(result.status, 200); d.state = result.body; return d; }; const enter = async (d, id) => { const result = await request(d, id ? 'resume' : 'enter', id ? { id } : {}); assert.equal(result.status, 200, JSON.stringify(result.body)); await d.client.close(); Object.assign(d, await connect(d.cookie, result.body.token)); assert.equal(d.welcome.playerId, result.body.gamePlayerId); assert.notEqual(d.token, result.body.token, 'the accepted socket rotates the account token'); return d; }; const start = async (d, mode = 'solo') => { await enter(d); assert.equal((await d.client.request({ t: 'room.create', mode, difficulty: 'NORMAL' })).t, 'ok'); const state = await d.client.waitFor('room.state'); assert.equal((await d.client.request({ t: 'room.start' })).t, 'ok'); const room = server.lobby.rooms.get(state.code); assert.ok(room.match, 'uses the real Match implementation'); await d.client.waitFor('m.public'); return room; }; const disconnect = async (d) => { const session = server.registry.byToken(d.token); const closed = once(session.ws, 'close'); await d.client.close(); await closed; }; return { server, connect, request, device, enter, start, disconnect }; } test('a fresh device queries the selected role and explicitly resumes the same live match and private state', async (t) => { const h = await fixture(t), a = await h.device(); const room = await h.start(a), match = room.match, player = match.players.get(a.welcome.playerId); player.funds = 37; player.shop.freeRefreshes = 2; const before = player.privateView(), oldToken = a.token; const b = await h.device([role(123, '官服', { nickName: '已改名', gameId: null })]); assert.notEqual(a.cookie, b.cookie, 'the device logs in independently, not by copying a JWT'); assert.equal(a.state.gamePlayerId, b.state.gamePlayerId, 'nickname and optional metadata do not select another identity'); assert.equal(a.client.isOpen, true, 'login alone does not take over the active device'); assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false); const result = await h.request(b, 'recovery'); const item = result.body.recovery; assert.equal(result.status, 200); assert.match(result.cache, /no-store/); assert.equal(item.code, room.code); assert.equal(item.inMatch, true); assert.equal(item.connected, true); assert.equal(Object.hasOwn(item, 'token'), false, 'the read-only summary exposes no reconnect credential'); assert.equal(a.client.isOpen, true, 'querying a match is not resuming it'); await h.enter(b, item.id); assert.equal((await a.client.closed).code, 4001, 'only successful explicit recovery replaces the old device'); assert.equal(b.welcome.playerId, a.welcome.playerId); assert.equal(room.match, match); assert.equal((await b.client.waitFor('room.state')).code, room.code); assert.deepEqual(await b.client.waitFor('m.private'), before); assert.equal(h.server.registry.byToken(oldToken), null, 'the old device token was revoked'); const stale = await h.connect(a.cookie, oldToken); assert.notEqual(stale.welcome.playerId, b.welcome.playerId, 'an old device cannot silently take the role back'); assert.equal(b.client.isOpen, true); assert.equal((await stale.client.request({ t: 'g.ready' })).t, 'error'); }); test('disconnected solo and co-op matches use the existing account-independent reconnect windows', async (t) => { for (const mode of ['solo', 'coop']) await t.test(mode, async (t) => { const h = await fixture(t), a = await h.device(); const room = await h.start(a, mode), session = h.server.registry.byId(a.welcome.playerId); await h.disconnect(a); const at = session.disconnectedAt; assert.ok(at != null); assert.equal(h.server.registry.windowOf(session), mode === 'solo' ? 86_400_000 : 600_000); const b = await h.device(); const item = (await h.request(b, 'recovery')).body.recovery; assert.equal(item.connected, false); assert.equal(session.disconnectedAt, at, 'looking at a recovery never renews its deadline'); await h.enter(b, item.id); assert.equal((await b.client.waitFor('room.state')).code, room.code); assert.equal(session.disconnectedAt, null); assert.equal(session.connected, true); }); }); test('recovery never follows a supplied player, nickname, UID, region or foreign recovery selector', async (t) => { const h = await fixture(t), a = await h.device(), room = await h.start(a); const item = (await h.request(a, 'recovery')).body.recovery; const strangers = [await h.device([role('456')]), await h.device([role('123', 'B服')]), await h.device([role('123'), role('456', '官服', { isDefault: true })])]; for (const b of strangers) { assert.equal((await h.request(b, 'recovery?player=' + a.welcome.playerId)).body.recovery, null); const result = await h.request(b, 'resume', { id: item.id, player: a.welcome.playerId, uid: '123', channelName: '官服', name: a.welcome.name }); assert.equal(result.status, 409); assert.equal(result.body.code, 'resume_expired'); const stolen = await h.connect(b.cookie, a.token); assert.notEqual(stolen.welcome.playerId, a.welcome.playerId); assert.equal(stolen.client.log.some((frame) => frame.t === 'm.private'), false); } const multi = strangers[2]; assert.equal((await h.request(multi, 'role/select', { roleKey: roleKey(role('123')) })).status, 200); assert.equal((await h.request(multi, 'recovery')).body.recovery.code, room.code); const anonymous = await h.connect(); assert.equal((await h.request(anonymous, 'recovery')).status, 401); assert.equal((await h.request(anonymous, 'resume', { id: item.id })).status, 401); for (const endpoint of ['enter', 'resume']) { assert.equal((await h.request(multi, endpoint, { id: item.id }, { origin: 'https://attacker.invalid' })).status, 403); } assert.equal(a.client.isOpen, true); }); test('a stale or expired recovery is refused without creating a session; later entry disposes the old seat first', async (t) => { const h = await fixture(t), a = await h.device(), room = await h.start(a); const session = h.server.registry.byId(a.welcome.playerId), item = (await h.request(a, 'recovery')).body.recovery; await h.disconnect(a); const at = session.disconnectedAt + h.server.registry.windowOf(session) + 1; h.server.registry.now = () => at; h.server.network.now = () => at; const b = await h.device(); assert.equal((await h.request(b, 'recovery')).body.recovery, null); const stale = await h.request(b, 'resume', { id: item.id }); assert.equal(stale.status, 409); assert.equal(stale.body.code, 'resume_expired'); assert.equal(h.server.registry.byId(session.playerId), session, 'a failed restore does not mint a fresh game'); await h.enter(b); assert.notEqual(h.server.registry.byId(session.playerId), session); assert.equal(session.roomCode, null); assert.equal(room.disposed, true, 'the expired solo match is abandoned, not orphaned under the stable playerId'); assert.equal((await h.request(b, 'recovery')).body.recovery, null); assert.equal(b.client.log.some((frame) => frame.t === 'm.private'), false); }); test('a room that starts a later match invalidates the old selector without stealing its connected seat', async (t) => { const h = await fixture(t), a = await h.device(), b = await h.device(); await h.enter(a); assert.equal((await a.client.request({ t: 'room.create', mode: 'solo', difficulty: 'NORMAL' })).t, 'ok'); const first = (await h.request(b, 'recovery')).body.recovery; assert.equal(first.inMatch, false); assert.equal((await a.client.request({ t: 'room.start' })).t, 'ok'); assert.equal((await h.request(b, 'resume', { id: first.id })).status, 409); const current = (await h.request(b, 'recovery')).body.recovery; assert.notEqual(current.id, first.id); assert.equal(current.code, first.code); assert.equal(a.client.isOpen, true); await h.enter(b, current.id); assert.equal((await a.client.closed).code, 4001); }); test('stable account identities and normal fields fit every existing client message limit', () => { const playerId = accountPlayerId(role()), fieldId = 'n:' + playerId; const perPlayer = { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, unitsEnd: [] }; for (const message of [ { t: 'room.kick', seat: 1, playerId }, { t: 'room.removeSpectator', playerId }, { t: 'g.watch', fieldId, playerId }, { t: 'b.result', battleId: 'match.1', result: { reason: 'cleared', time: 1, perPlayer: { [playerId]: perPlayer } } }, ]) assert.equal(validateC2S(message), null, message.t + ' accepts the account identity'); assert.equal(playerId, accountPlayerId(role(123, ' 官服 ', { nickName: '新名字', gameId: 2 }))); });