Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/__tests__/unit/models/ChannelVerdict.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ describe('ChannelVerdict', () => {
]));
});

// CodeRabbit (PR #3465): the Discord relay records decision-card verdicts
// with provider 'discord'; the enum must accept it on a real round trip.
it('stores and validates a Discord verdict', async () => {
const created = await ChannelVerdict.create(row({ provider: 'discord' }));
const stored = await ChannelVerdict.findById(created._id);
expect(stored.provider).toBe('discord');
await expect(stored.validate()).resolves.toBeUndefined();
});

it('accepts a lazily-linked decision id and a later channel receipt', async () => {
const verdict = new ChannelVerdict(row({
provider: 'slack',
Expand Down
11 changes: 11 additions & 0 deletions backend/__tests__/unit/routes/discordBindingContainment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,17 @@ describe('Discord binding containment (TASK-123 a)', () => {
});
});

it('refuses an installer who has left the pod (review finding 1)', async () => {
Integration.findById.mockResolvedValue(provenRow());
Pod.findById.mockResolvedValue({ _id: 'pod-1', members: ['user-3'], createdBy: 'user-3' });

const res = await patch({ config: { liveRelay: true } });

expect(res.status).toBe(400);
expect(res.body.message).toMatch(/member of this pod/);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});

it('refuses the pod creator who did not add the bot: their id would author the installer’s lines', async () => {
// MEMBER created the pod (canDeleteIntegration passes) but user-3 added the bot.
Integration.findById.mockResolvedValue(provenRow({ createdBy: { toString: () => 'user-3' } }));
Expand Down
33 changes: 26 additions & 7 deletions backend/__tests__/unit/services/chatRelayCore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ describe('the adapter registry drives the dispatcher', () => {
});

test('every registered platform has a <provider>.relay handler', () => {
expect(chatRelayIntegrationTypes()).toEqual(['telegram', 'slack']);
expect(chatRelayIntegrationTypes()).toEqual(['telegram', 'slack', 'discord']);
chatRelayIntegrationTypes().forEach((provider) => {
expect(typeof eventHandlers[`${provider}.relay`]).toBe('function');
});
Expand All @@ -283,7 +283,20 @@ describe('the adapter registry drives the dispatcher', () => {
delete chatRelayAdapters.discordish;
}
const [[pipeline]] = Integration.aggregate.mock.calls;
expect(pipeline[0].$match.type).toEqual({ $in: ['telegram', 'slack', 'discordish'] });
expect(pipeline[0].$match.type).toEqual({ $in: ['telegram', 'slack', 'discord', 'discordish'] });
});

test('the direct-row arm reads each adapter’s directRows flag, not a type literal', async () => {
chatRelayAdapters.discordish = { provider: 'discordish', directRows: true };
try {
await activeHandlersForPod(podId);
} finally {
delete chatRelayAdapters.discordish;
}
const [[pipeline]] = Integration.aggregate.mock.calls;
const projected = JSON.stringify(pipeline.find((stage) => stage.$project && stage.$project.handlers));
expect(projected).toContain('["telegram","discord","discordish"]');
expect(projected).toContain('"$concat":["internal:","$type",".relay"]');
});
});

Expand Down Expand Up @@ -326,14 +339,14 @@ describe('inbound: only a proven sender reaches the write (F131)', () => {
expect(Pod.findById).not.toHaveBeenCalled();
});

test('every platform that relays inbound declares an inbound adapter; outbound stays telegram and slack', () => {
test('every platform that relays inbound declares an inbound adapter; all three relay outbound (F132)', () => {
expect(Object.keys(inboundRelayAdapters)).toEqual(['telegram', 'slack', 'discord']);
Object.entries(inboundRelayAdapters).forEach(([provider, adapter]) => {
expect(adapter.provider).toBe(provider);
expect(typeof adapter.senderIsLinkedUser).toBe('function');
expect(typeof adapter.liveRelayRefusal).toBe('function');
});
expect(chatRelayIntegrationTypes()).toEqual(['telegram', 'slack']);
expect(chatRelayIntegrationTypes()).toEqual(['telegram', 'slack', 'discord']);
});

test('Telegram keeps the private-chat live-relay rule; an undeclared type falls back to it', () => {
Expand Down Expand Up @@ -363,9 +376,15 @@ describe('inbound: only a proven sender reaches the write (F131)', () => {
});

test('Discord’s rule is its own declaration: a proven installer, not a chat type', () => {
expect(liveRelayRefusalFor('discord', { config: {}, createdBy: 'u' }, 'u')).toMatch(/Discord account/);
expect(liveRelayRefusalFor('discord', { config: { discordUserId: '345678901234567890' }, createdBy: 'u' }, 'u')).toBeNull();
expect(liveRelayRefusalFor('discord', { config: { discordUserId: '345678901234567890' }, createdBy: 'u' }, 'v')).toMatch(/Only the person/);
const pod = { members: ['u', 'v'] };
const proven = { discordUserId: '345678901234567890' };
expect(liveRelayRefusalFor('discord', { config: {}, createdBy: 'u', pod }, 'u')).toMatch(/Discord account/);
expect(liveRelayRefusalFor('discord', { config: proven, createdBy: 'u', pod }, 'u')).toBeNull();
expect(liveRelayRefusalFor('discord', { config: proven, createdBy: 'u', pod }, 'v')).toMatch(/Only the person/);
// Review finding 1: the installer must still be a listed member of the pod.
expect(liveRelayRefusalFor('discord', { config: proven, createdBy: 'u', pod: { members: ['v'] } }, 'u'))
.toMatch(/member of this pod/);
expect(liveRelayRefusalFor('discord', { config: proven, createdBy: 'u' }, 'u')).toMatch(/member of this pod/);
});

test('an inherited property name is not a platform', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,78 @@ describe('which chat may flip', () => {
expect(await Activity.countDocuments({})).toBe(0);
});
});

// F132: Discord's webhook send reports failure as an HTTP status with a JSON
// error code. Its bound surface is `config.channelId`, which the row keeps for
// good (F90 fixes a Discord binding), so the flip names the reason and leaves
// the channel in place for a reconnect.
describe('Discord classification (F132)', () => {
const discordFailure = (httpStatus, code, extra = {}) => ({
provider: 'discord', delivered: false, httpStatus, code, ...extra,
});

it('is permanent for the four codes that mean the channel cannot be posted to', () => {
expect(deliveryFailures.classifyDiscordDeliveryFailure(discordFailure(404, 10003)))
.toBe(deliveryFailures.DISCORD_CHANNEL_GONE_REASON);
expect(deliveryFailures.classifyDiscordDeliveryFailure(discordFailure(404, 10015)))
.toBe(deliveryFailures.DISCORD_WEBHOOK_GONE_REASON);
expect(deliveryFailures.classifyDiscordDeliveryFailure(discordFailure(403, 50001)))
.toBe(deliveryFailures.DISCORD_NO_ACCESS_REASON);
expect(deliveryFailures.classifyDiscordDeliveryFailure(discordFailure(403, 50013)))
.toBe(deliveryFailures.DISCORD_NO_PERMISSION_REASON);
});

it('reads a revoked webhook token (401, 50027) as the webhook being gone', () => {
expect(deliveryFailures.classifyDiscordDeliveryFailure(discordFailure(401, 50027)))
.toBe(deliveryFailures.DISCORD_WEBHOOK_GONE_REASON);
});

it('never flips for a rate limit, a server error, content, or a success', () => {
[
discordFailure(429, 0, { retryAfterMs: 1200 }),
discordFailure(429, undefined, { rateLimited: true }),
discordFailure(500, undefined),
discordFailure(400, 50035),
{ provider: 'discord', delivered: true, httpStatus: 200, messageId: '1' },
null,
].forEach((result) => {
expect(deliveryFailures.classifyDiscordDeliveryFailure(result)).toBeNull();
});
});

it('sorts a Discord-shaped result to the Discord classifier', () => {
expect(deliveryFailures.classifyDeliveryFailure(discordFailure(404, 10003)))
.toBe(deliveryFailures.DISCORD_CHANNEL_GONE_REASON);
});

const makeDiscordRow = () => makeIntegration({
name: 'Discord',
type: 'discord',
config: { serverId: '111111111111111111', channelId: '222222222222222222' },
});

it('flips a Discord row on its bound channel and keeps the binding', async () => {
const integration = await makeDiscordRow();
const flipped = await deliveryFailures.noteBoundChatDeliveryFailure(
integration.toObject(), '222222222222222222', discordFailure(403, 50013),
);
expect(flipped).toBe(true);
const stored = await storedIntegration(integration._id);
expect(stored.status).toBe('error');
expect(stored.errorMessage).toBe(deliveryFailures.DISCORD_NO_PERMISSION_REASON);
expect(stored.errorMessageUserFacing).toBe(true);
expect(stored.config.channelId).toBe('222222222222222222');
});

it('does not flip a Discord row for a 429, or for a channel it is not bound to', async () => {
const integration = await makeDiscordRow();
expect(await deliveryFailures.noteBoundChatDeliveryFailure(
integration.toObject(), '222222222222222222', discordFailure(429, 0, { retryAfterMs: 500 }),
)).toBe(false);
expect(await deliveryFailures.noteBoundChatDeliveryFailure(
integration.toObject(), '333333333333333333', discordFailure(404, 10003),
)).toBe(false);
const stored = await storedIntegration(integration._id);
expect(stored.status).toBe('connected');
});
});
Loading
Loading