251 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|