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
6 changes: 6 additions & 0 deletions backend/__tests__/service/self-serve-install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const Pod = require('../../models/Pod');
const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry');
const AgentProfile = require('../../models/AgentProfile').default || require('../../models/AgentProfile');
const Machine = require('../../models/Machine');
const AgentCredential = require('../../models/AgentCredential');

const registryRoutes = require('../../routes/registry');
const agentsRuntimeRoutes = require('../../routes/agentsRuntime');
Expand Down Expand Up @@ -164,6 +165,11 @@ describe('self-serve BYO install', () => {
const machine = await Machine.create({
ownerUserId: podMember._id, machineId: 'self-serve-machine', name: 'My computer',
});
// Placement now requires a live daemon key, as a real registration mints.
await AgentCredential.create({
kind: 'daemon', ownerUserId: podMember._id, machineId: machine.machineId,
tokenHash: 'a'.repeat(64), scopes: ['machine:heartbeat', 'agents:adopt'],
});
const installRes = await request(app)
.post('/api/registry/install')
.set('Authorization', `Bearer ${memberToken}`)
Expand Down
95 changes: 95 additions & 0 deletions backend/__tests__/unit/routes/agentBinding.phase2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ const requestPlacement = (machineId) => request(app)
.post('/api/agent-binding/request')
.send({ agentName: 'wren-test', instanceId: 'default', machineId });

const movePlacement = (machineId, fromMachineId = 'machine-a') => request(app)
.post('/api/agent-binding/move')
.send({ agentName: 'wren-test', instanceId: 'default', machineId, fromMachineId });

const requestReconnect = () => request(app)
.post('/api/agent-binding/reconnect')
.send({ agentName: 'wren-test', instanceId: 'default' });
Expand Down Expand Up @@ -135,6 +139,97 @@ const runtimeAuthProbe = (token) => new Promise((resolve, reject) => {
});

describe('placement request', () => {
it('moves a revoked binding in one update and preserves it if the target is revoked after preflight', async () => {
await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
await AgentCredential.updateOne({ _id: daemonCredA._id }, { $set: { status: 'revoked', revokedAt: new Date() } });
await request(app).get('/api/agent-binding/placement').query({ agentName: 'wren-test' }).expect(200);
await AgentCredential.updateOne({ machineId: 'machine-b' }, { $set: { status: 'revoked', revokedAt: new Date() } });
const refused = await movePlacement('machine-b').expect(409);
expect(refused.body.code).toBe('target_revoked');
expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: 'machine-a' });
await AgentCredential.updateOne({ machineId: 'machine-b' }, { $set: { status: 'active', revokedAt: null } });
await movePlacement('machine-b').expect(200);
expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: null, requestedMachineId: 'machine-b' });
});

it('does not offer or select an expired daemon credential', async () => {
await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
await AgentCredential.updateOne({ machineId: 'machine-b' }, { $set: { expiresAt: new Date(Date.now() - 1000) } });
const placement = await request(app).get('/api/agent-binding/placement').query({ agentName: 'wren-test' }).expect(200);
expect(placement.body.activeMachines).not.toContainEqual(expect.objectContaining({ machineId: 'machine-b' }));
const refused = await movePlacement('machine-b').expect(409);
expect(refused.body.code).toBe('target_revoked');
expect((await User.findById(bot._id)).botMetadata.machineId).toBe('machine-a');
});

it('lets only the sole installer view a revoked binding and request a live replacement after release', async () => {
await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
await AgentCredential.updateOne({ _id: daemonCredA._id }, { $set: { status: 'revoked', revokedAt: new Date() } });
const placement = () => request(app).get('/api/agent-binding/placement')
.query({ agentName: 'wren-test', instanceId: 'default' });

global.__CALLER_ID = String(new mongoose.Types.ObjectId());
await placement().expect(403);
await request(app).post('/api/agent-binding/release')
.send({ agentName: 'wren-test', instanceId: 'default' }).expect(403);
global.__CALLER_ID = String(owner._id);

const state = await placement().expect(200);
expect(state.body).toMatchObject({ boundMachineId: 'machine-a', boundMachineRevoked: true,
activeMachines: [{ machineId: 'machine-b', name: 'Mac B' }] });
await requestPlacement('machine-a').expect(409);
await request(app).post('/api/agent-binding/release')
.send({ agentName: 'wren-test', instanceId: 'default' }).expect(200);
await requestPlacement('machine-b').expect(200);
expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: null, requestedMachineId: 'machine-b' });
});

it('ignores agent-DM projections but names a second human installation before releasing', async () => {
await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
await AgentCredential.updateOne({ _id: daemonCredA._id }, { $set: { status: 'revoked', revokedAt: new Date() } });
const peer = await User.create({ username: `peer${Date.now() % 1e6}`, email: `peer${Date.now()}@agents.commonly.local`,
password: 'x'.repeat(12), isBot: true });
const peer2 = await User.create({ username: `peer2${Date.now() % 1e6}`, email: `peer2${Date.now()}@agents.commonly.local`,
password: 'x'.repeat(12), isBot: true });
for (const installer of [peer, bot]) {
// eslint-disable-next-line no-await-in-loop
const dm = await Pod.create({ name: `DM ${installer.username}`, type: 'agent-dm',
createdBy: installer._id, members: installer === bot ? [bot._id, peer2._id] : [peer._id, bot._id] });
// eslint-disable-next-line no-await-in-loop
await AgentInstallation.create({ agentName: 'wren-test', instanceId: 'default', podId: dm._id,
version: '1.0.0', status: 'active', installedBy: installer._id });
}
const placement = () => request(app).get('/api/agent-binding/placement')
.query({ agentName: 'wren-test', instanceId: 'default' });
await placement().expect(200);
await request(app).post('/api/agent-binding/release')
.send({ agentName: 'wren-test', instanceId: 'default' }).expect(200);
await requestPlacement('machine-b').expect(200);
await adopt(DAEMON_B).expect(200);

await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
const secondHuman = await User.create({ username: `second${Date.now() % 1e6}`,
email: `second${Date.now()}@x.com`, password: 'x'.repeat(12) });
const otherPod = await Pod.create({ name: 'Other human pod', type: 'team',
createdBy: secondHuman._id, members: [secondHuman._id, bot._id] });
await AgentInstallation.create({ agentName: 'wren-test', instanceId: 'default', podId: otherPod._id,
version: '1.0.0', status: 'active', installedBy: secondHuman._id });
const refusal = await placement().expect(403);
expect(refusal.body.code).toBe('another_installer');
expect(refusal.body.message).toContain('another pod');
expect(JSON.stringify(refusal.body)).not.toContain('Other human pod');
expect(JSON.stringify(refusal.body)).not.toContain(secondHuman.username);
expect(refusal.body.blockers).toEqual([]);
await Pod.updateOne({ _id: otherPod._id }, { $addToSet: { members: owner._id } });
const visibleRefusal = await placement().expect(403);
expect(visibleRefusal.body.message).toContain(`Other human pod (${secondHuman.username})`);
expect(visibleRefusal.body.blockers).toContainEqual({ podName: 'Other human pod', installerName: secondHuman.username });
await movePlacement('machine-b').expect(403);
await request(app).post('/api/agent-binding/release')
.send({ agentName: 'wren-test', instanceId: 'default' }).expect(403);
expect((await User.findById(bot._id)).botMetadata.machineId).toBe('machine-a');
});

it('records a directive without binding, and null withdraws it', async () => {
const res = await requestPlacement('machine-a');
expect(res.status).toBe(200);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ const matches = (doc, filter) => Object.entries(filter).every(([key, want]) => {
});
jest.mock('../../../models/AgentRegistry', () => ({
AgentInstallation: {
find: jest.fn(() => ({
collation: () => ({ select: () => ({ lean: () => Promise.resolve([]) }) }),
find: jest.fn((filter) => ({
collation: () => ({ select: () => ({ lean: () => Promise.resolve(mockInstalls.filter((doc) => matches(doc, filter))) }) }),
})),
findOne: jest.fn((filter) => {
const hit = mockInstalls.find((doc) => matches(doc, filter)) || null;
Expand All @@ -42,7 +42,10 @@ jest.mock('../../../models/AgentRegistry', () => ({
},
}));

jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/Pod', () => ({
findById: jest.fn(),
find: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve([]) }) })),
}));
jest.mock('../../../models/User', () => ({ findById: jest.fn(), findOne: jest.fn() }));
jest.mock('../../../models/AgentCredential', () => ({ updateOne: jest.fn().mockResolvedValue({}) }));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ jest.mock('../../../models/AgentRegistry', () => ({
findOne: jest.fn((filter) => queryResult(
'installedBy' in filter ? (mockInstalls.find((doc) => matches(doc, filter)) || null) : mockPrimary,
)),
find: jest.fn(),
find: jest.fn((filter) => (
filter.status === 'active' ? queryResult(mockInstalls) : queryResult([mockPrimary])
)),
install: jest.fn(),
},
}));
Expand Down Expand Up @@ -112,6 +114,7 @@ beforeEach(() => {
mockPrimary = null;
setCallerRole('user');
setPod();
Pod.find.mockReturnValue(queryResult([]));
});

describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => {
Expand All @@ -132,7 +135,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => {
test('the sole installer can change it', async () => {
mockPrimary = row();
mockInstalls = [activeInstall(INSTALLER, 'pod-1')];
AgentInstallation.find.mockResolvedValue([mockPrimary]);

const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: NEW_URL } });

Expand All @@ -144,7 +146,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => {
test('this row\'s installer is refused when another user also has the agent installed, and nothing is written', async () => {
mockPrimary = row();
mockInstalls = [activeInstall(INSTALLER, 'pod-1'), activeInstall(OTHER, 'pod-9')];
AgentInstallation.find.mockResolvedValue([mockPrimary]);

const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: NEW_URL } });

Expand All @@ -159,7 +160,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => {
mockCaller = MEMBER;
mockPrimary = row();
mockInstalls = [activeInstall(INSTALLER, 'pod-1')];
AgentInstallation.find.mockResolvedValue([mockPrimary]);

const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: NEW_URL } });

Expand All @@ -175,7 +175,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => {
setCallerRole('admin');
mockPrimary = row();
mockInstalls = [activeInstall(INSTALLER, 'pod-1'), activeInstall(OTHER, 'pod-9')];
AgentInstallation.find.mockResolvedValue([mockPrimary]);

const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: NEW_URL } });

Expand All @@ -186,7 +185,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => {
test('resending the stored URL is not a change, so a shared agent\'s installer can still patch its config', async () => {
mockPrimary = row();
mockInstalls = [activeInstall(INSTALLER, 'pod-1'), activeInstall(OTHER, 'pod-9')];
AgentInstallation.find.mockResolvedValue([mockPrimary]);

const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: STORED_URL }, note: 'renamed' });

Expand Down Expand Up @@ -215,7 +213,15 @@ describe('POST /install — supplying a webhook URL', () => {
manifest: { context: { required: [] }, runtime: { type: 'standalone' } },
});
AgentRegistry.incrementInstalls.mockResolvedValue({ acknowledged: true });
AgentInstallation.find.mockReturnValue({ lean: () => Promise.resolve([]) });
AgentInstallation.find.mockImplementation(() => {
let ownershipRead = false;
const chain = {
collation: () => { ownershipRead = true; return chain; },
select: () => chain,
lean: () => Promise.resolve(ownershipRead ? mockInstalls : []),
};
return chain;
});
AgentInstallation.install.mockImplementation(async (agentName, podId, options) => ({
_id: { toString: () => 'install-1' },
agentName,
Expand Down
Loading
Loading