import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { generateKeyPairSync, sign } from 'node:crypto'; import { defaultDiscordSettings, validateDiscordSettings } from '../../src/lib/discord.js'; import { routePermission, roleAllows } from '../../src/lib/server/auth/access.js'; import { completeDiscordInteraction, validInteractionEnvelope, verifiedDiscordBody } from '../../src/lib/server/discord/webhook.js'; const { privateKey, publicKey } = generateKeyPairSync('ed25519'); const applicationId = '123456789012345678'; const guildId = '223456789012345678'; const userId = '323456789012345678'; const settings = vi.hoisted(() => ({ enabled: true, guildId: '223456789012345678' })); const handle = vi.hoisted(() => vi.fn()); vi.mock('../../src/lib/server/discord/store.js', () => ({ getDiscordSettings: () => settings, claimDiscordInteraction: () => true })); vi.mock('../../src/lib/server/discord/interactions.js', () => ({ handleDiscordInteraction: handle })); function signedRequest(body: unknown, timestamp = String(Math.floor(Date.now() / 1000))) { const raw = typeof body === 'string' ? body : JSON.stringify(body); const signature = sign(null, Buffer.from(timestamp + raw), privateKey).toString('hex'); return new Request('https://ec.example/api/discord/interactions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Signature-Ed25519': signature, 'X-Signature-Timestamp': timestamp }, body: raw }); } const interaction = () => ({ id: '423456789012345678', application_id: applicationId, guild_id: guildId, token: 'private-interaction-token', type: 2, member: { user: { id: userId } }, data: { name: 'servers' } }); beforeEach(() => { vi.resetModules(); vi.stubEnv( 'EC_DISCORD_PUBLIC_KEY', publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('hex') ); vi.stubEnv('EC_DISCORD_APPLICATION_ID', applicationId); settings.enabled = true; handle.mockReset().mockResolvedValue({ type: 4, data: { flags: 64, content: 'Private results', allowed_mentions: { parse: [] } } }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); describe('Discord signed webhook boundary', () => { it('verifies original bytes and accepts signed PING before enabling a bot', async () => { expect(await verifiedDiscordBody(signedRequest({ type: 1 }))).toEqual({ type: 1 }); settings.enabled = false; const { POST } = await import('../../src/routes/api/discord/interactions/+server.js'); expect(await (await POST({ request: signedRequest({ type: 1 }) })).json()).toEqual({ type: 1 }); expect(handle).not.toHaveBeenCalled(); }); it('rejects tampering, missing signatures and timestamps outside the replay window', async () => { const original = signedRequest({ type: 1 }); const altered = new Request(original.url, { method: 'POST', headers: original.headers, body: '{"type":2}' }); await expect(verifiedDiscordBody(altered)).rejects.toMatchObject({ status: 401 }); await expect( verifiedDiscordBody(new Request(original.url, { method: 'POST', body: '{}' })) ).rejects.toMatchObject({ status: 401 }); for (const offset of [-360_000, 360_000]) await expect( verifiedDiscordBody( signedRequest({ type: 1 }, String(Math.floor((Date.now() + offset) / 1000))) ) ).rejects.toMatchObject({ status: 401 }); }); it('bounds signed request bodies and rejects malformed objects', async () => { await expect( verifiedDiscordBody(signedRequest('a'.repeat(65 * 1024))) ).rejects.toMatchObject({ status: 413 }); await expect(verifiedDiscordBody(signedRequest('not json'))).rejects.toMatchObject({ status: 400 }); await expect(verifiedDiscordBody(signedRequest([]))).rejects.toMatchObject({ status: 400 }); }); it('requires the configured application and a real guild member identity', () => { expect(validInteractionEnvelope(interaction())).toBe(true); expect(validInteractionEnvelope({ ...interaction(), application_id: guildId })).toBe(false); expect(validInteractionEnvelope({ ...interaction(), guild_id: undefined })).toBe(false); expect( validInteractionEnvelope({ ...interaction(), member: {}, user: { id: userId } }) ).toBe(false); }); it('does not dispatch disabled or foreign-guild interactions', async () => { const { POST } = await import('../../src/routes/api/discord/interactions/+server.js'); const foreign = await POST({ request: signedRequest({ ...interaction(), guild_id: userId }) }); expect((await foreign.json()).data.flags).toBe(64); settings.enabled = false; expect( (await (await POST({ request: signedRequest(interaction()) })).json()).data.flags ).toBe(64); expect(handle).not.toHaveBeenCalled(); }); it('defers privately, dispatches once per interaction and delivers without bot credentials', async () => { const fetcher = vi.fn().mockResolvedValue(new Response('{}')); vi.stubGlobal('fetch', fetcher); const { POST } = await import('../../src/routes/api/discord/interactions/+server.js'); const pending = { type: 5, data: { flags: 64 } }; expect(await (await POST({ request: signedRequest(interaction()) })).json()).toEqual( pending ); expect(await (await POST({ request: signedRequest(interaction()) })).json()).toEqual( pending ); expect(handle).not.toHaveBeenCalled(); await new Promise((resolve) => setImmediate(resolve)); expect(handle).toHaveBeenCalledTimes(1); expect(fetcher).toHaveBeenCalledOnce(); const [url, init] = fetcher.mock.calls[0]; expect(url).toContain( `/webhooks/${applicationId}/private-interaction-token/messages/@original` ); expect(init.headers.Authorization).toBeUndefined(); expect(JSON.parse(init.body).allowed_mentions).toEqual({ parse: [] }); }); it('prevents recipient mentions and reports sanitized delivery errors', async () => { const fetcher = vi .fn() .mockResolvedValue(new Response('secret error body', { status: 403 })); await expect( completeDiscordInteraction( applicationId, 'secret/token', { content: '@everyone', allowed_mentions: { parse: ['everyone'] } }, fetcher ) ).rejects.toThrow('Discord interaction delivery failed (403).'); const [url, init] = fetcher.mock.calls[0]; expect(url).toContain('secret%2Ftoken'); expect(JSON.parse(init.body).allowed_mentions.parse).toEqual([]); }); it('honors short rate limits and rechecks publication before retrying', async () => { vi.useFakeTimers(); let published = true; const fetcher = vi .fn() .mockResolvedValueOnce( new Response(JSON.stringify({ retry_after: 2 }), { status: 429 }) ) .mockResolvedValueOnce(new Response('{}')); const delivery = completeDiscordInteraction( applicationId, 'token', { content: 'Private player names', allowed_mentions: { parse: [] } }, fetcher, () => published ); await vi.advanceTimersByTimeAsync(1999); expect(fetcher).toHaveBeenCalledTimes(1); published = false; await vi.advanceTimersByTimeAsync(1); await delivery; expect(fetcher).toHaveBeenCalledTimes(2); const payload = fetcher.mock.calls[1][1].body; expect(payload).not.toContain('Private player names'); expect(payload).toContain('Publication settings changed'); }); }); describe('Discord configuration and access', () => { it('defaults to no publication and validates IDs and an explicit bounded allowlist', () => { expect(validateDiscordSettings(defaultDiscordSettings)).toEqual(defaultDiscordSettings); expect(defaultDiscordSettings.enabled).toBe(false); expect(defaultDiscordSettings.showPlayers).toBe(false); expect(defaultDiscordSettings.showAddresses).toBe(false); expect(defaultDiscordSettings.leaderboardEnabled).toBe(false); expect(() => validateDiscordSettings({ ...defaultDiscordSettings, enabled: true }) ).toThrow(); expect(() => validateDiscordSettings({ ...defaultDiscordSettings, guildId: 'bad' }) ).toThrow(); expect(() => validateDiscordSettings({ ...defaultDiscordSettings, publishedServers: ['../../secrets'] }) ).toThrow(); expect(() => validateDiscordSettings({ ...defaultDiscordSettings, publishedServers: Array(101).fill('t5_map_1') }) ).toThrow(); expect( validateDiscordSettings({ ...defaultDiscordSettings, publishedServers: ['t5_map_2', 't5_map_1', 't5_map_1'] }).publishedServers ).toEqual(['t5_map_1', 't5_map_2']); }); it('limits browser configuration writes to administrators and exempts only exact signed POSTs', () => { for (const route of [ '/api/integrations/discord', '/api/integrations/discord/register', '/settings/integrations/discord' ]) { expect(routePermission(route, 'GET')).toBe('admin-read'); expect(roleAllows('super_viewer', routePermission(route, 'PUT'))).toBe(false); expect(roleAllows('operator', routePermission(route, 'GET'))).toBe(false); expect(roleAllows('admin', routePermission(route, 'PUT'))).toBe(true); } expect(routePermission('/api/discord/interactions', 'POST')).toBe('machine'); expect(routePermission('/api/discord/interactions', 'GET')).not.toBe('machine'); expect(routePermission('/api/discord/interactions/other', 'POST')).not.toBe('machine'); expect(routePermission('/api/discord/interactions-extra', 'POST')).not.toBe('machine'); }); });