293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
import { createHmac } from 'node:crypto';
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
import { buildServer } from '../../packages/server/src/index.js';
|
|
import {
|
|
users, teams, teamMembers, tasks, messages,
|
|
teamEntities, teamResources, agentAuditLog,
|
|
} from '../../packages/server/src/db/schema.js';
|
|
import { sql } from 'drizzle-orm';
|
|
|
|
const SIGNING_KEY = Buffer.from('waggle-m3-full-stack-test-secret');
|
|
const SIGNING_SECRET = `whsec_${SIGNING_KEY.toString('base64')}`;
|
|
|
|
function signedHeaders(payload: object) {
|
|
const id = 'msg_waggle_m3_full_stack';
|
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
const signature = createHmac('sha256', SIGNING_KEY)
|
|
.update(`${id}.${timestamp}.${JSON.stringify(payload)}`)
|
|
.digest('base64');
|
|
|
|
return {
|
|
'svix-id': id,
|
|
'svix-timestamp': String(timestamp),
|
|
'svix-signature': `v1,${signature}`,
|
|
};
|
|
}
|
|
|
|
describe('M3 Full Stack Integration', () => {
|
|
let server: Awaited<ReturnType<typeof buildServer>>;
|
|
let ownerId: string;
|
|
let memberId: string;
|
|
let teamId: string;
|
|
let originalSigningSecret: string | undefined;
|
|
const teamSlug = 'integ-team';
|
|
|
|
beforeAll(async () => {
|
|
originalSigningSecret = process.env.CLERK_WEBHOOK_SIGNING_SECRET;
|
|
process.env.CLERK_WEBHOOK_SIGNING_SECRET = SIGNING_SECRET;
|
|
server = await buildServer();
|
|
|
|
// Clean up leftover test data from previous runs (reverse dependency order)
|
|
await server.db.execute(sql`DELETE FROM agent_audit_log WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'integ_%')`);
|
|
await server.db.execute(sql`DELETE FROM team_resources WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_relations WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_entities WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM messages WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM tasks WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM agent_jobs WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM cron_schedules WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM teams WHERE slug = 'integ-team'`);
|
|
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'integ_%'`);
|
|
|
|
// Override auth handler for testing
|
|
server._authHandler.fn = async function (request: FastifyRequest, reply: FastifyReply) {
|
|
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 () => {
|
|
try {
|
|
// Clean up all test data
|
|
await server.db.execute(sql`DELETE FROM agent_audit_log WHERE user_id IN (SELECT id FROM users WHERE clerk_id LIKE 'integ_%')`);
|
|
await server.db.execute(sql`DELETE FROM team_resources WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_relations WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_entities WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM messages WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM tasks WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM agent_jobs WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM cron_schedules WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug = 'integ-team')`);
|
|
await server.db.execute(sql`DELETE FROM teams WHERE slug = 'integ-team'`);
|
|
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'integ_%'`);
|
|
} finally {
|
|
try {
|
|
await server?.close();
|
|
} finally {
|
|
if (originalSigningSecret === undefined) {
|
|
delete process.env.CLERK_WEBHOOK_SIGNING_SECRET;
|
|
} else {
|
|
process.env.CLERK_WEBHOOK_SIGNING_SECRET = originalSigningSecret;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('Step 1: Creates users via webhook', async () => {
|
|
// Create owner
|
|
const ownerEvent = {
|
|
type: 'user.created',
|
|
data: {
|
|
id: 'integ_owner',
|
|
first_name: 'Owner',
|
|
last_name: 'User',
|
|
email_addresses: [{ email_address: 'integ_owner@test.com' }],
|
|
},
|
|
};
|
|
let res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/webhooks/clerk',
|
|
headers: signedHeaders(ownerEvent),
|
|
payload: ownerEvent,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
// Create member
|
|
const memberEvent = {
|
|
type: 'user.created',
|
|
data: {
|
|
id: 'integ_member',
|
|
first_name: 'Member',
|
|
last_name: 'User',
|
|
email_addresses: [{ email_address: 'integ_member@test.com' }],
|
|
},
|
|
};
|
|
res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/webhooks/clerk',
|
|
headers: signedHeaders(memberEvent),
|
|
payload: memberEvent,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
// Get user IDs
|
|
const [owner] = await server.db.select().from(users).where(sql`clerk_id = 'integ_owner'`);
|
|
const [member] = await server.db.select().from(users).where(sql`clerk_id = 'integ_member'`);
|
|
expect(owner).toBeDefined();
|
|
expect(member).toBeDefined();
|
|
ownerId = owner.id;
|
|
memberId = member.id;
|
|
});
|
|
|
|
it('Step 2: Creates team and invites member', async () => {
|
|
let res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/teams',
|
|
headers: { 'x-test-user-id': ownerId },
|
|
payload: { name: 'Integration Team', slug: teamSlug },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
teamId = JSON.parse(res.body).id;
|
|
|
|
// Add member
|
|
res = await server.inject({
|
|
method: 'POST',
|
|
url: `/api/teams/${teamSlug}/members`,
|
|
headers: { 'x-test-user-id': ownerId },
|
|
payload: { email: 'integ_member@test.com', role: 'member' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
});
|
|
|
|
it('Step 3: Creates task on team board', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: `/api/teams/${teamSlug}/tasks`,
|
|
headers: { 'x-test-user-id': ownerId },
|
|
payload: { title: 'Research competitor pricing', priority: 'high' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.title).toBe('Research competitor pricing');
|
|
expect(body.priority).toBe('high');
|
|
expect(body.status).toBe('open');
|
|
});
|
|
|
|
it('Step 4: Sends Waggle Dance message', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: `/api/teams/${teamSlug}/messages`,
|
|
headers: { 'x-test-user-id': ownerId },
|
|
payload: {
|
|
type: 'request',
|
|
subtype: 'knowledge_check',
|
|
content: { topic: 'competitor pricing' },
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.type).toBe('request');
|
|
expect(body.subtype).toBe('knowledge_check');
|
|
});
|
|
|
|
it('Step 5: Shares knowledge entity to team graph', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: `/api/teams/${teamSlug}/entities`,
|
|
headers: { 'x-test-user-id': ownerId },
|
|
payload: { entityType: 'competitor', name: 'Acme Corp', properties: { pricing: '$99/mo' } },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.entityType).toBe('competitor');
|
|
expect(body.name).toBe('Acme Corp');
|
|
});
|
|
|
|
it('Step 6: Shares team resource', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: `/api/teams/${teamSlug}/resources`,
|
|
headers: { 'x-test-user-id': ownerId },
|
|
payload: {
|
|
resourceType: 'model_recipe',
|
|
name: 'Fast Research Config',
|
|
config: { model: 'claude-haiku-4-5', temperature: 0.3 },
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.resourceType).toBe('model_recipe');
|
|
expect(body.name).toBe('Fast Research Config');
|
|
});
|
|
|
|
it('Step 7: Creates audit trail entry', async () => {
|
|
const { AuditService } = await import('../../packages/server/src/services/audit-service.js');
|
|
const auditService = new AuditService(server.db);
|
|
const entry = await auditService.log({
|
|
userId: ownerId,
|
|
teamId,
|
|
agentName: 'memory-weaver',
|
|
actionType: 'consolidation',
|
|
description: 'Consolidated meeting notes',
|
|
});
|
|
expect(entry.agentName).toBe('memory-weaver');
|
|
|
|
// Verify via admin API
|
|
// First promote owner to admin role (owner already has admin+ perms)
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/admin/teams/${teamSlug}/audit`,
|
|
headers: { 'x-test-user-id': ownerId },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('Step 8: Member cannot access admin audit endpoint', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/admin/teams/${teamSlug}/audit`,
|
|
headers: { 'x-test-user-id': memberId },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it('Step 9: Lists tasks, messages, entities for the team', async () => {
|
|
// Tasks
|
|
const tasksRes = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/teams/${teamSlug}/tasks`,
|
|
headers: { 'x-test-user-id': memberId },
|
|
});
|
|
expect(tasksRes.statusCode).toBe(200);
|
|
expect(JSON.parse(tasksRes.body).length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Messages
|
|
const msgsRes = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/teams/${teamSlug}/messages`,
|
|
headers: { 'x-test-user-id': memberId },
|
|
});
|
|
expect(msgsRes.statusCode).toBe(200);
|
|
expect(JSON.parse(msgsRes.body).length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Entities
|
|
const entRes = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/teams/${teamSlug}/entities`,
|
|
headers: { 'x-test-user-id': memberId },
|
|
});
|
|
expect(entRes.statusCode).toBe(200);
|
|
expect(JSON.parse(entRes.body).length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('Step 10: Health check passes', async () => {
|
|
const res = await server.inject({ method: 'GET', url: '/health' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).status).toBe('ok');
|
|
});
|
|
});
|