This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -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',

View File

@@ -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,
});
});
});

View File

@@ -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' });

View File

@@ -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({

View File

@@ -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',

View File

@@ -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',

View 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();
});
});

View File

@@ -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);

View File

@@ -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');