feat: Phase 1 MVP — complete AgentIdP implementation

Implements all P0 features per OpenSpec change phase-1-mvp-implementation:
- Agent Registry Service (CRUD) — full lifecycle management
- OAuth 2.0 Token Service (Client Credentials flow)
- Credential Management (generate, rotate, revoke)
- Immutable Audit Log Service

Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+
Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types
Quality: 18 unit test suites, 244 tests passing, 97%+ coverage
OpenAPI: 4 complete specs (14 endpoints total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 09:14:41 +00:00
parent 245f8df427
commit d3530285b9
78 changed files with 20590 additions and 1 deletions

View File

@@ -0,0 +1,175 @@
/**
* Unit tests for src/repositories/TokenRepository.ts
* Uses mocked pg.Pool and Redis client — no real infrastructure.
*/
import { Pool } from 'pg';
import { RedisClientType } from 'redis';
import { TokenRepository } from '../../../src/repositories/TokenRepository';
jest.mock('pg', () => ({
Pool: jest.fn().mockImplementation(() => ({
query: jest.fn(),
connect: jest.fn(),
})),
}));
// ─── helpers ─────────────────────────────────────────────────────────────────
function buildMockRedis(): jest.Mocked<Pick<RedisClientType, 'get' | 'set' | 'incr' | 'expire'>> {
return {
get: jest.fn(),
set: jest.fn(),
incr: jest.fn(),
expire: jest.fn(),
};
}
// ─── suite ───────────────────────────────────────────────────────────────────
describe('TokenRepository', () => {
let pool: jest.Mocked<Pool>;
let redis: ReturnType<typeof buildMockRedis>;
let repo: TokenRepository;
beforeEach(() => {
jest.clearAllMocks();
pool = new Pool() as jest.Mocked<Pool>;
redis = buildMockRedis();
repo = new TokenRepository(pool, redis as unknown as RedisClientType);
});
// ── addToRevocationList ───────────────────────────────────────────────────────
describe('addToRevocationList()', () => {
it('should write to Redis with correct key and TTL, then insert to DB', async () => {
redis.set.mockResolvedValue('OK');
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 });
const jti = 'test-jti-001';
const expiresAt = new Date(Date.now() + 3600_000); // 1 hour from now
await repo.addToRevocationList(jti, expiresAt);
// Redis set call
expect(redis.set).toHaveBeenCalledTimes(1);
const [redisKey, value, options] = redis.set.mock.calls[0] as [string, string, { EX: number }];
expect(redisKey).toBe(`revoked:${jti}`);
expect(value).toBe('1');
expect(options.EX).toBeGreaterThan(0);
// DB insert call
expect(pool.query).toHaveBeenCalledTimes(1);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('INSERT INTO token_revocations');
expect(params).toContain(jti);
expect(params).toContain(expiresAt);
});
it('should use a minimum TTL of 1 second for already-expired tokens', async () => {
redis.set.mockResolvedValue('OK');
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 });
const jti = 'expired-jti';
const expiresAt = new Date(Date.now() - 5000); // already expired
await repo.addToRevocationList(jti, expiresAt);
const [, , options] = redis.set.mock.calls[0] as [string, string, { EX: number }];
expect(options.EX).toBe(1);
});
});
// ── isRevoked ─────────────────────────────────────────────────────────────────
describe('isRevoked()', () => {
it('should return true immediately when Redis has the key', async () => {
redis.get.mockResolvedValue('1');
const result = await repo.isRevoked('revoked-jti');
expect(result).toBe(true);
// DB should NOT be queried
expect(pool.query).not.toHaveBeenCalled();
});
it('should fall back to DB and return true when found there', async () => {
redis.get.mockResolvedValue(null);
(pool.query as jest.Mock).mockResolvedValueOnce({
rows: [{ jti: 'db-revoked-jti', expires_at: new Date(), revoked_at: new Date() }],
rowCount: 1,
});
const result = await repo.isRevoked('db-revoked-jti');
expect(redis.get).toHaveBeenCalledTimes(1);
expect(pool.query).toHaveBeenCalledTimes(1);
expect(result).toBe(true);
});
it('should return false when neither Redis nor DB has the key', async () => {
redis.get.mockResolvedValue(null);
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.isRevoked('valid-jti');
expect(result).toBe(false);
});
});
// ── incrementMonthlyCount ─────────────────────────────────────────────────────
describe('incrementMonthlyCount()', () => {
it('should increment the Redis key and return the new count', async () => {
redis.incr.mockResolvedValue(5);
redis.expire.mockResolvedValue(true);
const count = await repo.incrementMonthlyCount('client-001');
expect(redis.incr).toHaveBeenCalledTimes(1);
const [key] = redis.incr.mock.calls[0] as [string];
expect(key).toMatch(/^monthly:tokens:client-001:/);
expect(count).toBe(5);
});
it('should set TTL when count becomes 1 (first token of the month)', async () => {
redis.incr.mockResolvedValue(1);
redis.expire.mockResolvedValue(true);
await repo.incrementMonthlyCount('client-new');
expect(redis.expire).toHaveBeenCalledTimes(1);
const [, ttl] = redis.expire.mock.calls[0] as [string, number];
expect(ttl).toBeGreaterThan(0);
});
it('should NOT set TTL when count is greater than 1', async () => {
redis.incr.mockResolvedValue(10);
await repo.incrementMonthlyCount('client-existing');
expect(redis.expire).not.toHaveBeenCalled();
});
});
// ── getMonthlyCount ───────────────────────────────────────────────────────────
describe('getMonthlyCount()', () => {
it('should return the count from Redis', async () => {
redis.get.mockResolvedValue('42');
const count = await repo.getMonthlyCount('client-001');
expect(redis.get).toHaveBeenCalledTimes(1);
const [key] = redis.get.mock.calls[0] as [string];
expect(key).toMatch(/^monthly:tokens:client-001:/);
expect(count).toBe(42);
});
it('should return 0 when the Redis key does not exist', async () => {
redis.get.mockResolvedValue(null);
const count = await repo.getMonthlyCount('client-no-tokens');
expect(count).toBe(0);
});
});
});