Skip to content
Draft
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
427 changes: 425 additions & 2 deletions backend/__tests__/unit/middleware/agentRuntimeAuth.test.js

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions backend/__tests__/unit/routes/agentsRuntime.claims.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@ describe('claim routes', () => {
}));
});

test('an unavailable comment lookup returns 500 without minting a claim', async () => {
const log = jest.spyOn(console, 'error').mockImplementation(() => {});
mockMessageExists.mockRejectedValue(new Error('MongoServerSelectionError'));
const res = await request(app)
.post('/api/agents/runtime/messages/507f1f77bcf86cd799439011/claim')
.send({ podId: 'p1' });
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'claim_failed' });
expect(mockClaim).not.toHaveBeenCalled();
expect(mockTypingStart).not.toHaveBeenCalled();
log.mockRestore();
});

test('missing podId → 400', async () => {
const res = await request(app).post('/api/agents/runtime/messages/52907/claim').send({});
expect(res.status).toBe(400);
Expand Down
23 changes: 23 additions & 0 deletions backend/__tests__/unit/routes/registry.list-agents-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ jest.mock('../../../services/dmService', () => ({
canViewPod: jest.fn().mockResolvedValue(true),
}));

jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({
agentsWithMultipleOutsiderGrantPods: jest.fn(),
}));

// AgentIdentityService surface used by helpers.ts. Two import shapes are
// consumed: (a) the named export `buildAgentUsername` (used by the route
// handler to compose user lookup keys) and (b) `.default` (used by
Expand Down Expand Up @@ -81,6 +85,7 @@ const AgentProfile = require('../../../models/AgentProfile');
const AgentTemplate = require('../../../models/AgentTemplate');
const User = require('../../../models/User');
const { AgentRegistry, AgentInstallation } = require('../../../models/AgentRegistry');
const { agentsWithMultipleOutsiderGrantPods } = require('../../../services/nativeOutsiderGrantPolicy');
const registryRoutes = require('../../../routes/registry');

const app = express();
Expand All @@ -90,9 +95,11 @@ app.use('/api/registry', registryRoutes);
describe('registry list pod agents config payload', () => {
beforeEach(() => {
jest.clearAllMocks();
agentsWithMultipleOutsiderGrantPods.mockResolvedValue(new Set());
});

it('returns persisted config fields used by agent settings UI', async () => {
agentsWithMultipleOutsiderGrantPods.mockResolvedValue(new Set(['openclaw:x-curator']));
Pod.findById.mockReturnValue({
lean: jest.fn().mockResolvedValue({
_id: 'pod-1',
Expand Down Expand Up @@ -151,6 +158,11 @@ describe('registry list pod agents config payload', () => {

expect(res.status).toBe(200);
expect(res.body.agents).toHaveLength(1);
expect(agentsWithMultipleOutsiderGrantPods).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ agentName: 'openclaw', instanceId: 'x-curator' })]),
);
expect(res.body.agents[0].runtimeNarrowedByMultipleGrantPods).toBe(true);
expect(res.body.agents[0].runtimeNarrowingUnavailable).toBe(false);
expect(res.body.agents[0].config).toEqual({
presetId: null,
customizations: null,
Expand All @@ -174,6 +186,17 @@ describe('registry list pod agents config payload', () => {
skillEnvKeys: ['github'],
}));
expect(res.body.agents[0].runtime).not.toHaveProperty('webhookSecret');

agentsWithMultipleOutsiderGrantPods.mockRejectedValueOnce(new Error('grant DB unavailable'));
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const unavailable = await request(app).get('/api/registry/pods/pod-1/agents');
expect(unavailable.status).toBe(200);
expect(unavailable.body.agents[0]).toEqual(expect.objectContaining({
runtimeNarrowedByMultipleGrantPods: false,
runtimeNarrowingUnavailable: true,
}));
} finally { warn.mockRestore(); }
});

it('uses the chat principal label when scoped labels differ', async () => {
Expand Down
47 changes: 45 additions & 2 deletions backend/__tests__/unit/services/agentCredential.substrate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ const makeRes = () => {
return res;
};

const runAuth = async (rawToken) => {
const req = { header: (h) => (h === 'Authorization' ? `Bearer ${rawToken}` : undefined) };
const runAuth = async (rawToken, route) => {
const req = { header: (h) => (h === 'Authorization' ? `Bearer ${rawToken}` : undefined), ...route };
const res = makeRes();
let nexted = false;
await agentRuntimeAuth(req, res, () => { nexted = true; });
Expand Down Expand Up @@ -208,6 +208,49 @@ describe('per-spawn child credentials (TASK-094)', () => {
expect(req.agentAuthorizedPodIds).toContain(String(podId));
});

it('a minted child and its seat keep own-state calls while two grant pods refuse outward and pod writes', async () => {
const { raw, bot, seat } = await makeSeat();
const { AgentInstallation } = require('../../../models/AgentRegistry');
const installation = await AgentInstallation.findOne({ agentName: 'testbot', instanceId: 'default' });
const otherPodId = new mongoose.Types.ObjectId();
await AgentInstallation.create({
agentName: 'testbot', instanceId: 'default', podId: otherPodId,
version: '1.0.0', installedBy: bot._id,
});
const grantPolicy = require('../../../services/nativeOutsiderGrantPolicy');
const spy = jest.spyOn(grantPolicy, 'podsWithOutsiderWritableGrants')
.mockResolvedValue(new Set([String(installation.podId), String(otherPodId)]));
const outward = { method: 'POST', baseUrl: '/api/agents/runtime',
route: { path: '/agent-dm' }, params: {} };
const podWrite = { method: 'POST', baseUrl: '/api/agents/runtime',
route: { path: '/pods/:podId/messages' }, params: { podId: String(installation.podId) } };
const ownAck = { method: 'POST', baseUrl: '/api/agents/runtime',
route: { path: '/events/:id/ack' }, params: { id: 'event-1' } };
try {
const express = require('express');
const request = require('supertest');
const app = express();
app.use(express.json());
app.use('/api/agents/runtime/spawn-credentials', require('../../../routes/spawnCredentials'));
const minted = await request(app).post('/api/agents/runtime/spawn-credentials')
.set('Authorization', `Bearer ${raw}`).send({ spawnId: 'spawn-narrowed' });
expect(minted.status).toBe(201);
const child = await AgentCredential.findById(minted.body.credentialId);
expect(child.scopes).toEqual(['spawn']);
expect(String(child.parentId)).toBe(String(seat._id));
for (const token of [raw, minted.body.token]) {
expect((await runAuth(token, ownAck)).nexted).toBe(true);
for (const route of [outward, podWrite]) {
const { res, nexted } = await runAuth(token, route);
expect(nexted).toBe(false);
expect(res.statusCode).toBe(403);
expect(res.body.code).toBe('outsider_grant_runtime_narrowed');
}
}
expect(spy).toHaveBeenCalledTimes(7);
} finally { spy.mockRestore(); }
});

it('the negative control: a credential row with an agentUserId but no spawn scope is NOT an authentication path', async () => {
const owner = new mongoose.Types.ObjectId();
const bot = await makeBot(`cm_agent_${'z'.repeat(32)}`);
Expand Down
30 changes: 17 additions & 13 deletions backend/__tests__/unit/services/messageClaimService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ describe('messageExists', () => {
await expect(MessageClaimService.messageExists('507f1f77bcf86cd799439011', 'p1')).resolves.toBe(true);
// The id is asked of the comment's own store, by the path the comment
// actually lives under — `comments._id`, a subdocument of Post.
expect(Post.exists).toHaveBeenCalledWith({ 'comments._id': '507f1f77bcf86cd799439011' });
expect(Post.exists).toHaveBeenCalledWith({ podId: 'p1', 'comments._id': '507f1f77bcf86cd799439011' });
// ...and NOT of Postgres, which cannot see a comment at all.
expect(pool.query).not.toHaveBeenCalled();
// Pod is still required first — the arm does not bypass the scope check
Expand All @@ -701,23 +701,27 @@ describe('messageExists', () => {
await expect(MessageClaimService.messageExists('123456789012345678901234', 'p1')).resolves.toBe(false);
// The store was ASKED: this false is an answer about the comment, not a
// shape refusal that never reached it.
expect(Post.exists).toHaveBeenCalledWith({ 'comments._id': '123456789012345678901234' });
expect(Post.exists).toHaveBeenCalledWith({ podId: 'p1', 'comments._id': '123456789012345678901234' });
expect(pool.query).not.toHaveBeenCalled();
});

test('a store that cannot answer passes the id through — refusing would unguard every woken seat', async () => {
// The direction is the design: enforcement.js:414 turns the route's non-2xx
// into {failOpen: true}, so a refusal for a real comment wake stops the
// race deduping and every woken seat proceeds. An unconfirmed id must
// therefore degrade to the pre-TASK-122 behaviour (dedupe holds, a phantom
// is possible), not to a refusal.
test('a real comment in another pod cannot be claimed under this pod', async () => {
Post.exists.mockImplementation(async (filter) =>
(filter.podId === 'pod-b' ? { _id: 'post-b' } : null));
const commentId = '507f1f77bcf86cd799439011';
await expect(MessageClaimService.messageExists(commentId, 'pod-a')).resolves.toBe(false);
await expect(MessageClaimService.messageExists(commentId, 'pod-b')).resolves.toBe(true);
expect(Post.exists).toHaveBeenCalledWith({ podId: 'pod-a', 'comments._id': commentId });
expect(Post.exists).toHaveBeenCalledWith({ podId: 'pod-b', 'comments._id': commentId });
});

test('a store failure stops an unconfirmed comment claim before SQL mutation', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
Post.exists.mockRejectedValue(new Error('MongoServerSelectionError'));
await expect(MessageClaimService.messageExists('507f1f77bcf86cd799439011', 'p1')).resolves.toBe(true);
await expect(MessageClaimService.messageExists('507f1f77bcf86cd799439011', 'p1'))
.rejects.toThrow('MongoServerSelectionError');
expect(Post.exists).toHaveBeenCalledTimes(1);
// ...and it SAYS so, on one line, rather than reverting the hardening in
// silence. A stored check that quietly stops checking is the failure mode
// this whole row is about.
expect(pool.query).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toMatch(/comment existence check failed.*MongoServerSelectionError/);
warn.mockRestore();
Expand All @@ -743,7 +747,7 @@ describe('messageExists', () => {
// costing a real comment wake its dedupe (wren 71962 — order matters).
await expect(MessageClaimService.messageExists('123456789012345678901234', 'p1')).resolves.toBe(true);
// Answered by the comment store, never by the range test above it.
expect(Post.exists).toHaveBeenCalledWith({ 'comments._id': '123456789012345678901234' });
expect(Post.exists).toHaveBeenCalledWith({ podId: 'p1', 'comments._id': '123456789012345678901234' });
expect(pool.query).not.toHaveBeenCalled();
});
});
70 changes: 64 additions & 6 deletions backend/__tests__/unit/services/nativeOutsiderGrantPolicy.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/* eslint-disable global-require, import/no-unresolved, import/extensions */
const mockPodFindById = jest.fn();
const mockPodFind = jest.fn();
const mockGrantFind = jest.fn();
const mockConnectionFind = jest.fn();
const mockInstallationFind = jest.fn();

jest.mock('../../../models/Pod', () => ({
__esModule: true,
default: { findById: (...args) => mockPodFindById(...args) },
default: { find: (...args) => mockPodFind(...args) },
}));
jest.mock('../../../models/RoomGrant', () => ({
__esModule: true,
Expand All @@ -15,8 +16,11 @@ jest.mock('../../../models/Integration', () => ({
__esModule: true,
default: { find: (...args) => mockConnectionFind(...args) },
}));
jest.mock('../../../models/AgentRegistry', () => ({
AgentInstallation: { find: (...args) => mockInstallationFind(...args) },
}));

const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy');
const { podHasOutsiderWritableGrant, podsWithOutsiderWritableGrants, agentsWithMultipleOutsiderGrantPods } = require('../../../services/nativeOutsiderGrantPolicy');

const POD = '507f1f77bcf86cd799439011';
const SEAT = '507f1f77bcf86cd799439013';
Expand All @@ -32,9 +36,10 @@ const grant = (overrides = {}) => ({

beforeEach(() => {
jest.clearAllMocks();
mockPodFindById.mockReturnValue(chain({ members: [SEAT] }));
mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }]));
mockGrantFind.mockReturnValue(chain([grant()]));
mockConnectionFind.mockReturnValue(chain([{ _id: CONNECTION }]));
mockConnectionFind.mockReturnValue(chain([{ _id: CONNECTION, type: 'hosted-mcp' }]));
mockInstallationFind.mockReturnValue(chain([]));
});

test('a live pod grant with a current audience member narrows every native agent in the pod', async () => {
Expand All @@ -43,7 +48,7 @@ test('a live pod grant with a current audience member narrows every native agent
revokedAt: null,
expiresAt: { $gt: expect.any(Date) },
$or: expect.arrayContaining([
{ 'target.kind': 'pod', 'target.id': POD },
{ 'target.kind': 'pod', 'target.id': { $in: [POD] } },
{ 'target.kind': 'seat', 'target.id': { $in: [SEAT] } },
]),
}));
Expand All @@ -55,12 +60,65 @@ test('a live pod grant with a current audience member narrows every native agent

test('a GitHub App grant may name an installation id rather than a Mongo id', async () => {
mockGrantFind.mockReturnValue(chain([grant({ connectionId: 'github-installation-123' })]));
mockConnectionFind.mockReturnValue(chain([{ _id: CONNECTION, type: 'github-app', installationId: 'github-installation-123' }]));
expect(await podHasOutsiderWritableGrant(POD)).toBe(true);
expect(mockConnectionFind).toHaveBeenCalledWith(expect.objectContaining({
$or: [{ type: 'github-app', installationId: { $in: ['github-installation-123'] } }],
}));
});

test('batch census checks multiple installation pods with one query per collection', async () => {
mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }, { _id: OTHER, members: [SEAT] }]));
mockGrantFind.mockReturnValue(chain([grant({ target: { kind: 'pod', id: OTHER } })]));
expect(await podsWithOutsiderWritableGrants([POD, OTHER])).toEqual(new Set([OTHER]));
expect(mockPodFind).toHaveBeenCalledTimes(1);
expect(mockGrantFind).toHaveBeenCalledTimes(1);
expect(mockConnectionFind).toHaveBeenCalledTimes(1);
});

test('roster marker uses every installation pod, including pods outside the viewer roster', async () => {
mockInstallationFind.mockReturnValue(chain([
{ agentName: 'scout', instanceId: 'default', podId: POD },
{ agentName: 'scout', instanceId: 'default', podId: OTHER },
]));
mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }, { _id: OTHER, members: [SEAT] }]));
mockGrantFind.mockReturnValue(chain([grant(), grant({ target: { kind: 'pod', id: OTHER } })]));
expect(await agentsWithMultipleOutsiderGrantPods([{ agentName: 'scout', instanceId: 'default' }]))
.toEqual(new Set(['scout:default']));
expect(mockInstallationFind).toHaveBeenCalledTimes(1);
expect(mockPodFind).toHaveBeenCalledTimes(1);
mockGrantFind.mockReturnValue(chain([grant()]));
expect(await agentsWithMultipleOutsiderGrantPods([{ agentName: 'scout' }])).toEqual(new Set());
});

test('default-instance roster census includes an older row without instanceId', async () => {
mockInstallationFind.mockReturnValue(chain([
{ agentName: 'scout', instanceId: 'default', podId: POD },
{ agentName: 'scout', podId: OTHER },
]));
mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }, { _id: OTHER, members: [SEAT] }]));
mockGrantFind.mockReturnValue(chain([grant(), grant({ target: { kind: 'pod', id: OTHER } })]));
expect(await agentsWithMultipleOutsiderGrantPods([{ agentName: 'scout' }]))
.toEqual(new Set(['scout:default']));
expect(mockInstallationFind).toHaveBeenCalledWith({
status: 'active',
$or: [{ agentName: 'scout', $or: [{ instanceId: 'default' }, { instanceId: null }] }],
});
});

test('roster marker propagates a failed grant census for the unavailable state', async () => {
mockInstallationFind.mockReturnValue(chain([
{ agentName: 'scout', instanceId: 'default', podId: POD },
{ agentName: 'scout', instanceId: 'default', podId: OTHER },
]));
mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }, { _id: OTHER, members: [SEAT] }]));
mockGrantFind.mockReturnValue({ select: () => ({ lean: async () => {
throw new Error('grant DB unavailable');
} }) });
await expect(agentsWithMultipleOutsiderGrantPods([{ agentName: 'scout', instanceId: 'default' }]))
.rejects.toThrow('grant DB unavailable');
});

test('a seat grant is in scope only when its seat is a current member and in its audience', async () => {
mockGrantFind.mockReturnValue(chain([grant({ target: { kind: 'seat', id: SEAT } })]));
expect(await podHasOutsiderWritableGrant(POD)).toBe(true);
Expand Down
Loading
Loading