import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDiscordIngressEnvelope, verifyDiscordIngressEnvelope, DiscordPlugin, type DiscordIngressPayload, parseDiscordInteractionBindings, resolveDiscordInteractionActorId, resolveDiscordInteractionBinding, } from '@mosaicstack/discord-plugin'; import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js'; 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(); 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: role === 'admin' ? 'admin' : 'operator', mosaicUserId: 'mosaic-admin-001', }, }, }, ]); } 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(); 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 }; consumedActions: Array<{ actorId: string; correlationId: string }>; durable: { getSnapshot: ReturnType }; audit: { record: ReturnType }; } { const authorization = commandAuthorization(role); const consumedActions: Array<{ actorId: string; correlationId: string }> = []; const durable = { getSnapshot: vi.fn().mockResolvedValue({ identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, }), }; const audit = { record: vi.fn().mockResolvedValue(undefined) }; const runtimeRegistry = new RuntimeProviderService( { require: () => ({ capabilities: async () => ({ supported: ['session.terminate'] }), terminate: async () => undefined, }), } as never, { record: async () => undefined } as never, { consume: async (approvalId, action) => { consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId }); return authorization.consumeRuntimeTerminationApproval(approvalId, action); }, }, ); return { gateway: new ChatGateway( {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, authorization, runtimeRegistry, durable as never, audit as never, ), client: { data: { discordService: true }, emit: vi.fn() }, consumedActions, durable, audit, }; } function ingressEnvelope( content: string, messageId: string, overrides: Partial = {}, ): ReturnType { return createDiscordIngressEnvelope( createPayload({ content, messageId, ...overrides }), SERVICE_TOKEN, ); } function createPayload(overrides: Partial = {}): 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('keeps legacy role-only bindings valid while withholding privileged actor identity', () => { const [binding] = parseDiscordInteractionBindings( JSON.stringify([ { instanceId: 'Nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'user-001': 'admin' }, }, ]), ); expect( resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'), ).toEqual(binding); expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull(); }); 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': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, }, ], '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) => { 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('consumes the exact target once when approval and stop are separate Discord messages', async () => { configureDiscordEnv(); const { gateway, client, consumedActions } = discordGateway('admin'); await gateway.handleDiscordApproval( client as never, ingressEnvelope('/approve', 'approve-message', { correlationId: 'approval-ingress-correlation', }), ); 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 ${approval.approvalId}`, 'stop-message', { correlationId: 'stop-ingress-correlation', }), ); expect(client.emit).toHaveBeenCalledWith('discord:stop', { correlationId: 'stop-ingress-correlation', success: true, }); expect(consumedActions).toEqual([ { actorId: 'mosaic-admin-001', correlationId: expect.stringMatching(/^discord-action:v1:/), }, ]); }); it('audits a Discord mint-side authorization denial', async () => { configureDiscordEnv(); const { gateway, client, audit } = discordGateway('member'); await gateway.handleDiscordApproval( client as never, ingressEnvelope('/approve', 'denied-approve'), ); expect(client.emit).toHaveBeenCalledWith('discord:approval', { correlationId: 'correlation-001', success: false, approvalId: undefined, expiresAt: undefined, }); expect(audit.record).toHaveBeenCalledWith( expect.objectContaining({ outcome: 'denied', operation: 'session.terminate', errorCode: 'policy_denied', }), ); }); it.each([ [ 'binding', () => { process.env['MOSAIC_AGENT_NAME'] = 'Other'; }, ], [ 'durable session', (durable: { getSnapshot: ReturnType }) => { durable.getSnapshot.mockResolvedValueOnce({ identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, }); }, ], ])( 'rejects approval when the %s targets a different runtime agent', async (_source, configure) => { configureDiscordEnv(); const { gateway, client, durable } = discordGateway('admin'); configure(durable); await gateway.handleDiscordApproval( client as never, ingressEnvelope('/approve', 'mismatched-agent-approve'), ); expect(client.emit).toHaveBeenCalledWith('discord:approval', { correlationId: 'correlation-001', success: false, approvalId: undefined, expiresAt: undefined, }); }, ); 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', '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 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', 'replay-approve', { correlationId: 'replay-approval-correlation', }), ); const approval = client.emit.mock.calls.find( ([event]) => event === 'discord:approval', )?.[1] as { approvalId: string; }; await gateway.handleDiscordStop( client as never, ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', { correlationId: 'replay-stop-correlation-one', }), ); await gateway.handleDiscordStop( client as never, ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', { correlationId: 'replay-stop-correlation-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': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, }, ], }); const internals = plugin as unknown as { client: { user: { id: string } }; socket: { connected: boolean; emit: ReturnType }; 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, ]; expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001'); }); });