Files
electric-cherry/tests/unit/discord-interactions.test.ts
T
2026-10-18 16:55:49 -04:00

472 lines
18 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: '' },
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');
});
});