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>
176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|