/** * Integration tests for Organization endpoints. * Uses a real Postgres test DB and Redis test instance. */ import crypto from 'crypto'; import request from 'supertest'; import { Application } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { Pool } from 'pg'; // Set test environment variables before importing app const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL'] ?? 'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test'; process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1'; process.env['JWT_PRIVATE_KEY'] = privateKey; process.env['JWT_PUBLIC_KEY'] = publicKey; process.env['NODE_ENV'] = 'test'; process.env['DEFAULT_ORG_ID'] = 'org_system'; import { createApp } from '../../src/app'; import { signToken } from '../../src/utils/jwt'; import { closePool } from '../../src/db/pool'; import { closeRedisClient } from '../../src/cache/redis'; const CALLER_ID = uuidv4(); /** admin:orgs is required for all /organizations endpoints */ const ORG_SCOPE = 'admin:orgs'; function makeToken(sub: string = CALLER_ID, scope: string = ORG_SCOPE): string { return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey); } describe('Organization Endpoints Integration Tests', () => { let app: Application; let pool: Pool; beforeAll(async () => { app = await createApp(); pool = new Pool({ connectionString: process.env['DATABASE_URL'] }); // Create all required tables in dependency order const migrations: string[] = [ // schema_migrations tracking table `CREATE TABLE IF NOT EXISTS schema_migrations ( name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, // organizations must exist before agents (FK) `CREATE TABLE IF NOT EXISTS organizations ( organization_id VARCHAR(40) PRIMARY KEY, name VARCHAR(100) NOT NULL, slug VARCHAR(50) NOT NULL UNIQUE, plan_tier VARCHAR(20) NOT NULL DEFAULT 'free', max_agents INTEGER NOT NULL DEFAULT 100, max_tokens_per_month INTEGER NOT NULL DEFAULT 10000, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT organizations_status_check CHECK (status IN ('active', 'suspended', 'deleted')), CONSTRAINT organizations_plan_check CHECK (plan_tier IN ('free', 'pro', 'enterprise')) )`, // Seed system org required by FK default on agents `INSERT INTO organizations (organization_id, name, slug, plan_tier, max_agents, max_tokens_per_month, status) VALUES ('org_system', 'System', 'system', 'enterprise', 999999, 999999999, 'active') ON CONFLICT (organization_id) DO NOTHING`, // agents table (with organization_id FK) `CREATE TABLE IF NOT EXISTS agents ( agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id VARCHAR(40) NOT NULL DEFAULT 'org_system' REFERENCES organizations(organization_id), email VARCHAR(255) NOT NULL UNIQUE, agent_type VARCHAR(32) NOT NULL, version VARCHAR(64) NOT NULL, capabilities TEXT[] NOT NULL DEFAULT '{}', owner VARCHAR(128) NOT NULL, deployment_env VARCHAR(16) NOT NULL, status VARCHAR(24) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, // credentials table `CREATE TABLE IF NOT EXISTS credentials ( credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), client_id UUID NOT NULL, secret_hash VARCHAR(255) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ )`, // audit_events table `CREATE TABLE IF NOT EXISTS audit_events ( event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), agent_id UUID NOT NULL, action VARCHAR(32) NOT NULL, outcome VARCHAR(16) NOT NULL, ip_address VARCHAR(64) NOT NULL, user_agent TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}', timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, // token_revocations table `CREATE TABLE IF NOT EXISTS token_revocations ( jti UUID PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, // organization_members table (FK to both organizations and agents) `CREATE TABLE IF NOT EXISTS organization_members ( member_id VARCHAR(40) PRIMARY KEY, organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id), agent_id VARCHAR(40) NOT NULL REFERENCES agents(agent_id), role VARCHAR(20) NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT organization_members_role_check CHECK (role IN ('member', 'admin')), UNIQUE (organization_id, agent_id) )`, ]; for (const sql of migrations) { await pool.query(sql); } }); afterEach(async () => { // Delete in FK-safe order await pool.query('DELETE FROM organization_members'); await pool.query('DELETE FROM audit_events'); await pool.query('DELETE FROM credentials'); await pool.query('DELETE FROM agents'); // Delete all orgs EXCEPT system org (other tests may depend on it) await pool.query(`DELETE FROM organizations WHERE organization_id != 'org_system'`); }); afterAll(async () => { await pool.end(); await closePool(); await closeRedisClient(); }); // ──────────────────────────────────────────────────────────────── // POST /api/v1/organizations — Create // ──────────────────────────────────────────────────────────────── describe('POST /api/v1/organizations', () => { const validOrg = { name: 'Acme Corp', slug: 'acme-corp', planTier: 'pro' }; it('should create a new organization and return 201', async () => { const token = makeToken(); const res = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send(validOrg); expect(res.status).toBe(201); expect(res.body.organizationId).toBeDefined(); expect(res.body.name).toBe('Acme Corp'); expect(res.body.slug).toBe('acme-corp'); expect(res.body.planTier).toBe('pro'); expect(res.body.status).toBe('active'); }); it('should return 401 without a token', async () => { const res = await request(app).post('/api/v1/organizations').send(validOrg); expect(res.status).toBe(401); }); it('should return 403 with insufficient scope', async () => { const token = makeToken(CALLER_ID, 'agents:read'); const res = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send(validOrg); expect(res.status).toBe(403); }); it('should return 400 when name is missing', async () => { const token = makeToken(); const res = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ slug: 'no-name' }); expect(res.status).toBe(400); }); it('should return 400 when slug is missing', async () => { const token = makeToken(); const res = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'No Slug Org' }); expect(res.status).toBe(400); }); it('should return 400 when slug contains uppercase letters', async () => { const token = makeToken(); const res = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Bad Slug', slug: 'Bad-Slug' }); expect(res.status).toBe(400); }); it('should return 400 when planTier is invalid', async () => { const token = makeToken(); const res = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Test', slug: 'test-org', planTier: 'invalid-tier' }); expect(res.status).toBe(400); }); it('should return 400 when slug is already taken (SLUG_ALREADY_EXISTS)', async () => { const token = makeToken(); // Create org with same slug await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send(validOrg); const res = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Another Corp', slug: 'acme-corp' }); expect(res.status).toBe(400); expect(res.body.details?.code).toBe('SLUG_ALREADY_EXISTS'); }); it('should create org with defaults when optional fields are omitted', async () => { const token = makeToken(); const res = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Minimal Org', slug: 'minimal-org' }); expect(res.status).toBe(201); expect(res.body.planTier).toBe('free'); expect(res.body.maxAgents).toBe(100); expect(res.body.maxTokensPerMonth).toBe(10000); }); }); // ──────────────────────────────────────────────────────────────── // GET /api/v1/organizations — List // ──────────────────────────────────────────────────────────────── describe('GET /api/v1/organizations', () => { it('should return a paginated list of organizations', async () => { const token = makeToken(); await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Org A', slug: 'org-a' }); const res = await request(app) .get('/api/v1/organizations') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.data).toBeInstanceOf(Array); expect(res.body.total).toBeGreaterThanOrEqual(1); expect(res.body.page).toBe(1); expect(res.body.limit).toBe(20); }); it('should return 401 without a token', async () => { const res = await request(app).get('/api/v1/organizations'); expect(res.status).toBe(401); }); it('should support filtering by status', async () => { const token = makeToken(); await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Active Org', slug: 'active-org' }); const res = await request(app) .get('/api/v1/organizations?status=active') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); res.body.data.forEach((org: { status: string }) => { expect(org.status).toBe('active'); }); }); it('should support pagination via page and limit query params', async () => { const token = makeToken(); const res = await request(app) .get('/api/v1/organizations?page=1&limit=5') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.page).toBe(1); expect(res.body.limit).toBe(5); }); it('should return 400 for invalid status query param', async () => { const token = makeToken(); const res = await request(app) .get('/api/v1/organizations?status=invalid') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(400); }); it('should return 400 for invalid page param', async () => { const token = makeToken(); const res = await request(app) .get('/api/v1/organizations?page=0') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(400); }); }); // ──────────────────────────────────────────────────────────────── // GET /api/v1/organizations/:orgId — Get Single // ──────────────────────────────────────────────────────────────── describe('GET /api/v1/organizations/:orgId', () => { it('should return an organization by ID', async () => { const token = makeToken(); const created = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Get Org', slug: 'get-org' }); const res = await request(app) .get(`/api/v1/organizations/${created.body.organizationId}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.organizationId).toBe(created.body.organizationId); expect(res.body.name).toBe('Get Org'); }); it('should return 401 without a token', async () => { const res = await request(app).get('/api/v1/organizations/org_nonexistent'); expect(res.status).toBe(401); }); it('should return 404 for unknown orgId', async () => { const token = makeToken(); const res = await request(app) .get('/api/v1/organizations/org_NONEXISTENT000000000000') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(404); expect(res.body.code).toBe('ORG_NOT_FOUND'); }); }); // ──────────────────────────────────────────────────────────────── // PATCH /api/v1/organizations/:orgId — Update // ──────────────────────────────────────────────────────────────── describe('PATCH /api/v1/organizations/:orgId', () => { it('should update the organization and return 200', async () => { const token = makeToken(); const created = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Patch Org', slug: 'patch-org' }); const res = await request(app) .patch(`/api/v1/organizations/${created.body.organizationId}`) .set('Authorization', `Bearer ${token}`) .send({ name: 'Patch Org Updated' }); expect(res.status).toBe(200); expect(res.body.name).toBe('Patch Org Updated'); }); it('should update planTier successfully', async () => { const token = makeToken(); const created = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Plan Org', slug: 'plan-org' }); const res = await request(app) .patch(`/api/v1/organizations/${created.body.organizationId}`) .set('Authorization', `Bearer ${token}`) .send({ planTier: 'enterprise' }); expect(res.status).toBe(200); expect(res.body.planTier).toBe('enterprise'); }); it('should return 401 without a token', async () => { const res = await request(app) .patch('/api/v1/organizations/org_nonexistent') .send({ name: 'Updated' }); expect(res.status).toBe(401); }); it('should return 404 for unknown orgId', async () => { const token = makeToken(); const res = await request(app) .patch('/api/v1/organizations/org_NONEXISTENT000000000000') .set('Authorization', `Bearer ${token}`) .send({ name: 'Updated' }); expect(res.status).toBe(404); expect(res.body.code).toBe('ORG_NOT_FOUND'); }); it('should return 400 for invalid planTier', async () => { const token = makeToken(); const created = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Bad Plan Org', slug: 'bad-plan-org' }); const res = await request(app) .patch(`/api/v1/organizations/${created.body.organizationId}`) .set('Authorization', `Bearer ${token}`) .send({ planTier: 'invalid' }); expect(res.status).toBe(400); }); it('should return 400 when status is set to "deleted" via update', async () => { const token = makeToken(); const created = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Delete Status Org', slug: 'delete-status-org' }); const res = await request(app) .patch(`/api/v1/organizations/${created.body.organizationId}`) .set('Authorization', `Bearer ${token}`) .send({ status: 'deleted' }); expect(res.status).toBe(400); }); }); // ──────────────────────────────────────────────────────────────── // DELETE /api/v1/organizations/:orgId — Soft Delete // ──────────────────────────────────────────────────────────────── describe('DELETE /api/v1/organizations/:orgId', () => { it('should soft-delete the organization and return 204', async () => { const token = makeToken(); const created = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Delete Org', slug: 'delete-org' }); const res = await request(app) .delete(`/api/v1/organizations/${created.body.organizationId}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(204); }); it('should return 401 without a token', async () => { const res = await request(app).delete('/api/v1/organizations/org_nonexistent'); expect(res.status).toBe(401); }); it('should return 404 for unknown orgId', async () => { const token = makeToken(); const res = await request(app) .delete('/api/v1/organizations/org_NONEXISTENT000000000000') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(404); expect(res.body.code).toBe('ORG_NOT_FOUND'); }); it('should return 409 when organization has active agents (ORG_HAS_ACTIVE_AGENTS)', async () => { const token = makeToken(); // Create a new org const orgRes = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Org With Agents', slug: 'org-with-agents' }); const orgId = orgRes.body.organizationId as string; // Directly insert an agent belonging to this org to avoid FK/RLS complexity await pool.query( `INSERT INTO agents (agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status) VALUES ($1, $2, $3, 'screener', '1.0.0', '{}', 'test-team', 'development', 'active')`, [uuidv4(), orgId, `agent-${uuidv4()}@test.ai`], ); const res = await request(app) .delete(`/api/v1/organizations/${orgId}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(409); expect(res.body.code).toBe('ORG_HAS_ACTIVE_AGENTS'); }); }); // ──────────────────────────────────────────────────────────────── // POST /api/v1/organizations/:orgId/members — Add Member // ──────────────────────────────────────────────────────────────── describe('POST /api/v1/organizations/:orgId/members', () => { it('should add an agent as a member and return 201', async () => { const token = makeToken(); // Create org const orgRes = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Member Org', slug: 'member-org' }); const orgId = orgRes.body.organizationId as string; // Create an agent directly via SQL (to use org system scope) const agentId = uuidv4(); await pool.query( `INSERT INTO agents (agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status) VALUES ($1, 'org_system', $2, 'screener', '1.0.0', '{}', 'test-team', 'development', 'active')`, [agentId, `member-agent-${uuidv4()}@test.ai`], ); const res = await request(app) .post(`/api/v1/organizations/${orgId}/members`) .set('Authorization', `Bearer ${token}`) .send({ agentId, role: 'member' }); expect(res.status).toBe(201); expect(res.body.memberId).toBeDefined(); expect(res.body.organizationId).toBe(orgId); expect(res.body.agentId).toBe(agentId); expect(res.body.role).toBe('member'); }); it('should return 401 without a token', async () => { const res = await request(app) .post('/api/v1/organizations/org_system/members') .send({ agentId: uuidv4(), role: 'member' }); expect(res.status).toBe(401); }); it('should return 400 when agentId is missing', async () => { const token = makeToken(); const orgRes = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Validate Org', slug: 'validate-org' }); const res = await request(app) .post(`/api/v1/organizations/${orgRes.body.organizationId}/members`) .set('Authorization', `Bearer ${token}`) .send({ role: 'member' }); expect(res.status).toBe(400); }); it('should return 400 when role is invalid', async () => { const token = makeToken(); const orgRes = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Role Org', slug: 'role-org' }); const res = await request(app) .post(`/api/v1/organizations/${orgRes.body.organizationId}/members`) .set('Authorization', `Bearer ${token}`) .send({ agentId: uuidv4(), role: 'superadmin' }); expect(res.status).toBe(400); }); it('should return 404 when organization does not exist', async () => { const token = makeToken(); const res = await request(app) .post('/api/v1/organizations/org_NONEXISTENT000000000000/members') .set('Authorization', `Bearer ${token}`) .send({ agentId: uuidv4(), role: 'member' }); expect(res.status).toBe(404); expect(res.body.code).toBe('ORG_NOT_FOUND'); }); it('should return 404 when agent does not exist', async () => { const token = makeToken(); const orgRes = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Agent 404 Org', slug: 'agent-404-org' }); const res = await request(app) .post(`/api/v1/organizations/${orgRes.body.organizationId}/members`) .set('Authorization', `Bearer ${token}`) .send({ agentId: uuidv4(), role: 'member' }); expect(res.status).toBe(404); expect(res.body.code).toBe('AGENT_NOT_FOUND'); }); it('should return 409 when agent is already a member (ALREADY_MEMBER)', async () => { const token = makeToken(); // Create org const orgRes = await request(app) .post('/api/v1/organizations') .set('Authorization', `Bearer ${token}`) .send({ name: 'Dup Member Org', slug: 'dup-member-org' }); const orgId = orgRes.body.organizationId as string; // Insert agent directly const agentId = uuidv4(); await pool.query( `INSERT INTO agents (agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status) VALUES ($1, 'org_system', $2, 'screener', '1.0.0', '{}', 'test-team', 'development', 'active')`, [agentId, `dup-member-${uuidv4()}@test.ai`], ); // Add agent as member the first time await request(app) .post(`/api/v1/organizations/${orgId}/members`) .set('Authorization', `Bearer ${token}`) .send({ agentId, role: 'member' }); // Attempt to add again const res = await request(app) .post(`/api/v1/organizations/${orgId}/members`) .set('Authorization', `Bearer ${token}`) .send({ agentId, role: 'member' }); expect(res.status).toBe(409); expect(res.body.code).toBe('ALREADY_MEMBER'); }); }); });