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
15 changes: 14 additions & 1 deletion backend/__tests__/service/integrations-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const DiscordIntegration = require('../../models/DiscordIntegration');
// Services
const AgentEventService = require('../../services/agentEventService');
const IntegrationSummaryService = require('../../services/integrationSummaryService');
const { storeGroupmeAccessToken } = require('../../services/chatConnectorTokens');
const schedulerService = require('../../services/schedulerService');

// SchedulerService is exported as an instance, static methods are on constructor
Expand Down Expand Up @@ -1671,13 +1672,21 @@ describe('Integrations E2E Tests', () => {
.set('Authorization', `Bearer ${authToken}`)
.send({ label: 'GroupMe Messages Token' });

// TASK-539: the token lives in connectorSecrets; the row keeps the ref.
const savedKeys = process.env.CONNECTOR_SECRET_KEYS;
const savedActiveKey = process.env.CONNECTOR_SECRET_ACTIVE_KEY;
process.env.CONNECTOR_SECRET_KEYS = `k1:${Buffer.alloc(32, 5).toString('base64')}`;
process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k1';
const integrationId = new mongoose.Types.ObjectId();
const refs = await storeGroupmeAccessToken(String(integrationId), 'groupme-token-xyz');
const integration = await Integration.create({
_id: integrationId,
podId: testPod._id,
type: 'groupme',
status: 'connected',
config: {
groupId: 'group-xyz',
accessToken: 'groupme-token-xyz',
...refs,
agentAccessEnabled: true,
},
createdBy: testUser._id,
Expand Down Expand Up @@ -1706,9 +1715,13 @@ describe('Integrations E2E Tests', () => {
.get(`/api/agents/runtime/pods/${testPod._id}/integrations/${integration._id}/messages?limit=20`)
.set('Authorization', `Bearer ${tokenRes.body.token}`);

process.env.CONNECTOR_SECRET_KEYS = savedKeys;
process.env.CONNECTOR_SECRET_ACTIVE_KEY = savedActiveKey;

expect(res.status).toBe(200);
expect(res.body.messages).toHaveLength(1);
expect(res.body.messages[0].authorName).toBe('bob');
expect(JSON.stringify(axios.get.mock.calls)).toContain('groupme-token-xyz');
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,13 @@ describe('slackProvider — the row shape the bind writes', () => {
});
});

it('still answers for a legacy row from its own channel and token', async () => {
it('does not read a legacy row\'s stored token copy; only the ref is a source (TASK-539)', async () => {
mockHistory.mockResolvedValue({ messages: [] });

await legacyRow().syncRecent({ hours: 1 });

await expect(legacyRow().syncRecent({ hours: 1 })).rejects.toThrow(/missing its bot token/);
expect(mockGet).not.toHaveBeenCalled();
expect(mockSlackApi).toHaveBeenCalledWith('xoxb-legacy');
expect(mockHistory.mock.calls[0][0]).toBe('C0456');
expect(mockSlackApi).not.toHaveBeenCalled();
await expect(legacyRow().health()).resolves.toEqual({ ok: false, error: 'Slack connector has no bot token' });
});

it('does not fall back to the instance token, which is retired (TASK-151)', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,17 @@ jest.mock('../../../services/dmService', () => ({ getOrCreateAgentDM: jest.fn()
jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { findOne: jest.fn(), find: jest.fn() } }));
// TASK-124: the discord branch's fetch is asserted to receive the env token.
jest.mock('../../../services/discordService', () => ({ fetchMessages: jest.fn() }));
// TASK-539: the groupme branch reads its token through connectorSecrets.
jest.mock('../../../services/groupmeService', () => ({ fetchMessages: jest.fn() }));
jest.mock('../../../services/connectorSecrets', () => {
class ConnectorSecretNotFoundError extends Error {}
return { get: jest.fn(), ConnectorSecretNotFoundError };
});

const Integration = require('../../../models/Integration');
const DiscordService = require('../../../services/discordService');
const GroupMeService = require('../../../services/groupmeService');
const connectorSecrets = require('../../../services/connectorSecrets');
const router = require('../../../routes/agentsRuntime');

const getRouteHandler = (path, method) => {
Expand Down Expand Up @@ -197,4 +205,97 @@ describe('agentsRuntime integration messages route', () => {
expect(DiscordService.fetchMessages).not.toHaveBeenCalled();
});
});
// TASK-539: the token lives in connectorSecrets, and the row's own copy is
// never read, so a stale plaintext token cannot be served.
describe('groupme: the token comes from the connector secret', () => {
const groupmeRow = (config = {}) => ({
lean: jest.fn().mockResolvedValue({
_id: 'groupme-1',
type: 'groupme',
config: {
agentAccessEnabled: true, globalAgentAccess: true, groupId: 'group-1', ...config,
},
}),
});

const call = async () => {
const handler = getRouteHandler('/pods/:podId/integrations/:integrationId/messages', 'get');
const req = {
params: { podId: 'pod-1', integrationId: 'groupme-1' },
query: {},
agentInstallation: { podId: 'pod-1', scopes: ['integration:messages:read'] },
agentInstallations: [{ podId: 'pod-1', scopes: ['integration:messages:read'] }],
};
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await handler(req, res);
return res;
};

it('fetches with the secret behind the ref, not the row copy', async () => {
connectorSecrets.get.mockResolvedValue('groupme-secret-token');
GroupMeService.fetchMessages.mockResolvedValue([]);
Integration.findOne.mockReturnValue(groupmeRow({ accessTokenRef: 'ref-1', accessToken: 'stale-copy' }));

const res = await call();

expect(connectorSecrets.get).toHaveBeenCalledWith('ref-1');
expect(GroupMeService.fetchMessages).toHaveBeenCalledWith(
expect.objectContaining({ accessToken: 'groupme-secret-token', groupId: 'group-1' }),
);
expect(res.status).not.toHaveBeenCalledWith(400);
});

it('refuses a row that carries only a plaintext copy', async () => {
Integration.findOne.mockReturnValue(groupmeRow({ accessToken: 'plaintext-only' }));

const res = await call();

expect(res.status).toHaveBeenCalledWith(400);
expect(GroupMeService.fetchMessages).not.toHaveBeenCalled();
});

it('refuses, rather than 500s, when the secret behind the ref is gone', async () => {
connectorSecrets.get.mockRejectedValue(new connectorSecrets.ConnectorSecretNotFoundError('gone'));
Integration.findOne.mockReturnValue(groupmeRow({ accessTokenRef: 'ref-gone' }));

const res = await call();

expect(res.status).toHaveBeenCalledWith(400);
expect(GroupMeService.fetchMessages).not.toHaveBeenCalled();
});

it('answers 500, not "missing token", when the key ring or decryption fails', async () => {
connectorSecrets.get.mockRejectedValue(new Error('key ring unavailable'));
Integration.findOne.mockReturnValue(groupmeRow({ accessTokenRef: 'ref-1' }));

const res = await call();

expect(res.status).toHaveBeenCalledWith(500);
expect(res.status).not.toHaveBeenCalledWith(400);
expect(GroupMeService.fetchMessages).not.toHaveBeenCalled();
});

it('logs a failed fetch without the request config that carries the token', async () => {
const util = require('util'); // eslint-disable-line global-require
connectorSecrets.get.mockResolvedValue('groupme-secret-token');
GroupMeService.fetchMessages.mockRejectedValue(Object.assign(
new Error('Request failed with status code 401'),
{ config: { params: { token: 'groupme-secret-token' } }, response: { status: 401 } },
));
Integration.findOne.mockReturnValue(groupmeRow({ accessTokenRef: 'ref-1' }));
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
const res = await call();

expect(res.status).toHaveBeenCalledWith(500);
expect(spy).toHaveBeenCalled();
spy.mock.calls.forEach((args) => {
expect(util.inspect(args, { depth: 10 })).not.toContain('groupme-secret-token');
});
expect(JSON.stringify(res.json.mock.calls)).not.toContain('groupme-secret-token');
} finally {
spy.mockRestore();
}
});
});
});
194 changes: 194 additions & 0 deletions backend/__tests__/unit/scripts/migrateChatTokenCopies.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
jest.mock('jsonwebtoken', () => ({}));

const mongoose = require('mongoose');
const path = require('path');
const { spawnSync } = require('child_process');
const ConnectorSecret = require('../../../models/ConnectorSecret');
const connectorSecrets = require('../../../services/connectorSecrets');
const { GROUPME_ACCESS_TOKEN } = require('../../../services/connectorSecretKinds');
const { readGroupmeAccessToken } = require('../../../services/chatConnectorTokens');
const { migrateChatTokenCopies, formatReport } = require('../../../scripts/migrate-chat-token-copies');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils');

const rows = () => mongoose.connection.collection('integrations');
const key = (fill) => Buffer.alloc(32, fill).toString('base64');
const GM = 'groupme-plaintext-sentinel';
const GM_OLD = 'groupme-stale-copy-sentinel';
const SLACK = 'slack-plaintext-sentinel';
const REFRESH = 'refresh-plaintext-sentinel';

const seed = async () => {
const ids = {
move: new mongoose.Types.ObjectId(),
hasRef: new mongoose.Types.ObjectId(),
blank: new mongoose.Types.ObjectId(),
clean: new mongoose.Types.ObjectId(),
slack: new mongoose.Types.ObjectId(),
slackBound: new mongoose.Types.ObjectId(),
x: new mongoose.Types.ObjectId(),
};
const existingRef = await connectorSecrets.put(String(ids.hasRef), GROUPME_ACCESS_TOKEN, 'groupme-current-token');
await rows().insertMany([
{
_id: ids.move,
type: 'groupme',
config: {
botId: 'b1', groupId: 'g1', accessToken: GM, refreshToken: REFRESH,
},
},
{ _id: ids.hasRef, type: 'groupme', config: { botId: 'b2', accessToken: GM_OLD, accessTokenRef: existingRef } },
{ _id: ids.blank, type: 'groupme', config: { botId: 'b3', accessToken: '', refreshToken: null } },
{ _id: ids.clean, type: 'groupme', config: { botId: 'b4' } },
{ _id: ids.slack, type: 'slack', config: { accessToken: SLACK, refreshToken: REFRESH, signingSecret: 'kept-signing' } },
{ _id: ids.slackBound, type: 'slack', config: { botTokenRef: 'slack-ref', botToken: 'kept-bot-token' } },
{ _id: ids.x, type: 'x', config: { accessToken: 'x-not-ours', feedAccessTokenRef: 'x-ref' } },
]);
return { ids, existingRef };
};

const byId = async (id) => rows().findOne({ _id: id });

describe('migrate GroupMe and Slack token copies', () => {
const saved = {
keys: process.env.CONNECTOR_SECRET_KEYS,
activeKey: process.env.CONNECTOR_SECRET_ACTIVE_KEY,
};

beforeAll(async () => {
await setupMongoDb();
await ConnectorSecret.syncIndexes();
});
afterAll(async () => {
await closeMongoDb();
process.env.CONNECTOR_SECRET_KEYS = saved.keys;
process.env.CONNECTOR_SECRET_ACTIVE_KEY = saved.activeKey;
});
beforeEach(async () => {
await clearMongoDb();
process.env.CONNECTOR_SECRET_KEYS = `k1:${key(9)}`;
process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k1';
});

it('defaults to dry run: counts every field by type, writes nothing, prints no value', async () => {
const { existingRef } = await seed();
const before = await rows().find().toArray();
const secretsBefore = await ConnectorSecret.countDocuments();
const report = await migrateChatTokenCopies();
expect(report).toEqual({
groupme: {
candidates: 3,
accessFields: 3,
refreshFields: 2,
toMove: 1,
moved: 0,
cleared: 0,
remaining: 3,
inventory: { botToken: 0, signingSecret: 0, appToken: 0 },
},
slack: {
candidates: 1,
accessFields: 1,
refreshFields: 1,
toMove: 0,
moved: 0,
cleared: 0,
remaining: 1,
inventory: { botToken: 1, signingSecret: 1, appToken: 0 },
},
});
expect(await rows().find().toArray()).toEqual(before);
expect(await ConnectorSecret.countDocuments()).toBe(secretsBefore);
const output = formatReport(report, false).join('\n');
expect(output).toContain('DRY-RUN');
[GM, GM_OLD, SLACK, REFRESH, existingRef, 'kept-bot-token'].forEach((value) => (
expect(output).not.toContain(value)
));
});

it('moves a GroupMe token into connectorSecrets, keeps an existing ref, and scrubs both types', async () => {
const { ids, existingRef } = await seed();
const before = await rows().find().toArray();
const report = await migrateChatTokenCopies({ apply: true });
expect(report.groupme).toMatchObject({
toMove: 1, moved: 1, cleared: 3, remaining: 0,
});
expect(report.slack).toMatchObject({ cleared: 1, remaining: 0 });

const moved = await byId(ids.move);
expect(moved.config).toEqual({ botId: 'b1', groupId: 'g1', accessTokenRef: expect.any(String) });
expect(await readGroupmeAccessToken(moved.config)).toBe(GM);

// The ref is authoritative: an older plaintext copy never replaces it.
const kept = await byId(ids.hasRef);
expect(kept.config).toEqual({ botId: 'b2', accessTokenRef: existingRef });
expect(await readGroupmeAccessToken(kept.config)).toBe('groupme-current-token');

expect((await byId(ids.blank)).config).toEqual({ botId: 'b3' });
expect((await byId(ids.slack)).config).toEqual({ signingSecret: 'kept-signing' });

// Rows with nothing to do, and other types, are untouched.
const untouched = await Promise.all([ids.clean, ids.slackBound, ids.x].map(byId));
untouched.forEach((row) => expect(row).toEqual(before.find((entry) => entry._id.equals(row._id))));
const output = formatReport(report, true).join('\n');
[GM, GM_OLD, SLACK, REFRESH, existingRef].forEach((value) => expect(output).not.toContain(value));
});

it('is idempotent after apply', async () => {
await seed();
await migrateChatTokenCopies({ apply: true });
const once = await rows().find().toArray();
const secrets = await ConnectorSecret.countDocuments();
const report = await migrateChatTokenCopies({ apply: true });
expect(report.groupme).toMatchObject({
candidates: 0, toMove: 0, moved: 0, cleared: 0, remaining: 0,
});
expect(report.slack).toMatchObject({ candidates: 0, cleared: 0, remaining: 0 });
expect(await rows().find().toArray()).toEqual(once);
expect(await ConnectorSecret.countDocuments()).toBe(secrets);
});

it('keeps a token a live edit stored after the read, instead of writing the older copy over it', async () => {
const id = new mongoose.Types.ObjectId();
await rows().insertOne({ _id: id, type: 'groupme', config: { botId: 'b5', accessToken: GM_OLD } });
// A PATCH has stored its new token but not yet set the row's ref.
const liveRef = await connectorSecrets.put(String(id), GROUPME_ACCESS_TOKEN, 'groupme-live-edit');
const report = await migrateChatTokenCopies({ apply: true });
expect(report.groupme).toMatchObject({
toMove: 1, moved: 0, cleared: 1, remaining: 0,
});
const row = await byId(id);
expect(row.config).toEqual({ botId: 'b5', accessTokenRef: liveRef });
expect(await readGroupmeAccessToken(row.config)).toBe('groupme-live-edit');
});

it('stops before unsetting a GroupMe token it could not encrypt', async () => {
const { ids } = await seed();
delete process.env.CONNECTOR_SECRET_KEYS;
delete process.env.CONNECTOR_SECRET_ACTIVE_KEY;
await expect(migrateChatTokenCopies({ apply: true })).rejects.toThrow();
const row = await byId(ids.move);
expect(row.config.accessToken).toBe(GM);
expect(row.config.accessTokenRef).toBeUndefined();
});
});

describe('migrate CLI failure output', () => {
it.each([{ args: [] }, { args: ['--unexpected-sensitive-argument'] }])('fails closed without echoing sensitive input ($args)', ({ args }) => {
const secretUri = 'invalid-secret-uri-sentinel';
const result = spawnSync(process.execPath, [
'-r', require.resolve('ts-node/register'),
path.resolve(__dirname, '../../../scripts/migrate-chat-token-copies.ts'),
...args,
], {
cwd: path.resolve(__dirname, '../../..'),
env: { ...process.env, MONGO_URI: secretUri },
encoding: 'utf8',
timeout: 30000,
});
expect(result.status).toBe(1);
expect(result.stdout).toBe('');
expect(result.stderr).toBe('[migrate-chat-token-copies] Failed. No values logged; rerun dry-run to inspect counts.\n');
expect(result.stderr).not.toContain(secretUri);
expect(result.stderr).not.toContain('unexpected-sensitive-argument');
});
});
Loading
Loading