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
8 changes: 8 additions & 0 deletions backend/__tests__/unit/integrations/groupmeProvider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ jest.mock('../../../services/agentEventService', () => ({
enqueue: jest.fn(),
}));
jest.mock('../../../services/groupmeService', () => ({ sendMessage: jest.fn() }));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/User', () => ({ findById: jest.fn() }));

const Integration = require('../../../models/Integration');
const Summary = require('../../../models/Summary');
Expand Down Expand Up @@ -126,10 +128,16 @@ describe('GroupMe provider commands', () => {
});

it('sends latest pod summary on !pod-summary', async () => {
// Bound by the pod's creator: a GroupMe group is a shared surface (F143).
const latest = {
...integration,
createdBy: 'creator-1',
config: { ...integration.config, messageBuffer: [] },
};
require('../../../models/Pod').findById.mockReturnValue({
lean: jest.fn().mockResolvedValue({ _id: 'pod-1', createdBy: 'creator-1', members: ['creator-1'] }),
});
require('../../../models/User').findById.mockResolvedValue({ _id: 'creator-1', role: 'user' });
Integration.findById.mockReturnValue({
lean: jest.fn().mockResolvedValue(latest),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ const createGroupMeProvider = require('../../../integrations/providers/groupmePr
const Integration = require('../../../models/Integration');

jest.mock('../../../models/Integration');
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/User', () => ({ findById: jest.fn() }));
jest.mock('../../../models/Summary', () => ({ findOne: jest.fn() }));
jest.mock('../../../services/groupmeService', () => ({ sendMessage: jest.fn() }));

beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -50,3 +54,62 @@ describe('groupmeProvider', () => {
});
});

// F143: `!pod` is what a GroupMe binding sends OUT — the pod's latest summary,
// to everyone in the group. A group bound by someone without authority over the
// pod (its creator or an instance admin) gets nothing.
describe('groupmeProvider !pod in a shared group', () => {
const Pod = require('../../../models/Pod');
const User = require('../../../models/User');
const Summary = require('../../../models/Summary');
const groupmeService = require('../../../services/groupmeService');
const row = (createdBy) => ({
_id: 'int1', podId: 'pod-1', createdBy, config: { botId: 'bot', groupId: '123' },
});
const ask = async (createdBy) => {
Integration.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(row(createdBy)) });
const { events } = createGroupMeProvider(row(createdBy)).getWebhookHandlers();
const res = { sendStatus: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() };
await events({ body: { group_id: '123', text: '!pod' } }, res);
return res;
};

beforeEach(() => {
Pod.findById.mockReturnValue({
lean: jest.fn().mockResolvedValue({ _id: 'pod-1', createdBy: 'creator-1', members: ['creator-1', 'member-1'] }),
});
User.findById.mockImplementation(async (id) => ({ _id: String(id), role: String(id) === 'admin-1' ? 'admin' : 'user' }));
Summary.findOne.mockReturnValue({
sort: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ title: 'T', content: 'pod secrets' }) }),
});
});

test('withholds the pod summary from a group a plain member bound', async () => {
const res = await ask('member-1');
expect(res.sendStatus).toHaveBeenCalledWith(200);
expect(Summary.findOne).not.toHaveBeenCalled();
const text = groupmeService.sendMessage.mock.calls[0][1];
expect(text).not.toContain('pod secrets');
expect(text).toMatch(/only the pod's creator/i);
});

test('reads the summary from the pod it checked, the stored row', async () => {
// The live row was moved to pod-2 after this provider was built for pod-1.
const stale = { ...row('creator-1'), podId: 'pod-1' };
const live = { ...row('creator-1'), podId: 'pod-2' };
Integration.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(live) });
const { events } = createGroupMeProvider(stale).getWebhookHandlers();
const res = { sendStatus: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() };
await events({ body: { group_id: '123', text: '!pod' } }, res);
expect(Pod.findById).toHaveBeenCalledWith('pod-2');
expect(Summary.findOne).toHaveBeenCalledWith(expect.objectContaining({ podId: 'pod-2' }));
});

test.each([['the pod creator', 'creator-1'], ['an instance admin', 'admin-1']])(
'sends the pod summary to a group %s bound',
async (_label, createdBy) => {
await ask(createdBy);
expect(groupmeService.sendMessage.mock.calls[0][1]).toContain('pod secrets');
},
);
});

Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// F143 / CodeRabbit on #3451: when several pods share one Discord channel, the
// route folds their /commonly-summary results into one reply. A pod refused by
// the shared-surface bind rule shows only its short refusal, and that is not a
// failure for visibility: the reply stays public when every other pod's result
// succeeded, so channel members still see the allowed summaries. A real error,
// or a reply where every pod was refused, stays ephemeral.
const request = require('supertest');
const express = require('express');

jest.mock('tweetnacl', () => ({ sign: { detached: { verify: jest.fn(() => true) } } }));
jest.mock('../../../models/Integration', () => ({ find: jest.fn(), findOne: jest.fn() }));
jest.mock('../../../models/DiscordIntegration', () => ({}));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() }));
jest.mock('../../../models/User', () => ({ findById: jest.fn() }));
jest.mock('../../../services/discordService', () => jest.fn());
// The real visibility rule; only the per-pod command runs are stubbed.
jest.mock('../../../services/discordMultiCommandService', () => ({
...jest.requireActual('../../../services/discordMultiCommandService'),
runDiscordCommandForIntegrations: jest.fn(),
}));

process.env.DISCORD_PUBLIC_KEY = 'aa'.repeat(32);
const Integration = require('../../../models/Integration');
const { runDiscordCommandForIntegrations } = require('../../../services/discordMultiCommandService');
const discordRoutes = require('../../../routes/discord');

const app = express();
app.use('/api/discord/interactions', express.raw({ type: '*/*' }));
app.use('/api/discord', discordRoutes);

const integrations = [
{ _id: 'i-open', podId: { name: 'Open pod' } },
{ _id: 'i-member', podId: { name: 'Member-bound pod' } },
];
const ALLOWED = { success: true, content: 'Open pod summary text' };
const REFUSED = {
success: false,
refused: true,
content: "Only the pod's creator can share this pod's summary with a channel. Ask them to reconnect it.",
};
const ERROR = { success: false, content: '❌ An error occurred while fetching the summary.' };

const summarize = (results) => {
Integration.find.mockReturnValue({ populate: jest.fn().mockResolvedValue(integrations) });
runDiscordCommandForIntegrations.mockResolvedValue(results.map((result, i) => ({ integration: integrations[i], result })));
return request(app)
.post('/api/discord/interactions')
.set('Content-Type', 'application/json')
.set('X-Signature-Ed25519', 'ab')
.set('X-Signature-Timestamp', '1')
.send(JSON.stringify({
type: 2, guild_id: 'g1', channel_id: 'c1', data: { type: 1, name: 'commonly-summary' },
}));
};

describe('POST /api/discord/interactions — combined /commonly-summary visibility', () => {
beforeEach(() => jest.clearAllMocks());

it('stays public when one pod is refused and the other succeeds, without the refused summary', async () => {
const res = await summarize([ALLOWED, REFUSED]);

expect(res.status).toBe(200);
expect(res.body.data.flags).toBe(0);
expect(res.body.data.content).toContain('Open pod summary text');
expect(res.body.data.content).toContain(REFUSED.content);
});

it('is ephemeral when every pod is refused', async () => {
const res = await summarize([REFUSED, REFUSED]);
expect(res.body.data.flags).toBe(64);
});

it('stays ephemeral on a real error beside a success', async () => {
const res = await summarize([ALLOWED, ERROR]);
expect(res.body.data.flags).toBe(64);
});

it('is public when every pod succeeds', async () => {
const res = await summarize([ALLOWED, { ...ALLOWED, content: 'Second summary' }]);
expect(res.body.data.flags).toBe(0);
});
});
26 changes: 26 additions & 0 deletions backend/__tests__/unit/routes/discord.management-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,32 @@ describe('discord management route auth', () => {
expect(res.body).toEqual({ error: 'Access denied' });
});

// F143: the install link starts a Discord bind, so it asks the same rule
// POST /api/integrations does (services/chatRelay/bindSurface) and the two
// cannot disagree: the pod's creator, still a listed member, or an admin.
describe('install-link asks the shared-surface rule', () => {
beforeEach(() => { process.env.DISCORD_CLIENT_ID = '1111111111'; });
afterEach(() => { delete process.env.DISCORD_CLIENT_ID; });
const link = (token) => request(app).get('/api/discord/install-link/pod-1').set('Authorization', `Bearer ${token}`);

it('serves the pod creator who is a member', async () => {
Pod.findById.mockResolvedValue({ _id: 'pod-1', createdBy: 'user-1', members: ['user-1'] });
expect((await link('user-token')).status).toBe(200);
});

it('refuses a creator who has left the pod, as the bind would', async () => {
Pod.findById.mockResolvedValue({ _id: 'pod-1', createdBy: 'user-1', members: ['someone-else'] });
const res = await link('user-token');
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Access denied' });
});

it('serves an instance admin on a pod they did not create', async () => {
Pod.findById.mockResolvedValue({ _id: 'pod-1', createdBy: 'someone-else', members: ['someone-else'] });
expect((await link('admin-token')).status).toBe(200);
});
});

it('requires integration ownership for command registration', async () => {
Integration.findById.mockResolvedValue({
_id: 'integration-1',
Expand Down
Loading
Loading