Skip to content
Closed
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
99 changes: 99 additions & 0 deletions backend/__tests__/service/hostedMcpAgentIndexMigration.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* The TASK-330 index migration (scripts/migrate-hosted-mcp-agent-index.js).
* Before it runs, the old `(createdBy, entryId)` index refuses a second agent's
* key with E11000. After it runs, that key saves, and the old index is gone.
* Run against a real mongod, because the failure is an index, not a mock.
*
* Every arm seeds its own pre-migration state (old index present, new index
* absent, agent-a's key saved) so no arm depends on another having run first.
*/
process.env.PG_HOST = '';

const mongoose = require('mongoose');
const Integration = require('../../models/Integration');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils');
const { run, OLD_INDEX } = require('../../scripts/migrate-hosted-mcp-agent-index');

const OWNER = new mongoose.Types.ObjectId();
const keyFor = (agentId) => ({
scope: 'user',
type: 'hosted-mcp',
status: 'connected',
createdBy: OWNER,
config: { entryId: 'latchkey', intake: 'operator_key', agentId },
});

const indexNames = async () => (await Integration.collection.indexes()).map((index) => index.name);

/** Put the database in the production state: the old index present, the agent-scoped one absent. */
const seedPreMigration = async () => {
const agentIndex = (await Integration.collection.indexes())
.find((index) => index.key.createdBy === 1 && index.key['config.agentId'] === 1);
if (agentIndex) await Integration.collection.dropIndex(agentIndex.name);
if (!(await indexNames()).includes(OLD_INDEX)) {
await Integration.collection.createIndex(
{ createdBy: 1, 'config.entryId': 1 },
{ unique: true, partialFilterExpression: { type: 'hosted-mcp' }, name: OLD_INDEX },
);
}
};

describe('the TASK-330 agent-scoped index migration', () => {
beforeAll(async () => {
await setupMongoDb();
await Integration.init();
});

beforeEach(async () => {
await Integration.deleteMany({});
await seedPreMigration();
});

afterEach(() => {
jest.restoreAllMocks();
});

afterAll(async () => {
await clearMongoDb();
await closeMongoDb();
});

test('before the migration, the old index refuses a second agent key for the same person and entry', async () => {
await Integration.create(keyFor('agent-a'));
await expect(Integration.create(keyFor('agent-b'))).rejects.toMatchObject({ code: 11000 });
});

test('the migration builds the new index, drops the old one, and lets a second agent key save', async () => {
await Integration.create(keyFor('agent-a'));

await run({ apply: true });

const names = await indexNames();
expect(names).not.toContain(OLD_INDEX);
expect(names.some((name) => name.includes('config.agentId'))).toBe(true);
await expect(Integration.create(keyFor('agent-b'))).resolves.toBeDefined();
// The migration deletes no rows: agent-a's key is still there.
expect(await Integration.countDocuments({ 'config.agentId': 'agent-a' })).toBe(1);
});

test('a failed build leaves the old index in place, so uniqueness is never lost', async () => {
jest.spyOn(Integration.collection, 'createIndex').mockRejectedValueOnce(new Error('build failed'));

await expect(run({ apply: true })).rejects.toThrow('build failed');

expect(await indexNames()).toContain(OLD_INDEX);
await Integration.create(keyFor('agent-a'));
await expect(Integration.create(keyFor('agent-b'))).rejects.toMatchObject({ code: 11000 });
});

test('a second run is a no-op rather than an error', async () => {
await run({ apply: true });
await expect(run({ apply: true })).resolves.toBeUndefined();
expect(await indexNames()).not.toContain(OLD_INDEX);
});

test('read-only by default: a dry run leaves the old index in place', async () => {
await run({ apply: false });
expect(await indexNames()).toContain(OLD_INDEX);
});
});
25 changes: 25 additions & 0 deletions backend/__tests__/service/hostedMcpConnectionRow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const shape = (overrides = {}) => ({
// failure names the key that was dropped rather than the object that lost it.
const RECORD_KEYS = {
'config.entryId': 'linear',
// The agent an operator key is bound to (TASK-330). Declared, so a $set the
// schema drops is caught here and not by a mint that refuses "not bound".
'config.agentId': 'agent-a',
'config.intake': 'oauth',
'config.clientId': 'https://api.commonly.me/api/integrations/connect/hosted-mcp/linear/client-metadata',
'config.providerSubject': 'subject-1',
Expand Down Expand Up @@ -158,6 +161,28 @@ describe('the hosted-mcp connection row', () => {
await expect(Integration.create(shape({ createdBy: OWNER_B }))).resolves.toBeDefined();
});

test('one owner holds one key per bound agent, and an OAuth row co-exists with them', async () => {
// TASK-330: an operator key is one agent's bearer, so the second agent's
// key for the same vendor is a second row, not an E11000. A second key for
// the SAME agent is still one row, and an OAuth row (no agentId) is its own
// row beside them. Each is per (person, entry, agent).
const keyFor = (agentId) => shape({ config: { entryId: ENTRY_A, intake: 'operator_key', agentId } });

await expect(Integration.create(keyFor('agent-a'))).resolves.toBeDefined();
await expect(Integration.create(keyFor('agent-b'))).resolves.toBeDefined();
await expect(Integration.create(keyFor('agent-a'))).rejects.toMatchObject({ code: 11000 });
await expect(Integration.create(shape())).resolves.toBeDefined();
await expect(Integration.create(shape())).rejects.toMatchObject({ code: 11000 });
});

test('the declared index keys on the bound agent as well as the person and entry', () => {
const declared = Integration.schema.indexes().find(([keys, options]) => (
keys.createdBy === 1 && options.partialFilterExpression?.type === 'hosted-mcp'
));
expect(declared[0]).toEqual({ createdBy: 1, 'config.entryId': 1, 'config.agentId': 1 });
expect(declared[1].unique).toBe(true);
});

test('positive control: the index is PARTIAL, not a unique index on createdBy', async () => {
// If the declaration lost `partialFilterExpression`, the unique key would
// still be enforced on hosted-mcp rows and the arm above would keep
Expand Down
50 changes: 50 additions & 0 deletions backend/__tests__/unit/routes/grants.mint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,53 @@ describe('POST /api/grants on a hosted-mcp connection', () => {
expect(await RoomGrant.countDocuments({})).toBe(0);
});
});

/**
* TASK-330: an operator-key row is one agent's bearer, so its grant may name
* only that agent, and only alone. The helper has its own arms; these run the
* mint, so the test goes red if the grants.ts call is removed.
*/
describe('POST /api/grants on an operator-key hosted connection', () => {
const KEY_ENTRY = { ...ENTRY, id: 'latchkey', title: 'Latchkey', credentialMode: 'operator_key' };
const KEY_ID = 'eeeeeeeeeeeeeeeeeeeeee02';
const keyRow = (config = {}) => ({
_id: KEY_ID, type: 'hosted-mcp', status: 'connected', createdBy: OWNER,
config: { entryId: 'latchkey', agentId: SEAT, credentialRef: 'secret-ref-key', ...config },
});
const keyMint = (body = {}) => mint({ connectionId: KEY_ID, tools: ['latchkey.list_issues'], ...body });

beforeEach(() => {
Integration.findById.mockResolvedValue(keyRow());
});

test('an omitted audience is refused, not defaulted to every pod member', async () => {
const res = await withCatalog([KEY_ENTRY], () => keyMint());
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: 'invalid_audience' });
expect(res.body.message).toMatch(/omitted audience/);
expect(await RoomGrant.countDocuments({})).toBe(0);
});

test('an audience naming another agent is refused', async () => {
const res = await withCatalog([KEY_ENTRY], () => keyMint({ audience: [OWNER] }));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_audience', message: `audience must be exactly [${SEAT}]` });
expect(await RoomGrant.countDocuments({})).toBe(0);
});

test('an audience of the bound agent plus another member is refused', async () => {
const res = await withCatalog([KEY_ENTRY], () => keyMint({ audience: [SEAT, OWNER] }));
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: 'invalid_audience' });
expect(await RoomGrant.countDocuments({})).toBe(0);
});

test('an audience of exactly the bound agent passes the audience check', async () => {
const res = await withCatalog([KEY_ENTRY], () => keyMint({ audience: [SEAT] }));
// The mint then reaches the broker, which this harness does not stand up
// (see the 503 arm above), so the arm asserts what it names: not refused
// for its audience.
expect(res.body.error).not.toBe('invalid_audience');
expect(res.status).not.toBe(400);
});
});
14 changes: 14 additions & 0 deletions backend/__tests__/unit/routes/hostedMcpConnect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,20 @@ describe('hosted-mcp connect: start', () => {
entries.HOSTED_MCP_ENTRIES.pop();
});

it('refuses an operator-key entry before any client, discovery or row read', async () => {
const entries = jest.requireMock('../../../integrations/hostedMcp/entries');
entries.HOSTED_MCP_ENTRIES.push({
...FIXTURE_ENTRY, id: 'latchkey', credentialMode: 'operator_key', client: undefined,
});
const res = await start('latchkey');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'unknown_entry' });
expect(intake.discoverAuthorizationServer).not.toHaveBeenCalled();
expect(Integration.findOne).not.toHaveBeenCalled();
expect(Integration.findOneAndUpdate).not.toHaveBeenCalled();
entries.HOSTED_MCP_ENTRIES.pop();
});

it('requires a person, because it is the person who will be consenting', async () => {
mockAuthedUser = undefined;
const res = await start();
Expand Down
Loading
Loading