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:
175
tests/unit/repositories/TokenRepository.test.ts
Normal file
175
tests/unit/repositories/TokenRepository.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user