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
91 changes: 91 additions & 0 deletions backend/__tests__/unit/routes/integrations.linkedUserId.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// PATCH /api/integrations/:id — bridge attribution guard.
// config.linkedUserId is the identity every inbound live-relay message is
// AUTHORED as (pod row, socket payload, agent wake). The route derives it from
// the authenticated caller when liveRelay flips on and rejects any
// client-supplied value: without this, any caller passing canDeleteIntegration
// could name someone else as the bridge author (sprint-review on #1290).
const request = require('supertest');
const express = require('express');

jest.mock('../../../middleware/auth', () => (req, res, next) => {
req.user = { id: 'user-1' };
next();
});
jest.mock('../../../middleware/adminAuth', () => (req, res, next) => next());
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/User', () => ({ findById: jest.fn() }));
jest.mock('../../../models/DiscordIntegration', () => function DiscordIntegration(data) {
Object.assign(this, data);
this.save = jest.fn().mockResolvedValue(this);
});
jest.mock('../../../services/discordService', () => jest.fn());
jest.mock('../../../models/Integration', () => {
function Integration(data) { Object.assign(this, data); }
Integration.findById = jest.fn();
Integration.findByIdAndUpdate = jest.fn();
Integration.aggregate = jest.fn().mockResolvedValue([]);
return Integration;
});

const Integration = require('../../../models/Integration');
const User = require('../../../models/User');
const Pod = require('../../../models/Pod');
const integrationRoutes = require('../../../routes/integrations');

const app = express();
app.use(express.json());
app.use('/api/integrations', integrationRoutes);

const telegramIntegration = () => ({
_id: 'integration-1',
type: 'telegram',
podId: 'pod-1',
createdBy: { toString: () => 'user-1' },
config: {
chatId: '42',
chatType: 'private',
toObject() { return { chatId: '42', chatType: 'private' }; },
},
});

describe('PATCH /api/integrations/:id — linkedUserId guard', () => {
beforeEach(() => {
jest.clearAllMocks();
// canDeleteIntegration: non-admin caller who created the integration.
User.findById.mockResolvedValue({ _id: 'user-1', role: 'member' });
Pod.findById.mockResolvedValue(null);
Integration.findById.mockResolvedValue(telegramIntegration());
Integration.findByIdAndUpdate.mockResolvedValue({ _id: 'integration-1' });
});

it('rejects a client-supplied linkedUserId naming someone else', async () => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { liveRelay: true, linkedUserId: 'VICTIM-USER-ID' } });

expect(res.status).toBe(400);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});

it('derives linkedUserId from the caller when liveRelay flips on', async () => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { liveRelay: true } });

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

it('does not stamp linkedUserId when liveRelay is switched off', async () => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { liveRelay: false } });

expect(res.status).toBe(200);
const [, update] = Integration.findByIdAndUpdate.mock.calls[0];
expect(update.config.liveRelay).toBe(false);
expect(update.config.linkedUserId).toBeUndefined();
});
});
9 changes: 9 additions & 0 deletions backend/routes/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,16 @@ router.patch('/:id', auth, async (req: AuthReq, res: Res) => {
const canUpdate = await canDeleteIntegration(integration, req.user?.id || '');
if (!canUpdate) return res.status(403).json({ message: 'Access denied' });
const currentConfig = integration.config?.toObject ? integration.config.toObject() : (integration.config || {}) as Record<string, unknown>;
// Bridge attribution guard: config.linkedUserId is the identity every
// inbound live-relay message is AUTHORED as (pod row, socket payload,
// agent wake). It is derived from the authenticated caller when liveRelay
// flips on — never accepted from the body, where it would let any caller
// who passes canDeleteIntegration name someone else as the bridge author.
if (config && 'linkedUserId' in config && String(config.linkedUserId) !== String(req.user?.id)) {
return res.status(400).json({ message: 'linkedUserId is derived from the authenticated caller and cannot be set' });
}
const nextConfig = config ? { ...currentConfig, ...config } : currentConfig;
if (config && config.liveRelay === true) nextConfig.linkedUserId = req.user?.id;
const missingRequired = getMissingRequiredFields(integration.type || '', nextConfig);
if (missingRequired.length && status === 'connected') return res.status(400).json({ message: `Missing required fields: ${missingRequired.join(', ')}`, missing: missingRequired });
validateManifestIfComplete(integration.type || '', nextConfig);
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"pods": "Pods",
"agents": "Agents",
"community": "Community",
"settings": "Settings"
"settings": "Settings",
"connectors": "Connectors"
},
"closePodsList": "Close pods list",
"communityRedirect": {
Expand Down Expand Up @@ -1557,5 +1558,23 @@
"generic": "Could not open billing just now. Please try again.",
"notConfigured": "Billing isn't switched on yet. Please contact us and we'll set you up."
}
},
"connectors": {
"loading": "Loading connectors…",
"loadError": "Could not load connectors.",
"createError": "Could not create the connector.",
"toggleError": "Could not update live relay.",
"empty": "No connectors yet. Link a channel below — your pod gets a voice where your team already talks.",
"connected": "Connected",
"pending": "Waiting for the channel",
"enableHint": "Open a private chat with the Commonly bot",
"enableHintSend": " and send:",
"liveRelay": "Live relay",
"liveRelayHint": "chat messages post into the pod and wake mentioned agents; agent escalations reach the channel.",
"newTitle": "Connect a channel",
"podPicker": "Pod to bridge",
"creating": "Creating…",
"createTelegram": "New Telegram connector",
"footnote": "You get a one-time code to send to the bot. More platforms are on the way."
}
}
21 changes: 20 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"pods": "Pod",
"agents": "智能体",
"community": "社区",
"settings": "设置"
"settings": "设置",
"connectors": "连接器"
},
"closePodsList": "关闭 Pod 列表",
"communityRedirect": {
Expand Down Expand Up @@ -1551,5 +1552,23 @@
"generic": "暂时无法打开付款页面,请稍后再试。",
"notConfigured": "付款功能尚未开启,请联系我们为你开通。"
}
},
"connectors": {
"loading": "正在加载连接器…",
"loadError": "无法加载连接器。",
"createError": "无法创建连接器。",
"toggleError": "无法更新实时中继。",
"empty": "还没有连接器。在下方绑定一个频道,让你的 Pod 出现在团队已有的聊天工具里。",
"connected": "已连接",
"pending": "等待频道确认",
"enableHint": "打开与 Commonly 机器人的私聊",
"enableHintSend": ",然后发送:",
"liveRelay": "实时中继",
"liveRelayHint": "聊天消息会发布到 Pod 并唤醒被提及的智能体;智能体的升级消息会回到频道。",
"newTitle": "连接频道",
"podPicker": "要桥接的 Pod",
"creating": "创建中…",
"createTelegram": "新建 Telegram 连接器",
"footnote": "你会获得一个一次性代码,发送给机器人即可。更多平台即将支持。"
}
}
5 changes: 5 additions & 0 deletions frontend/src/v2/V2App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import './marketplace/V2MarketplaceDetailPage.css';
import AgentsHub from '../components/agents/AgentsHub';
import V2PersonaCatalog from './agents/V2PersonaCatalog';
import V2AgentBYO from './components/V2AgentBYO';
import V2ConnectorsPage from './components/V2ConnectorsPage';
import V2PodBoard from './components/V2PodBoard';
import SkillsCatalogPage from '../components/skills/SkillsCatalogPage';
import ActivityFeedPage from '../components/activity/ActivityFeedPage';
Expand Down Expand Up @@ -289,6 +290,10 @@ const V2App: React.FC = () => {
path="agents/byo"
element={<V2AgentBYO />}
/>
<Route
path="connectors"
element={feature('Connectors', 'Bridge pods to the channels your team already uses.', <V2ConnectorsPage />, false)}
/>
<Route
path="marketplace"
element={feature('Marketplace', 'Browse and install agents, apps, and integrations.', <V2MarketplacePage />, false, false)}
Expand Down
131 changes: 131 additions & 0 deletions frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// @ts-nocheck
// Connectors page: lists the user's channel bridges, surfaces the one-time
// /commonly-enable code while pending, and toggles live relay via PATCH with
// linkedUserId set to the toggler (the bridge's attribution identity).
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2ConnectorsPage from '../components/V2ConnectorsPage';
import { AuthContext } from '../../context/AuthContext';

jest.mock('axios', () => {
const mock = {
get: jest.fn(),
post: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
defaults: { baseURL: '', headers: { common: {} } },
interceptors: {
request: { use: jest.fn(), eject: jest.fn() },
response: { use: jest.fn(), eject: jest.fn() },
},
};
return { __esModule: true, default: mock, ...mock };
});

const axios = jest.requireMock('axios').default;

const authValue = {
currentUser: { _id: 'u1', username: 'sam' },
user: { _id: 'u1', username: 'sam' },
token: 'user-jwt',
loading: false,
error: null,
isAuthenticated: true,
register: jest.fn(),
login: jest.fn(),
logout: jest.fn(),
updateProfile: jest.fn(),
};

const connectors = [
{
_id: 'i-pending',
type: 'telegram',
status: 'pending',
config: { connectCode: 'abc123' },
podId: { _id: 'p1', name: 'Rewire Live Demo' },
},
{
_id: 'i-live',
type: 'telegram',
status: 'connected',
config: { chatTitle: 'Rewire crew', liveRelay: false },
podId: { _id: 'p2', name: 'Ops' },
},
];

const mockGets = (list = connectors) => {
axios.get.mockImplementation((url) => {
if (url === '/api/integrations/user/all') return Promise.resolve({ data: list });
if (url === '/api/pods') {
return Promise.resolve({
data: [
{ _id: 'p1', name: 'Rewire Live Demo', type: 'chat' },
{ _id: 'pub', name: 'Town Square', type: 'community' },
],
});
}
return Promise.resolve({ data: [] });
});
};

const renderPage = () => render(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<V2ConnectorsPage />
</MemoryRouter>
</AuthContext.Provider>,
);

describe('V2ConnectorsPage', () => {
beforeEach(() => jest.clearAllMocks());

it('lists connectors with pod, status, and the enable code while pending', async () => {
mockGets();
renderPage();
// The pod name renders in the card AND as a picker option — assert on both.
expect((await screen.findAllByText('Rewire Live Demo')).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/\/commonly-enable abc123/)).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.getByText(/Rewire crew/)).toBeInTheDocument();
});

it('toggling live relay PATCHes liveRelay only — linkedUserId is server-derived', async () => {
mockGets();
axios.patch.mockResolvedValue({ data: {} });
renderPage();
const toggle = await screen.findByRole('checkbox');
fireEvent.click(toggle);
await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
'/api/integrations/i-live',
// No linkedUserId: the server stamps the authenticated caller and
// rejects a client-supplied value (impersonation guard, #1290 review).
{ config: { liveRelay: true } },
expect.anything(),
));
});

it('excludes public pods from the bridge target picker', async () => {
mockGets([]);
renderPage();
await screen.findByText(/No connectors yet/);
const picker = screen.getByLabelText('Pod to bridge');
const options = Array.from(picker.querySelectorAll('option')).map((o) => o.textContent);
expect(options).toContain('Rewire Live Demo');
expect(options).not.toContain('Town Square');
});

it('creates a telegram connector for the selected pod', async () => {
mockGets([]);
axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } });
renderPage();
await screen.findByText(/No connectors yet/);
fireEvent.click(screen.getByText('New Telegram connector'));
await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
'/api/integrations',
{ podId: 'p1', type: 'telegram', config: {} },
expect.anything(),
));
});
});
Loading
Loading