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
36 changes: 36 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,39 @@ describe('POST /api/grants on a hosted-mcp connection', () => {
expect(await RoomGrant.countDocuments({})).toBe(0);
});
});

describe('task-bound mint', () => {
const Task = require('../../../models/Task');
beforeEach(async () => {
await Task.deleteMany({});
await Task.create({ podId: POD, taskNum: 1, taskId: 'TASK-1', title: 'Task-bound grant', status: 'pending' });
});
test('stores the authorized task tuple and ignores caller epoch values', async () => {
const res = await mint({ binding: { taskId: 'TASK-1', podId: POD }, taskEpoch: 999 });
expect(res.status).toBe(201);
expect(res.body.binding).toEqual({ taskId: 'TASK-1', podId: POD, endsOn: 'task.done' });
expect(res.body.taskEpoch).toBe(0);
});
test.each([
{ taskId: 'TASK-1', podId: 'aaaaaaaaaaaaaaaaaaaaaa02' },
{ taskId: 'TASK-404', podId: POD },
{ taskId: 'TASK-1' },
])('rejects foreign or missing task context without saving: %p', async (binding) => {
const res = await mint({ binding });
expect([400, 404]).toContain(res.status);
expect(await RoomGrant.countDocuments({})).toBe(0);
});
test('refuses a binding that ends on anything but task.done, without saving', async () => {
const res = await mint({ binding: { taskId: 'TASK-1', podId: POD, endsOn: 'expiresAt' } });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_task_binding');
expect(await RoomGrant.countDocuments({})).toBe(0);
});
test('rejects a closed task', async () => {
await Task.updateOne({ podId: POD, taskId: 'TASK-1' }, { $set: { status: 'done' } });
const res = await mint({ binding: { taskId: 'TASK-1', podId: POD } });
expect(res.status).toBe(403);
expect(res.body.error).toBe('task_closed');
expect(await RoomGrant.countDocuments({})).toBe(0);
});
});
10 changes: 10 additions & 0 deletions backend/__tests__/unit/routes/tasksApi.status-vocabulary.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ jest.mock('../../../services/attentionItemService', () => ({
resolveTaskAttention: (...args) => mockResolveTaskAttention(...args),
}));

// The close hook writes RoomGrant, which this file's mocked Task cannot back.
// Its real-Mongo behaviour is covered in tasksApi.taskGrantClose.test.js.
const mockOnTaskClosed = jest.fn(async () => {});
jest.mock('../../../services/roomGrantService', () => ({
onTaskClosed: (...args) => mockOnTaskClosed(...args),
}));

const tasksApi = require('../../../routes/tasksApi');
const Task = require('../../../models/Task');

Expand All @@ -77,6 +84,7 @@ beforeEach(() => {
mockRecordTaskAttention.mockResolvedValue(undefined);
mockResolveTaskAttention.mockReset();
mockResolveTaskAttention.mockResolvedValue(undefined);
mockOnTaskClosed.mockClear();
});

describe('PATCH /:podId/:taskId status vocabulary', () => {
Expand Down Expand Up @@ -129,6 +137,7 @@ describe('PATCH /:podId/:taskId status vocabulary', () => {

expect(res.status).toBe(200);
expect(mockResolveTaskAttention).toHaveBeenCalledWith(task);
expect(mockOnTaskClosed).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'TASK-001', status: 'done' }));
});

test('POST /complete resolves the task\'s outstanding handoffs, like a PATCH out of blocked does', async () => {
Expand All @@ -143,6 +152,7 @@ describe('PATCH /:podId/:taskId status vocabulary', () => {
.send({ prUrl: 'https://github.com/Team-Commonly/commonly/pull/1' });
expect(res.status).toBe(200);
expect(mockResolveTaskAttention).toHaveBeenCalledWith(expect.objectContaining({ _id: 'task-1' }));
expect(mockOnTaskClosed).toHaveBeenCalledWith(expect.objectContaining({ _id: 'task-1', status: 'done' }));
expect(mockNotifyPodAgents).toHaveBeenCalledWith(
POD_ID, task, 'updated', expect.any(Object), { status: 'claimed' },
);
Expand Down
135 changes: 135 additions & 0 deletions backend/__tests__/unit/routes/tasksApi.taskGrantClose.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { setupMongoDb, clearMongoDb, closeMongoDb } = require('../../utils/testUtils');
const mockActor = { id: new mongoose.Types.ObjectId().toString(), bot: false };
jest.mock('../../../middleware/auth', () => (req, _res, next) => {
req.userId = mockActor.id;
req.user = { _id: mockActor.id, isBot: mockActor.bot, botMetadata: { instanceId: 'default' } };
next();
});
jest.mock('../../../middleware/agentRuntimeAuth', () => (req, _res, next) => next());
jest.mock('../../../services/taskEventService', () => ({ emitTaskUpdated: jest.fn(), notifyPodAgents: jest.fn(async () => {}) }));
jest.mock('../../../services/attentionItemService', () => ({ resolveTaskAttention: jest.fn(async () => {}), recordTaskAttention: jest.fn(async () => {}) }));
jest.mock('../../../services/systemExchangeTriggers', () => ({ recordTaskCompleted: jest.fn(async () => {}) }));
jest.mock('../../../services/agentSendFreshness', () => ({ unseenMessages: jest.fn(async () => null) }));
// An in-memory record chain store with the repository's contract.
const mockChains = new Map();
jest.mock('../../../repositories/recordChainRepository', () => {
const actual = jest.requireActual('../../../repositories/recordChainRepository');
return {
...actual,
appendToChain: jest.fn(async (ref, runId, eventKey, build) => {
const key = `${ref.podId}|${ref.taskId || ''}`;
if (!mockChains.has(key)) mockChains.set(key, { runId, lines: [], keys: new Set() });
const chain = mockChains.get(key);
if (chain.keys.has(eventKey)) return { appended: 0, duplicate: true };
const { lines } = build(chain.lines[chain.lines.length - 1] || null, chain.runId);
chain.lines.push(...lines);
chain.keys.add(eventKey);
return { appended: lines.length, duplicate: false };
}),
};
});
const { flushRecordWrites } = require('../../../services/recordService');
const { verifyRecord } = require('../../../vendor/seats/src/record/format');
const Task = require('../../../models/Task');
const Pod = require('../../../models/Pod');
const RoomGrant = require('../../../models/RoomGrant');
const { mintGrant, assertGrantUsable } = require('../../../services/roomGrantService');
const { completeTaskForMergedPull } = require('../../../services/githubGigService');
const { completeConnectAgentStarterTask, CONNECT_AGENT_SOURCE_REF } = require('../../../services/starterTaskService');
const router = require('../../../routes/tasksApi');
const POD = new mongoose.Types.ObjectId().toString();
let app;
let grant;
const recordPods = process.env.RECORD_V1_PODS;
beforeAll(async () => {
process.env.RECORD_V1_PODS = POD;
await setupMongoDb(); app = express(); app.use(express.json()); app.use('/tasks', router);
});
afterEach(async () => { await flushRecordWrites(); await clearMongoDb(); });
afterAll(async () => {
if (recordPods === undefined) delete process.env.RECORD_V1_PODS;
else process.env.RECORD_V1_PODS = recordPods;
await closeMongoDb();
});
beforeEach(async () => {
mockChains.clear();
mockActor.bot = false;
await Pod.create({ _id: POD, name: 'Bound task', createdBy: mockActor.id, members: [mockActor.id] });
await Task.create({ podId: POD, taskId: 'TASK-1', taskNum: 1, title: 'Bound task', status: 'claimed',
claimedBy: mockActor.id, claimExpiresAt: new Date(Date.now() + 60000),
prUrl: 'https://github.com/Team-Commonly/commonly/pull/123', sourceRef: CONNECT_AGENT_SOURCE_REF });
grant = await mintGrant({ connectionId: 'c', installationId: 'i', target: { kind: 'pod', id: POD },
tools: ['issues.read'], audience: [mockActor.id], writeMode: 'read', brokerId: 'b',
expiresAt: new Date(Date.now() + 3600000), binding: { podId: POD, taskId: 'TASK-1' } });
});

test.each(['complete', 'patch', 'github', 'onboarding'])('%s close revokes the bound grant before returning', async (writer) => {
if (writer === 'complete') expect((await request(app).post(`/tasks/${POD}/TASK-1/complete`).send({})).status).toBe(200);
if (writer === 'patch') expect((await request(app).patch(`/tasks/${POD}/TASK-1`).send({ status: 'done' })).status).toBe(200);
if (writer === 'github') await completeTaskForMergedPull(POD, 'https://github.com/Team-Commonly/commonly/pull/123');
if (writer === 'onboarding') await completeConnectAgentStarterTask({ podIds: [POD], agentLabel: 'Scout' });
const task = await Task.findOne({ podId: POD, taskId: 'TASK-1' });
expect(task.status).toBe('done');
expect(task.grantEpoch).toBe(1);
const revoked = await RoomGrant.findById(grant._id);
expect(revoked.revocationReason).toBe('task.done');
expect(revoked.revokedAt).toBeInstanceOf(Date);
await expect(assertGrantUsable({ grantId: grant.grantId, agentUserId: mockActor.id, currentMemberIds: [mockActor.id] }))
.rejects.toMatchObject({ code: 'grant_revoked' });
// ADR-033 §4: the close's task.done, then grant.revoked for its bound grants.
await flushRecordWrites();
const { lines } = mockChains.get(`${POD}|TASK-1`);
expect(verifyRecord(lines.map((line) => `${line}\n`).join(''))).toMatchObject({ ok: true });
expect(lines.map((line) => JSON.parse(line)).map((l) => [l.kind, l.data.grantIds || null])).toEqual([
['grant.issued', null], ['task.done', null], ['grant.revoked', [grant.grantId]],
]);
});

test('a failed revocation write leaves the close committed and the grant refused by its epoch', async () => {
const spy = jest.spyOn(RoomGrant, 'updateMany').mockRejectedValueOnce(new Error('revocation write failed'));
expect((await request(app).post(`/tasks/${POD}/TASK-1/complete`).send({})).status).toBe(200);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
expect((await Task.findOne({ podId: POD, taskId: 'TASK-1' })).status).toBe('done');
expect((await RoomGrant.findById(grant._id)).revokedAt).toBeNull();
await Task.updateOne({ podId: POD, taskId: 'TASK-1' }, { $set: { status: 'claimed', claimedByUserId: mockActor.id,
claimExpiresAt: new Date(Date.now() + 60000) } });
await expect(assertGrantUsable({ grantId: grant.grantId, agentUserId: mockActor.id, currentMemberIds: [mockActor.id] }))
.rejects.toMatchObject({ code: 'task_closed' });
});

test('claim stamps authenticated identity and refuses renewal by a different User sharing its display key', async () => {
await Task.updateOne({ taskId: 'TASK-1' }, { $set: { status: 'pending' } });
mockActor.bot = true;
const first = await request(app).post(`/tasks/${POD}/TASK-1/claim`).send({ continueAnyway: true });
expect(first.status).toBe(200);
expect(first.body.task.claimedBy).toBe('default');
expect(first.body.task.claimedByUserId).toBe(mockActor.id);
await Task.updateOne({ taskId: 'TASK-1' }, { $set: { claimedByUserId: new mongoose.Types.ObjectId().toString() } });
const peer = await request(app).post(`/tasks/${POD}/TASK-1/claim`).send({ continueAnyway: true });
expect(peer.status).toBe(409);
});

test('status-only release and reclaim cannot reuse the previous authenticated principal', async () => {
await Task.updateOne({ taskId: 'TASK-1' }, { $set: { claimedByUserId: mockActor.id } });
expect((await request(app).patch(`/tasks/${POD}/TASK-1`).send({ status: 'pending' })).status).toBe(200);
expect((await Task.findOne({ taskId: 'TASK-1' })).claimedByUserId).toBeNull();
expect((await request(app).patch(`/tasks/${POD}/TASK-1`).send({ status: 'claimed', claimedByUserId: mockActor.id })).status).toBe(200);
expect((await Task.findOne({ taskId: 'TASK-1' })).claimedByUserId).toBeNull();
await expect(assertGrantUsable({ grantId: grant.grantId, agentUserId: mockActor.id, currentMemberIds: [mockActor.id] }))
.rejects.toMatchObject({ code: 'task_claim_required' });
});


test('legacy display-key renewal stays compatible without granting authenticated task authority', async () => {
mockActor.bot = true;
await Task.updateOne({ taskId: 'TASK-1' }, { $set: { claimedBy: 'default', claimedByUserId: null } });
const renewed = await request(app).post(`/tasks/${POD}/TASK-1/claim`).send({ continueAnyway: true });
expect(renewed.status).toBe(200);
expect(renewed.body.task.claimedByUserId).toBeNull();
await expect(assertGrantUsable({ grantId: grant.grantId, agentUserId: mockActor.id, currentMemberIds: [mockActor.id] }))
.rejects.toMatchObject({ code: 'task_claim_required' });
});
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ describe('#1080 part 3 — provenance survives the clearing', () => {
// as unclaimed work. lapsedFrom is the record that outlives it.
expect(update.$set.assignee).toBeNull();
expect(update.$set.claimedBy).toBeNull();
expect(update.$set.claimedByUserId).toBeNull();
expect(update.$set.lapsedFrom).toBe('sprint-impl');
expect(update.$push.updates.text).toContain('was: sprint-impl');
});
Expand Down
170 changes: 170 additions & 0 deletions backend/__tests__/unit/services/roomGrantRecord.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Task-bound grants write record-v1 lines (ADR-033 §4): grant.issued on mint
// and attenuation, and grant.revoked {reason: task.done} when the task closes.
// The chain store is in memory with the repository's contract, as in
// recordService.test.js; every line must pass the shared reference verifier.
const mockChains = new Map();
jest.mock('../../../repositories/recordChainRepository', () => {
const actual = jest.requireActual('../../../repositories/recordChainRepository');
const key = (ref) => `${ref.podId}|${ref.taskId || ''}`;
return {
...actual,
appendToChain: jest.fn(async (ref, runId, eventKey, build) => {
if (!mockChains.has(key(ref))) mockChains.set(key(ref), { runId, lines: [], keys: new Set() });
const chain = mockChains.get(key(ref));
if (chain.keys.has(eventKey)) return { appended: 0, duplicate: true };
const { lines } = build(chain.lines[chain.lines.length - 1] || null, chain.runId);
chain.lines.push(...lines);
chain.keys.add(eventKey);
return { appended: lines.length, duplicate: false };
}),
};
});

const mongoose = require('mongoose');
const { setupMongoDb, clearMongoDb, closeMongoDb } = require('../../utils/testUtils');
const Task = require('../../../models/Task');
const Pod = require('../../../models/Pod');
const service = require('../../../services/roomGrantService');
const { flushRecordWrites } = require('../../../services/recordService');
const { verifyRecord } = require('../../../vendor/seats/src/record/format');

const POD = new mongoose.Types.ObjectId().toString();
const OWNER = new mongoose.Types.ObjectId().toString();
const AGENT = new mongoose.Types.ObjectId().toString();
const binding = { taskId: 'TASK-1', podId: POD };
const input = (overrides = {}) => ({
connectionId: 'connection',
installationId: 'installation',
target: { kind: 'pod', id: POD },
tools: ['issues.read'],
writeMode: 'read',
audience: [AGENT],
expiresAt: new Date(Date.now() + 3600000),
brokerId: 'broker',
binding,
issuer: { type: 'human', id: OWNER },
...overrides,
});
const chainLines = async (podId = POD, taskId = 'TASK-1') => {
await flushRecordWrites();
const chain = mockChains.get(`${podId}|${taskId}`);
if (!chain) return [];
expect(verifyRecord(chain.lines.map((line) => `${line}\n`).join(''))).toMatchObject({ ok: true });
return chain.lines.map((line) => JSON.parse(line));
};
const close = async () => {
const task = await Task.findOneAndUpdate(
{ podId: POD, taskId: 'TASK-1' },
{ $inc: { grantEpoch: 1 }, $set: { status: 'done' } },
{ new: true },
);
await service.onTaskClosed(task);
};

const env = process.env.RECORD_V1_PODS;
beforeAll(setupMongoDb);
afterEach(async () => { await flushRecordWrites(); await clearMongoDb(); });
afterAll(async () => {
if (env === undefined) delete process.env.RECORD_V1_PODS;
else process.env.RECORD_V1_PODS = env;
await closeMongoDb();
});
beforeEach(async () => {
mockChains.clear();
process.env.RECORD_V1_PODS = POD;
await Pod.create({
_id: POD, name: 'Task pod', createdBy: OWNER, members: [OWNER, AGENT],
});
await Task.create({
podId: POD,
taskNum: 1,
taskId: 'TASK-1',
title: 'Investigate issue',
status: 'claimed',
claimedBy: AGENT,
claimExpiresAt: new Date(Date.now() + 1800000),
});
});

test('a bound mint writes grant.issued on its task chain, with the binding and the issuer', async () => {
const grant = await service.mintGrant(input({ budget: { calls: 5 } }));
const lines = await chainLines();
expect(lines.map((l) => l.kind)).toEqual(['grant.issued']);
expect(lines[0].actor).toEqual({ type: 'human', id: OWNER });
expect(lines[0].data).toEqual({
grantId: grant.grantId,
scope: {
connectionId: 'connection', tools: ['issues.read'], writeMode: 'read', budget: { calls: 5 }, audience: [AGENT],
},
target: { kind: 'pod', id: POD },
issuedBy: OWNER,
expiresAt: grant.expiresAt.toISOString(),
binding: { ...binding, endsOn: 'task.done' },
});
});

test('an agent attenuating a bound grant is recorded as the agent, with the parent', async () => {
const parent = await service.mintGrant(input());
const child = await service.attenuateGrant({ parentGrantId: parent.grantId, issuer: { type: 'agent', id: AGENT } });
const lines = await chainLines();
expect(lines.map((l) => [l.kind, l.data.grantId])).toEqual([
['grant.issued', parent.grantId], ['grant.issued', child.grantId],
]);
expect(lines[1].actor).toEqual({ type: 'agent', id: AGENT });
expect(lines[1].data).toMatchObject({ parentGrantId: parent.grantId, issuedBy: AGENT });
});

test('an unbound mint has no task chain and writes nothing', async () => {
await service.mintGrant(input({ binding: undefined }));
await flushRecordWrites();
expect(mockChains.size).toBe(0);
});

test('a mint refused after insertion writes no grant.issued', async () => {
const realCreate = mongoose.model('RoomGrant').create.bind(mongoose.model('RoomGrant'));
const spy = jest.spyOn(mongoose.model('RoomGrant'), 'create').mockImplementationOnce(async (doc) => {
const created = await realCreate(doc);
await Task.updateOne({ podId: POD, taskId: 'TASK-1' }, { $inc: { grantEpoch: 1 } });
return created;
});
await expect(service.mintGrant(input())).rejects.toMatchObject({ code: 'task_closed' });
spy.mockRestore();
expect(await chainLines()).toEqual([]);
});

test('close writes one grant.revoked naming the bound grant and its descendant, as the board', async () => {
const parent = await service.mintGrant(input());
const child = await service.attenuateGrant({ parentGrantId: parent.grantId, issuer: { type: 'agent', id: AGENT } });
await close();
await service.onTaskClosed(await Task.findOne({ podId: POD, taskId: 'TASK-1' }));
const revoked = (await chainLines()).filter((l) => l.kind === 'grant.revoked');
expect(revoked).toHaveLength(1);
expect(revoked[0].actor).toEqual({ type: 'system', id: 'board' });
expect(revoked[0].data.reason).toBe('task.done');
expect([...revoked[0].data.grantIds].sort()).toEqual([parent.grantId, child.grantId].sort());
});

test('a second close after reopen names only the grant issued since, not the one already revoked', async () => {
const first = await service.mintGrant(input());
await close();
await Task.updateOne({ podId: POD, taskId: 'TASK-1' }, { $set: { status: 'claimed' } });
const second = await service.mintGrant(input());
await close();
const revoked = (await chainLines()).filter((l) => l.kind === 'grant.revoked');
expect(revoked.map((l) => l.data.grantIds)).toEqual([[first.grantId], [second.grantId]]);
});

test('a close with no live bound grant writes no grant.revoked', async () => {
await service.mintGrant(input({ binding: undefined }));
await close();
expect((await chainLines()).filter((l) => l.kind === 'grant.revoked')).toEqual([]);
});

test('a pod outside RECORD_V1_PODS gets no lines, and its grants still revoke', async () => {
process.env.RECORD_V1_PODS = new mongoose.Types.ObjectId().toString();
const grant = await service.mintGrant(input());
await close();
await flushRecordWrites();
expect(mockChains.size).toBe(0);
expect((await mongoose.model('RoomGrant').findById(grant._id)).revocationReason).toBe('task.done');
});
Loading
Loading