This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
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: '' },
|
||||
publishedServers: [name]
|
||||
};
|
||||
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('projects only opted-in servers and 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,
|
||||
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: null, round: null, playerNames: [] });
|
||||
});
|
||||
|
||||
it('never treats stale or previous-process capacity as an available space', () => {
|
||||
for (const change of [
|
||||
{ capacity_observed_at: new Date(now - 100000).toISOString() },
|
||||
{ telemetry_after: new Date(now - 5000).toISOString() }
|
||||
]) {
|
||||
const projected = projectFleet([server(change)], settings, now);
|
||||
expect(projected[0].capacity).toBeNull();
|
||||
expect(filterServers(projected, { status: 'space' })).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
const setup = { ...settings, publishedServers: [town.name] };
|
||||
expect(projectFleet([town], setup, now)[0]).toMatchObject({
|
||||
map: 'Town Survival',
|
||||
mapKey: 't6:zm_transit:town'
|
||||
});
|
||||
expect(projectFleet([{ ...town, last_map: 'zm_tomb' }], setup, 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.publishedServers = [];
|
||||
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.publishedServers = [];
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user