332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
createDiscordIngressEnvelope,
|
|
verifyDiscordIngressEnvelope,
|
|
DiscordPlugin,
|
|
type DiscordIngressPayload,
|
|
resolveDiscordInteractionBinding,
|
|
} from '@mosaicstack/discord-plugin';
|
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
|
|
|
const SERVICE_TOKEN = 'test-service-token';
|
|
const ENV_KEYS = [
|
|
'DISCORD_SERVICE_TOKEN',
|
|
'DISCORD_SERVICE_USER_ID',
|
|
'DISCORD_SERVICE_TENANT_ID',
|
|
'DISCORD_INTERACTION_BINDINGS',
|
|
'DISCORD_ALLOWED_GUILD_IDS',
|
|
'DISCORD_ALLOWED_CHANNEL_IDS',
|
|
'DISCORD_ALLOWED_USER_IDS',
|
|
'MOSAIC_AGENT_NAME',
|
|
] as const;
|
|
const savedEnv = new Map<string, string | undefined>();
|
|
|
|
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
|
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
|
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
|
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
|
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
|
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
|
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
|
{
|
|
instanceId: 'Nova',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
pairedUsers: { 'user-001': role === 'admin' ? 'admin' : 'operator' },
|
|
},
|
|
]);
|
|
}
|
|
|
|
afterEach((): void => {
|
|
for (const key of ENV_KEYS) {
|
|
const value = savedEnv.get(key);
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
savedEnv.clear();
|
|
});
|
|
|
|
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
|
|
const entries = new Map<string, string>();
|
|
const db = {
|
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
|
};
|
|
const redis = {
|
|
get: async (key: string) => entries.get(key) ?? null,
|
|
set: async (key: string, value: string) => entries.set(key, value),
|
|
del: async (key: string) => Number(entries.delete(key)),
|
|
};
|
|
return new CommandAuthorizationService(db as never, redis);
|
|
}
|
|
|
|
function discordGateway(role: 'admin' | 'member'): {
|
|
gateway: ChatGateway;
|
|
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
|
} {
|
|
const authorization = commandAuthorization(role);
|
|
const runtimeRegistry = {
|
|
terminate: async (
|
|
providerId: string,
|
|
sessionId: string,
|
|
approvalId: string,
|
|
context: {
|
|
actorScope: { userId: string; tenantId: string };
|
|
channelId: string;
|
|
correlationId: string;
|
|
},
|
|
): Promise<void> => {
|
|
const approved = await authorization.consumeRuntimeTerminationApproval(approvalId, {
|
|
providerId,
|
|
sessionId,
|
|
actorId: context.actorScope.userId,
|
|
tenantId: context.actorScope.tenantId,
|
|
channelId: context.channelId,
|
|
correlationId: context.correlationId,
|
|
agentName: 'Nova',
|
|
});
|
|
if (!approved) throw new Error('approval denied');
|
|
},
|
|
};
|
|
return {
|
|
gateway: new ChatGateway(
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
authorization,
|
|
runtimeRegistry as never,
|
|
),
|
|
client: { data: { discordService: true }, emit: vi.fn() },
|
|
};
|
|
}
|
|
|
|
function ingressEnvelope(
|
|
content: string,
|
|
messageId: string,
|
|
): ReturnType<typeof createDiscordIngressEnvelope> {
|
|
return createDiscordIngressEnvelope(createPayload({ content, messageId }), SERVICE_TOKEN);
|
|
}
|
|
|
|
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
|
return {
|
|
correlationId: 'correlation-001',
|
|
messageId: 'discord-message-001',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
userId: 'user-001',
|
|
conversationId: 'discord-channel-001',
|
|
content: 'hello Tess',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('Discord ingress security', () => {
|
|
it('binds a differently named configured interaction instance without code changes', () => {
|
|
const binding = resolveDiscordInteractionBinding(
|
|
[
|
|
{
|
|
instanceId: 'Nova',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
pairedUsers: { 'user-001': 'operator' },
|
|
},
|
|
],
|
|
'guild-001',
|
|
'channel-001',
|
|
'user-001',
|
|
'send',
|
|
);
|
|
|
|
expect(binding?.instanceId).toBe('Nova');
|
|
});
|
|
|
|
it('accepts only the configured Discord service identity', () => {
|
|
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
|
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
|
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
|
|
});
|
|
|
|
it('rejects unauthenticated or tampered service envelopes', () => {
|
|
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
|
|
|
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
|
|
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
|
|
expect(
|
|
verifyDiscordIngressEnvelope(
|
|
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
|
|
SERVICE_TOKEN,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
['guild', { guildId: 'unlisted-guild' }],
|
|
['channel', { channelId: 'unlisted-channel' }],
|
|
['user', { userId: 'unlisted-user' }],
|
|
])(
|
|
'rejects an unallowlisted Discord %s',
|
|
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
|
|
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
|
|
|
|
expect(
|
|
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
|
guildIds: ['guild-001'],
|
|
channelIds: ['channel-001'],
|
|
userIds: ['user-001'],
|
|
}),
|
|
).toBeNull();
|
|
},
|
|
);
|
|
|
|
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
|
|
const payload = createPayload({
|
|
correlationId: 'correlation-trace-123',
|
|
messageId: 'discord-snowflake-987',
|
|
});
|
|
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
|
|
|
|
expect(
|
|
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
|
guildIds: ['guild-001'],
|
|
channelIds: ['channel-001'],
|
|
userIds: ['user-001'],
|
|
}),
|
|
).toEqual(payload);
|
|
});
|
|
|
|
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
|
|
const replayProtector = new DiscordReplayProtector(60_000, 2);
|
|
|
|
expect(replayProtector.claim('discord-message-001')).toBe(true);
|
|
expect(replayProtector.claim('discord-message-001')).toBe(false);
|
|
expect(replayProtector.claim('discord-message-002')).toBe(true);
|
|
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
|
expect(replayProtector.size).toBe(2);
|
|
});
|
|
|
|
it('mints a Discord approval and consumes it for the exact stop action', async () => {
|
|
configureDiscordEnv();
|
|
const { gateway, client } = discordGateway('admin');
|
|
await gateway.handleDiscordApproval(
|
|
client as never,
|
|
ingressEnvelope('/approve fleet runtime-1', 'approve-message'),
|
|
);
|
|
const approval = client.emit.mock.calls.find(
|
|
([event]) => event === 'discord:approval',
|
|
)?.[1] as {
|
|
approvalId: string;
|
|
success: boolean;
|
|
};
|
|
expect(approval.success).toBe(true);
|
|
|
|
await gateway.handleDiscordStop(
|
|
client as never,
|
|
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'stop-message'),
|
|
);
|
|
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
|
correlationId: 'correlation-001',
|
|
success: true,
|
|
});
|
|
});
|
|
|
|
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
|
configureDiscordEnv();
|
|
const { gateway, client } = discordGateway('member');
|
|
await gateway.handleDiscordApproval(
|
|
client as never,
|
|
ingressEnvelope('/approve fleet runtime-1', 'member-approve'),
|
|
);
|
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
correlationId: 'correlation-001',
|
|
success: false,
|
|
approvalId: undefined,
|
|
expiresAt: undefined,
|
|
});
|
|
|
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
|
|
await gateway.handleDiscordStop(
|
|
client as never,
|
|
ingressEnvelope('/stop fleet runtime-1 forged', 'unpaired-stop'),
|
|
);
|
|
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
|
});
|
|
|
|
it('rejects replaying a Discord-created termination approval', async () => {
|
|
configureDiscordEnv();
|
|
const { gateway, client } = discordGateway('admin');
|
|
await gateway.handleDiscordApproval(
|
|
client as never,
|
|
ingressEnvelope('/approve fleet runtime-1', 'replay-approve'),
|
|
);
|
|
const approval = client.emit.mock.calls.find(
|
|
([event]) => event === 'discord:approval',
|
|
)?.[1] as {
|
|
approvalId: string;
|
|
};
|
|
await gateway.handleDiscordStop(
|
|
client as never,
|
|
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-one'),
|
|
);
|
|
await gateway.handleDiscordStop(
|
|
client as never,
|
|
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-two'),
|
|
);
|
|
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
|
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
|
true,
|
|
false,
|
|
]);
|
|
});
|
|
|
|
it('accepts a thread message through its allowed bound parent channel', () => {
|
|
const emitted = vi.fn();
|
|
const plugin = new DiscordPlugin({
|
|
token: 'unused',
|
|
gatewayUrl: 'http://unused',
|
|
serviceToken: SERVICE_TOKEN,
|
|
allowedGuildIds: ['guild-001'],
|
|
allowedChannelIds: ['channel-001'],
|
|
allowedUserIds: ['user-001'],
|
|
interactionBindings: [
|
|
{
|
|
instanceId: 'Nova',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
pairedUsers: { 'user-001': 'operator' },
|
|
},
|
|
],
|
|
});
|
|
const internals = plugin as unknown as {
|
|
client: { user: { id: string } };
|
|
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
|
handleDiscordMessage(message: unknown): void;
|
|
};
|
|
internals.client = { user: { id: 'bot-001' } };
|
|
internals.socket = { connected: true, emit: emitted };
|
|
internals.handleDiscordMessage({
|
|
id: 'thread-message',
|
|
guildId: 'guild-001',
|
|
channelId: 'thread-001',
|
|
author: { id: 'user-001', bot: false },
|
|
mentions: { has: () => true },
|
|
content: '<@bot-001> hello from thread',
|
|
channel: { parentId: 'channel-001' },
|
|
attachments: new Map(),
|
|
});
|
|
|
|
const [, envelope] = emitted.mock.calls[0] as [
|
|
string,
|
|
ReturnType<typeof createDiscordIngressEnvelope>,
|
|
];
|
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
|
|
});
|
|
});
|