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
87 changes: 87 additions & 0 deletions backend/__tests__/unit/services/connectorRelayPolicy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// TASK-156 spec fix (wren 74618): the gate reading and the gate+membership
// conjunction have one home, because three copies of the same ternary is how a
// list and a call drift apart. These are that home's own arms.
const {
isGatedPodTarget,
isRoutedPodTarget,
} = require('../../../services/connectorRelayPolicy');

const userScoped = (gates) => ({
scope: 'user',
type: 'telegram',
podId: 'active-pod',
config: { gates },
});
const podScoped = (podId, gates) => ({
scope: 'pod',
type: 'telegram',
podId,
config: { gates },
});
const pod = ({ members = [], createdBy } = {}) => ({ members, createdBy });

describe('connectorRelayPolicy — the gate reading', () => {
it('keys a user-scoped connector on its gate for that pod', () => {
const integration = userScoped({
'pod-a': { enabled: true },
'pod-b': { enabled: false },
});
expect(isGatedPodTarget(integration, 'pod-a')).toBe(true);
expect(isGatedPodTarget(integration, 'pod-b')).toBe(false);
// Absent key, and a key that exists without `enabled: true`, are both closed.
expect(isGatedPodTarget(integration, 'pod-c')).toBe(false);
expect(isGatedPodTarget(userScoped({ 'pod-a': {} }), 'pod-a')).toBe(false);
expect(isGatedPodTarget(userScoped({ 'pod-a': { enabled: 'yes' } }), 'pod-a')).toBe(false);
expect(isGatedPodTarget(userScoped(undefined), 'pod-a')).toBe(false);
});

it('keys a pod-scoped connector on its own pod and never on gates', () => {
expect(isGatedPodTarget(podScoped('pod-a', undefined), 'pod-a')).toBe(true);
expect(isGatedPodTarget(podScoped('pod-a', undefined), 'pod-b')).toBe(false);
// Two identifier spaces for the same allow-list: a pod-scoped connector's
// gates object is not a second switch, and must not be read as one.
expect(isGatedPodTarget(podScoped('pod-a', { 'pod-a': { enabled: true } }), 'pod-b')).toBe(false);
expect(isGatedPodTarget(podScoped('pod-a', { 'pod-b': { enabled: true } }), 'pod-b')).toBe(false);
});

it('compares pod ids by value, not by identity', () => {
const integration = podScoped('pod-a', undefined);
integration.podId = { toString: () => 'pod-a' };
expect(isGatedPodTarget(integration, 'pod-a')).toBe(true);
});
});

describe('connectorRelayPolicy — the routed-target conjunction', () => {
const target = (overrides = {}) => ({
integration: userScoped({ 'pod-a': { enabled: true } }),
pod: pod({ members: ['user-1'] }),
podId: 'pod-a',
userId: 'user-1',
...overrides,
});

it('admits a gated pod the linked user is still in', () => {
expect(isRoutedPodTarget(target())).toBe(true);
});

it('needs BOTH halves — the gate off, or the membership gone, is a refusal', () => {
expect(isRoutedPodTarget(target({
integration: userScoped({ 'pod-a': { enabled: false } }),
}))).toBe(false);
expect(isRoutedPodTarget(target({ pod: pod({ members: ['someone-else'] }) }))).toBe(false);
expect(isRoutedPodTarget(target({ pod: undefined }))).toBe(false);
});

it('admits a pod whose creator is not listed in members', () => {
expect(isRoutedPodTarget(target({ pod: pod({ createdBy: 'user-1' }) }))).toBe(true);
});

it('refuses a missing user id rather than stringifying it into a match', () => {
// `String(undefined)` is the truthy string 'undefined'; a caller that passed
// that through would reach the membership read with a user who cannot exist.
// Not exploitable, but the guard is what keeps this half honest.
expect(isRoutedPodTarget(target({ userId: undefined }))).toBe(false);
expect(isRoutedPodTarget(target({ userId: null }))).toBe(false);
expect(isRoutedPodTarget(target({ userId: '' }))).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ jest.mock('../../../services/slackApi', () => {
mock.escapeSlackMrkdwn = actual.escapeSlackMrkdwn;
return mock;
});
jest.mock('../../../services/connectorRelayPolicy', () => ({ shouldEscalate: jest.fn(() => false) }));
// Delegate to the real module: the bridges also read isGatedPodTarget /
// isRoutedPodTarget from here, and a factory that stubs the whole module makes
// them undefined (TASK-156).
jest.mock('../../../services/connectorRelayPolicy', () => ({
...jest.requireActual('../../../services/connectorRelayPolicy'),
shouldEscalate: jest.fn(() => false),
}));
jest.mock('../../../services/channelVerdictService', () => ({ record: jest.fn() }));

const Integration = require('../../../models/Integration');
Expand Down
166 changes: 165 additions & 1 deletion backend/__tests__/unit/services/slackBridgeService.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
jest.mock('../../../models/Integration', () => ({ findOne: jest.fn(), findByIdAndUpdate: jest.fn() }));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/User', () => ({ findById: jest.fn() }));
jest.mock('../../../models/pg/Message', () => ({ create: jest.fn(), findById: jest.fn() }));
jest.mock('../../../services/messageAgentDeliveryService', () => ({ deliverMessageToAgents: jest.fn() }));
jest.mock('../../../config/socket', () => ({ getIO: jest.fn(() => null) }));
jest.mock('../../../services/connectorSecrets', () => ({ get: jest.fn() }));
// The constructor is stubbed (the network); the escape is the real one, because
// the escaping these tests assert is the behaviour we ship.
Expand All @@ -20,6 +23,8 @@ jest.mock('../../../services/connectorDeliveryFailureService', () => ({
const Integration = require('../../../models/Integration');
const Pod = require('../../../models/Pod');
const User = require('../../../models/User');
const PGMessage = require('../../../models/pg/Message');
const { deliverMessageToAgents } = require('../../../services/messageAgentDeliveryService');
const connectorSecrets = require('../../../services/connectorSecrets');
const SlackApi = require('../../../services/slackApi');
const deliveryFailures = require('../../../services/connectorDeliveryFailureService');
Expand Down Expand Up @@ -132,7 +137,166 @@ describe('Slack installable bridge', () => {
content: 'Can you clarify?',
threadTs: '171234.0001',
relayMap: [{ externalMessageId: '171234.0001', agentUsername: 'kai' }],
})).toEqual({ content: '@kai Can you clarify?', routedAgent: 'kai' });
})).toEqual({ content: '@kai Can you clarify?', routedAgent: 'kai', podId: null });
});

// Two fixture shapes, because the routed pod and the active pod are different
// documents: the first names the pod that may not be reachable any more.
const podWithMembers = (name) => ({
select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue({ name, type: 'team', members: ['user-1'] }),
}),
});
const podsById = (gatedPodName) => (id) => ({
select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue(
String(id) === 'pod-2'
? { name: gatedPodName, type: 'team', members: ['user-1'] }
: { name: 'Alpha', type: 'team', members: ['user-1'] },
),
}),
});

test('sends a thread reply into the quoted pod, not the connector\'s active one', async () => {
// ADR-025 D11. The map entry names the pod its line came from; before this,
// the reader kept only the agent and the reply landed in the active pod.
Pod.findById.mockImplementation(podsById('Launch'));
PGMessage.create.mockResolvedValue({ id: 'pg-1' });
PGMessage.findById.mockResolvedValue({ id: 'pg-1', content: 'relayed' });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ username: 'sam' }) }),
});
deliverMessageToAgents.mockResolvedValue(undefined);
Integration.findOne.mockResolvedValue(null);

const result = await relaySlackMessageToPod({
integration: {
...integration,
scope: 'user',
config: {
...integration.config,
linkedUserId: 'user-1',
slackUserId: 'U1',
gates: { 'pod-2': { enabled: true } },
relayMap: [{ externalMessageId: '171234.0001', agentUsername: 'kai', podId: 'pod-2' }],
},
},
event: { text: 'yes, ship it', user: 'U1', thread_ts: '171234.0001' },
});

expect(result).toEqual({ relayed: true, routedAgent: 'kai' });
expect(PGMessage.create.mock.calls[0][0]).toBe('pod-2');
});

test('still posts an unquoted Slack message to the active pod', async () => {
Pod.findById.mockReturnValue(podWithMembers('Alpha'));
PGMessage.create.mockResolvedValue({ id: 'pg-1' });
PGMessage.findById.mockResolvedValue({ id: 'pg-1', content: 'relayed' });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ username: 'sam' }) }),
});
deliverMessageToAgents.mockResolvedValue(undefined);

await relaySlackMessageToPod({
integration: {
...integration,
scope: 'user',
config: {
...integration.config, linkedUserId: 'user-1', slackUserId: 'U1', gates: {},
},
},
event: { text: 'hello', user: 'U1' },
});

expect(PGMessage.create.mock.calls[0][0]).toBe('pod-1');
});

test('refuses a thread reply into a pod whose gate is off, naming it, posting nothing', async () => {
Pod.findById.mockImplementation(podsById('Launch'));

const result = await relaySlackMessageToPod({
integration: {
...integration,
scope: 'user',
config: {
...integration.config,
linkedUserId: 'user-1',
slackUserId: 'U1',
gates: {},
relayMap: [{ externalMessageId: '171234.0001', agentUsername: 'kai', podId: 'pod-2' }],
},
},
event: { text: 'yes, ship it', user: 'U1', thread_ts: '171234.0001' },
});

expect(result).toEqual({ relayed: false });
expect(PGMessage.create).not.toHaveBeenCalled();
expect(deliverMessageToAgents).not.toHaveBeenCalled();
const api = SlackApi.mock.results[0].value;
expect(api.postMessage).toHaveBeenCalledWith('D1', expect.stringContaining('Launch'));
});

test('refuses a thread reply into a pod the linked user has left, posting nothing', async () => {
// The gate is still on for pod-2; the membership half is what has to refuse.
// Without this arm the Slack path's membership re-check is unwitnessed —
// dropping the shared predicate left it green (ledger M8, first run).
Pod.findById.mockImplementation((id) => ({
select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue(
String(id) === 'pod-2'
? { name: 'Launch', type: 'team', members: ['someone-else'] }
: { name: 'Alpha', type: 'team', members: ['user-1'] },
),
}),
}));

const result = await relaySlackMessageToPod({
integration: {
...integration,
scope: 'user',
config: {
...integration.config,
linkedUserId: 'user-1',
slackUserId: 'U1',
gates: { 'pod-2': { enabled: true } },
relayMap: [{ externalMessageId: '171234.0001', agentUsername: 'kai', podId: 'pod-2' }],
},
},
event: { text: 'yes, ship it', user: 'U1', thread_ts: '171234.0001' },
});

expect(result).toEqual({ relayed: false });
expect(PGMessage.create).not.toHaveBeenCalled();
expect(deliverMessageToAgents).not.toHaveBeenCalled();
const api = SlackApi.mock.results[0].value;
expect(api.postMessage).toHaveBeenCalledWith('D1', expect.stringContaining('Launch'));
});

test('routes a pre-D11 map entry (no podId) to the active pod', async () => {
Pod.findById.mockReturnValue(podWithMembers('Alpha'));
PGMessage.create.mockResolvedValue({ id: 'pg-1' });
PGMessage.findById.mockResolvedValue({ id: 'pg-1', content: 'relayed' });
User.findById.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ username: 'sam' }) }),
});
deliverMessageToAgents.mockResolvedValue(undefined);

await relaySlackMessageToPod({
integration: {
...integration,
scope: 'user',
config: {
...integration.config,
linkedUserId: 'user-1',
slackUserId: 'U1',
gates: {},
relayMap: [{ externalMessageId: '171234.0001', agentUsername: 'kai' }],
},
},
event: { text: 'yes, ship it', user: 'U1', thread_ts: '171234.0001' },
});

expect(PGMessage.create.mock.calls[0][0]).toBe('pod-1');
});

test('does not relay through a visible recovery row whose secret is unavailable', async () => {
Expand Down
Loading
Loading