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
293 changes: 293 additions & 0 deletions backend/__tests__/service/machineRevocation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
const request = require('supertest');
const express = require('express');
const mongoose = require('mongoose');
const { setupMongoDb, closeMongoDb, clearMongoDb, generateTestToken } = require('../utils/testUtils');
const { hash } = require('../../utils/secret');
const { hashDeviceCredential } = require('../../services/deviceAuthorizationService');
const User = require('../../models/User');
const Machine = require('../../models/Machine');
const AgentCredential = require('../../models/AgentCredential');
const { AgentInstallation } = require('../../models/AgentRegistry');

const app = express();
app.use(express.json());
app.use('/api/auth', require('../../routes/auth'));
app.use('/api/machines', require('../../routes/machines'));
app.use('/api/credentials', require('../../routes/credentials'));
const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth').default;

const agentCall = async (token) => {
const req = { header: (name) => (name === 'Authorization' ? `Bearer ${token}` : undefined) };
const res = { statusCode: 200 };
res.status = (code) => { res.statusCode = code; return res; };
res.json = () => res;
let nexted = false;
await agentRuntimeAuth(req, res, () => { nexted = true; });
return nexted ? 204 : res.statusCode;
};

const owner = async () => User.create({
username: `owner-${new mongoose.Types.ObjectId().toString().slice(0, 8)}`,
email: `${new mongoose.Types.ObjectId()}@example.test`,
password: 'Password123!',
verified: true,
});

const addDevice = async (user, suffix) => {
const token = `cm_${suffix.repeat(64)}`;
user.deviceTokens.push({
tokenHash: hashDeviceCredential(token), label: `laptop-${suffix} · commonly-cli`, createdAt: new Date(),
});
await user.save();
return { token, id: String(user.deviceTokens[user.deviceTokens.length - 1]._id) };
};

const register = async (token, name) => request(app)
.post('/api/machines')
.set('Authorization', `Bearer ${token}`)
.send({ name })
.expect(201);

const daemonCall = (token, machineDbId) => request(app)
.post(`/api/machines/${machineDbId}/heartbeat`)
.set('Authorization', `Bearer ${token}`)
.send({ agents: [] });

const addRuntimeChild = async (user, machine, daemonToken, suffix) => {
const bot = await User.create({
username: `bot-${suffix}`, email: `bot-${suffix}@agents.commonly.local`,
password: 'Password123!', isBot: true,
botMetadata: { agentName: `bot-${suffix}`, instanceId: 'default', machineId: machine.machineId },
});
await AgentInstallation.create({
agentName: `bot-${suffix}`, instanceId: 'default', podId: new mongoose.Types.ObjectId(),
version: '1.0.0', installedBy: user._id,
});
const token = `cm_agent_${suffix.repeat(32)}`;
bot.agentRuntimeTokens.push({ tokenHash: hash(token), label: 'seat', createdAt: new Date() });
await bot.save();
const daemon = await AgentCredential.findOne({ tokenHash: hash(daemonToken) });
const child = await AgentCredential.create({
tokenHash: hash(token), kind: 'runtime', ownerUserId: user._id,
agentUserId: bot._id, machineId: machine.machineId, parentId: daemon._id,
});
return { token, child };
};

beforeAll(async () => {
process.env.JWT_SECRET = 'machine-revocation-test-secret';
await setupMongoDb();
});
afterEach(async () => clearMongoDb());
afterAll(async () => closeMongoDb());

describe('machine credential revocation', () => {
it('revoking a CLI device removes its machine and rejects daemon and minted child on the next call', async () => {
const user = await owner();
const device = await addDevice(user, 'a');
const otherDevice = await addDevice(user, 'b');
const first = (await register(device.token, 'first')).body;
const other = (await register(otherDevice.token, 'other')).body;
const child = await addRuntimeChild(user, first.machine, first.daemonToken, 'first');

await daemonCall(first.daemonToken, first.machine.id).expect(200);
expect(await agentCall(child.token)).toBe(204);

await request(app).delete(`/api/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200);

expect(await Machine.findById(first.machine.id)).toBeNull();
expect(await AgentCredential.findById(child.child._id)).toMatchObject({ status: 'revoked' });
await daemonCall(first.daemonToken, first.machine.id).expect(401);
expect(await agentCall(child.token)).toBe(401);
await daemonCall(other.daemonToken, other.machine.id).expect(200);
});

it('a failed revoke cleanup still denies daemon and runtime credentials immediately', async () => {
const user = await owner();
const device = await addDevice(user, 'q');
const first = (await register(device.token, 'cleanup-fails')).body;
const child = await addRuntimeChild(user, first.machine, first.daemonToken, 'cleanup');
const revokeSpy = jest.spyOn(AgentCredential, 'revokeCascade').mockRejectedValueOnce(new Error('cleanup failed'));
try {
await request(app).delete(`/api/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(500);
} finally {
revokeSpy.mockRestore();
}

expect(await Machine.findById(first.machine.id)).toBeTruthy();
expect(await AgentCredential.findById(child.child._id)).toMatchObject({ status: 'active' });
await daemonCall(first.daemonToken, first.machine.id).expect(401);
expect(await agentCall(child.token)).toBe(401);
// A retry completes the cleanup after the durable revoke.
await request(app).delete(`/api/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200);
expect(await Machine.findById(first.machine.id)).toBeNull();
});

it('removing a machine revokes every daemon and minted child, including a standalone machine credential', async () => {
const user = await owner();
const device = await addDevice(user, 'c');
const first = (await register(device.token, 'first')).body;
const child = await addRuntimeChild(user, first.machine, first.daemonToken, 'removed');
const extraToken = `cm_daemon_${'e'.repeat(32)}`;
await AgentCredential.create({
tokenHash: hash(extraToken), kind: 'daemon', ownerUserId: user._id,
machineId: first.machine.machineId, scopes: ['machine:heartbeat'],
});

await request(app).delete(`/api/machines/${first.machine.id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200);

const remaining = await AgentCredential.find({ machineId: first.machine.machineId });
expect(remaining).toHaveLength(3);
expect(remaining.every((credential) => credential.status === 'revoked')).toBe(true);
await daemonCall(first.daemonToken, first.machine.id).expect(401);
await daemonCall(extraToken, first.machine.id).expect(401);
expect(await agentCall(child.token)).toBe(401);

// Even if an old daemon row escaped the revocation sweep, its missing
// machine still vetoes the next request.
await AgentCredential.updateOne(
{ tokenHash: hash(first.daemonToken) },
{ $set: { status: 'active', revokedAt: null } },
);
await daemonCall(first.daemonToken, first.machine.id).expect(401);
expect(await agentCall(child.token)).toBe(401);
});

it('revoking a daemon credential in the owner UI rejects it and its minted child on the next call', async () => {
const user = await owner();
const device = await addDevice(user, 'f');
const first = (await register(device.token, 'credential-revoke')).body;
const child = await addRuntimeChild(user, first.machine, first.daemonToken, 'credential');
const daemon = await AgentCredential.findOne({ tokenHash: hash(first.daemonToken) });

await request(app).delete(`/api/credentials/${daemon._id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200);

expect(await AgentCredential.findById(child.child._id)).toMatchObject({ status: 'revoked' });
await daemonCall(first.daemonToken, first.machine.id).expect(401);
expect(await agentCall(child.token)).toBe(401);
});

it('retires pre-binding machines conservatively when their owner revokes a device', async () => {
const user = await owner();
const device = await addDevice(user, 'd');
const first = (await register(device.token, 'older')).body;
await Machine.updateOne({ _id: first.machine.id }, { $unset: { registeredByDeviceTokenHash: 1 } });

await request(app).delete(`/api/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200);

expect(await Machine.findById(first.machine.id)).toBeNull();
await daemonCall(first.daemonToken, first.machine.id).expect(401);
});

it('keeps a new browser-registered machine separate from an unrelated revoked device', async () => {
const user = await owner();
const device = await addDevice(user, 'g');
const browserToken = generateTestToken(user._id);
const machine = (await register(browserToken, 'browser-registered')).body;

expect((await Machine.findById(machine.machine.id)).registeredByDeviceTokenHash).toBeNull();
await request(app).delete(`/api/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${browserToken}`).expect(200);

expect(await Machine.findById(machine.machine.id)).toBeTruthy();
await daemonCall(machine.daemonToken, machine.machine.id).expect(200);
});

it('a device revoke racing registration cannot leave a newly minted daemon alive', async () => {
const user = await owner();
const device = await addDevice(user, 'h');
let continueCheck;
let checkStarted;
const started = new Promise((resolve) => { checkStarted = resolve; });
const resume = new Promise((resolve) => { continueCheck = resolve; });
const originalExists = User.exists.bind(User);
const existsSpy = jest.spyOn(User, 'exists').mockImplementationOnce(async (...args) => {
checkStarted();
await resume;
return originalExists(...args);
});
try {
const registration = request(app).post('/api/machines')
.set('Authorization', `Bearer ${device.token}`).send({ name: 'racing' });
const pending = registration.then((response) => response);
await started;
await request(app).delete(`/api/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200);
continueCheck();
expect((await pending).status).toBe(401);
expect(await Machine.countDocuments({ ownerUserId: user._id })).toBe(0);
expect(await AgentCredential.countDocuments({ ownerUserId: user._id, status: 'active' })).toBe(0);
} finally {
existsSpy.mockRestore();
continueCheck();
}
});

it('a machine deletion racing registration refuses the new bearer', async () => {
const user = await owner();
const device = await addDevice(user, 'i');
let continueCheck;
let checkStarted;
const started = new Promise((resolve) => { checkStarted = resolve; });
const resume = new Promise((resolve) => { continueCheck = resolve; });
const originalExists = Machine.exists.bind(Machine);
const existsSpy = jest.spyOn(Machine, 'exists').mockImplementationOnce(async (...args) => {
checkStarted();
await resume;
return originalExists(...args);
});
try {
const registration = request(app).post('/api/machines')
.set('Authorization', `Bearer ${device.token}`).send({ name: 'racing-machine' });
const pending = registration.then((response) => response);
await started;
const machine = await Machine.findOne({ ownerUserId: user._id, name: 'racing-machine' });
await request(app).delete(`/api/machines/${machine._id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200);
continueCheck();
expect((await pending).status).toBe(409);
expect(await AgentCredential.countDocuments({ machineId: machine.machineId, status: 'active' })).toBe(0);
} finally {
existsSpy.mockRestore();
continueCheck();
}
});

it('removal catches a credential inserted after its first scan', async () => {
const user = await owner();
const device = await addDevice(user, 'j');
const first = (await register(device.token, 'late-mint')).body;
let continueDelete;
let deleteStarted;
const started = new Promise((resolve) => { deleteStarted = resolve; });
const resume = new Promise((resolve) => { continueDelete = resolve; });
const originalDelete = Machine.deleteOne.bind(Machine);
const deleteSpy = jest.spyOn(Machine, 'deleteOne').mockImplementationOnce(async (...args) => {
deleteStarted();
await resume;
return originalDelete(...args);
});
try {
const deletion = request(app).delete(`/api/machines/${first.machine.id}`)
.set('Authorization', `Bearer ${generateTestToken(user._id)}`);
const pending = deletion.then((response) => response);
await started;
const late = await AgentCredential.create({
tokenHash: hash(`cm_daemon_${'k'.repeat(32)}`), kind: 'daemon', ownerUserId: user._id,
machineId: first.machine.machineId, scopes: ['machine:heartbeat'],
});
continueDelete();
expect((await pending).status).toBe(200);
expect(await AgentCredential.findById(late._id)).toMatchObject({ status: 'revoked' });
} finally {
deleteSpy.mockRestore();
continueDelete();
}
});
});
8 changes: 7 additions & 1 deletion backend/__tests__/unit/middleware/daemonAuth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ const { hash } = require('../../../utils/secret');

let mongod;
let AgentCredential;
let Machine;
let daemonAuth;
let agentRuntimeAuth;

beforeAll(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
AgentCredential = require('../../../models/AgentCredential');
Machine = require('../../../models/Machine');
daemonAuth = require('../../../middleware/daemonAuth');
agentRuntimeAuth = require('../../../middleware/agentRuntimeAuth').default;
});

afterEach(async () => {
await AgentCredential.deleteMany({});
await Machine.deleteMany({});
});

afterAll(async () => {
Expand All @@ -35,6 +38,7 @@ describe('daemonAuth', () => {
it('authenticates only a scoped daemon credential and never assigns an agent identity', async () => {
const token = `cm_daemon_${'a'.repeat(32)}`;
const ownerUserId = new mongoose.Types.ObjectId();
await Machine.create({ ownerUserId, machineId: 'machine-1', name: 'Test machine' });
await AgentCredential.create({
tokenHash: hash(token),
kind: 'daemon',
Expand Down Expand Up @@ -72,10 +76,12 @@ describe('daemonAuth', () => {

it('requires the route scope before exposing a machine identity', async () => {
const token = `cm_daemon_${'b'.repeat(32)}`;
const ownerUserId = new mongoose.Types.ObjectId();
await Machine.create({ ownerUserId, machineId: 'machine-2', name: 'Test machine' });
await AgentCredential.create({
tokenHash: hash(token),
kind: 'daemon',
ownerUserId: new mongoose.Types.ObjectId(),
ownerUserId,
machineId: 'machine-2',
scopes: ['machine:heartbeat'],
});
Expand Down
8 changes: 6 additions & 2 deletions backend/__tests__/unit/routes/agentBinding.adoption.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jest.mock('../../../middleware/auth', () => {

const { hash } = require('../../../utils/secret');

let mongod; let app; let AgentCredential; let User; let AgentInstallation;
let mongod; let app; let AgentCredential; let User; let AgentInstallation; let Machine;
const DAEMON_A = `cm_daemon_${'a'.repeat(32)}`;
const DAEMON_B = `cm_daemon_${'b'.repeat(32)}`;

Expand All @@ -24,6 +24,7 @@ beforeAll(async () => {
await mongoose.connect(mongod.getUri());
AgentCredential = require('../../../models/AgentCredential');
User = require('../../../models/User');
Machine = require('../../../models/Machine');
AgentInstallation = require('../../../models/AgentRegistry').AgentInstallation;
app = express();
app.use(express.json());
Expand All @@ -35,7 +36,7 @@ afterAll(async () => { await mongoose.disconnect(); await mongod.stop(); });
let owner; let bot;
beforeEach(async () => {
await Promise.all([
User.deleteMany({}), AgentCredential.deleteMany({}), AgentInstallation.deleteMany({}),
User.deleteMany({}), AgentCredential.deleteMany({}), AgentInstallation.deleteMany({}), Machine.deleteMany({}),
]);
owner = await User.create({ username: `o${Date.now() % 1e6}`, email: `o${Date.now()}@x.com`, password: 'x'.repeat(12) });
global.__CALLER_ID = String(owner._id);
Expand All @@ -48,6 +49,7 @@ beforeEach(async () => {
version: '1.0.0', status: 'active', installedBy: owner._id,
});
for (const [tok, mid] of [[DAEMON_A, 'machine-a'], [DAEMON_B, 'machine-b']]) {
await Machine.create({ ownerUserId: owner._id, machineId: mid, name: mid });
await AgentCredential.create({ tokenHash: hash(tok), kind: 'daemon', ownerUserId: owner._id, machineId: mid, scopes: ['machine:heartbeat', 'agents:adopt'] });
}
});
Expand Down Expand Up @@ -84,6 +86,7 @@ describe('adoption CAS', () => {
it('refuses adoption of an agent the daemon owner does not own', async () => {
const stranger = await User.create({ username: 'str', email: 's@x.com', password: 'x'.repeat(12) });
const tok = `cm_daemon_${'s'.repeat(32)}`;
await Machine.create({ ownerUserId: stranger._id, machineId: 'machine-s', name: 'Stranger machine' });
await AgentCredential.create({ tokenHash: hash(tok), kind: 'daemon', ownerUserId: stranger._id, machineId: 'machine-s', scopes: ['machine:heartbeat', 'agents:adopt'] });
const res = await adopt(tok);
expect(res.status).toBe(403);
Expand All @@ -92,6 +95,7 @@ describe('adoption CAS', () => {

it('a heartbeat-only credential cannot adopt — scopes are enforced, not decorative', async () => {
const tok = `cm_daemon_${'h'.repeat(32)}`;
await Machine.create({ ownerUserId: owner._id, machineId: 'machine-h', name: 'Heartbeat machine' });
await AgentCredential.create({
tokenHash: hash(tok), kind: 'daemon', ownerUserId: owner._id, machineId: 'machine-h',
scopes: ['machine:heartbeat'],
Expand Down
Loading
Loading