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
7 changes: 7 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ regexes = [
# captured string — a real Secret value that merely STARTS with this prefix is not
# whitelisted and still fails. Verified against gitleaks 8.21.2 locally.
'''^secretKey: credentials$''',
# Two fake Discord user ids (snowflakes) in the F131 tests (#3456):
# `INSTALLER_DISCORD = '…'` and `discordUserId: '…'` sit next to the word
# "discord", so the discord-client-id rule reads the sequential digits as a
# client id. A Discord user id is public, and these two are made up.
# ANCHORED to the exact captured value, so any other id still fails.
'''^345678901234567890$''',
'''^456789012345678901$''',
]

paths = [
Expand Down
8 changes: 7 additions & 1 deletion backend/__tests__/service/discordInstallBind.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const OWNER = '6a8f6de2a1dccf2e02f31401';
const MEMBER = '6a8f6de2a1dccf2e02f31402';
const POD_ID = '6a8f6de2a1dccf2e02f31499';
const GUILD = '123456789012345678';
const INSTALLER_DISCORD = '456789012345678901';
const CHANNEL = '234567890123456789';
const WEBHOOK = { id: '345678901234567890', token: 'webhook-token' };

Expand Down Expand Up @@ -116,6 +117,7 @@ beforeEach(async () => {
return { data: [{ id: CHANNEL, name: 'general', type: 0 }, { id: '999999999999999999', name: 'Voice', type: 2 }] };
}
if (url === `https://discord.com/api/channels/${CHANNEL}`) return { data: { id: CHANNEL, guild_id: GUILD, type: 0 } };
if (url === 'https://discord.com/api/users/@me') return { data: { id: INSTALLER_DISCORD, username: 'owner' } };
throw new Error(`unexpected GET ${url}`);
});
});
Expand All @@ -133,7 +135,8 @@ test('the install link sends Discord back to the callback with a code', async ()
// A signed state, not the bare pod id a crafted link could carry.
expect(url.searchParams.get('state')).not.toBe(`pod_${POD_ID}`);
expect(url.searchParams.get('state')).not.toContain(POD_ID);
expect(url.searchParams.get('scope')).toBe('bot applications.commands');
// `identify` lets the callback read the installer's Discord user id (F131).
expect(url.searchParams.get('scope')).toBe('bot applications.commands identify');
});

test('the link ignores a caller-supplied client id', async () => {
Expand Down Expand Up @@ -192,6 +195,9 @@ test('a bot add from the link ends in a bound pod', async () => {
expect(row.config.serverId).toBe(GUILD);
expect(row.config.channelId).toBe(CHANNEL);
expect(row.config.webhookUrlRef).toBe('secret-ref-1');
// F131: the installer's Discord id, from users/@me through the proof, survives
// the strict config subdocument.
expect(row.config.discordUserId).toBe(INSTALLER_DISCORD);
const platform = await DiscordIntegration.findOne({ integrationId: row._id }).lean();
expect(platform).toMatchObject({ serverId: GUILD, channelId: CHANNEL, webhookId: WEBHOOK.id });

Expand Down
10 changes: 10 additions & 0 deletions backend/__tests__/unit/models/integrationPublicConfig.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ describe('projectIntegrationForViewer', () => {
expect(out.config).not.toHaveProperty('messageBuffer');
});

it('keeps a Discord installer’s user id from every viewer but the creator and an admin (F131)', () => {
const discord = () => connector({ type: 'discord', config: { channelId: 'c', linkedUserId: CREATOR, discordUserId: '345678901234567890' } });
const other = projectIntegrationForViewer(discord(), { requesterId: '64b000000000000000000099', isAdmin: false });
expect(other.config).not.toHaveProperty('discordUserId');
expect(projectIntegrationForViewer(discord(), { requesterId: CREATOR, isAdmin: false }).config.discordUserId)
.toBe('345678901234567890');
expect(projectIntegrationForViewer(discord(), { requesterId: 'x', isAdmin: true }).config.discordUserId)
.toBe('345678901234567890');
});

it('gives the connector creator and an admin the row whole, `linked` included', () => {
for (const viewer of [{ requesterId: CREATOR, isAdmin: false }, { requesterId: 'someone-else', isAdmin: true }]) {
const out = projectIntegrationForViewer(connector(), viewer);
Expand Down
185 changes: 185 additions & 0 deletions backend/__tests__/unit/routes/discordBindingContainment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -746,4 +746,189 @@ describe('Discord binding containment (TASK-123 a)', () => {
expect(Object.keys(update).filter((key) => key.includes('botToken'))).toEqual([]);
});
});
// F131: the installer's Discord user id is proven by the OAuth callback's
// users/@me read, carried in the signed install proof, and stored server-side
// as config.discordUserId. Live relay on a Discord row needs it.
describe('the installer’s Discord identity (F131)', () => {
const { mintDiscordInstallProof, readDiscordInstallProof, mintDiscordInstallState } = require('../../../utils/discordInstallProof');
const INSTALLER_DISCORD = '456789012345678901';
const STATE = () => encodeURIComponent(mintDiscordInstallState({ podId: 'pod-1', userId: MEMBER }));
const callback = () => request(app).get(`/api/discord/callback?code=abc&state=${STATE()}&guild_id=${GUILD}`);
const proofIn = (res) => readDiscordInstallProof(new URL(res.headers.location).searchParams.get('install'));
const meResponds = (impl) => axios.get.mockImplementation(async (url, ...rest) => (
url === 'https://discord.com/api/users/@me' ? impl(url, ...rest) : { data: { name: 'Guild' } }));

it('asks Discord for identify on the install link', async () => {
const res = await request(app).get('/api/discord/install-link/pod-1').set('Authorization', 'Bearer user-token');

expect(res.status).toBe(200);
expect(new URL(res.body.installUrl).searchParams.get('scope').split(' ')).toEqual(
expect.arrayContaining(['bot', 'applications.commands', 'identify']),
);
});

it('reads users/@me with the exchange’s user token and carries that id in the proof', async () => {
axios.post.mockResolvedValueOnce({ data: { access_token: 'user-access', guild: { id: GUILD } } });
meResponds(async () => ({ data: { id: INSTALLER_DISCORD, username: 'sam' } }));

const res = await callback();

expect(axios.get).toHaveBeenCalledWith(
'https://discord.com/api/users/@me',
expect.objectContaining({ headers: { Authorization: 'Bearer user-access' } }),
);
expect(new URL(res.headers.location).pathname).toBe('/discord/success');
expect(proofIn(res)).toEqual({
podId: 'pod-1', guildId: GUILD, userId: MEMBER, discordUserId: INSTALLER_DISCORD,
});
});

it('a failed users/@me read still installs, with no Discord id in the proof', async () => {
axios.post.mockResolvedValueOnce({ data: { access_token: 'user-access', guild: { id: GUILD } } });
meResponds(async () => { throw Object.assign(new Error('401'), { response: { status: 401 } }); });
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const res = await callback();

expect(new URL(res.headers.location).pathname).toBe('/discord/success');
expect(proofIn(res)).toEqual({ podId: 'pod-1', guildId: GUILD, userId: MEMBER });
} finally {
warn.mockRestore();
}
});

it('never takes a malformed id from users/@me', async () => {
axios.post.mockResolvedValueOnce({ data: { access_token: 'user-access', guild: { id: GUILD } } });
meResponds(async () => ({ data: { id: 'not-a-snowflake' } }));

const res = await callback();

expect(proofIn(res).discordUserId).toBeUndefined();
});

describe('POST /api/integrations', () => {
const BODY = { podId: 'pod-1', type: 'discord', config: { serverId: GUILD, channelId: CHANNEL } };
const post = (body) => request(app).post('/api/integrations').set('Authorization', 'Bearer user-token').send(body);
const stored = () => Integration.__getLastInstance().config;

beforeEach(() => {
Integration.findOne.mockResolvedValue(null);
axios.get.mockImplementation(async (url) => (
url === `https://discord.com/api/channels/${CHANNEL}`
? { data: { id: CHANNEL, type: 0, guild_id: GUILD } }
: { data: [] }));
});

it('stores the proof’s Discord id as config.discordUserId', async () => {
const install = mintDiscordInstallProof({
podId: 'pod-1', guildId: GUILD, userId: MEMBER, discordUserId: INSTALLER_DISCORD,
});

const res = await post({ ...BODY, install });

expect(res.status).toBe(201);
expect(stored().discordUserId).toBe(INSTALLER_DISCORD);
});

it('strips a body-supplied discordUserId, with or without a proof', async () => {
const noId = mintDiscordInstallProof({ podId: 'pod-1', guildId: GUILD, userId: MEMBER });
const res = await post({ ...BODY, config: { ...BODY.config, discordUserId: '999999999999999999' }, install: noId });
expect(res.status).toBe(201);
expect(stored().discordUserId).toBeUndefined();

const withId = mintDiscordInstallProof({
podId: 'pod-1', guildId: GUILD, userId: MEMBER, discordUserId: INSTALLER_DISCORD,
});
await post({ ...BODY, config: { ...BODY.config, discordUserId: '999999999999999999' }, install: withId });
expect(stored().discordUserId).toBe(INSTALLER_DISCORD);
});

it('takes no Discord id from a binding made on the prior-binding arm', async () => {
Integration.findOne.mockResolvedValue(discordRow());

const res = await post(BODY);

expect(res.status).toBe(201);
expect(stored().discordUserId).toBeUndefined();
});

it('refuses live relay on create without a proven Discord id, before the row or the webhook', async () => {
Integration.findOne.mockResolvedValue(discordRow());
const before = Integration.__getLastInstance();

const res = await post({ ...BODY, config: { ...BODY.config, liveRelay: true } });

expect(res.status).toBe(400);
expect(res.body.message).toMatch(/Discord account/);
expect(Integration.__getLastInstance()).toBe(before);
expect(axios.post).not.toHaveBeenCalled();
});

it('allows live relay on create with the proof, stamping the installer as the linked user', async () => {
const install = mintDiscordInstallProof({
podId: 'pod-1', guildId: GUILD, userId: MEMBER, discordUserId: INSTALLER_DISCORD,
});

const res = await post({ ...BODY, config: { ...BODY.config, liveRelay: true }, install });

expect(res.status).toBe(201);
expect(stored()).toMatchObject({ liveRelay: true, linkedUserId: MEMBER, discordUserId: INSTALLER_DISCORD });
});
});

describe('PATCH /api/integrations/:id', () => {
const patch = (body) => request(app)
.patch('/api/integrations/64b64c7f8a9e2f0012345678')
.set('Authorization', 'Bearer user-token')
.send(body);
const provenRow = (overrides = {}) => discordRow({
config: { serverId: GUILD, channelId: CHANNEL, discordUserId: INSTALLER_DISCORD },
...overrides,
});

it('strips a body-supplied discordUserId', async () => {
Integration.findById.mockResolvedValue(discordRow());
Integration.findByIdAndUpdate.mockResolvedValue(discordRow());

const res = await patch({ config: { discordUserId: '999999999999999999', channelName: 'general' } });

expect(res.status).toBe(200);
const update = Integration.findByIdAndUpdate.mock.calls[0][1];
expect(Object.keys(update).filter((key) => key.includes('discordUserId'))).toEqual([]);
});

it('refuses live relay on a row with no proven Discord id', async () => {
Integration.findById.mockResolvedValue(discordRow());

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

expect(res.status).toBe(400);
expect(res.body.message).toMatch(/Discord account/);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});

it('lets the installer turn live relay on, stamped as the linked user', async () => {
Integration.findById.mockResolvedValue(provenRow());
Integration.findByIdAndUpdate.mockResolvedValue(provenRow());

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

expect(res.status).toBe(200);
expect(Integration.findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
'config.liveRelay': true, 'config.linkedUserId': MEMBER,
});
});

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' } }));

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

expect(res.status).toBe(400);
expect(res.body.message).toMatch(/Only the person who added the bot/);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});
});
});
});
49 changes: 49 additions & 0 deletions backend/__tests__/unit/routes/integrations.linkedUserId.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -565,3 +565,52 @@ describe('POST /api/integrations — telegram first-run defaults', () => {
expect(res.body.integration.config.relayAllAgentMessages).toBe(false);
});
});

// Slack keeps its OAuth DM as chatType 'im', not 'private'. The shared
// private-chat rule refused live relay on every bound Slack DM (CodeRabbit on
// #3456); Slack now declares its own rule.
describe('PATCH /api/integrations/:id — live relay on Slack', () => {
const slackRow = (config) => ({
_id: 'integration-1',
type: 'slack',
podId: 'pod-1',
createdBy: { toString: () => 'user-1' },
config: { ...config, toObject() { return { ...config }; } },
});

beforeEach(() => {
jest.clearAllMocks();
User.findById.mockResolvedValue({ _id: 'user-1', role: 'member' });
Pod.findById.mockResolvedValue(null);
Integration.findByIdAndUpdate.mockResolvedValue({ _id: 'integration-1' });
});

it('turns live relay on for a bound Slack DM, stamped as the caller', async () => {
Integration.findById.mockResolvedValue(slackRow({ teamId: 'T1', chatId: 'D1', chatType: 'im' }));

const res = await request(app)
.patch(`/api/integrations/${integrationId}`)
.send({ config: { liveRelay: true } });

expect(res.status).toBe(200);
expect(Integration.findByIdAndUpdate.mock.calls[0][1]).toMatchObject({
'config.liveRelay': true, 'config.linkedUserId': 'user-1',
});
});

it.each([
['a channel', { teamId: 'T1', chatId: 'C1', chatType: 'channel' }],
['a legacy per-channel row', { channelId: 'C1' }],
['a DM row that also names a channel', { teamId: 'T1', chatId: 'D1', chatType: 'im', channelId: 'C1' }],
])('refuses live relay on %s', async (_label, config) => {
Integration.findById.mockResolvedValue(slackRow(config));

const res = await request(app)
.patch(`/api/integrations/${integrationId}`)
.send({ config: { liveRelay: true } });

expect(res.status).toBe(400);
expect(res.body.message).toMatch(/direct message/);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});
});
86 changes: 86 additions & 0 deletions backend/__tests__/unit/services/chatRelayCore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,89 @@ describe('the adapter registry drives the dispatcher', () => {
expect(pipeline[0].$match.type).toEqual({ $in: ['telegram', 'slack', 'discordish'] });
});
});

describe('inbound: only a proven sender reaches the write (F131)', () => {
const {
proveInboundSender,
writeInboundAsLinkedUser,
loadInboundAuthor,
privateChatLiveRelayRefusal,
} = require('../../../services/chatRelay/chatRelayCore');
const { inboundRelayAdapters, liveRelayRefusalFor } = require('../../../services/chatRelay/adapters');

const alwaysAdapter = (answer) => ({
provider: 'test', senderIsLinkedUser: () => answer, liveRelayRefusal: () => null,
});

test('the adapter’s proof mints a sender naming the linked user', () => {
const sender = proveInboundSender(alwaysAdapter(true), { config: { linkedUserId: 'user-1' } }, {});
expect(sender).toMatchObject({ provider: 'test', linkedUserId: 'user-1' });
expect(Object.isFrozen(sender)).toBe(true);
});

test.each([
['a failed proof', alwaysAdapter(false), { config: { linkedUserId: 'user-1' } }],
['a truthy non-true proof', alwaysAdapter('yes'), { config: { linkedUserId: 'user-1' } }],
['a proof that throws', { provider: 't', senderIsLinkedUser: () => { throw new Error('x'); } }, { config: { linkedUserId: 'user-1' } }],
['no linked user', alwaysAdapter(true), { config: {} }],
])('mints nothing on %s', (_label, adapter, integration) => {
expect(proveInboundSender(adapter, integration, {})).toBeNull();
});

test('the write refuses a hand-built sender before loading or writing anything', async () => {
const forged = { provider: 'telegram', linkedUserId: 'victim' };
await expect(writeInboundAsLinkedUser({
podId: 'pod-1', sender: forged, linkedUser: {}, content: 'x', replyToMessageId: null, logPrefix: '[t]',
})).rejects.toThrow(/not proven/);
await expect(loadInboundAuthor({
podId: 'pod-1', sender: forged, logPrefix: '[t]', onNotMember: async () => {},
})).rejects.toThrow(/not proven/);
expect(Pod.findById).not.toHaveBeenCalled();
});

test('every platform that relays inbound declares an inbound adapter; outbound stays telegram and slack', () => {
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']);
});

test('Telegram keeps the private-chat live-relay rule; an undeclared type falls back to it', () => {
const group = { config: { chatId: '42', chatType: 'group' } };
const privateChat = { config: { chatId: '42', chatType: 'private' } };
const unbound = { config: {} };
['telegram', 'groupme', undefined].forEach((type) => {
expect(liveRelayRefusalFor(type, group, 'u')).toBe(privateChatLiveRelayRefusal(group));
expect(liveRelayRefusalFor(type, group, 'u')).toMatch(/private chat/);
expect(liveRelayRefusalFor(type, privateChat, 'u')).toBeNull();
expect(liveRelayRefusalFor(type, unbound, 'u')).toBeNull();
});
});

test('Slack’s rule is its own: the OAuth DM (`im`, no channel) may relay live, a channel may not', () => {
// Slack stores its DM as chatType 'im'; the shared private-chat rule read
// that as a group and refused every bound Slack DM (CodeRabbit on #3456).
// Same audience test as F143's surfaceBindDeclarations.slack.
expect(liveRelayRefusalFor('slack', { config: { chatId: 'D1', chatType: 'im' } }, 'u')).toBeNull();
expect(liveRelayRefusalFor('slack', { config: {} }, 'u')).toBeNull();
expect(liveRelayRefusalFor('slack', { config: { chatId: 'D1', chatType: 'im', channelId: 'C1' } }, 'u'))
.toMatch(/direct message/);
expect(liveRelayRefusalFor('slack', { config: { chatId: 'C1', chatType: 'channel' } }, 'u')).toMatch(/direct message/);
expect(liveRelayRefusalFor('slack', { config: { chatId: 'C1', chatType: 'private' } }, 'u')).toMatch(/direct message/);
// A legacy per-channel row names only channelId.
expect(liveRelayRefusalFor('slack', { config: { channelId: 'C1' } }, 'u')).toMatch(/direct message/);
});

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/);
});

test('an inherited property name is not a platform', () => {
expect(liveRelayRefusalFor('toString', { config: {} }, 'u')).toBeNull();
});
});
Loading
Loading