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
196 lines
11 KiB
JavaScript
196 lines
11 KiB
JavaScript
// 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 })));
|
|
});
|