import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import type { FastifyInstance } from 'fastify'; import { buildApp } from '../src/app.js'; import { loadConfig } from '../src/config.js'; import { pool, closePool } from '../src/db/pool.js'; import { drizzle } from 'drizzle-orm/node-postgres'; import { agents, apiTokens, auditEvents } from '../src/db/schema.js'; import { sql } from 'drizzle-orm'; describe('J3 API Integration Tests', () => { let app: FastifyInstance; let config: ReturnType; beforeAll(async () => { config = loadConfig({ ...process.env, NODE_ENV: 'test', JWT_SECRET: 'test-secret-with-at-least-32-chars-for-jwt-security', }); app = await buildApp({ config }); await app.ready(); // Clean up test data const db = drizzle(pool); await db.delete(auditEvents); await db.delete(apiTokens); await db.delete(agents); }); afterAll(async () => { await app.close(); await closePool(); }); describe('Complete authentication flow', () => { let agentId: string; let apiToken: string; let tokenId: string; let _jwt: string; it('should create an agent', async () => { const response = await request(app.server) .post('/api/v1/agents') .send({ name: 'test-agent', displayName: 'Test Agent', role: 'agent', }) .expect(201); expect(response.body).toHaveProperty('id'); expect(response.body.name).toBe('test-agent'); expect(response.body.role).toBe('agent'); agentId = response.body.id; // Verify audit event const db = drizzle(pool); const events = await db .select() .from(auditEvents) .where(sql`${auditEvents.type} = 'agent-created'`); expect(events.length).toBeGreaterThan(0); }); it('should list agents', async () => { const response = await request(app.server).get('/api/v1/agents').expect(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBeGreaterThan(0); expect(response.body.some((a: any) => a.id === agentId)).toBe(true); }); it('should issue an API token', async () => { const response = await request(app.server) .post(`/api/v1/agents/${agentId}/tokens`) .send({ scopes: { read: true, write: true }, }) .expect(201); expect(response.body).toHaveProperty('id'); expect(response.body).toHaveProperty('secret'); expect(response.body.secret).toMatch(/^ah_live_[a-zA-Z0-9]{4}_/); expect(response.body.agentId).toBe(agentId); expect(response.body.status).toBe('active'); apiToken = response.body.secret; tokenId = response.body.id; // Verify audit event const db = drizzle(pool); const events = await db .select() .from(auditEvents) .where(sql`${auditEvents.type} = 'token-issued'`); expect(events.length).toBeGreaterThan(0); }); it('should exchange API token for JWT', async () => { const response = await request(app.server) .post('/api/v1/sessions') .send({ apiToken, }) .expect(200); expect(response.body).toHaveProperty('jwt'); expect(response.body.expiresIn).toBe(900); // 15 minutes expect(response.body.agentId).toBe(agentId); expect(response.body.agentName).toBe('test-agent'); expect(response.body.agentRole).toBe('agent'); _jwt = response.body.jwt; // Verify audit event const db = drizzle(pool); const events = await db .select() .from(auditEvents) .where(sql`${auditEvents.type} = 'jwt-issued'`); expect(events.length).toBeGreaterThan(0); }); it('should reject invalid API token', async () => { await request(app.server) .post('/api/v1/sessions') .send({ apiToken: 'ah_live_XXXX_invalid', }) .expect(401); }); it('should revoke API token', async () => { await request(app.server).delete(`/api/v1/tokens/${tokenId}`).expect(204); // Verify audit event const db = drizzle(pool); const events = await db .select() .from(auditEvents) .where(sql`${auditEvents.type} = 'token-revoked'`); expect(events.length).toBeGreaterThan(0); }); it('should reject revoked token', async () => { await request(app.server) .post('/api/v1/sessions') .send({ apiToken, }) .expect(401); }); }); describe('Token rotation scenario', () => { let agentId: string; let oldToken: string; let oldTokenId: string; let newToken: string; it('should create agent for rotation test', async () => { const response = await request(app.server) .post('/api/v1/agents') .send({ name: 'rotation-agent', displayName: 'Rotation Agent', role: 'agent', }) .expect(201); agentId = response.body.id; }); it('should issue first token', async () => { const response = await request(app.server) .post(`/api/v1/agents/${agentId}/tokens`) .send({}) .expect(201); oldToken = response.body.secret; oldTokenId = response.body.id; }); it('old token should work before rotation', async () => { await request(app.server).post('/api/v1/sessions').send({ apiToken: oldToken }).expect(200); }); it('should issue new token (simulating rotation)', async () => { const response = await request(app.server) .post(`/api/v1/agents/${agentId}/tokens`) .send({}) .expect(201); newToken = response.body.secret; }); it('both tokens should work during overlap period', async () => { // Old token still valid await request(app.server).post('/api/v1/sessions').send({ apiToken: oldToken }).expect(200); // New token also valid await request(app.server).post('/api/v1/sessions').send({ apiToken: newToken }).expect(200); }); it('should revoke old token explicitly', async () => { await request(app.server).delete(`/api/v1/tokens/${oldTokenId}`).expect(204); }); it('old token should fail after revocation', async () => { await request(app.server).post('/api/v1/sessions').send({ apiToken: oldToken }).expect(401); }); it('new token should still work after old token revoked', async () => { await request(app.server).post('/api/v1/sessions').send({ apiToken: newToken }).expect(200); }); }); describe('Validation tests', () => { it('should reject invalid agent name', async () => { await request(app.server) .post('/api/v1/agents') .send({ name: 'Invalid Name!', displayName: 'Test', role: 'agent', }) .expect(400); }); it('should reject invalid role', async () => { await request(app.server) .post('/api/v1/agents') .send({ name: 'test', displayName: 'Test', role: 'invalid', }) .expect(400); }); it('should reject token creation for non-existent agent', async () => { await request(app.server) .post('/api/v1/agents/00000000-0000-0000-0000-000000000000/tokens') .send({}) .expect(404); }); }); });