fix(vv): resolve all 6 V&V issues — field trial unblocked
All findings from the inaugural LeadValidator audit resolved and confirmed. Release gate: PASS. VV_ISSUE_002 (BLOCKER): 15 OpenAPI specs verified present covering all 20 route groups (46 endpoints documented in docs/openapi/) VV_ISSUE_003 (MAJOR): Remove any types from src/db/pool.ts — replaced pool.query shim with unknown[] + Object.defineProperty, zero any types, eslint-disable suppressions removed VV_ISSUE_004 (MAJOR): Remove raw Pool from ScaffoldController and HealthDetailedController — injected AgentRepository/CredentialRepository and DbProbe interface respectively; added CredentialRepository.findActiveClientId() VV_ISSUE_005 (MAJOR): Add unit tests for 5 untested services — ComplianceStatusStore, EventPublisher, MarketplaceService, OIDCTrustPolicyService, UsageService VV_ISSUE_006 (MAJOR): Add integration tests for 7 missing route groups — analytics, billing, tiers, webhooks, marketplace, oidc-trust-policies, oidc-token-exchange VV_ISSUE_001 (MINOR): Create missing design.md and tasks.md in 4 OpenSpec archives — all archives now complete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
148
tests/integration/analytics.test.ts
Normal file
148
tests/integration/analytics.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Integration tests for Analytics endpoints.
|
||||
* Uses a real Postgres test DB and Redis test instance.
|
||||
*
|
||||
* Routes covered:
|
||||
* GET /api/v1/analytics/tokens — daily token issuance trend
|
||||
* GET /api/v1/analytics/agents/activity — agent activity heatmap
|
||||
* GET /api/v1/analytics/agents — per-agent usage summary
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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';
|
||||
process.env['ANALYTICS_ENABLED'] = 'true';
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { signToken } from '../../src/utils/jwt';
|
||||
import { closePool } from '../../src/db/pool';
|
||||
import { closeRedisClient } from '../../src/cache/redis';
|
||||
|
||||
const ORG_ID = uuidv4();
|
||||
const AGENT_ID = uuidv4();
|
||||
const SCOPE = 'analytics:read';
|
||||
|
||||
function makeToken(sub: string = AGENT_ID, scope: string = SCOPE, orgId: string = ORG_ID): string {
|
||||
return signToken({ sub, client_id: sub, scope, organization_id: orgId, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
describe('Analytics Endpoints Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id VARCHAR(40) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
plan VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS analytics_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id VARCHAR(40) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
metric_type VARCHAR(50) NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(tenant_id, date, metric_type)
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[ORG_ID, 'Test Analytics Org'],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query(`DELETE FROM analytics_events WHERE tenant_id = $1`, [ORG_ID]);
|
||||
await pool.query(`DELETE FROM organizations WHERE organization_id = $1`, [ORG_ID]);
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
// ─── GET /analytics/tokens ──────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/analytics/tokens', () => {
|
||||
it('should return 200 with token trend data', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/analytics/tokens')
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).get('/api/v1/analytics/tokens');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should accept a ?days= query parameter', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/analytics/tokens?days=7')
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /analytics/agents/activity ─────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/analytics/agents/activity', () => {
|
||||
it('should return 200 with activity data', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/analytics/agents/activity')
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).get('/api/v1/analytics/agents/activity');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /analytics/agents ───────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/analytics/agents', () => {
|
||||
it('should return 200 with agent usage summary', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/analytics/agents')
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).get('/api/v1/analytics/agents');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
141
tests/integration/billing.test.ts
Normal file
141
tests/integration/billing.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Integration tests for Billing endpoints.
|
||||
* Uses a real Postgres test DB and Redis test instance.
|
||||
*
|
||||
* Routes covered:
|
||||
* POST /api/v1/billing/checkout — create Stripe Checkout Session (authenticated)
|
||||
* POST /api/v1/billing/webhook — Stripe webhook handler (unauthenticated, raw body)
|
||||
* GET /api/v1/billing/usage — today's usage summary (authenticated)
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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';
|
||||
// Use a test Stripe key placeholder — actual Stripe calls will not be made in unit context
|
||||
process.env['STRIPE_SECRET_KEY'] = 'sk_test_placeholder';
|
||||
process.env['STRIPE_WEBHOOK_SECRET'] = 'whsec_test_placeholder';
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { signToken } from '../../src/utils/jwt';
|
||||
import { closePool } from '../../src/db/pool';
|
||||
import { closeRedisClient } from '../../src/cache/redis';
|
||||
|
||||
const ORG_ID = uuidv4();
|
||||
const AGENT_ID = uuidv4();
|
||||
|
||||
function makeToken(sub: string = AGENT_ID, scope = 'billing:manage', orgId: string = ORG_ID): string {
|
||||
return signToken({ sub, client_id: sub, scope, organization_id: orgId, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
describe('Billing Endpoints Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id VARCHAR(40) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
plan VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id VARCHAR(40) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
metric_type VARCHAR(50) NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[ORG_ID, 'Test Billing Org'],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query(`DELETE FROM usage_events WHERE tenant_id = $1`, [ORG_ID]);
|
||||
await pool.query(`DELETE FROM organizations WHERE organization_id = $1`, [ORG_ID]);
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
// ─── GET /billing/usage ──────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/billing/usage', () => {
|
||||
it('should return 200 with usage summary for authenticated user', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/billing/usage')
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('apiCalls');
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).get('/api/v1/billing/usage');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /billing/checkout ──────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/billing/checkout', () => {
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/billing/checkout')
|
||||
.send({ targetTier: 'pro' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 422 when targetTier is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/billing/checkout')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send({});
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /billing/webhook ───────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/billing/webhook', () => {
|
||||
it('should return 400 when Stripe-Signature header is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/billing/webhook')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ type: 'checkout.session.completed' }));
|
||||
|
||||
// Stripe webhook verification will fail without the signature
|
||||
expect([400, 401, 403]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
159
tests/integration/marketplace.test.ts
Normal file
159
tests/integration/marketplace.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Integration tests for Marketplace endpoints.
|
||||
* Uses a real Postgres test DB and Redis test instance.
|
||||
*
|
||||
* Routes covered:
|
||||
* GET /api/v1/marketplace — list public agents
|
||||
* GET /api/v1/marketplace/:id — get a specific public agent
|
||||
*
|
||||
* Marketplace endpoints are unauthenticated (public listing).
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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 { closePool } from '../../src/db/pool';
|
||||
import { closeRedisClient } from '../../src/cache/redis';
|
||||
|
||||
const ORG_ID = uuidv4();
|
||||
const PUBLIC_AGENT_ID = uuidv4();
|
||||
const PRIVATE_AGENT_ID = uuidv4();
|
||||
|
||||
describe('Marketplace Endpoints Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id VARCHAR(40) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
plan VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id VARCHAR(40) PRIMARY KEY,
|
||||
organization_id VARCHAR(40) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
agent_type VARCHAR(50) NOT NULL,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
capabilities TEXT[] NOT NULL DEFAULT '{}',
|
||||
owner VARCHAR(100) NOT NULL,
|
||||
deployment_env VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
did TEXT,
|
||||
did_created_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[ORG_ID, 'Test Marketplace Org'],
|
||||
);
|
||||
|
||||
// Insert a public agent
|
||||
await pool.query(
|
||||
`INSERT INTO agents
|
||||
(agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status, is_public)
|
||||
VALUES ($1, $2, $3, 'screener', 'v1.0.0', '{resume:read}', 'test-team', 'production', 'active', true)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[PUBLIC_AGENT_ID, ORG_ID, `public-${PUBLIC_AGENT_ID}@test.com`],
|
||||
);
|
||||
|
||||
// Insert a private agent
|
||||
await pool.query(
|
||||
`INSERT INTO agents
|
||||
(agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status, is_public)
|
||||
VALUES ($1, $2, $3, 'screener', 'v1.0.0', '{resume:read}', 'test-team', 'production', 'active', false)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[PRIVATE_AGENT_ID, ORG_ID, `private-${PRIVATE_AGENT_ID}@test.com`],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query(`DELETE FROM agents WHERE organization_id = $1`, [ORG_ID]);
|
||||
await pool.query(`DELETE FROM organizations WHERE organization_id = $1`, [ORG_ID]);
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
// ─── GET /marketplace ────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/marketplace', () => {
|
||||
it('should return 200 with a list of public agents', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const items: unknown[] = res.body.data ?? res.body;
|
||||
expect(Array.isArray(items)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not expose private agents in the listing', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const items = (res.body.data ?? res.body) as Array<{ agentId: string }>;
|
||||
const privateIds = items.map((a) => a.agentId);
|
||||
expect(privateIds).not.toContain(PRIVATE_AGENT_ID);
|
||||
});
|
||||
|
||||
it('should support pagination via ?page= and ?limit=', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace?page=1&limit=5');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /marketplace/:id ────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/marketplace/:id', () => {
|
||||
it('should return 200 with the public agent card', async () => {
|
||||
const res = await request(app).get(`/api/v1/marketplace/${PUBLIC_AGENT_ID}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.agentId).toBe(PUBLIC_AGENT_ID);
|
||||
});
|
||||
|
||||
it('should return 404 for a private agent', async () => {
|
||||
const res = await request(app).get(`/api/v1/marketplace/${PRIVATE_AGENT_ID}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 for a non-existent agent', async () => {
|
||||
const res = await request(app).get(`/api/v1/marketplace/${uuidv4()}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
tests/integration/oidc-token-exchange.test.ts
Normal file
132
tests/integration/oidc-token-exchange.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Integration tests for OIDC Token Exchange endpoint.
|
||||
* Uses a real Postgres test DB and Redis test instance.
|
||||
*
|
||||
* Routes covered:
|
||||
* POST /api/v1/oidc/token — exchange a GitHub OIDC JWT for a SentryAgent.ai access token
|
||||
*
|
||||
* This is an unauthenticated endpoint — the GitHub OIDC JWT is the credential.
|
||||
* Trust-policy enforcement requires a matching oidc_trust_policies record.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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 { closePool } from '../../src/db/pool';
|
||||
import { closeRedisClient } from '../../src/cache/redis';
|
||||
|
||||
describe('OIDC Token Exchange Endpoint Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS oidc_trust_policies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
repository VARCHAR(255) NOT NULL,
|
||||
branch VARCHAR(100),
|
||||
agent_id VARCHAR(40) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
// ─── POST /oidc/token ────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/oidc/token', () => {
|
||||
it('should return 400 when request body is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/token')
|
||||
.send({});
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('should return 400 when provider is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/token')
|
||||
.send({ token: 'fake-jwt', agentId: uuidv4() });
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('should return 400 when token is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/token')
|
||||
.send({ provider: 'github', agentId: uuidv4() });
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('should return 401 or 403 for an invalid GitHub OIDC token', async () => {
|
||||
// A malformed JWT will fail verification — the endpoint should reject it
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/token')
|
||||
.send({
|
||||
provider: 'github',
|
||||
token: 'eyJhbGciOiJSUzI1NiJ9.invalid.payload',
|
||||
agentId: uuidv4(),
|
||||
scope: 'agents:read',
|
||||
});
|
||||
|
||||
expect([400, 401, 403, 422]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('should return 403 when no trust policy matches the repository', async () => {
|
||||
// Build a minimally valid JWT structure with github claims (but won't pass GitHub JWKS)
|
||||
// The endpoint will reject after trust-policy lookup fails
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||
const claims = {
|
||||
iss: 'https://token.actions.githubusercontent.com',
|
||||
sub: 'repo:nonexistent/repo:ref:refs/heads/main',
|
||||
repository: 'nonexistent/repo',
|
||||
ref: 'refs/heads/main',
|
||||
aud: 'sentryagent.ai',
|
||||
};
|
||||
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||
const fakeJwt = `${header}.${payload}.fakesig`;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/token')
|
||||
.send({
|
||||
provider: 'github',
|
||||
token: fakeJwt,
|
||||
agentId: uuidv4(),
|
||||
scope: 'agents:read',
|
||||
});
|
||||
|
||||
// Either 401 (invalid JWT signature) or 403 (trust policy violation)
|
||||
expect([400, 401, 403]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
207
tests/integration/oidc-trust-policies.test.ts
Normal file
207
tests/integration/oidc-trust-policies.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Integration tests for OIDC Trust Policy endpoints.
|
||||
* Uses a real Postgres test DB and Redis test instance.
|
||||
*
|
||||
* Routes covered:
|
||||
* POST /api/v1/oidc/trust-policies — create a trust policy
|
||||
* GET /api/v1/oidc/trust-policies/:agentId — list trust policies for an agent
|
||||
* DELETE /api/v1/oidc/trust-policies/:id — delete a trust policy
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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 ORG_ID = uuidv4();
|
||||
const AGENT_ID = uuidv4();
|
||||
const SCOPE = 'agents:write';
|
||||
|
||||
function makeToken(sub: string = AGENT_ID, scope: string = SCOPE, orgId: string = ORG_ID): string {
|
||||
return signToken({ sub, client_id: sub, scope, organization_id: orgId, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
describe('OIDC Trust Policy Endpoints Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
let createdPolicyId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id VARCHAR(40) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
plan VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id VARCHAR(40) PRIMARY KEY,
|
||||
organization_id VARCHAR(40) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
agent_type VARCHAR(50) NOT NULL,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
capabilities TEXT[] NOT NULL DEFAULT '{}',
|
||||
owner VARCHAR(100) NOT NULL,
|
||||
deployment_env VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
did TEXT,
|
||||
did_created_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS oidc_trust_policies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
repository VARCHAR(255) NOT NULL,
|
||||
branch VARCHAR(100),
|
||||
agent_id VARCHAR(40) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[ORG_ID, 'Test OIDC Org'],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO agents
|
||||
(agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env)
|
||||
VALUES ($1, $2, $3, 'ci-runner', 'v1.0.0', '{}', 'ci-team', 'production')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[AGENT_ID, ORG_ID, `oidc-agent-${AGENT_ID}@test.com`],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query(`DELETE FROM oidc_trust_policies WHERE agent_id = $1`, [AGENT_ID]);
|
||||
await pool.query(`DELETE FROM agents WHERE agent_id = $1`, [AGENT_ID]);
|
||||
await pool.query(`DELETE FROM organizations WHERE organization_id = $1`, [ORG_ID]);
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
// ─── POST /oidc/trust-policies ───────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/oidc/trust-policies', () => {
|
||||
it('should create a trust policy and return 201', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/trust-policies')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send({
|
||||
provider: 'github',
|
||||
repository: 'acme/my-repo',
|
||||
branch: 'main',
|
||||
agentId: AGENT_ID,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toHaveProperty('id');
|
||||
createdPolicyId = res.body.id as string;
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/trust-policies')
|
||||
.send({ provider: 'github', repository: 'acme/repo', agentId: AGENT_ID });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 422 for invalid provider', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/trust-policies')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send({ provider: 'gitlab', repository: 'acme/repo', agentId: AGENT_ID });
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('should return 422 for malformed repository', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/oidc/trust-policies')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send({ provider: 'github', repository: 'no-slash', agentId: AGENT_ID });
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /oidc/trust-policies/:agentId ──────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/oidc/trust-policies/:agentId', () => {
|
||||
it('should return 200 with list of trust policies', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/oidc/trust-policies/${AGENT_ID}`)
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).get(`/api/v1/oidc/trust-policies/${AGENT_ID}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /oidc/trust-policies/:id ────────────────────────────────────────
|
||||
|
||||
describe('DELETE /api/v1/oidc/trust-policies/:id', () => {
|
||||
it('should return 204 when deleting an existing policy', async () => {
|
||||
if (!createdPolicyId) return;
|
||||
const res = await request(app)
|
||||
.delete(`/api/v1/oidc/trust-policies/${createdPolicyId}`)
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return 404 when policy does not exist', async () => {
|
||||
const res = await request(app)
|
||||
.delete(`/api/v1/oidc/trust-policies/${uuidv4()}`)
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).delete(`/api/v1/oidc/trust-policies/${uuidv4()}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
137
tests/integration/tiers.test.ts
Normal file
137
tests/integration/tiers.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Integration tests for Tier management endpoints.
|
||||
* Uses a real Postgres test DB and Redis test instance.
|
||||
*
|
||||
* Routes covered:
|
||||
* GET /api/v1/tiers/status — current tier, limits, and usage
|
||||
* POST /api/v1/tiers/upgrade — initiate Stripe checkout for tier upgrade
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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';
|
||||
process.env['TIER_ENFORCEMENT'] = 'false';
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { signToken } from '../../src/utils/jwt';
|
||||
import { closePool } from '../../src/db/pool';
|
||||
import { closeRedisClient } from '../../src/cache/redis';
|
||||
|
||||
const ORG_ID = uuidv4();
|
||||
const AGENT_ID = uuidv4();
|
||||
|
||||
function makeToken(sub: string = AGENT_ID, scope = 'agents:read', orgId: string = ORG_ID): string {
|
||||
return signToken({ sub, client_id: sub, scope, organization_id: orgId, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
describe('Tier Endpoints Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id VARCHAR(40) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
plan VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS tenant_tiers (
|
||||
tenant_id VARCHAR(40) PRIMARY KEY REFERENCES organizations(organization_id),
|
||||
tier VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[ORG_ID, 'Test Tier Org'],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO tenant_tiers (tenant_id, tier) VALUES ($1, 'free') ON CONFLICT DO NOTHING`,
|
||||
[ORG_ID],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query(`DELETE FROM tenant_tiers WHERE tenant_id = $1`, [ORG_ID]);
|
||||
await pool.query(`DELETE FROM organizations WHERE organization_id = $1`, [ORG_ID]);
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
// ─── GET /tiers/status ───────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/tiers/status', () => {
|
||||
it('should return 200 with tier status', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/tiers/status')
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('tier');
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).get('/api/v1/tiers/status');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /tiers/upgrade ─────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/tiers/upgrade', () => {
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/tiers/upgrade')
|
||||
.send({ targetTier: 'pro' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 422 when targetTier is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/tiers/upgrade')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send({});
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('should return 422 when targetTier is invalid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/tiers/upgrade')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send({ targetTier: 'platinum' });
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
208
tests/integration/webhooks.test.ts
Normal file
208
tests/integration/webhooks.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Integration tests for Webhook endpoints.
|
||||
* Uses a real Postgres test DB and Redis test instance.
|
||||
*
|
||||
* Routes covered:
|
||||
* POST /api/v1/webhooks — create a webhook subscription
|
||||
* GET /api/v1/webhooks — list webhook subscriptions for org
|
||||
* GET /api/v1/webhooks/:id — get a webhook subscription by ID
|
||||
* PATCH /api/v1/webhooks/:id — update a webhook subscription
|
||||
* DELETE /api/v1/webhooks/:id — delete a webhook subscription
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
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 ORG_ID = uuidv4();
|
||||
const AGENT_ID = uuidv4();
|
||||
const SCOPE = 'webhooks:manage';
|
||||
|
||||
function makeToken(sub: string = AGENT_ID, scope: string = SCOPE, orgId: string = ORG_ID): string {
|
||||
return signToken({ sub, client_id: sub, scope, organization_id: orgId, jti: uuidv4() }, privateKey);
|
||||
}
|
||||
|
||||
const VALID_SUBSCRIPTION = {
|
||||
url: 'https://example.com/webhook',
|
||||
events: ['agent.created', 'agent.updated'],
|
||||
secret: 'test-secret-123',
|
||||
};
|
||||
|
||||
describe('Webhook Endpoints Integration Tests', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
let createdId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id VARCHAR(40) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
plan VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id VARCHAR(40) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
events JSONB NOT NULL,
|
||||
secret_hash TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[ORG_ID, 'Test Webhook Org'],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query(`DELETE FROM webhook_subscriptions WHERE organization_id = $1`, [ORG_ID]);
|
||||
await pool.query(`DELETE FROM organizations WHERE organization_id = $1`, [ORG_ID]);
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
// ─── POST /webhooks ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/webhooks', () => {
|
||||
it('should create a webhook subscription and return 201', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/webhooks')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send(VALID_SUBSCRIPTION);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toHaveProperty('id');
|
||||
expect(res.body.url).toBe(VALID_SUBSCRIPTION.url);
|
||||
createdId = res.body.id as string;
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).post('/api/v1/webhooks').send(VALID_SUBSCRIPTION);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 422 when url is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/webhooks')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send({ events: ['agent.created'] });
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('should return 422 when events array is empty', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/webhooks')
|
||||
.set('Authorization', `Bearer ${makeToken()}`)
|
||||
.send({ url: 'https://example.com/wh', events: [] });
|
||||
|
||||
expect([400, 422]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /webhooks ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/webhooks', () => {
|
||||
it('should return 200 with list of subscriptions', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/webhooks')
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.data ?? res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).get('/api/v1/webhooks');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /webhooks/:id ───────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/webhooks/:id', () => {
|
||||
it('should return 200 with the subscription', async () => {
|
||||
if (!createdId) return; // depends on POST test
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/webhooks/${createdId}`)
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe(createdId);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent subscription', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/webhooks/${uuidv4()}`)
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).get(`/api/v1/webhooks/${uuidv4()}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /webhooks/:id ────────────────────────────────────────────────────
|
||||
|
||||
describe('DELETE /api/v1/webhooks/:id', () => {
|
||||
it('should return 204 when deleting owned subscription', async () => {
|
||||
if (!createdId) return;
|
||||
const res = await request(app)
|
||||
.delete(`/api/v1/webhooks/${createdId}`)
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return 404 when subscription does not exist', async () => {
|
||||
const res = await request(app)
|
||||
.delete(`/api/v1/webhooks/${uuidv4()}`)
|
||||
.set('Authorization', `Bearer ${makeToken()}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
const res = await request(app).delete(`/api/v1/webhooks/${uuidv4()}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
102
tests/unit/services/ComplianceStatusStore.test.ts
Normal file
102
tests/unit/services/ComplianceStatusStore.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Unit tests for src/services/ComplianceStatusStore.ts
|
||||
*
|
||||
* Uses jest.isolateModules to reset module-level state between test groups
|
||||
* since the store is a module-level Map singleton.
|
||||
*/
|
||||
|
||||
describe('ComplianceStatusStore', () => {
|
||||
// Re-import the module fresh for each describe block to reset state
|
||||
let updateControlStatus: (id: string, status: string) => void;
|
||||
let getAllControlStatuses: () => unknown[];
|
||||
let getControlStatus: (id: string) => unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const store = require('../../../src/services/ComplianceStatusStore');
|
||||
updateControlStatus = store.updateControlStatus;
|
||||
getAllControlStatuses = store.getAllControlStatuses;
|
||||
getControlStatus = store.getControlStatus;
|
||||
});
|
||||
|
||||
describe('getAllControlStatuses()', () => {
|
||||
it('should return 5 controls on fresh module load', () => {
|
||||
const statuses = getAllControlStatuses();
|
||||
expect(statuses).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should default all controls to unknown status', () => {
|
||||
const statuses = getAllControlStatuses() as Array<{ status: string }>;
|
||||
expect(statuses.every((s) => s.status === 'unknown')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return controls in canonical order', () => {
|
||||
const statuses = getAllControlStatuses() as Array<{ id: string }>;
|
||||
const ids = statuses.map((s) => s.id);
|
||||
expect(ids).toEqual(['CC6.1', 'CC6.7', 'CC7.2', 'CC9.2', 'CC7.1']);
|
||||
});
|
||||
|
||||
it('should include name and lastChecked fields on each control', () => {
|
||||
const statuses = getAllControlStatuses() as Array<{ id: string; name: string; lastChecked: string }>;
|
||||
for (const s of statuses) {
|
||||
expect(typeof s.name).toBe('string');
|
||||
expect(s.name.length).toBeGreaterThan(0);
|
||||
expect(typeof s.lastChecked).toBe('string');
|
||||
expect(() => new Date(s.lastChecked)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('should map CC6.1 to Encryption at Rest', () => {
|
||||
const statuses = getAllControlStatuses() as Array<{ id: string; name: string }>;
|
||||
const cc61 = statuses.find((s) => s.id === 'CC6.1');
|
||||
expect(cc61?.name).toBe('Encryption at Rest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateControlStatus()', () => {
|
||||
it('should update a control to passing', () => {
|
||||
updateControlStatus('CC6.1', 'passing');
|
||||
const status = getControlStatus('CC6.1') as { status: string };
|
||||
expect(status.status).toBe('passing');
|
||||
});
|
||||
|
||||
it('should update a control to failing', () => {
|
||||
updateControlStatus('CC7.2', 'failing');
|
||||
const status = getControlStatus('CC7.2') as { status: string };
|
||||
expect(status.status).toBe('failing');
|
||||
});
|
||||
|
||||
it('should overwrite a previous status', () => {
|
||||
updateControlStatus('CC9.2', 'passing');
|
||||
updateControlStatus('CC9.2', 'failing');
|
||||
const status = getControlStatus('CC9.2') as { status: string };
|
||||
expect(status.status).toBe('failing');
|
||||
});
|
||||
|
||||
it('should update lastChecked timestamp on each update', async () => {
|
||||
const before = Date.now();
|
||||
updateControlStatus('CC7.1', 'passing');
|
||||
const status = getControlStatus('CC7.1') as { lastChecked: string };
|
||||
const after = new Date(status.lastChecked).getTime();
|
||||
expect(after).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it('should not affect other controls when one is updated', () => {
|
||||
updateControlStatus('CC6.1', 'passing');
|
||||
const all = getAllControlStatuses() as Array<{ id: string; status: string }>;
|
||||
const others = all.filter((s) => s.id !== 'CC6.1');
|
||||
expect(others.every((s) => s.status === 'unknown')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getControlStatus()', () => {
|
||||
it('should return the correct control record', () => {
|
||||
updateControlStatus('CC6.7', 'passing');
|
||||
const status = getControlStatus('CC6.7') as { id: string; name: string; status: string };
|
||||
expect(status.id).toBe('CC6.7');
|
||||
expect(status.name).toBe('TLS Enforcement');
|
||||
expect(status.status).toBe('passing');
|
||||
});
|
||||
});
|
||||
});
|
||||
163
tests/unit/services/EventPublisher.test.ts
Normal file
163
tests/unit/services/EventPublisher.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Unit tests for src/services/EventPublisher.ts
|
||||
*/
|
||||
|
||||
import { EventPublisher } from '../../../src/services/EventPublisher';
|
||||
import { WebhookDeliveryWorker } from '../../../src/workers/WebhookDeliveryWorker';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
jest.mock('../../../src/workers/WebhookDeliveryWorker');
|
||||
jest.mock('pg');
|
||||
|
||||
const MockPool = Pool as jest.MockedClass<typeof Pool>;
|
||||
const MockWorker = WebhookDeliveryWorker as jest.MockedClass<typeof WebhookDeliveryWorker>;
|
||||
|
||||
function makePool(queryImpl?: jest.Mock): jest.Mocked<Pool> {
|
||||
const pool = new MockPool() as jest.Mocked<Pool>;
|
||||
pool.query = queryImpl ?? jest.fn();
|
||||
return pool;
|
||||
}
|
||||
|
||||
function makeWorker(): jest.Mocked<WebhookDeliveryWorker> {
|
||||
const worker = new MockWorker({} as never) as jest.Mocked<WebhookDeliveryWorker>;
|
||||
worker.enqueue = jest.fn().mockResolvedValue(undefined);
|
||||
return worker;
|
||||
}
|
||||
|
||||
const ORG_ID = 'org-abc-123';
|
||||
const EVENT_TYPE = 'agent.created' as const;
|
||||
const DATA = { agentId: 'agent-001' };
|
||||
|
||||
describe('EventPublisher', () => {
|
||||
let pool: jest.Mocked<Pool>;
|
||||
let worker: jest.Mocked<WebhookDeliveryWorker>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
pool = makePool();
|
||||
worker = makeWorker();
|
||||
});
|
||||
|
||||
describe('publishEvent() — webhook fanout', () => {
|
||||
it('should query for active subscriptions and create a delivery record', async () => {
|
||||
const subscriptionRows = [{ id: 'sub-001', organization_id: ORG_ID }];
|
||||
const deliveryRow = [{ id: 'del-001' }];
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: subscriptionRows, rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: deliveryRow, rowCount: 1 });
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, null);
|
||||
await publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledTimes(2);
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('webhook_subscriptions'),
|
||||
[ORG_ID, JSON.stringify([EVENT_TYPE])],
|
||||
);
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('webhook_deliveries'),
|
||||
expect.arrayContaining(['sub-001', EVENT_TYPE]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enqueue a Bull delivery job for each matching subscription', async () => {
|
||||
const subscriptionRows = [{ id: 'sub-001', organization_id: ORG_ID }];
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: subscriptionRows, rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'del-001' }], rowCount: 1 });
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, null);
|
||||
await publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA);
|
||||
|
||||
expect(worker.enqueue).toHaveBeenCalledTimes(1);
|
||||
expect(worker.enqueue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deliveryId: 'del-001',
|
||||
subscriptionId: 'sub-001',
|
||||
organizationId: ORG_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fan out to multiple subscriptions', async () => {
|
||||
const subscriptionRows = [
|
||||
{ id: 'sub-001', organization_id: ORG_ID },
|
||||
{ id: 'sub-002', organization_id: ORG_ID },
|
||||
];
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: subscriptionRows, rowCount: 2 })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'del-001' }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'del-002' }], rowCount: 1 });
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, null);
|
||||
await publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA);
|
||||
|
||||
expect(worker.enqueue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not enqueue any jobs when no matching subscriptions exist', async () => {
|
||||
pool.query = jest.fn().mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, null);
|
||||
await publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA);
|
||||
|
||||
expect(worker.enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when subscription DB query fails', async () => {
|
||||
pool.query = jest.fn().mockRejectedValueOnce(new Error('DB down'));
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, null);
|
||||
await expect(publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not throw when delivery insert fails for a subscription', async () => {
|
||||
const subscriptionRows = [{ id: 'sub-001', organization_id: ORG_ID }];
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: subscriptionRows, rowCount: 1 })
|
||||
.mockRejectedValueOnce(new Error('Insert failed'));
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, null);
|
||||
await expect(publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishEvent() — Kafka fanout', () => {
|
||||
it('should produce to Kafka when kafkaProducer is provided', async () => {
|
||||
pool.query = jest.fn().mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
const kafkaProducer = { send: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, kafkaProducer as never);
|
||||
await publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA);
|
||||
|
||||
expect(kafkaProducer.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
topic: 'agentidp-events',
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({ key: ORG_ID }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call Kafka when kafkaProducer is null', async () => {
|
||||
pool.query = jest.fn().mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
const kafkaProducer = { send: jest.fn() };
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, null);
|
||||
await publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA);
|
||||
|
||||
expect(kafkaProducer.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when Kafka produce fails', async () => {
|
||||
pool.query = jest.fn().mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
const kafkaProducer = { send: jest.fn().mockRejectedValue(new Error('Kafka error')) };
|
||||
|
||||
const publisher = new EventPublisher(worker, pool, kafkaProducer as never);
|
||||
await expect(publisher.publishEvent(ORG_ID, EVENT_TYPE, DATA)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
117
tests/unit/services/MarketplaceService.test.ts
Normal file
117
tests/unit/services/MarketplaceService.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Unit tests for src/services/MarketplaceService.ts
|
||||
*/
|
||||
|
||||
import { MarketplaceService } from '../../../src/services/MarketplaceService';
|
||||
import { AgentRepository } from '../../../src/repositories/AgentRepository';
|
||||
import { AgentNotFoundError } from '../../../src/utils/errors';
|
||||
import { IAgent, IMarketplaceFilters } from '../../../src/types/index';
|
||||
|
||||
jest.mock('../../../src/repositories/AgentRepository');
|
||||
|
||||
const MockAgentRepo = AgentRepository as jest.MockedClass<typeof AgentRepository>;
|
||||
|
||||
const BASE_FILTERS: IMarketplaceFilters = { page: 1, limit: 10 };
|
||||
|
||||
function makeAgent(overrides: Partial<IAgent> = {}): IAgent {
|
||||
return {
|
||||
agentId: 'agent-001',
|
||||
organizationId: 'org-001',
|
||||
email: 'agent@example.com',
|
||||
agentType: 'screener',
|
||||
version: 'v1.0.0',
|
||||
capabilities: ['resume:read'],
|
||||
owner: 'test-team',
|
||||
deploymentEnv: 'production',
|
||||
status: 'active',
|
||||
isPublic: true,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-02'),
|
||||
did: null,
|
||||
didCreatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MarketplaceService', () => {
|
||||
let service: MarketplaceService;
|
||||
let agentRepo: jest.Mocked<AgentRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
agentRepo = new MockAgentRepo({} as never) as jest.Mocked<AgentRepository>;
|
||||
service = new MarketplaceService(agentRepo);
|
||||
});
|
||||
|
||||
describe('listPublicAgents()', () => {
|
||||
it('should return mapped agent cards', async () => {
|
||||
const agent = makeAgent();
|
||||
agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [agent], total: 1 });
|
||||
|
||||
const result = await service.listPublicAgents(BASE_FILTERS);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].agentId).toBe('agent-001');
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should strip private fields (email, organizationId) from cards', async () => {
|
||||
const agent = makeAgent();
|
||||
agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [agent], total: 1 });
|
||||
|
||||
const result = await service.listPublicAgents(BASE_FILTERS);
|
||||
const card = result.data[0] as Record<string, unknown>;
|
||||
|
||||
expect(card['email']).toBeUndefined();
|
||||
expect(card['organizationId']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include a minimal DID document when agent has a DID', async () => {
|
||||
const agent = makeAgent({ did: 'did:web:sentryagent.ai:agents:agent-001' });
|
||||
agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [agent], total: 1 });
|
||||
|
||||
const result = await service.listPublicAgents(BASE_FILTERS);
|
||||
|
||||
expect(result.data[0].didDocument).not.toBeNull();
|
||||
expect(result.data[0].didDocument?.id).toBe('did:web:sentryagent.ai:agents:agent-001');
|
||||
});
|
||||
|
||||
it('should return null DID document when agent has no DID', async () => {
|
||||
const agent = makeAgent({ did: null });
|
||||
agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [agent], total: 1 });
|
||||
|
||||
const result = await service.listPublicAgents(BASE_FILTERS);
|
||||
|
||||
expect(result.data[0].didDocument).toBeNull();
|
||||
});
|
||||
|
||||
it('should return empty data array when no public agents exist', async () => {
|
||||
agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [], total: 0 });
|
||||
|
||||
const result = await service.listPublicAgents(BASE_FILTERS);
|
||||
|
||||
expect(result.data).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicAgent()', () => {
|
||||
it('should return a card for a public agent', async () => {
|
||||
const agent = makeAgent();
|
||||
agentRepo.findPublicById = jest.fn().mockResolvedValue(agent);
|
||||
|
||||
const card = await service.getPublicAgent('agent-001');
|
||||
|
||||
expect(card.agentId).toBe('agent-001');
|
||||
expect(card.owner).toBe('test-team');
|
||||
});
|
||||
|
||||
it('should throw AgentNotFoundError when agent is not found', async () => {
|
||||
agentRepo.findPublicById = jest.fn().mockResolvedValue(null);
|
||||
|
||||
await expect(service.getPublicAgent('nonexistent')).rejects.toThrow(AgentNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
200
tests/unit/services/OIDCTrustPolicyService.test.ts
Normal file
200
tests/unit/services/OIDCTrustPolicyService.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Unit tests for src/services/OIDCTrustPolicyService.ts
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
OIDCTrustPolicyService,
|
||||
TrustPolicyNotFoundError,
|
||||
TrustPolicyViolationError,
|
||||
} from '../../../src/services/OIDCTrustPolicyService';
|
||||
import { ValidationError } from '../../../src/utils/errors';
|
||||
|
||||
jest.mock('pg');
|
||||
|
||||
const MockPool = Pool as jest.MockedClass<typeof Pool>;
|
||||
|
||||
function makePool(): jest.Mocked<Pool> {
|
||||
const pool = new MockPool() as jest.Mocked<Pool>;
|
||||
pool.query = jest.fn();
|
||||
return pool;
|
||||
}
|
||||
|
||||
function makePolicyRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 'policy-001',
|
||||
provider: 'github',
|
||||
repository: 'acme/my-repo',
|
||||
branch: null,
|
||||
agent_id: 'agent-001',
|
||||
created_at: new Date('2026-01-01'),
|
||||
updated_at: new Date('2026-01-01'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('OIDCTrustPolicyService', () => {
|
||||
let service: OIDCTrustPolicyService;
|
||||
let pool: jest.Mocked<Pool>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
pool = makePool();
|
||||
service = new OIDCTrustPolicyService(pool);
|
||||
});
|
||||
|
||||
describe('createTrustPolicy()', () => {
|
||||
it('should create a trust policy successfully', async () => {
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{ agent_id: 'agent-001' }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [makePolicyRow()], rowCount: 1 });
|
||||
|
||||
const result = await service.createTrustPolicy({
|
||||
provider: 'github',
|
||||
repository: 'acme/my-repo',
|
||||
branch: null,
|
||||
agentId: 'agent-001',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('github');
|
||||
expect(result.repository).toBe('acme/my-repo');
|
||||
expect(result.branch).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw ValidationError for non-github provider', async () => {
|
||||
await expect(
|
||||
service.createTrustPolicy({
|
||||
provider: 'gitlab' as never,
|
||||
repository: 'acme/my-repo',
|
||||
branch: null,
|
||||
agentId: 'agent-001',
|
||||
}),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError for malformed repository', async () => {
|
||||
await expect(
|
||||
service.createTrustPolicy({
|
||||
provider: 'github',
|
||||
repository: 'no-slash-here',
|
||||
branch: null,
|
||||
agentId: 'agent-001',
|
||||
}),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError when agentId is empty', async () => {
|
||||
await expect(
|
||||
service.createTrustPolicy({
|
||||
provider: 'github',
|
||||
repository: 'acme/my-repo',
|
||||
branch: null,
|
||||
agentId: '',
|
||||
}),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError when agent not found', async () => {
|
||||
pool.query = jest.fn().mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
await expect(
|
||||
service.createTrustPolicy({
|
||||
provider: 'github',
|
||||
repository: 'acme/my-repo',
|
||||
branch: null,
|
||||
agentId: 'nonexistent',
|
||||
}),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTrustPoliciesForAgent()', () => {
|
||||
it('should return mapped policies', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({
|
||||
rows: [makePolicyRow(), makePolicyRow({ id: 'policy-002' })],
|
||||
rowCount: 2,
|
||||
});
|
||||
|
||||
const policies = await service.listTrustPoliciesForAgent('agent-001');
|
||||
|
||||
expect(policies).toHaveLength(2);
|
||||
expect(policies[0].id).toBe('policy-001');
|
||||
});
|
||||
|
||||
it('should return an empty array when no policies exist', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
const policies = await service.listTrustPoliciesForAgent('agent-001');
|
||||
|
||||
expect(policies).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTrustPolicy()', () => {
|
||||
it('should delete a policy successfully', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({ rowCount: 1 });
|
||||
|
||||
await expect(service.deleteTrustPolicy('policy-001')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw TrustPolicyNotFoundError when policy does not exist', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({ rowCount: 0 });
|
||||
|
||||
await expect(service.deleteTrustPolicy('nonexistent')).rejects.toThrow(TrustPolicyNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforceTrustPolicy()', () => {
|
||||
it('should pass when a wildcard branch policy exists (branch: null)', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({
|
||||
rows: [makePolicyRow({ branch: null })],
|
||||
rowCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.enforceTrustPolicy('github', 'acme/my-repo', 'refs/heads/main', 'agent-001'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass when branch matches exactly', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({
|
||||
rows: [makePolicyRow({ branch: 'main' })],
|
||||
rowCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.enforceTrustPolicy('github', 'acme/my-repo', 'main', 'agent-001'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should normalize refs/heads/ prefix and match', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({
|
||||
rows: [makePolicyRow({ branch: 'main' })],
|
||||
rowCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.enforceTrustPolicy('github', 'acme/my-repo', 'refs/heads/main', 'agent-001'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw TrustPolicyViolationError when no policies exist', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
await expect(
|
||||
service.enforceTrustPolicy('github', 'acme/my-repo', 'main', 'agent-001'),
|
||||
).rejects.toThrow(TrustPolicyViolationError);
|
||||
});
|
||||
|
||||
it('should throw TrustPolicyViolationError when branch does not match constrained policy', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({
|
||||
rows: [makePolicyRow({ branch: 'main' })],
|
||||
rowCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.enforceTrustPolicy('github', 'acme/my-repo', 'feature/evil', 'agent-001'),
|
||||
).rejects.toThrow(TrustPolicyViolationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
116
tests/unit/services/UsageService.test.ts
Normal file
116
tests/unit/services/UsageService.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Unit tests for src/services/UsageService.ts
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { UsageService } from '../../../src/services/UsageService';
|
||||
|
||||
jest.mock('pg');
|
||||
|
||||
const MockPool = Pool as jest.MockedClass<typeof Pool>;
|
||||
|
||||
function makePool(): jest.Mocked<Pool> {
|
||||
const pool = new MockPool() as jest.Mocked<Pool>;
|
||||
pool.query = jest.fn();
|
||||
return pool;
|
||||
}
|
||||
|
||||
const TENANT_ID = 'org-abc-123';
|
||||
const DATE = '2026-04-07';
|
||||
|
||||
describe('UsageService', () => {
|
||||
let service: UsageService;
|
||||
let pool: jest.Mocked<Pool>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
pool = makePool();
|
||||
service = new UsageService(pool);
|
||||
});
|
||||
|
||||
describe('getDailyUsage()', () => {
|
||||
it('should return usage summary with real api call count', async () => {
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{ count: '42' }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [{ count: '5' }], rowCount: 1 });
|
||||
|
||||
const result = await service.getDailyUsage(TENANT_ID, DATE);
|
||||
|
||||
expect(result.tenantId).toBe(TENANT_ID);
|
||||
expect(result.date).toBe(DATE);
|
||||
expect(result.apiCalls).toBe(42);
|
||||
expect(result.agentCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should default apiCalls to 0 when no usage row exists', async () => {
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [{ count: '3' }], rowCount: 1 });
|
||||
|
||||
const result = await service.getDailyUsage(TENANT_ID, DATE);
|
||||
|
||||
expect(result.apiCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle missing count row gracefully (defaults to 0)', async () => {
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
|
||||
.mockResolvedValueOnce({ rows: [{ count: '2' }], rowCount: 1 });
|
||||
|
||||
const result = await service.getDailyUsage(TENANT_ID, DATE);
|
||||
|
||||
expect(result.apiCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('should query usage_events with correct tenant and date params', async () => {
|
||||
pool.query = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{ count: '10' }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 });
|
||||
|
||||
await service.getDailyUsage(TENANT_ID, DATE);
|
||||
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('usage_events'),
|
||||
[TENANT_ID, DATE],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveAgentCount()', () => {
|
||||
it('should return the count of non-decommissioned agents', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({ rows: [{ count: '7' }], rowCount: 1 });
|
||||
|
||||
const count = await service.getActiveAgentCount(TENANT_ID);
|
||||
|
||||
expect(count).toBe(7);
|
||||
});
|
||||
|
||||
it('should return 0 when no agents exist for tenant', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({ rows: [{ count: '0' }], rowCount: 1 });
|
||||
|
||||
const count = await service.getActiveAgentCount(TENANT_ID);
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should exclude decommissioned agents (query contains status check)', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({ rows: [{ count: '3' }], rowCount: 1 });
|
||||
|
||||
await service.getActiveAgentCount(TENANT_ID);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('decommissioned'),
|
||||
[TENANT_ID],
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing count row gracefully', async () => {
|
||||
pool.query = jest.fn().mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
const count = await service.getActiveAgentCount(TENANT_ID);
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user