This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user