Complete implementation ready for Coolify: - Node.js 22 + Fastify + socket.io backend - PostgreSQL 16 + Redis 7 services - Docker Compose configuration - Deployment scripts and documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
250 lines
7.3 KiB
TypeScript
250 lines
7.3 KiB
TypeScript
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<typeof loadConfig>;
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|