/** * Integration tests for Federation endpoints. * Uses a real Postgres test DB and Redis test instance. * * For the verify endpoint, a real RS256 key pair is generated in test setup. * A partner is registered with a mocked JWKS endpoint, and a valid JWT is * signed with the private key and verified through the full stack. */ import crypto, { generateKeyPairSync } from 'crypto'; import request from 'supertest'; import { Application } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { Pool } from 'pg'; import jwt from 'jsonwebtoken'; // 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'; // ─── Token helpers ──────────────────────────────────────────────────────────── const CALLER_ID = uuidv4(); const ADMIN_SCOPE = 'admin:orgs'; const AGENT_SCOPE = 'agents:read'; function makeToken(sub: string = CALLER_ID, scope: string = ADMIN_SCOPE): string { return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey); } // ─── Partner IdP key pair (for federated token signing) ────────────────────── /** * A separate RS256 key pair representing an external IdP that is registered * as a federation partner. Tokens signed with this key should be verifiable * through POST /api/v1/federation/verify. */ const partnerKeys = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); const partnerJwkRaw = crypto.createPublicKey(partnerKeys.publicKey).export({ format: 'jwk' }) as Record; const PARTNER_KID = 'partner-key-001'; const partnerJwk = { kid: PARTNER_KID, kty: 'RSA', use: 'sig', alg: 'RS256', n: partnerJwkRaw['n'], e: partnerJwkRaw['e'], }; const PARTNER_ISSUER = 'https://external-idp.test.sentryagent.ai'; const PARTNER_JWKS_URI = 'https://external-idp.test.sentryagent.ai/.well-known/jwks.json'; /** Signs a JWT with the partner's private key, simulating an external IdP token. */ function makePartnerToken(claims: Record = {}): string { return jwt.sign( { iss: PARTNER_ISSUER, sub: `partner-agent-${uuidv4()}`, aud: 'sentryagent-idp', ...claims, }, partnerKeys.privateKey, { algorithm: 'RS256', header: { alg: 'RS256', kid: PARTNER_KID, typ: 'JWT' }, }, ); } // ─── Tests ─────────────────────────────────────────────────────────────────── describe('Federation Endpoints Integration Tests', () => { let app: Application; let pool: Pool; beforeAll(async () => { app = await createApp(); pool = new Pool({ connectionString: process.env['DATABASE_URL'] }); const migrations: string[] = [ `CREATE TABLE IF NOT EXISTS schema_migrations ( name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, `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() )`, `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`, `CREATE TABLE IF NOT EXISTS agents ( agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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', organization_id VARCHAR(40) NOT NULL DEFAULT 'org_system', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, `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', organization_id VARCHAR(40) NOT NULL DEFAULT 'org_system', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ )`, `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 '{}', organization_id VARCHAR(40) NOT NULL DEFAULT 'org_system', timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS token_revocations ( jti UUID PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS oidc_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), kid VARCHAR(128) NOT NULL UNIQUE, algorithm VARCHAR(16) NOT NULL, public_key_jwk JSONB NOT NULL, vault_key_path VARCHAR(512) NOT NULL, is_current BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL )`, `CREATE TABLE IF NOT EXISTS federation_partners ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, issuer VARCHAR(512) NOT NULL UNIQUE, jwks_uri VARCHAR(512) NOT NULL, allowed_organizations JSONB NOT NULL DEFAULT '[]', status VARCHAR(32) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ )`, ]; for (const sql of migrations) { await pool.query(sql); } }); afterEach(async () => { await pool.query('DELETE FROM federation_partners'); }); afterAll(async () => { await pool.end(); await closePool(); await closeRedisClient(); }); // ─── Helper: register a partner, mocking the JWKS fetch ─────────────────── async function registerPartner(overrides: Record = {}): Promise<{ body: Record; status: number }> { // Mock global fetch to return the partner's JWKS when called during registration global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ keys: [partnerJwk] }), } as unknown as Response); const token = makeToken(); const res = await request(app) .post('/api/v1/federation/trust') .set('Authorization', `Bearer ${token}`) .send({ name: 'Test External IdP', issuer: PARTNER_ISSUER, jwks_uri: PARTNER_JWKS_URI, ...overrides, }); return { body: res.body as Record, status: res.status }; } // ─── POST /api/v1/federation/trust ──────────────────────────────────────── describe('POST /api/v1/federation/trust', () => { it('registers a partner and returns 201', async () => { const { status, body } = await registerPartner(); expect(status).toBe(201); expect(body['id']).toBeDefined(); expect(body['issuer']).toBe(PARTNER_ISSUER); expect(body['status']).toBe('active'); }); it('returns 401 without a token', async () => { const res = await request(app) .post('/api/v1/federation/trust') .send({ name: 'x', issuer: 'https://x.example.com', jwks_uri: 'https://x.example.com/jwks' }); expect(res.status).toBe(401); }); it('returns 403 without admin:orgs scope', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ keys: [partnerJwk] }), } as unknown as Response); const token = makeToken(CALLER_ID, AGENT_SCOPE); const res = await request(app) .post('/api/v1/federation/trust') .set('Authorization', `Bearer ${token}`) .send({ name: 'x', issuer: 'https://x.example.com', jwks_uri: 'https://x.example.com/jwks' }); expect(res.status).toBe(403); }); it('returns 400 when JWKS endpoint is unreachable', async () => { global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); const token = makeToken(); const res = await request(app) .post('/api/v1/federation/trust') .set('Authorization', `Bearer ${token}`) .send({ name: 'Bad Partner', issuer: 'https://bad.example.com', jwks_uri: 'https://bad.example.com/.well-known/jwks.json', }); expect(res.status).toBe(400); }); }); // ─── GET /api/v1/federation/partners ────────────────────────────────────── describe('GET /api/v1/federation/partners', () => { it('returns list of registered partners (admin:orgs scope)', async () => { await registerPartner(); const token = makeToken(); const res = await request(app) .get('/api/v1/federation/partners') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body).toHaveLength(1); expect((res.body as Array>)[0]['issuer']).toBe(PARTNER_ISSUER); }); it('returns 403 without admin:orgs scope', async () => { const token = makeToken(CALLER_ID, AGENT_SCOPE); const res = await request(app) .get('/api/v1/federation/partners') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); }); it('returns 401 without a token', async () => { const res = await request(app).get('/api/v1/federation/partners'); expect(res.status).toBe(401); }); }); // ─── GET /api/v1/federation/partners/:id ────────────────────────────────── describe('GET /api/v1/federation/partners/:id', () => { it('returns the specific partner by ID', async () => { const { body: created } = await registerPartner(); const partnerId = created['id'] as string; const token = makeToken(); const res = await request(app) .get(`/api/v1/federation/partners/${partnerId}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect((res.body as Record)['id']).toBe(partnerId); }); it('returns 404 for unknown partner id', async () => { const token = makeToken(); const res = await request(app) .get(`/api/v1/federation/partners/${uuidv4()}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(404); }); }); // ─── PATCH /api/v1/federation/partners/:id ──────────────────────────────── describe('PATCH /api/v1/federation/partners/:id', () => { it('updates the partner name and returns 200', async () => { const { body: created } = await registerPartner(); const partnerId = created['id'] as string; const token = makeToken(); const res = await request(app) .patch(`/api/v1/federation/partners/${partnerId}`) .set('Authorization', `Bearer ${token}`) .send({ name: 'Updated Partner Name' }); expect(res.status).toBe(200); expect((res.body as Record)['name']).toBe('Updated Partner Name'); }); it('returns 404 for unknown partner id', async () => { const token = makeToken(); const res = await request(app) .patch(`/api/v1/federation/partners/${uuidv4()}`) .set('Authorization', `Bearer ${token}`) .send({ name: 'Ghost' }); expect(res.status).toBe(404); }); }); // ─── DELETE /api/v1/federation/partners/:id ─────────────────────────────── describe('DELETE /api/v1/federation/partners/:id', () => { it('deletes the partner and returns 204', async () => { const { body: created } = await registerPartner(); const partnerId = created['id'] as string; const token = makeToken(); const res = await request(app) .delete(`/api/v1/federation/partners/${partnerId}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(204); }); it('returns 404 when deleting a non-existent partner', async () => { const token = makeToken(); const res = await request(app) .delete(`/api/v1/federation/partners/${uuidv4()}`) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(404); }); }); // ─── POST /api/v1/federation/verify ─────────────────────────────────────── describe('POST /api/v1/federation/verify', () => { it('verifies a valid token from a registered partner and returns 200', async () => { // Register the partner (JWKS cached from registration) await registerPartner(); // After registration, the JWKS is cached in Redis from the registration fetch. // Now mock fetch to return the JWKS again for the verify path (cache miss fallback). global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ keys: [partnerJwk] }), } as unknown as Response); const federatedToken = makePartnerToken(); const token = makeToken(CALLER_ID, AGENT_SCOPE); const res = await request(app) .post('/api/v1/federation/verify') .set('Authorization', `Bearer ${token}`) .send({ token: federatedToken }); expect(res.status).toBe(200); expect((res.body as Record)['valid']).toBe(true); expect((res.body as Record)['issuer']).toBe(PARTNER_ISSUER); }); it('rejects a token from an unknown issuer with 401', async () => { const unknownToken = jwt.sign( { iss: 'https://completely-unknown.example.com', sub: 'x', aud: 'a' }, partnerKeys.privateKey, { algorithm: 'RS256' }, ); const token = makeToken(CALLER_ID, AGENT_SCOPE); const res = await request(app) .post('/api/v1/federation/verify') .set('Authorization', `Bearer ${token}`) .send({ token: unknownToken }); expect(res.status).toBe(401); expect((res.body as Record)['code']).toBe('FEDERATION_VERIFICATION_ERROR'); }); it('rejects an alg:none token with 401', async () => { const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); const payload = Buffer.from(JSON.stringify({ iss: PARTNER_ISSUER, sub: 'x', aud: 'a', iat: 1, exp: 9999999999, })).toString('base64url'); const noneToken = `${header}.${payload}.`; const token = makeToken(CALLER_ID, AGENT_SCOPE); const res = await request(app) .post('/api/v1/federation/verify') .set('Authorization', `Bearer ${token}`) .send({ token: noneToken }); expect(res.status).toBe(401); }); it('returns 401 without a bearer token', async () => { const res = await request(app) .post('/api/v1/federation/verify') .send({ token: 'some-token' }); expect(res.status).toBe(401); }); }); });