Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
155 changes: 152 additions & 3 deletions backend/__tests__/unit/routes/integrations.linkedUserId.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,41 @@ describe('PATCH /api/integrations/:id — linkedUserId guard', () => {
expect(update.config.linkedUserId).toBe('user-1');
});

it("derives linkedUserId when liveRelay arrives as the string 'true' (#1293)", 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');
});

// Mongoose's Boolean cast is wider than the two literals above: 1, '1' and
// 'yes' are all stored as true. Any of them would be written as a live relay
// while skipping the === true guards (sprint-review's must-fix on #1297), so
// the edge refuses everything that is not a boolean or 'true'/'false'.
it.each([1, '1', 'yes', 'TRUE', 0, 'no'])(
'refuses liveRelay %p with 400 instead of letting Mongoose cast it',
async (value) => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { liveRelay: value } });
expect(res.status).toBe(400);
expect(res.body.message).toMatch(/liveRelay must be true or false/);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
},
);

it.each([1, '1', 'yes'])('refuses relayAllAgentMessages %p the same way', async (value) => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { relayAllAgentMessages: value } });
expect(res.status).toBe(400);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});

it('does not stamp linkedUserId when liveRelay is switched off', async () => {
const res = await request(app)
.patch('/api/integrations/integration-1')
Expand All @@ -94,18 +129,118 @@ describe('PATCH /api/integrations/:id — linkedUserId guard', () => {
});
});

describe('POST /api/integrations — telegram first-run defaults', () => {
// POST /api/integrations — the create path had none of the PATCH guards:
// config was spread verbatim (linkedUserId, connectCode, chatId all client-
// settable) and there was no pod-membership check (ADR-025 review, 2026-08-26).
describe('POST /api/integrations — create-path guards', () => {
beforeEach(() => {
jest.clearAllMocks();
Integration.prototype.save = jest.fn().mockResolvedValue(undefined);
Pod.findById.mockResolvedValue({ _id: 'pod-1', type: 'private', members: ['user-1'] });
});

it('rejects a client-supplied linkedUserId naming someone else', async () => {
const res = await request(app)
.post('/api/integrations')
.send({ podId: 'pod-1', type: 'telegram', config: { liveRelay: true, linkedUserId: 'VICTIM-USER-ID' } });
expect(res.status).toBe(400);
});

it.each([1, '1', 'yes'])('refuses liveRelay %p on create before anything is saved', async (value) => {
const res = await request(app)
.post('/api/integrations')
.send({ podId: 'pod-1', type: 'telegram', config: { liveRelay: value } });
expect(res.status).toBe(400);
expect(res.body.message).toMatch(/liveRelay must be true or false/);
expect(Integration.prototype.save).not.toHaveBeenCalled();
});

it('refuses non-members of the target pod', async () => {
Pod.findById.mockResolvedValue({ _id: 'pod-1', type: 'private', members: ['someone-else'] });
User.findById.mockReturnValue({ select: () => ({ lean: () => Promise.resolve({ role: 'member' }) }) });
const res = await request(app)
.post('/api/integrations')
.send({ podId: 'pod-1', type: 'telegram', config: {} });
expect(res.status).toBe(403);
});

it('mints a server-side 128-bit expiring code and strips client binding fields', async () => {
const res = await request(app)
.post('/api/integrations')
.send({
podId: 'pod-1',
type: 'telegram',
config: {
connectCode: 'chosen', chatId: '999', chatType: 'private', liveRelay: true,
},
});
expect(res.status).toBe(201);
const { config } = res.body.integration;
expect(config.connectCode).toMatch(/^[0-9a-f]{32}$/);
expect(new Date(config.connectCodeExpiresAt).getTime()).toBeGreaterThan(Date.now());
expect(config.chatId).toBeUndefined();
expect(config.chatType).toBeUndefined();
expect(config.linkedUserId).toBe('user-1');
});
});

describe('PATCH /api/integrations/:id — live relay on a group chat', () => {
beforeEach(() => {
jest.clearAllMocks();
const User = require('../../../models/User');
User.findById.mockResolvedValue({ _id: 'user-1', role: 'member' });
Pod.findById.mockResolvedValue(null);
Integration.findById.mockResolvedValue({
...telegramIntegration(),
config: { chatId: '42', chatType: 'group', toObject() { return { chatId: '42', chatType: 'group' }; } },
});
Integration.findByIdAndUpdate.mockResolvedValue({ _id: 'integration-1' });
});

it('refuses to flip liveRelay on when the bound chat is not private', async () => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { liveRelay: true } });
expect(res.status).toBe(400);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});

it("refuses the same flip when liveRelay arrives as the string 'true' (#1293)", async () => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { liveRelay: 'true' } });
expect(res.status).toBe(400);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});

it.each([1, '1', 'yes'])('refuses the flip when liveRelay is %p on a group', async (value) => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { liveRelay: value } });
expect(res.status).toBe(400);
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});

it('ignores client-supplied chatId on PATCH', async () => {
const res = await request(app)
.patch('/api/integrations/integration-1')
.send({ config: { chatId: '777', leadAgentUsername: 'theo' } });
expect(res.status).toBe(200);
const [, update] = Integration.findByIdAndUpdate.mock.calls[0];
expect(update.config.chatId).toBe('42');
expect(update.config.leadAgentUsername).toBe('theo');
});
});

describe('POST /api/integrations — telegram first-run defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
Pod.findById.mockResolvedValue({ _id: 'pod-1', type: 'private', members: ['user-1'] });
});

// A fresh connector must never default into the silence trap: attention
// mode with no lead configured relays nothing, so mirror is the first-run
// default and the Connected message teaches /mode attention.
it('defaults a new telegram connector to liveRelay + mirror', async () => {
const Integration = require('../../../models/Integration');
const res = await request(app)
.post('/api/integrations')
.send({ podId: 'pod-1', type: 'telegram', config: {} });
Expand All @@ -114,6 +249,20 @@ describe('POST /api/integrations — telegram first-run defaults', () => {
expect(created.config.relayAllAgentMessages).toBe(true);
expect(created.config.liveRelay).toBe(true);
expect(created.config.connectCode).toBeTruthy();
// The default switched liveRelay on, so the stamp must follow it: a live
// relay with no linkedUserId authors nothing inbound and still streams
// outbound (ordering ruled at the #1297 gate).
expect(created.config.linkedUserId).toBe('user-1');
});

it('respects an explicit liveRelay:false at create and does not stamp', async () => {
const res = await request(app)
.post('/api/integrations')
.send({ podId: 'pod-1', type: 'telegram', config: { liveRelay: false } });
expect(res.status).toBe(201);
expect(res.body.integration.config.liveRelay).toBe(false);
expect(res.body.integration.config.relayAllAgentMessages).toBe(true);
expect(res.body.integration.config.linkedUserId).toBeUndefined();
});

it('respects an explicit attention-mode choice at create', async () => {
Expand Down
107 changes: 107 additions & 0 deletions backend/__tests__/unit/routes/telegram.webhook.connectCode.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// /commonly-enable hardening: expired/legacy codes are refused, attempts are
// rate-limited per chat, and a live-relay integration cannot be bound from a
// group (the relay authors inbound as the linked user and streams outbound).
const request = require('supertest');
const express = require('express');

jest.mock('../../../models/Integration');
jest.mock('../../../models/Pod');
jest.mock('../../../models/Summary', () => ({ findOne: jest.fn() }));
jest.mock('../../../services/integrationSummaryService', () => ({ createSummary: jest.fn() }));
jest.mock('../../../services/agentEventService', () => ({ enqueue: jest.fn() }));
jest.mock('../../../services/telegramService', () => ({ sendMessage: jest.fn() }));
jest.mock('../../../integrations', () => ({ get: jest.fn() }));
jest.mock('../../../services/telegramBridgeService', () => ({ relayTelegramMessageToPod: jest.fn() }));
jest.mock('../../../models/WebhookDelivery', () => ({
create: jest.fn(),
deleteOne: jest.fn(),
}));

const Integration = require('../../../models/Integration');
const Pod = require('../../../models/Pod');
const WebhookDelivery = require('../../../models/WebhookDelivery');
const telegramService = require('../../../services/telegramService');
const { resetEnableAttempts, ENABLE_ATTEMPT_LIMIT } = require('../../../services/telegramConnectCode');
const telegramRoutes = require('../../../routes/webhooks/telegram');

const app = express();
app.use(express.json());
app.use('/api/webhooks/telegram', telegramRoutes);

const enable = (code, chat = { id: 42, type: 'private', first_name: 'Sam' }) => request(app)
.post('/api/webhooks/telegram')
.send({ message: { text: `/commonly-enable ${code}`, chat, from: { id: 7 } } });

const freshCode = () => ({
connectCode: 'c'.repeat(32),
connectCodeExpiresAt: new Date(Date.now() + 60000),
});

describe('/commonly-enable hardening', () => {
beforeEach(() => {
jest.clearAllMocks();
resetEnableAttempts();
process.env.TELEGRAM_BOT_TOKEN = 'bot-token';
delete process.env.TELEGRAM_SECRET_TOKEN;
// Verification is fail-closed on main (hardening H1); these tests exercise
// the enable handler, not auth, so they run with the explicit dev override
// and a stubbed delivery-claim store, same as telegram.webhook.test.js.
process.env.TELEGRAM_WEBHOOK_ALLOW_UNVERIFIED = 'true';
WebhookDelivery.create.mockResolvedValue({});
WebhookDelivery.deleteOne.mockResolvedValue({});
Integration.findByIdAndUpdate = jest.fn().mockResolvedValue({});
Pod.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue({ name: 'Test Pod' }) });
});

it('refuses a legacy code with no expiry', async () => {
Integration.findOne = jest.fn()
.mockResolvedValueOnce({ _id: 'i1', podId: 'p1', config: { connectCode: 'abc123' } });
await enable('abc123');
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
expect(telegramService.sendMessage.mock.calls[0][2]).toMatch(/expired/i);
});

it('refuses an expired code', async () => {
Integration.findOne = jest.fn().mockResolvedValueOnce({
_id: 'i1', podId: 'p1', config: { connectCode: 'x', connectCodeExpiresAt: new Date(Date.now() - 1) },
});
await enable('x');
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
});

it('binds a fresh code and clears both code fields', async () => {
Integration.findOne = jest.fn()
.mockResolvedValueOnce({ _id: 'i1', podId: 'p1', config: freshCode() })
.mockResolvedValueOnce(null);
await enable('c'.repeat(32));
const [, update] = Integration.findByIdAndUpdate.mock.calls[0];
expect(update.$unset).toEqual({ 'config.connectCode': '', 'config.connectCodeExpiresAt': '' });
expect(update.$set['config.chatType']).toBe('private');
});

it('rate-limits attempts per chat and stops looking codes up', async () => {
Integration.findOne = jest.fn().mockResolvedValue(null);
for (let i = 0; i < ENABLE_ATTEMPT_LIMIT; i += 1) await enable(`guess${i}`); // eslint-disable-line no-await-in-loop
expect(Integration.findOne).toHaveBeenCalledTimes(ENABLE_ATTEMPT_LIMIT);
await enable('one-more');
expect(Integration.findOne).toHaveBeenCalledTimes(ENABLE_ATTEMPT_LIMIT);
expect(telegramService.sendMessage.mock.calls.at(-1)[2]).toMatch(/too many attempts/i);
});

it('refuses to bind a live-relay integration from a group chat', async () => {
Integration.findOne = jest.fn()
.mockResolvedValueOnce({ _id: 'i1', podId: 'p1', config: { ...freshCode(), liveRelay: true } })
.mockResolvedValueOnce(null);
await enable('c'.repeat(32), { id: -100, type: 'supergroup', title: 'Crew' });
expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled();
expect(telegramService.sendMessage.mock.calls[0][2]).toMatch(/private chat/i);
});

it('still binds a legacy (buffer) integration from a group', async () => {
Integration.findOne = jest.fn()
.mockResolvedValueOnce({ _id: 'i1', podId: 'p1', config: freshCode() })
.mockResolvedValueOnce(null);
await enable('c'.repeat(32), { id: -100, type: 'group', title: 'Crew' });
expect(Integration.findByIdAndUpdate).toHaveBeenCalled();
});
});
6 changes: 4 additions & 2 deletions backend/__tests__/unit/routes/telegram.webhook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe('Telegram webhook routes', () => {
const integration = {
_id: 'integration-1',
podId: 'pod-1',
config: { connectCode: 'abc123' },
config: { connectCode: 'abc123', connectCodeExpiresAt: new Date(Date.now() + 60000) },
};

Integration.findOne = jest.fn()
Expand Down Expand Up @@ -170,7 +170,9 @@ describe('Telegram webhook routes', () => {
_id: 'integration-1',
type: 'telegram',
podId: 'pod-1',
config: { chatId: '42', chatType: 'private', liveRelay: true, linkedUserId: 'user-1' },
config: {
chatId: '42', chatType: 'private', liveRelay: true, linkedUserId: 'user-1',
},
};

const post = () => request(app)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,19 @@ describe('relayTelegramMessageToPod — who the pod row is attributed to', () =>
expect(User.findById).not.toHaveBeenCalled();
});
});

// Outbound mirror of the inbound gate (connector-verify F2, 2026-08-26): a
// code redeemed into a group must not receive the pod's escalation stream.
describe('outbound relay — chatType gate', () => {
it('only resolves live integrations bound to a private chat', async () => {
// eslint-disable-next-line global-require
const Integration = require('../../../models/Integration');
// eslint-disable-next-line global-require
const { relayAgentMessageToTelegram } = require('../../../services/telegramBridgeService');
Integration.findOne.mockReturnValue({ lean: () => Promise.resolve(null) });
await relayAgentMessageToTelegram({
podId: 'p1', agentUsername: 'theo', displayName: 'Theo', content: '[BLOCKED] x',
});
expect(Integration.findOne).toHaveBeenCalledWith(expect.objectContaining({ 'config.chatType': 'private' }));
});
});
53 changes: 53 additions & 0 deletions backend/__tests__/unit/services/telegramConnectCode.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Connect-code lifecycle: 128-bit, 10-minute TTL, legacy codes (no expiry)
// are dead, and /commonly-enable attempts are rate-limited per chat.
const {
mintConnectCode, isConnectCodeExpired, registerEnableAttempt, resetEnableAttempts,
CONNECT_CODE_TTL_MS, ENABLE_ATTEMPT_LIMIT, ENABLE_ATTEMPT_WINDOW_MS, ENABLE_ATTEMPT_MAX_CHATS,
} = require('../../../services/telegramConnectCode');

describe('telegramConnectCode', () => {
beforeEach(() => resetEnableAttempts());

it('mints a 128-bit hex code with a 10-minute expiry', () => {
const now = 1000000;
const { connectCode, connectCodeExpiresAt } = mintConnectCode(now);
expect(connectCode).toMatch(/^[0-9a-f]{32}$/);
expect(connectCodeExpiresAt.getTime()).toBe(now + CONNECT_CODE_TTL_MS);
expect(mintConnectCode().connectCode).not.toBe(connectCode);
});

it('treats a code with no expiry (legacy 24-bit) as expired', () => {
expect(isConnectCodeExpired({ connectCode: 'abc123' })).toBe(true);
expect(isConnectCodeExpired(undefined)).toBe(true);
});

it('expires exactly at the deadline', () => {
const cfg = { connectCodeExpiresAt: new Date(2000) };
expect(isConnectCodeExpired(cfg, 1999)).toBe(false);
expect(isConnectCodeExpired(cfg, 2000)).toBe(true);
});

it('allows N attempts per chat per window, then refuses until the window slides', () => {
for (let i = 0; i < ENABLE_ATTEMPT_LIMIT; i += 1) expect(registerEnableAttempt('42', 0)).toBe(true);
expect(registerEnableAttempt('42', 1)).toBe(false);
expect(registerEnableAttempt('43', 1)).toBe(true); // other chats unaffected
expect(registerEnableAttempt('42', ENABLE_ATTEMPT_WINDOW_MS + 1)).toBe(true);
});

// The key is any chat id an attacker chooses, so the map cannot grow without
// bound: once the cap is reached, chats whose window has slid out are evicted
// before a new key is admitted — and a chat still inside its window keeps
// its count, so the sweep never resets a live limiter.
it('evicts idle chats at the cap and keeps a live window intact', () => {
for (let i = 0; i < ENABLE_ATTEMPT_LIMIT; i += 1) registerEnableAttempt('hot', 0);
for (let i = 0; i < ENABLE_ATTEMPT_MAX_CHATS - 1; i += 1) registerEnableAttempt(`idle-${i}`, 0);
// Cap reached; a new key one window later triggers the sweep.
const later = ENABLE_ATTEMPT_WINDOW_MS - 1;
expect(registerEnableAttempt('hot', later)).toBe(false); // still limited within its window
expect(registerEnableAttempt('new', ENABLE_ATTEMPT_WINDOW_MS + 1)).toBe(true);
// Everything from t=0 has slid out and was evicted; 'new' is the only key.
expect(registerEnableAttempt('idle-0', ENABLE_ATTEMPT_WINDOW_MS + 1)).toBe(true);
for (let i = 0; i < ENABLE_ATTEMPT_LIMIT - 1; i += 1) registerEnableAttempt('idle-0', ENABLE_ATTEMPT_WINDOW_MS + 1);
expect(registerEnableAttempt('idle-0', ENABLE_ATTEMPT_WINDOW_MS + 1)).toBe(false);
});
});
Loading
Loading