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 { 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[] { const result: Record[] = []; const visit = (component: Record) => { result.push(component); for (const child of (component.components ?? []) as Record[]) visit(child); if (component.accessory) visit(component.accessory as Record); }; 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'); }); });