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

251 lines
10 KiB
TypeScript

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');
});
});