523 lines
20 KiB
TypeScript
523 lines
20 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
defaultDiscordSettings,
|
|
type DiscordMessage,
|
|
type DiscordSettings
|
|
} from '../../src/lib/discord.js';
|
|
import type { FleetServer } from '../../src/lib/fleet-overview.js';
|
|
import type { LeaderboardResponse } from '../../src/lib/leaderboard.js';
|
|
import { gameEvent } from '../helpers/telemetry.js';
|
|
|
|
vi.mock('../../src/lib/server/fleet/snapshot.js', () => ({ fleetSnapshot: vi.fn() }));
|
|
vi.mock('../../src/lib/server/reports/index.js', () => ({ leaderboardReport: vi.fn() }));
|
|
vi.mock('../../src/lib/server/discord/store.js', () => ({
|
|
getDiscordSettings: vi.fn(),
|
|
subscribeToMap: vi.fn(),
|
|
listSubscriptions: vi.fn(),
|
|
cancelSubscription: vi.fn()
|
|
}));
|
|
|
|
import { fleetSnapshot } from '../../src/lib/server/fleet/snapshot.js';
|
|
import { leaderboardReport } from '../../src/lib/server/reports/index.js';
|
|
import {
|
|
getDiscordSettings,
|
|
subscribeToMap,
|
|
listSubscriptions,
|
|
cancelSubscription
|
|
} from '../../src/lib/server/discord/store.js';
|
|
import {
|
|
discordServerKey,
|
|
discordText,
|
|
projectFleet
|
|
} from '../../src/lib/server/discord/projection.js';
|
|
import {
|
|
browserId,
|
|
filterServers,
|
|
renderBoard,
|
|
renderBrowser,
|
|
renderServer
|
|
} from '../../src/lib/server/discord/render.js';
|
|
import {
|
|
handleDiscordInteraction,
|
|
type DiscordInteraction
|
|
} from '../../src/lib/server/discord/interactions.js';
|
|
import { discordCommands, discordMaps } from '../../src/lib/server/discord/commands.js';
|
|
|
|
const now = Date.parse('2026-10-18T20:00:00Z');
|
|
const stamp = new Date(now - 10000).toISOString();
|
|
const guildId = '111111111111111111';
|
|
const userId = '222222222222222222';
|
|
const applicationId = '333333333333333333';
|
|
const name = 't5_zombie_theater_1';
|
|
let settings: DiscordSettings;
|
|
|
|
function server(overrides: Partial<FleetServer> = {}): FleetServer {
|
|
return {
|
|
name,
|
|
game: 't5',
|
|
map: 'zombie_theater',
|
|
map_slug: 'theater',
|
|
game_mode: 'zombie',
|
|
health: 'online',
|
|
last_players: 2,
|
|
capacity: 4,
|
|
capacity_observed_at: stamp,
|
|
last_map: 'zombie_theater',
|
|
last_gametype: 'zombie',
|
|
last_seen_at: stamp,
|
|
last_error: null,
|
|
held: [],
|
|
active_since: new Date(now - 60000).toISOString(),
|
|
inactive_since: null,
|
|
game_started_at: new Date(now - 60000).toISOString(),
|
|
game_ended_at: null,
|
|
roster: [
|
|
{
|
|
name: '@everyone <@123> **spoof**',
|
|
guid: 'private-guid',
|
|
score: 1000,
|
|
num: 0,
|
|
ping: 10
|
|
}
|
|
],
|
|
telemetry: { ...gameEvent('heartbeat', 28, 2), receivedAt: stamp },
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function interaction(data: DiscordInteraction['data'], type = 2): DiscordInteraction {
|
|
return {
|
|
type,
|
|
application_id: applicationId,
|
|
guild_id: guildId,
|
|
member: { user: { id: userId } },
|
|
data
|
|
};
|
|
}
|
|
|
|
function components(message: DiscordMessage): Record<string, unknown>[] {
|
|
const result: Record<string, unknown>[] = [];
|
|
const visit = (component: Record<string, unknown>) => {
|
|
result.push(component);
|
|
for (const child of (component.components ?? []) as Record<string, unknown>[]) visit(child);
|
|
if (component.accessory) visit(component.accessory as Record<string, unknown>);
|
|
};
|
|
for (const component of message.components ?? []) visit(component);
|
|
return result;
|
|
}
|
|
|
|
function messageText(message: DiscordMessage): string {
|
|
return components(message)
|
|
.map((component) => component.content ?? '')
|
|
.join('\n');
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(now);
|
|
vi.stubEnv('EC_DISCORD_APPLICATION_ID', applicationId);
|
|
settings = {
|
|
...structuredClone(defaultDiscordSettings),
|
|
enabled: true,
|
|
guildId,
|
|
channels: { t5: '444444444444444444', t6: '' },
|
|
excludedServers: []
|
|
};
|
|
vi.mocked(getDiscordSettings).mockImplementation(() => settings);
|
|
vi.mocked(fleetSnapshot).mockReturnValue({ servers: [server()] } as ReturnType<
|
|
typeof fleetSnapshot
|
|
>);
|
|
vi.mocked(subscribeToMap).mockReturnValue({ id: 'watch-123', expiresAt: now + 86400000 });
|
|
vi.mocked(listSubscriptions).mockReturnValue([]);
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('Discord publication boundary', () => {
|
|
it('publishes current and newly added servers by default while respecting exclusions', () => {
|
|
const original = server();
|
|
const added = server({ name: 't5_zombie_theater_2' });
|
|
expect(projectFleet([original], settings, now).map((item) => item.name)).toEqual([name]);
|
|
expect(projectFleet([original, added], settings, now).map((item) => item.name)).toEqual([
|
|
name,
|
|
added.name
|
|
]);
|
|
expect(
|
|
projectFleet([original, added], { ...settings, excludedServers: [name] }, now).map(
|
|
(item) => item.name
|
|
)
|
|
).toEqual([added.name]);
|
|
});
|
|
|
|
it('excludes unpublished servers and projects only explicitly approved fields', () => {
|
|
const raw = {
|
|
...server(),
|
|
host: { id: 'internal-host' },
|
|
endpoint: { host: 'secret-rcon', port: 28960 },
|
|
network: { publicAddress: 'games.example.com', publicPort: 28960 },
|
|
secret: 'token-value'
|
|
};
|
|
const projected = projectFleet(
|
|
[raw as unknown as FleetServer, server({ name: 'unpublished' })],
|
|
{ ...settings, excludedServers: ['unpublished'] },
|
|
now
|
|
);
|
|
expect(projected).toHaveLength(1);
|
|
expect(projected[0]).toMatchObject({
|
|
name,
|
|
state: 'in-progress',
|
|
players: 2,
|
|
capacity: 4,
|
|
round: 28,
|
|
mapKey: 't5:zombie_theater',
|
|
playerNames: [],
|
|
connection: null
|
|
});
|
|
for (const secret of [
|
|
'internal-host',
|
|
'secret-rcon',
|
|
'private-guid',
|
|
'token-value',
|
|
'@everyone'
|
|
])
|
|
expect(JSON.stringify(projected)).not.toContain(secret);
|
|
const optedIn = projectFleet(
|
|
[raw as unknown as FleetServer],
|
|
{ ...settings, showPlayers: true, showAddresses: true },
|
|
now
|
|
)[0];
|
|
expect(optedIn.playerNames).toHaveLength(1);
|
|
expect(optedIn.connection).toBe('games.example.com:28960');
|
|
expect(JSON.stringify(optedIn)).not.toContain('private-guid');
|
|
expect(
|
|
projectFleet([server()], { ...settings, showAddresses: true }, now)[0].connection
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
[{ last_players: 0 }, 'empty', 0],
|
|
[{ last_players: 4 }, 'full', 4],
|
|
[{ last_players: null }, 'unknown', null],
|
|
[{ health: 'starting' }, 'starting', null],
|
|
[{ health: 'offline' }, 'offline', null],
|
|
[{ health: 'stale' }, 'stale', null],
|
|
[{ last_seen_at: new Date(now - 100000).toISOString() }, 'stale', null],
|
|
[{ last_error: 'timeout: secret diagnostics' }, 'stale', null]
|
|
] as const)('reports truthful state for %j', (change, state, players) => {
|
|
const projected = projectFleet([server(change)], settings, now)[0];
|
|
expect(projected).toMatchObject({ state, players });
|
|
if (players === null)
|
|
expect(projected).toMatchObject({ capacity: 4, round: null, playerNames: [] });
|
|
});
|
|
|
|
it('uses the four-player lobby limit when current capacity is unavailable', () => {
|
|
for (const change of [
|
|
{ capacity: null, capacity_observed_at: null },
|
|
{ capacity: 0 },
|
|
{ capacity: 8, capacity_observed_at: new Date(now - 100000).toISOString() },
|
|
{
|
|
capacity: 8,
|
|
capacity_observed_at: new Date(now - 20000).toISOString(),
|
|
telemetry_after: new Date(now - 15000).toISOString()
|
|
}
|
|
]) {
|
|
const projected = projectFleet([server(change)], settings, now);
|
|
expect(projected[0].capacity).toBe(4);
|
|
expect(filterServers(projected, { status: 'space' })).toEqual(projected);
|
|
for (const message of [
|
|
...renderBoard('t5', projected),
|
|
renderBrowser(projected),
|
|
renderServer(projected[0])
|
|
]) {
|
|
expect(messageText(message)).toContain('2/4 players');
|
|
expect(messageText(message)).not.toContain('2/?');
|
|
}
|
|
}
|
|
const full = projectFleet([server({ last_players: 4, capacity: null })], settings, now);
|
|
expect(full[0]).toMatchObject({ capacity: 4, state: 'full' });
|
|
expect(filterServers(full, { status: 'space' })).toEqual([]);
|
|
});
|
|
|
|
it('uses a current measured capacity when available', () => {
|
|
const projected = projectFleet([server({ capacity: 8, last_players: 4 })], settings, now);
|
|
expect(projected[0]).toMatchObject({ capacity: 8, state: 'in-progress' });
|
|
expect(messageText(renderServer(projected[0]))).toContain('4/8 players');
|
|
expect(filterServers(projected, { status: 'space' })).toEqual(projected);
|
|
});
|
|
|
|
it('never treats stale or previous-process occupancy as an available space', () => {
|
|
for (const change of [
|
|
{ last_seen_at: new Date(now - 100000).toISOString() },
|
|
{ health: 'stale' as const },
|
|
{ telemetry_after: new Date(now - 5000).toISOString() }
|
|
]) {
|
|
const projected = projectFleet([server({ ...change, capacity: null })], settings, now);
|
|
expect(projected[0]).toMatchObject({ capacity: 4, players: null });
|
|
expect(filterServers(projected, { status: 'space' })).toEqual([]);
|
|
expect(messageText(renderServer(projected[0]))).toContain('Players unknown');
|
|
}
|
|
});
|
|
|
|
it('preserves T6 variants and drops registered variants after a live map change', () => {
|
|
const town = server({
|
|
name: 't6_zombie_town_1',
|
|
game: 't6',
|
|
map: 'zm_transit',
|
|
last_map: 'zm_transit',
|
|
map_slug: 'town',
|
|
telemetry: undefined
|
|
});
|
|
expect(projectFleet([town], settings, now)[0]).toMatchObject({
|
|
map: 'Town Survival',
|
|
mapKey: 't6:zm_transit:town'
|
|
});
|
|
expect(projectFleet([{ ...town, last_map: 'zm_tomb' }], settings, now)[0]).toMatchObject({
|
|
map: 'Origins',
|
|
mapKey: 't6:zm_tomb:tomb'
|
|
});
|
|
});
|
|
|
|
it('neutralizes mentions, markdown links and control characters in rendered player names', () => {
|
|
const projected = projectFleet([server()], { ...settings, showPlayers: true }, now)[0];
|
|
const message = renderServer(projected);
|
|
expect(message.allowed_mentions).toEqual({ parse: [] });
|
|
expect(messageText(message)).not.toContain('@everyone');
|
|
expect(messageText(message)).not.toContain('<@123>');
|
|
expect(discordText('[link](https://example.com)\n@here')).toBe(
|
|
'\\[link\\]\\(https://example.com\\) @\u200bhere'
|
|
);
|
|
expect(JSON.stringify(message)).not.toContain('private-guid');
|
|
});
|
|
});
|
|
|
|
describe('native Discord messages', () => {
|
|
it('paginates large boards within Discord limits with unique component IDs', () => {
|
|
const fleet = Array.from({ length: 100 }, (_, i) => ({
|
|
...projectFleet([server()], settings, now)[0],
|
|
name: `server-${i}`,
|
|
label: '*'.repeat(128),
|
|
map: '_'.repeat(255),
|
|
players: Number.MAX_SAFE_INTEGER,
|
|
capacity: Number.MAX_SAFE_INTEGER,
|
|
round: Number.MAX_SAFE_INTEGER
|
|
}));
|
|
const boards = renderBoard('t5', fleet);
|
|
expect(boards).toHaveLength(13);
|
|
for (const message of [
|
|
...boards,
|
|
renderBrowser(fleet),
|
|
renderBrowser(fleet, { page: 1 })
|
|
]) {
|
|
const all = components(message);
|
|
expect(all.length).toBeLessThanOrEqual(40);
|
|
expect(messageText(message).length).toBeLessThanOrEqual(4000);
|
|
const ids = all.flatMap((component) =>
|
|
typeof component.custom_id === 'string' ? [component.custom_id] : []
|
|
);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
expect(ids.every((id) => id.length <= 100)).toBe(true);
|
|
expect(message).not.toHaveProperty('content');
|
|
expect(message).not.toHaveProperty('embeds');
|
|
}
|
|
expect(boards.every((message) => message.flags === 32768)).toBe(true);
|
|
expect(renderBrowser(fleet).flags).toBe(32832);
|
|
});
|
|
|
|
it('defines only supported guild commands with bounded choices and required options first', () => {
|
|
expect(discordCommands.map((command) => command.name)).toEqual([
|
|
'servers',
|
|
'server',
|
|
'leaderboard',
|
|
'watch',
|
|
'help'
|
|
]);
|
|
expect(JSON.stringify(discordCommands)).not.toContain('autocomplete');
|
|
interface Schema {
|
|
name: string;
|
|
description: string;
|
|
required?: boolean;
|
|
options?: Schema[];
|
|
choices?: { name: string; value: string | number }[];
|
|
}
|
|
const inspect = (entries: Schema[]) => {
|
|
expect(entries.length).toBeLessThanOrEqual(25);
|
|
expect(new Set(entries.map((entry) => entry.name)).size).toBe(entries.length);
|
|
let optional = false;
|
|
for (const entry of entries) {
|
|
expect(entry.name).toMatch(/^[a-z_-]{1,32}$/);
|
|
expect(entry.description.length).toBeLessThanOrEqual(100);
|
|
if (entry.required) expect(optional).toBe(false);
|
|
else optional = true;
|
|
if (entry.options) inspect(entry.options);
|
|
if (entry.choices) {
|
|
expect(entry.choices.length).toBeLessThanOrEqual(25);
|
|
for (const choice of entry.choices) {
|
|
expect(choice.name.length).toBeLessThanOrEqual(100);
|
|
expect(String(choice.value).length).toBeLessThanOrEqual(100);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
inspect(discordCommands);
|
|
for (const map of discordMaps) {
|
|
for (const status of ['all', 'empty', 'space', 'full', 'in-progress'] as const) {
|
|
expect(
|
|
`${browserId({ map: map.value, status, page: 100000 })}:next`.length
|
|
).toBeLessThanOrEqual(100);
|
|
}
|
|
}
|
|
expect(
|
|
discordCommands.find((command) => command.name === 'watch')?.options?.[0]
|
|
).toMatchObject({
|
|
name: 'add',
|
|
options: [
|
|
expect.objectContaining({ name: 'game', required: true }),
|
|
expect.objectContaining({ name: 'map', required: true }),
|
|
expect.objectContaining({ name: 'condition', required: true })
|
|
]
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Discord interaction isolation', () => {
|
|
it.each(['guild', 'user', 'application', 'disabled'] as const)(
|
|
'rejects unauthorized %s before reading fleet or reports',
|
|
async (failure) => {
|
|
const input = interaction({ name: 'servers' });
|
|
if (failure === 'guild') input.guild_id = '555555555555555555';
|
|
if (failure === 'user') input.member = {};
|
|
if (failure === 'application') input.application_id = '555555555555555555';
|
|
if (failure === 'disabled') settings.enabled = false;
|
|
const result = await handleDiscordInteraction(input);
|
|
expect(result.type).toBe(4);
|
|
expect(result.data.flags).toBe(32832);
|
|
expect(fleetSnapshot).not.toHaveBeenCalled();
|
|
expect(leaderboardReport).not.toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
it('opens board details privately and refuses old unpublished IDs', async () => {
|
|
const result = await handleDiscordInteraction(
|
|
interaction({ custom_id: `ec:d:${discordServerKey(name)}` }, 3)
|
|
);
|
|
expect(result.type).toBe(4);
|
|
expect(result.data.flags).toBe(32832);
|
|
expect(messageText(result.data)).toContain('Round 28');
|
|
settings.excludedServers = [name];
|
|
expect(
|
|
messageText(
|
|
(
|
|
await handleDiscordInteraction(
|
|
interaction({ custom_id: `ec:d:${discordServerKey(name)}` }, 3)
|
|
)
|
|
).data
|
|
)
|
|
).toContain('no longer published');
|
|
});
|
|
|
|
it('keeps filtered pagination private and rejects forged filters', async () => {
|
|
const result = await handleDiscordInteraction(
|
|
interaction(
|
|
{
|
|
custom_id: browserId({
|
|
game: 't5',
|
|
map: 't5:zombie_theater',
|
|
status: 'empty',
|
|
page: 900
|
|
})
|
|
},
|
|
3
|
|
)
|
|
);
|
|
expect(result.data.flags).toBe(32832);
|
|
expect(messageText(result.data)).toContain('0 matching servers');
|
|
const bad = await handleDiscordInteraction(interaction({ custom_id: 'ec:b:t7:all:0:' }, 3));
|
|
expect(messageText(bad.data)).toContain('invalid');
|
|
});
|
|
|
|
it('creates one-shot watches only for published maps and scopes cancellation to the actor', async () => {
|
|
const result = await handleDiscordInteraction(
|
|
interaction({ custom_id: `ec:w:empty:${discordServerKey('t5:zombie_theater')}` }, 3)
|
|
);
|
|
expect(subscribeToMap).toHaveBeenCalledWith({
|
|
guildId,
|
|
userId,
|
|
game: 't5',
|
|
mapKey: 't5:zombie_theater',
|
|
mode: 'empty'
|
|
});
|
|
expect(messageText(result.data)).toContain('one DM');
|
|
vi.mocked(subscribeToMap).mockClear();
|
|
settings.excludedServers = [name];
|
|
await handleDiscordInteraction(
|
|
interaction({ custom_id: `ec:w:space:${discordServerKey('t5:zombie_theater')}` }, 3)
|
|
);
|
|
expect(subscribeToMap).not.toHaveBeenCalled();
|
|
await handleDiscordInteraction(
|
|
interaction({
|
|
name: 'watch',
|
|
options: [{ name: 'cancel', options: [{ name: 'id', value: 'other-watch' }] }]
|
|
})
|
|
);
|
|
expect(cancelSubscription).toHaveBeenCalledWith('other-watch', guildId, userId);
|
|
});
|
|
|
|
it('requires explicit leaderboard publication and reuses the qualified report query without leaking identities', async () => {
|
|
const input = interaction({
|
|
name: 'leaderboard',
|
|
options: [
|
|
{ name: 'game', value: 't5' },
|
|
{ name: 'map', value: 't5:zombie_theater' },
|
|
{ name: 'metric', value: 'highestRound' },
|
|
{ name: 'squad', value: 2 },
|
|
{ name: 'page', value: 3 }
|
|
]
|
|
});
|
|
await handleDiscordInteraction(input);
|
|
expect(leaderboardReport).not.toHaveBeenCalled();
|
|
settings.leaderboardEnabled = true;
|
|
vi.mocked(leaderboardReport).mockResolvedValue({
|
|
metric: 'highestRound',
|
|
offset: 20,
|
|
total: 21,
|
|
limit: 10,
|
|
maps: [{ key: 't5:zombie_theater', name: 'Kino Der Toten' }],
|
|
entries: [
|
|
{
|
|
rank: 21,
|
|
name: '@everyone',
|
|
guid: 'private-guid',
|
|
highestRound: 30,
|
|
records: { server: 'private-server' }
|
|
}
|
|
]
|
|
} as unknown as LeaderboardResponse);
|
|
const result = await handleDiscordInteraction(input);
|
|
expect(leaderboardReport).toHaveBeenCalledWith({
|
|
game: 't5',
|
|
map: 't5:zombie_theater',
|
|
metric: 'highestRound',
|
|
players: 2,
|
|
offset: 20,
|
|
limit: 10
|
|
});
|
|
expect(messageText(result.data)).toContain('Completed matches');
|
|
expect(messageText(result.data)).not.toContain('@everyone');
|
|
expect(JSON.stringify(result)).not.toContain('private-guid');
|
|
expect(JSON.stringify(result)).not.toContain('private-server');
|
|
});
|
|
|
|
it('drops an in-flight leaderboard when publication is disabled', async () => {
|
|
settings.leaderboardEnabled = true;
|
|
vi.mocked(leaderboardReport).mockImplementation(async () => {
|
|
settings.leaderboardEnabled = false;
|
|
return {} as LeaderboardResponse;
|
|
});
|
|
const result = await handleDiscordInteraction(interaction({ name: 'leaderboard' }));
|
|
expect(messageText(result.data)).toContain('no longer enabled');
|
|
});
|
|
});
|