moving
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildServer } from '../../src/index.js';
|
||||
import { users, agents, agentGroups, agentGroupMembers, agentJobs, teams } from '../../src/db/schema.js';
|
||||
import { users, agents, agentGroups, agentGroupMembers, agentJobs, teams, teamMembers } from '../../src/db/schema.js';
|
||||
import { sql, eq } from 'drizzle-orm';
|
||||
|
||||
describe('Agent API', () => {
|
||||
@@ -8,8 +8,11 @@ describe('Agent API', () => {
|
||||
let user1Id: string;
|
||||
let user2Id: string;
|
||||
let teamId: string;
|
||||
let team2Id: string;
|
||||
let agent1Id: string;
|
||||
let agent2Id: string;
|
||||
let foreignAgentId: string;
|
||||
let group1Id: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await buildServer();
|
||||
@@ -49,6 +52,17 @@ describe('Agent API', () => {
|
||||
}).returning();
|
||||
teamId = team.id;
|
||||
|
||||
await server.db.insert(teamMembers).values({ teamId, userId: user1Id, role: 'owner' });
|
||||
|
||||
const [team2] = await server.db.insert(teams).values({
|
||||
name: 'Agent Test Team 2',
|
||||
slug: 'agtest-team-2',
|
||||
ownerId: user2Id,
|
||||
}).returning();
|
||||
team2Id = team2.id;
|
||||
|
||||
await server.db.insert(teamMembers).values({ teamId: team2Id, userId: user2Id, role: 'owner' });
|
||||
|
||||
// Override auth handler for tests
|
||||
server._authHandler.fn = async function (request, reply) {
|
||||
const testUserId = request.headers['x-test-user-id'] as string;
|
||||
@@ -119,12 +133,14 @@ describe('Agent API', () => {
|
||||
|
||||
it('lists agents returns only the user\'s agents', async () => {
|
||||
// Create an agent for user2
|
||||
await server.inject({
|
||||
const foreignAgentResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/agents',
|
||||
headers: { 'x-test-user-id': user2Id },
|
||||
payload: { name: 'User2 Agent' },
|
||||
});
|
||||
expect(foreignAgentResponse.statusCode).toBe(201);
|
||||
foreignAgentId = JSON.parse(foreignAgentResponse.body).id;
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
@@ -198,6 +214,110 @@ describe('Agent API', () => {
|
||||
expect(body.name).toBe('Research Squad');
|
||||
expect(body.strategy).toBe('parallel');
|
||||
expect(body.members).toHaveLength(2);
|
||||
group1Id = body.id;
|
||||
});
|
||||
|
||||
it('rejects foreign and missing agents identically when creating a group', async () => {
|
||||
const payload = {
|
||||
name: 'Cross-tenant group',
|
||||
strategy: 'parallel',
|
||||
members: [
|
||||
{ agentId: agent1Id, roleInGroup: 'worker', executionOrder: 0 },
|
||||
{ agentId: foreignAgentId, roleInGroup: 'worker', executionOrder: 1 },
|
||||
],
|
||||
};
|
||||
const foreignResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/agent-groups',
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload,
|
||||
});
|
||||
const missingResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/agent-groups',
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: {
|
||||
...payload,
|
||||
members: [
|
||||
payload.members[0],
|
||||
{ ...payload.members[1], agentId: '00000000-0000-4000-8000-000000000001' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(foreignResponse.statusCode).toBe(404);
|
||||
expect(missingResponse.statusCode).toBe(404);
|
||||
expect(JSON.parse(foreignResponse.body)).toEqual({ error: 'Agent not found' });
|
||||
expect(missingResponse.body).toBe(foreignResponse.body);
|
||||
|
||||
const groups = await server.db
|
||||
.select()
|
||||
.from(agentGroups)
|
||||
.where(eq(agentGroups.userId, user1Id));
|
||||
expect(groups.some((group) => group.name === payload.name)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects foreign replacement members without altering the owned group', async () => {
|
||||
const foreignResponse = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/agent-groups/${group1Id}`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: {
|
||||
members: [
|
||||
{ agentId: agent1Id, roleInGroup: 'worker', executionOrder: 0 },
|
||||
{ agentId: foreignAgentId, roleInGroup: 'worker', executionOrder: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const missingResponse = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/agent-groups/${group1Id}`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: {
|
||||
members: [
|
||||
{ agentId: agent1Id, roleInGroup: 'worker', executionOrder: 0 },
|
||||
{
|
||||
agentId: '00000000-0000-4000-8000-000000000001',
|
||||
roleInGroup: 'worker',
|
||||
executionOrder: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(foreignResponse.statusCode).toBe(404);
|
||||
expect(missingResponse.statusCode).toBe(404);
|
||||
expect(JSON.parse(foreignResponse.body)).toEqual({ error: 'Agent not found' });
|
||||
expect(missingResponse.body).toBe(foreignResponse.body);
|
||||
|
||||
const groupResponse = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/agent-groups/${group1Id}`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
});
|
||||
expect(groupResponse.statusCode).toBe(200);
|
||||
const group = JSON.parse(groupResponse.body);
|
||||
expect(group.members.map((member: { agentId: string }) => member.agentId).sort())
|
||||
.toEqual([agent1Id, agent2Id].sort());
|
||||
});
|
||||
|
||||
it('replaces group membership when every agent belongs to the caller', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/agent-groups/${group1Id}`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: {
|
||||
members: [
|
||||
{ agentId: agent1Id, roleInGroup: 'lead', executionOrder: 0 },
|
||||
{ agentId: agent2Id, roleInGroup: 'worker', executionOrder: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const group = JSON.parse(response.body);
|
||||
expect(group.members.map((member: { agentId: string }) => member.agentId).sort())
|
||||
.toEqual([agent1Id, agent2Id].sort());
|
||||
});
|
||||
|
||||
it('creates a group with coordinator strategy and a lead agent', async () => {
|
||||
@@ -285,6 +405,160 @@ describe('Agent API', () => {
|
||||
expect(job.userId).toBe(user1Id);
|
||||
});
|
||||
|
||||
it('rejects an owned group run attributed to a foreign team', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/agent-groups/${group1Id}/run`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: { task: 'Cross-tenant run', teamId: team2Id },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(JSON.parse(response.body)).toEqual({ error: 'Team not found' });
|
||||
});
|
||||
|
||||
it('rejects generic job submission for a foreign team', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/jobs',
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: { jobType: 'chat', teamId: team2Id, input: { message: 'Cross-tenant job' } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(JSON.parse(response.body)).toEqual({ error: 'Team not found' });
|
||||
});
|
||||
|
||||
it('rejects a generic group job that references another user\'s group', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/jobs',
|
||||
headers: { 'x-test-user-id': user2Id },
|
||||
payload: {
|
||||
jobType: 'group',
|
||||
teamId: team2Id,
|
||||
input: { groupId: group1Id, taskInput: { task: 'Run foreign group' } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(JSON.parse(response.body)).toEqual({ error: 'Agent group not found' });
|
||||
});
|
||||
|
||||
it('hides job status from users outside the job team while preserving member access', async () => {
|
||||
const createResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/jobs',
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: { jobType: 'chat', teamId, input: { message: 'Private job' } },
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(202);
|
||||
const { jobId } = JSON.parse(createResponse.body);
|
||||
|
||||
const foreignResponse = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/jobs/${jobId}`,
|
||||
headers: { 'x-test-user-id': user2Id },
|
||||
});
|
||||
expect(foreignResponse.statusCode).toBe(404);
|
||||
expect(JSON.parse(foreignResponse.body)).toEqual({ error: 'Job not found' });
|
||||
|
||||
const memberResponse = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/jobs/${jobId}`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
});
|
||||
expect(memberResponse.statusCode).toBe(200);
|
||||
expect(JSON.parse(memberResponse.body).id).toBe(jobId);
|
||||
});
|
||||
|
||||
it('prevents users outside the job team from cancelling it', async () => {
|
||||
const createResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/jobs',
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: { jobType: 'chat', teamId, input: { message: 'Do not cancel' } },
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(202);
|
||||
const { jobId } = JSON.parse(createResponse.body);
|
||||
|
||||
const foreignResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/jobs/${jobId}/cancel`,
|
||||
headers: { 'x-test-user-id': user2Id },
|
||||
});
|
||||
expect(foreignResponse.statusCode).toBe(404);
|
||||
expect(JSON.parse(foreignResponse.body)).toEqual({ error: 'Job not found' });
|
||||
|
||||
const [unchanged] = await server.db
|
||||
.select()
|
||||
.from(agentJobs)
|
||||
.where(eq(agentJobs.id, jobId))
|
||||
.limit(1);
|
||||
expect(unchanged.status).toBe('queued');
|
||||
|
||||
const memberResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/jobs/${jobId}/cancel`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
});
|
||||
expect(memberResponse.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('fails closed when a legacy group contains a foreign-owned agent', async () => {
|
||||
const [legacyGroup] = await server.db.insert(agentGroups).values({
|
||||
userId: user1Id,
|
||||
name: 'Legacy compromised group',
|
||||
strategy: 'parallel',
|
||||
}).returning();
|
||||
await server.db.insert(agentGroupMembers).values({
|
||||
groupId: legacyGroup.id,
|
||||
agentId: foreignAgentId,
|
||||
roleInGroup: 'worker',
|
||||
executionOrder: 0,
|
||||
});
|
||||
|
||||
const getResponse = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/agent-groups/${legacyGroup.id}`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
});
|
||||
const patchResponse = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/agent-groups/${legacyGroup.id}`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: { name: 'Preserved compromise' },
|
||||
});
|
||||
const runResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/agent-groups/${legacyGroup.id}/run`,
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: { task: 'Run foreign configuration', teamId },
|
||||
});
|
||||
const jobResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/jobs',
|
||||
headers: { 'x-test-user-id': user1Id },
|
||||
payload: {
|
||||
jobType: 'group',
|
||||
teamId,
|
||||
input: { groupId: legacyGroup.id, taskInput: { task: 'Run foreign configuration' } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const response of [getResponse, patchResponse, runResponse, jobResponse]) {
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(JSON.parse(response.body)).toEqual({ error: 'Agent group not found' });
|
||||
}
|
||||
|
||||
const [unchanged] = await server.db
|
||||
.select()
|
||||
.from(agentGroups)
|
||||
.where(eq(agentGroups.id, legacyGroup.id))
|
||||
.limit(1);
|
||||
expect(unchanged.name).toBe('Legacy compromised group');
|
||||
});
|
||||
|
||||
it('deletes an agent', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -1,9 +1,191 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { buildServer } from '../../src/index.js';
|
||||
import {
|
||||
messages,
|
||||
teamCapabilityOverrides,
|
||||
teamCapabilityRequests,
|
||||
teamMembers,
|
||||
teams,
|
||||
users,
|
||||
} from '../../src/db/schema.js';
|
||||
|
||||
const SLUG_PREFIX = 'cap-bind-';
|
||||
const MISSING_ID = '00000000-0000-4000-8000-000000000000';
|
||||
|
||||
describe('Capability Governance Routes', () => {
|
||||
it('exports capabilityGovernanceRoutes function', async () => {
|
||||
const mod = await import('../../src/routes/capability-governance.js');
|
||||
expect(mod.capabilityGovernanceRoutes).toBeDefined();
|
||||
expect(typeof mod.capabilityGovernanceRoutes).toBe('function');
|
||||
let server: Awaited<ReturnType<typeof buildServer>>;
|
||||
let attackerAdminId: string;
|
||||
let victimOwnerId: string;
|
||||
let attackerTeamId: string;
|
||||
let victimTeamId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await buildServer();
|
||||
|
||||
await server.db.execute(sql`DELETE FROM messages WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'}`);
|
||||
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'cap_bind_%'`);
|
||||
|
||||
const [attackerAdmin, victimOwner] = await server.db.insert(users).values([
|
||||
{
|
||||
clerkId: 'cap_bind_attacker_admin',
|
||||
displayName: 'Capability Attacker Admin',
|
||||
email: 'cap-bind-attacker@test.invalid',
|
||||
},
|
||||
{
|
||||
clerkId: 'cap_bind_victim_owner',
|
||||
displayName: 'Capability Victim Owner',
|
||||
email: 'cap-bind-victim@test.invalid',
|
||||
},
|
||||
]).returning();
|
||||
attackerAdminId = attackerAdmin.id;
|
||||
victimOwnerId = victimOwner.id;
|
||||
|
||||
const [attackerTeam, victimTeam] = await server.db.insert(teams).values([
|
||||
{ name: 'Capability Attacker Team', slug: `${SLUG_PREFIX}attacker`, ownerId: attackerAdminId },
|
||||
{ name: 'Capability Victim Team', slug: `${SLUG_PREFIX}victim`, ownerId: victimOwnerId },
|
||||
]).returning();
|
||||
attackerTeamId = attackerTeam.id;
|
||||
victimTeamId = victimTeam.id;
|
||||
|
||||
await server.db.insert(teamMembers).values([
|
||||
{ teamId: attackerTeamId, userId: attackerAdminId, role: 'admin' },
|
||||
{ teamId: victimTeamId, userId: victimOwnerId, role: 'owner' },
|
||||
]);
|
||||
|
||||
server._authHandler.fn = async function (request, reply) {
|
||||
const testUserId = request.headers['x-test-user-id'] as string;
|
||||
if (!testUserId) {
|
||||
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
|
||||
}
|
||||
request.userId = testUserId;
|
||||
request.clerkId = 'test';
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.db.execute(sql`DELETE FROM messages WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'}`);
|
||||
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'cap_bind_%'`);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('does not let an authorized admin delete another team\'s override by ID', async () => {
|
||||
const [victimOverride] = await server.db.insert(teamCapabilityOverrides).values({
|
||||
teamId: victimTeamId,
|
||||
capabilityName: 'victim-secret-tool',
|
||||
capabilityType: 'mcp',
|
||||
decision: 'blocked',
|
||||
decidedBy: victimOwnerId,
|
||||
}).returning();
|
||||
|
||||
const foreign = await server.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/teams/${SLUG_PREFIX}attacker/capability-overrides/${victimOverride.id}`,
|
||||
headers: { 'x-test-user-id': attackerAdminId },
|
||||
});
|
||||
const missing = await server.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/teams/${SLUG_PREFIX}attacker/capability-overrides/${MISSING_ID}`,
|
||||
headers: { 'x-test-user-id': attackerAdminId },
|
||||
});
|
||||
|
||||
expect({ statusCode: foreign.statusCode, body: foreign.body }).toEqual({
|
||||
statusCode: missing.statusCode,
|
||||
body: missing.body,
|
||||
});
|
||||
const [stored] = await server.db.select().from(teamCapabilityOverrides).where(and(
|
||||
eq(teamCapabilityOverrides.id, victimOverride.id),
|
||||
eq(teamCapabilityOverrides.teamId, victimTeamId),
|
||||
));
|
||||
expect(stored).toBeDefined();
|
||||
});
|
||||
|
||||
it('still lets a same-team admin delete an override', async () => {
|
||||
const [ownOverride] = await server.db.insert(teamCapabilityOverrides).values({
|
||||
teamId: attackerTeamId,
|
||||
capabilityName: 'attacker-team-tool',
|
||||
capabilityType: 'mcp',
|
||||
decision: 'blocked',
|
||||
decidedBy: attackerAdminId,
|
||||
}).returning();
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/teams/${SLUG_PREFIX}attacker/capability-overrides/${ownOverride.id}`,
|
||||
headers: { 'x-test-user-id': attackerAdminId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(204);
|
||||
const [stored] = await server.db.select().from(teamCapabilityOverrides).where(eq(teamCapabilityOverrides.id, ownOverride.id));
|
||||
expect(stored).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let an authorized admin decide another team\'s request by ID', async () => {
|
||||
const [victimRequest] = await server.db.insert(teamCapabilityRequests).values({
|
||||
teamId: victimTeamId,
|
||||
requestedBy: victimOwnerId,
|
||||
capabilityName: 'victim-requested-tool',
|
||||
capabilityType: 'mcp',
|
||||
justification: 'Victim-only request',
|
||||
}).returning();
|
||||
|
||||
const foreign = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/teams/${SLUG_PREFIX}attacker/capability-requests/${victimRequest.id}`,
|
||||
headers: { 'x-test-user-id': attackerAdminId },
|
||||
payload: { status: 'rejected', reason: 'cross-team decision attempt' },
|
||||
});
|
||||
const missing = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/teams/${SLUG_PREFIX}attacker/capability-requests/${MISSING_ID}`,
|
||||
headers: { 'x-test-user-id': attackerAdminId },
|
||||
payload: { status: 'rejected', reason: 'missing request' },
|
||||
});
|
||||
|
||||
expect({ statusCode: foreign.statusCode, body: foreign.body }).toEqual({
|
||||
statusCode: missing.statusCode,
|
||||
body: missing.body,
|
||||
});
|
||||
const [stored] = await server.db.select().from(teamCapabilityRequests).where(and(
|
||||
eq(teamCapabilityRequests.id, victimRequest.id),
|
||||
eq(teamCapabilityRequests.teamId, victimTeamId),
|
||||
));
|
||||
expect(stored?.status).toBe('pending');
|
||||
expect(stored?.decidedBy).toBeNull();
|
||||
});
|
||||
|
||||
it('still lets a same-team admin decide a pending request', async () => {
|
||||
const [ownRequest] = await server.db.insert(teamCapabilityRequests).values({
|
||||
teamId: attackerTeamId,
|
||||
requestedBy: attackerAdminId,
|
||||
capabilityName: 'attacker-requested-tool',
|
||||
capabilityType: 'mcp',
|
||||
justification: 'Same-team request',
|
||||
}).returning();
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/teams/${SLUG_PREFIX}attacker/capability-requests/${ownRequest.id}`,
|
||||
headers: { 'x-test-user-id': attackerAdminId },
|
||||
payload: { status: 'rejected', reason: 'same-team decision' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
id: ownRequest.id,
|
||||
teamId: attackerTeamId,
|
||||
status: 'rejected',
|
||||
decidedBy: attackerAdminId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
@@ -16,7 +16,11 @@ describe('Health Endpoint', () => {
|
||||
const mind = new MindDB(personalPath);
|
||||
mind.close();
|
||||
|
||||
server = await buildLocalServer({ dataDir: tmpDir });
|
||||
server = await buildLocalServer({
|
||||
dataDir: tmpDir,
|
||||
port: 38123,
|
||||
instanceId: 'desktop-instance-test',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -44,6 +48,20 @@ describe('Health Endpoint', () => {
|
||||
expect(body.database.healthy).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the desktop launch identity used for sidecar ownership checks', async () => {
|
||||
vi.stubEnv('WAGGLE_INSTANCE_ID', 'mutated-after-launch');
|
||||
try {
|
||||
const res = await server.inject({ method: 'GET', url: '/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.payload)).toMatchObject({
|
||||
instanceId: 'desktop-instance-test',
|
||||
port: 38123,
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('overall status reflects LLM health', async () => {
|
||||
// Default: llmProvider was set to unavailable (no init via startService)
|
||||
const res = await server.inject({ method: 'GET', url: '/health' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildServer } from '../../src/index.js';
|
||||
import { users, teams, teamMembers, teamEntities, teamRelations } from '../../src/db/schema.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { eq, or, sql } from 'drizzle-orm';
|
||||
|
||||
describe('Team Knowledge Graph API', () => {
|
||||
let server: Awaited<ReturnType<typeof buildServer>>;
|
||||
@@ -10,6 +10,7 @@ describe('Team Knowledge Graph API', () => {
|
||||
let outsiderId: string;
|
||||
let teamSlug: string;
|
||||
let teamId: string;
|
||||
let foreignEntityId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await buildServer();
|
||||
@@ -60,6 +61,25 @@ describe('Team Knowledge Graph API', () => {
|
||||
{ teamId, userId: memberId, role: 'member' },
|
||||
]);
|
||||
|
||||
const [foreignTeam] = await server.db.insert(teams).values({
|
||||
name: 'Foreign KG Team',
|
||||
slug: 'kgtest-foreign-knowledge',
|
||||
ownerId: outsiderId,
|
||||
}).returning();
|
||||
await server.db.insert(teamMembers).values({
|
||||
teamId: foreignTeam.id,
|
||||
userId: outsiderId,
|
||||
role: 'owner',
|
||||
});
|
||||
const [foreignEntity] = await server.db.insert(teamEntities).values({
|
||||
teamId: foreignTeam.id,
|
||||
entityType: 'secret',
|
||||
name: 'Foreign Entity',
|
||||
properties: { confidential: true },
|
||||
sharedBy: outsiderId,
|
||||
}).returning();
|
||||
foreignEntityId = foreignEntity.id;
|
||||
|
||||
// Override auth handler
|
||||
server._authHandler.fn = async function (request, reply) {
|
||||
const testUserId = request.headers['x-test-user-id'] as string;
|
||||
@@ -189,6 +209,48 @@ describe('Team Knowledge Graph API', () => {
|
||||
expect(body.teamId).toBe(teamId);
|
||||
});
|
||||
|
||||
it('rejects relations when either endpoint belongs to another team', async () => {
|
||||
const localRes = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/entities`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { entityType: 'concept', name: 'Local Endpoint' },
|
||||
});
|
||||
const localEntity = localRes.json();
|
||||
|
||||
const foreignSource = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/relations`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: {
|
||||
sourceId: foreignEntityId,
|
||||
targetId: localEntity.id,
|
||||
relationType: 'references',
|
||||
},
|
||||
});
|
||||
const foreignTarget = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/relations`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: {
|
||||
sourceId: localEntity.id,
|
||||
targetId: foreignEntityId,
|
||||
relationType: 'references',
|
||||
},
|
||||
});
|
||||
|
||||
expect(foreignSource.statusCode).toBe(404);
|
||||
expect(foreignSource.json()).toEqual({ error: 'Entity not found' });
|
||||
expect(foreignTarget.statusCode).toBe(404);
|
||||
expect(foreignTarget.json()).toEqual({ error: 'Entity not found' });
|
||||
|
||||
const leaked = await server.db.select().from(teamRelations).where(or(
|
||||
eq(teamRelations.sourceId, foreignEntityId),
|
||||
eq(teamRelations.targetId, foreignEntityId),
|
||||
));
|
||||
expect(leaked).toEqual([]);
|
||||
});
|
||||
|
||||
it('graph traversal returns connected entities up to depth N', async () => {
|
||||
// Create a chain: A -> B -> C
|
||||
const resA = await server.inject({
|
||||
@@ -259,6 +321,19 @@ describe('Team Knowledge Graph API', () => {
|
||||
expect(graph2.relations.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('does not return a foreign-team graph start entity', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/teams/${teamSlug}/graph?startId=${foreignEntityId}&depth=1`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({ error: 'Entity not found' });
|
||||
expect(response.body).not.toContain('Foreign Entity');
|
||||
expect(response.body).not.toContain('confidential');
|
||||
});
|
||||
|
||||
it('shared_by tracks who contributed', async () => {
|
||||
// Owner creates entity
|
||||
const res1 = await server.inject({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildServer } from '../../src/index.js';
|
||||
import { users, teams, teamMembers, teamResources } from '../../src/db/schema.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
describe('Team Resources API', () => {
|
||||
let server: Awaited<ReturnType<typeof buildServer>>;
|
||||
@@ -200,6 +200,43 @@ describe('Team Resources API', () => {
|
||||
expect(rated2.useCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not rate a resource owned by another team through this team route', async () => {
|
||||
const [foreignTeam] = await server.db.insert(teams).values({
|
||||
name: 'Foreign Resource Team',
|
||||
slug: 'restest-foreign-resources',
|
||||
ownerId: outsiderId,
|
||||
}).returning();
|
||||
await server.db.insert(teamMembers).values({
|
||||
teamId: foreignTeam.id,
|
||||
userId: outsiderId,
|
||||
role: 'owner',
|
||||
});
|
||||
const [foreignResource] = await server.db.insert(teamResources).values({
|
||||
teamId: foreignTeam.id,
|
||||
resourceType: 'model_recipe',
|
||||
name: 'Foreign Recipe',
|
||||
config: { model: 'private-model' },
|
||||
sharedBy: outsiderId,
|
||||
rating: 1,
|
||||
useCount: 1,
|
||||
}).returning();
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/teams/${teamSlug}/resources/${foreignResource.id}`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { rating: 5 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({ error: 'Resource not found' });
|
||||
|
||||
const [unchanged] = await server.db.select().from(teamResources)
|
||||
.where(eq(teamResources.id, foreignResource.id));
|
||||
expect(unchanged.rating).toBe(1);
|
||||
expect(unchanged.useCount).toBe(1);
|
||||
});
|
||||
|
||||
it('filters resources by resource_type', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
|
||||
@@ -277,6 +277,22 @@ describe('Team API', () => {
|
||||
expect(memberIds).not.toContain(memberId);
|
||||
});
|
||||
|
||||
it('does not let an admin demote the team owner', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/teams/test-team-crud/members/${ownerId}`,
|
||||
headers: { 'x-test-user-id': adminId },
|
||||
payload: { role: 'member' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({ error: 'Cannot change the team owner role' });
|
||||
|
||||
const [membership] = await server.db.select().from(teamMembers)
|
||||
.where(sql`team_id = (SELECT id FROM teams WHERE slug = 'test-team-crud') AND user_id = ${ownerId}`);
|
||||
expect(membership.role).toBe('owner');
|
||||
});
|
||||
|
||||
it('returns 401 without auth header', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
|
||||
173
packages/server/tests/routes/webhooks.test.ts
Normal file
173
packages/server/tests/routes/webhooks.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { webhookRoutes } from '../../src/routes/webhooks.js';
|
||||
|
||||
const SIGNING_KEY = Buffer.from('waggle-clerk-webhook-test-secret');
|
||||
const SIGNING_SECRET = `whsec_${SIGNING_KEY.toString('base64')}`;
|
||||
|
||||
const createEvent = {
|
||||
type: 'user.created',
|
||||
data: {
|
||||
id: 'clerk_webhook_user',
|
||||
first_name: 'Ada',
|
||||
last_name: 'Lovelace',
|
||||
email_addresses: [{ email_address: 'ada@example.com' }],
|
||||
image_url: 'https://example.com/ada.png',
|
||||
},
|
||||
};
|
||||
|
||||
function signedHeaders(payload: object, timestamp = Math.floor(Date.now() / 1000)) {
|
||||
const id = 'msg_waggle_clerk_test';
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = createHmac('sha256', SIGNING_KEY)
|
||||
.update(`${id}.${timestamp}.${body}`)
|
||||
.digest('base64');
|
||||
|
||||
return {
|
||||
'svix-id': id,
|
||||
'svix-timestamp': String(timestamp),
|
||||
'svix-signature': `v1,${signature}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createDbDouble() {
|
||||
const onConflictDoNothing = vi.fn().mockResolvedValue(undefined);
|
||||
const values = vi.fn(() => ({ onConflictDoNothing }));
|
||||
const insert = vi.fn(() => ({ values }));
|
||||
|
||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const set = vi.fn(() => ({ where: updateWhere }));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const deleteFrom = vi.fn(() => ({ where: deleteWhere }));
|
||||
|
||||
return {
|
||||
db: { insert, update, delete: deleteFrom },
|
||||
mutations: { insert, values, update, deleteFrom },
|
||||
};
|
||||
}
|
||||
|
||||
describe('Clerk webhook authenticity', () => {
|
||||
let app: FastifyInstance;
|
||||
let mutations: ReturnType<typeof createDbDouble>['mutations'];
|
||||
let originalSigningSecret: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalSigningSecret = process.env.CLERK_WEBHOOK_SIGNING_SECRET;
|
||||
process.env.CLERK_WEBHOOK_SIGNING_SECRET = SIGNING_SECRET;
|
||||
|
||||
const dbDouble = createDbDouble();
|
||||
mutations = dbDouble.mutations;
|
||||
app = Fastify();
|
||||
app.decorate('db', dbDouble.db as never);
|
||||
await app.register(webhookRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
if (originalSigningSecret === undefined) {
|
||||
delete process.env.CLERK_WEBHOOK_SIGNING_SECRET;
|
||||
} else {
|
||||
process.env.CLERK_WEBHOOK_SIGNING_SECRET = originalSigningSecret;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['user.created', createEvent.data],
|
||||
['user.updated', createEvent.data],
|
||||
['user.deleted', { id: createEvent.data.id }],
|
||||
])('rejects an unsigned %s event before any database mutation', async (type, data) => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/clerk',
|
||||
payload: { type, data },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: 'Invalid webhook signature' });
|
||||
expect(mutations.insert).not.toHaveBeenCalled();
|
||||
expect(mutations.update).not.toHaveBeenCalled();
|
||||
expect(mutations.deleteFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an otherwise signed event when its timestamp is stale', async () => {
|
||||
const staleTimestamp = Math.floor(Date.now() / 1000) - 301;
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/clerk',
|
||||
headers: signedHeaders(createEvent, staleTimestamp),
|
||||
payload: createEvent,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(mutations.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a signature that was generated for a different body', async () => {
|
||||
const tamperedEvent = {
|
||||
...createEvent,
|
||||
data: { ...createEvent.data, email_addresses: [{ email_address: 'attacker@example.com' }] },
|
||||
};
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/clerk',
|
||||
headers: signedHeaders(createEvent),
|
||||
payload: tamperedEvent,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(mutations.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when the Clerk webhook signing secret is not configured', async () => {
|
||||
delete process.env.CLERK_WEBHOOK_SIGNING_SECRET;
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/clerk',
|
||||
headers: signedHeaders(createEvent),
|
||||
payload: createEvent,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(mutations.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves a correctly signed user.created event and its field mapping', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/clerk',
|
||||
headers: signedHeaders(createEvent),
|
||||
payload: createEvent,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ received: true });
|
||||
expect(mutations.values).toHaveBeenCalledWith({
|
||||
clerkId: 'clerk_webhook_user',
|
||||
displayName: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
avatarUrl: 'https://example.com/ada.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves correctly signed user.updated and user.deleted events', async () => {
|
||||
const updateEvent = { ...createEvent, type: 'user.updated' };
|
||||
const deleteEvent = { type: 'user.deleted', data: { id: createEvent.data.id } };
|
||||
|
||||
for (const event of [updateEvent, deleteEvent]) {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webhooks/clerk',
|
||||
headers: signedHeaders(event),
|
||||
payload: event,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
expect(mutations.update).toHaveBeenCalledOnce();
|
||||
expect(mutations.deleteFrom).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,7 @@ describe('workspace-context', () => {
|
||||
const wsManager = makeManager(new Map([
|
||||
['rich-ws', { id: 'rich-ws', name: 'Rich Workspace' }],
|
||||
]));
|
||||
const activateWorkspaceMind = vi.fn(() => true);
|
||||
|
||||
setupWorkspace('rich-ws', {
|
||||
frames: [
|
||||
@@ -170,10 +171,11 @@ describe('workspace-context', () => {
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'rich-ws',
|
||||
wsManager,
|
||||
activateWorkspaceMind: noopActivate,
|
||||
activateWorkspaceMind,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(activateWorkspaceMind).not.toHaveBeenCalled();
|
||||
expect(result!.workspaceName).toBe('Rich Workspace');
|
||||
expect(result!.summary).toBeTruthy();
|
||||
expect(result!.summary.length).toBeGreaterThan(10);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
@@ -161,6 +161,7 @@ describe('workspace-state', () => {
|
||||
});
|
||||
|
||||
it('returns state with decisions from memory frames', () => {
|
||||
const activateWorkspaceMind = vi.fn(() => true);
|
||||
setupWorkspace('ws1', {
|
||||
frames: [
|
||||
{ content: 'Decision: Use SQLite for local storage', importance: 'critical' },
|
||||
@@ -172,10 +173,11 @@ describe('workspace-state', () => {
|
||||
dataDir: tmpDir,
|
||||
workspaceId: 'ws1',
|
||||
wsManager: makeManager(new Map([['ws1', { id: 'ws1', name: 'Project Alpha' }]])),
|
||||
activateWorkspaceMind: noopActivate,
|
||||
activateWorkspaceMind,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(activateWorkspaceMind).not.toHaveBeenCalled();
|
||||
expect(result!.recentDecisions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result!.recentDecisions[0].content).toContain('SQLite');
|
||||
expect(result!.recentDecisions[0].source).toBe('memory');
|
||||
|
||||
Reference in New Issue
Block a user