Skip to content
99 changes: 97 additions & 2 deletions backend/__tests__/unit/routes/grants.read.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ jest.mock('../../../middleware/agentRuntimeAuth', () => (req, res, next) => {
req.agentUser = { _id: id };
return next();
});
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/Integration', () => ({ findById: jest.fn(), findOne: jest.fn() }));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() }));
jest.mock('../../../models/Integration', () => ({ findById: jest.fn(), findOne: jest.fn(), find: jest.fn() }));
jest.mock('../../../models/ToolCall', () => ({
listForGrant: jest.fn(),
countsForGrant: jest.fn(),
Expand Down Expand Up @@ -124,10 +124,105 @@ beforeEach(async () => {
Integration.findOne.mockImplementation(async ({ installationId }) => (installationId === 'conn-1'
? { _id: 'conn-1', type: 'github-app', status: 'connected', createdBy: OWNER }
: null));
Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [
{ _id: 'conn-1', installationId: 'conn-1', createdBy: OWNER },
] }) }));
ToolCall.listForGrant.mockResolvedValue([]);
ToolCall.countsForGrant.mockResolvedValue({ total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 });
});

describe('GET /api/grants?connectionOwner=me', () => {
test('includes grants backed by both an external installation id and a hosted connection row id', async () => {
const hostedId = 'eeeeeeeeeeeeeeeeeeeeee01';
const githubGrant = await RoomGrant.create(grant());
const hostedGrant = await RoomGrant.create(grant({ connectionId: hostedId }));
Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [
{ _id: 'ffffffffffffffffffffff01', installationId: 'conn-1', createdBy: OWNER },
{ _id: hostedId, createdBy: OWNER },
] }) }));
Pod.findById.mockImplementation((id) => ({ select: () => ({ lean: async () => (
{ _id: id, type: 'public', members: [OWNER, SEAT] }
) }) }));

const res = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER);
expect(res.status).toBe(200);
expect(res.body.grants.map((row) => row.grantId).sort()).toEqual([
githubGrant.grantId, hostedGrant.grantId,
].sort());
});

test('lists the owner’s grants beyond 20 pods, preserves dead grants, and excludes another owner’s connection', async () => {
const podIds = Array.from({ length: 21 }, (_, index) => `aaaaaaaaaaaaaaaaaaaaaa${String(index + 1).padStart(2, '0')}`);
const owned = await RoomGrant.create(podIds.map((id) => grant({ target: { kind: 'pod', id } })));
const revokedAt = new Date('2026-09-11T00:00:00.000Z');
await RoomGrant.updateOne({ grantId: owned[0].grantId }, { $set: { revokedAt, revokedBy: OWNER } });
await RoomGrant.create(grant({ connectionId: 'conn-other', ownerId: STRANGER, target: { kind: 'pod', id: podIds[20] } }));
Pod.findById.mockImplementation((id) => ({ select: () => ({ lean: async () => (
{ _id: id, type: 'public', members: [OWNER, SEAT] }
) }) }));

const res = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER);
expect(res.status).toBe(200);
expect(res.body.grants).toHaveLength(21);
expect(res.body.grants.map((row) => row.grantId)).toEqual(expect.arrayContaining(owned.map((row) => row.grantId)));
expect(res.body.grants.find((row) => row.target.id === podIds[20])).toMatchObject({
grantId: owned[20].grantId, effectiveAudience: [SEAT], grantedBy: OWNER,
});
const revoked = res.body.grants.find((row) => row.grantId === owned[0].grantId);
expect(revoked).toMatchObject({ revokedBy: OWNER, revokedAt: revokedAt.toISOString() });
expect(res.body.grants.every((row) => row.expiresAt && Object.keys(row).sort().join() === [...FIELDS].sort().join())).toBe(true);
expect(JSON.stringify(res.body)).not.toContain('conn-other');
});

// The connection id now names a row another user holds. The list follows the
// binding fixed at mint: the bound owner keeps the grant, the holder does not
// get it, and an unbound grant is listed for nobody.
test('follows the binding, not the connection holder, and lists unbound grants for nobody', async () => {
const bound = await RoomGrant.create(grant({ target: { kind: 'pod', id: POD } }));
await RoomGrant.create(grant({ ownerId: null, connectionRowId: null, target: { kind: 'pod', id: POD } }));
Integration.find.mockImplementation(() => ({ select: () => ({ lean: async () => [
{ _id: 'conn-1', installationId: 'conn-1', createdBy: STRANGER },
] }) }));

const owner = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER);
expect(owner.body.grants.map((row) => row.grantId)).toEqual([bound.grantId]);
const holder = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', STRANGER);
expect(holder.body.grants).toEqual([]);
});

test('drops a pod grant once the owner can no longer view that pod', async () => {
const hidden = '0000000000000000000000ab';
const visible = '0000000000000000000000ac';
const [hiddenGrant, visibleGrant] = await RoomGrant.create([
grant({ target: { kind: 'pod', id: hidden } }),
grant({ target: { kind: 'pod', id: visible } }),
]);
Pod.findById.mockImplementation((id) => ({ select: () => ({ lean: async () => (
{ _id: id, type: 'private', members: id === hidden ? [SEAT] : [OWNER, SEAT] }
) }) }));

const res = await request(app).get('/api/grants?connectionOwner=me').set('x-test-user', OWNER);
expect(res.status).toBe(200);
const ids = res.body.grants.map((row) => row.grantId);
expect(ids).toContain(visibleGrant.grantId);
expect(ids).not.toContain(hiddenGrant.grantId);
expect(JSON.stringify(res.body)).not.toContain(hidden);
});

test('requires a signed-in owner and never gives a different user or admin the owner’s grants', async () => {
await RoomGrant.create(grant());
Pod.findById.mockImplementation(() => ({ select: () => ({ lean: async () => null }) }));
// Deliberately return a foreign row even for the admin caller. The route
// must enforce ownership after the query as well as in its predicate.
expect((await request(app).get('/api/grants?connectionOwner=me')).status).toBe(401);
expect((await request(app).get('/api/grants').set('x-test-user', OWNER)).status).toBe(400);
const other = await request(app).get('/api/grants?connectionOwner=me')
.set('x-test-user', STRANGER).set('x-test-role', 'admin');
expect(other.status).toBe(200);
expect(other.body.grants).toEqual([]);
});
});

describe('GET /api/pods/:podId/grants', () => {
test('GET /api/pods/:podId/grants is reachable with the pod routes mounted', async () => {
await RoomGrant.create(grant());
Expand Down
37 changes: 37 additions & 0 deletions backend/routes/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,43 @@ router.get('/:grantId', dualGrantCredential, grantRateLimit, dualAuth, async (re
}
});

/** The Tools page lists the caller's own connection grants across every pod. */
router.get('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: express.Response) => {
try {
const userId = callerId(req);
if (!userId) return res.status(401).json({ error: 'unauthorized' });
if (req.query.connectionOwner !== 'me') return res.status(400).json({ error: 'invalid_connection_owner' });

// The owner is the binding fixed at mint, not whoever holds the connection id
// now. An unbound grant is listed for nobody here.
const grants = await RoomGrant.find({ ownerId: userId })
.sort({ createdAt: -1 }).lean();
const podIds = Array.from(new Set(grants
.filter((grant) => grant.target.kind === 'pod' && Types.ObjectId.isValid(grant.target.id))
.map((grant) => grant.target.id)));
const [pods, confinements] = await Promise.all([
Promise.all(podIds.map(async (podId) => [podId, await loadPod(podId)] as const)),
seatGrantRefusals(grants),
]);
// A pod grant is returned only while the caller can still view its pod: an owner
// who has left a private pod does not see that pod's grants.
const visiblePods = new Map<string, string[]>();
for (const [podId, pod] of pods) {
if (pod && await DMService.canViewPod(userId, pod)) visiblePods.set(podId, memberIdsOf(pod));
}
return res.json({ grants: grants
.filter((grant) => grant.target.kind !== 'pod' || visiblePods.has(grant.target.id))
.map((grant) => projectGrant(
grant,
grant.target.kind === 'seat' ? grant.audience : visiblePods.get(grant.target.id) || [],
userId,
confinements.get(grant.grantId),
)) });
} catch (error) {
return handleError(res, error);
}
});

/**
* GET /api/pods/:podId/grants — every grant whose target is the pod or a seat
* in it, gated by canViewPod, each row projected. Mounted under /api/pods by
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const offers = ['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry'].map((labe
const mockTools = (entries: typeof catalog, grants: ReturnType<typeof grant>[]) => {
axios.get.mockImplementation((url: string) => {
if (url === '/api/installables') return Promise.resolve({ data: { installables: entries } });
if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { grants } });
if (url === '/api/grants') return Promise.resolve({ data: { grants } });
if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
return Promise.reject(new Error(`unmocked ${url}`));
Expand Down Expand Up @@ -85,7 +85,7 @@ test('two live grants stay visible and nine revoked or expired grants start in o
...Array.from({ length: 5 }, (_, i) => ({ ...grant(`expired-${i}`, null), expiresAt: yesterday }))];
axios.get.mockImplementation((url: string) => {
if (url === '/api/installables') return Promise.resolve({ data: { installables: catalog } });
if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { grants } });
if (url === '/api/grants') return Promise.resolve({ data: { grants } });
if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
return Promise.reject(new Error(`unmocked ${url}`));
Expand All @@ -112,7 +112,7 @@ test('search keeps a matching catalogue offer visible and folds only ended conne
if (url === '/api/installables') return Promise.resolve({ data: { installables: entries, hiddenToolConnections: [
{ connectionId: 'hidden-1', entryId: 'atlassian', label: 'Atlassian', status: 'inactive', revokePage: null },
] } });
if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { grants: [grant('live', null), grant('revoked', yesterday)] } });
if (url === '/api/grants') return Promise.resolve({ data: { grants: [grant('live', null), grant('revoked', yesterday)] } });
if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
return Promise.reject(new Error(`unmocked ${url}`));
Expand Down
Loading
Loading