/** * Unit tests for src/repositories/CredentialRepository.ts * Uses a mocked pg.Pool — no real database connection. */ import { Pool } from 'pg'; import { CredentialRepository } from '../../../src/repositories/CredentialRepository'; import { ICredential, ICredentialRow, ICredentialListFilters } from '../../../src/types/index'; jest.mock('pg', () => ({ Pool: jest.fn().mockImplementation(() => ({ query: jest.fn(), connect: jest.fn(), })), })); // ─── helpers ───────────────────────────────────────────────────────────────── const CREDENTIAL_ROW = { credential_id: 'cred-0000-0000-0000-000000000001', client_id: 'agent-0000-0000-0000-000000000001', secret_hash: '$2b$10$hashedSecret', status: 'active', created_at: new Date('2026-03-28T09:00:00Z'), expires_at: null, revoked_at: null, }; const EXPECTED_CREDENTIAL: ICredential = { credentialId: CREDENTIAL_ROW.credential_id, clientId: CREDENTIAL_ROW.client_id, status: 'active', createdAt: CREDENTIAL_ROW.created_at, expiresAt: null, revokedAt: null, }; const EXPECTED_CREDENTIAL_ROW: ICredentialRow = { ...EXPECTED_CREDENTIAL, secretHash: CREDENTIAL_ROW.secret_hash, }; // ─── suite ─────────────────────────────────────────────────────────────────── describe('CredentialRepository', () => { let pool: jest.Mocked; let repo: CredentialRepository; beforeEach(() => { jest.clearAllMocks(); pool = new Pool() as jest.Mocked; repo = new CredentialRepository(pool); }); // ── create ────────────────────────────────────────────────────────────────── describe('create()', () => { it('should insert a credential row and return ICredential without secret hash', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [CREDENTIAL_ROW], rowCount: 1 }); const result = await repo.create( CREDENTIAL_ROW.client_id, CREDENTIAL_ROW.secret_hash, null, ); expect(pool.query).toHaveBeenCalledTimes(1); const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(sql).toContain('INSERT INTO credentials'); expect(params).toContain(CREDENTIAL_ROW.client_id); expect(params).toContain(CREDENTIAL_ROW.secret_hash); // Secret hash must NOT be on the returned ICredential expect(result).toMatchObject(EXPECTED_CREDENTIAL); expect((result as ICredentialRow).secretHash).toBeUndefined(); }); it('should pass expiresAt when provided', async () => { const expiresAt = new Date('2027-01-01T00:00:00Z'); const rowWithExpiry = { ...CREDENTIAL_ROW, expires_at: expiresAt }; (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [rowWithExpiry], rowCount: 1 }); const result = await repo.create(CREDENTIAL_ROW.client_id, CREDENTIAL_ROW.secret_hash, expiresAt); const [, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(params).toContain(expiresAt); expect(result.expiresAt).toEqual(expiresAt); }); }); // ── findById ───────────────────────────────────────────────────────────────── describe('findById()', () => { it('should return ICredentialRow (with secretHash) when found', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [CREDENTIAL_ROW], rowCount: 1 }); const result = await repo.findById(CREDENTIAL_ROW.credential_id); expect(pool.query).toHaveBeenCalledWith( expect.stringContaining('credential_id'), [CREDENTIAL_ROW.credential_id], ); expect(result).toMatchObject(EXPECTED_CREDENTIAL_ROW); expect(result?.secretHash).toBe(CREDENTIAL_ROW.secret_hash); }); it('should return null when not found', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await repo.findById('nonexistent'); expect(result).toBeNull(); }); }); // ── findByAgentId ───────────────────────────────────────────────────────────── describe('findByAgentId()', () => { it('should return paginated credentials for an agent', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [CREDENTIAL_ROW], rowCount: 1 }); const filters: ICredentialListFilters = { page: 1, limit: 20 }; const result = await repo.findByAgentId(CREDENTIAL_ROW.client_id, filters); expect(pool.query).toHaveBeenCalledTimes(2); expect(result.total).toBe(1); expect(result.credentials).toHaveLength(1); expect(result.credentials[0]).toMatchObject(EXPECTED_CREDENTIAL); }); it('should apply status filter when provided', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); const filters: ICredentialListFilters = { page: 1, limit: 20, status: 'revoked' }; const result = await repo.findByAgentId(CREDENTIAL_ROW.client_id, filters); const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(countSql).toContain('status'); expect(result.total).toBe(0); expect(result.credentials).toHaveLength(0); }); it('should return empty list when no credentials exist', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await repo.findByAgentId('agent-no-creds', { page: 1, limit: 20 }); expect(result.total).toBe(0); expect(result.credentials).toEqual([]); }); it('should not include secretHash in returned credentials', async () => { (pool.query as jest.Mock) .mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 }) .mockResolvedValueOnce({ rows: [CREDENTIAL_ROW], rowCount: 1 }); const result = await repo.findByAgentId(CREDENTIAL_ROW.client_id, { page: 1, limit: 20 }); expect((result.credentials[0] as ICredentialRow).secretHash).toBeUndefined(); }); }); // ── updateHash ──────────────────────────────────────────────────────────────── describe('updateHash()', () => { it('should update the secret hash and return ICredential', async () => { const newHash = '$2b$10$newHash'; const updatedRow = { ...CREDENTIAL_ROW, secret_hash: newHash }; (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 }); const result = await repo.updateHash(CREDENTIAL_ROW.credential_id, newHash, null); expect(pool.query).toHaveBeenCalledTimes(1); const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(sql).toContain('secret_hash'); expect(params).toContain(newHash); expect(params).toContain(CREDENTIAL_ROW.credential_id); expect(result).toMatchObject(EXPECTED_CREDENTIAL); }); it('should return null when credential is not found', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await repo.updateHash('nonexistent', '$2b$10$hash', null); expect(result).toBeNull(); }); it('should pass new expiresAt when provided', async () => { const newExpiry = new Date('2028-01-01T00:00:00Z'); const updatedRow = { ...CREDENTIAL_ROW, expires_at: newExpiry }; (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 }); const result = await repo.updateHash(CREDENTIAL_ROW.credential_id, '$2b$10$hash', newExpiry); const [, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(params).toContain(newExpiry); expect(result?.expiresAt).toEqual(newExpiry); }); }); // ── revoke ──────────────────────────────────────────────────────────────────── describe('revoke()', () => { it('should set status to revoked and return ICredential', async () => { const revokedAt = new Date('2026-03-28T10:00:00Z'); const revokedRow = { ...CREDENTIAL_ROW, status: 'revoked', revoked_at: revokedAt }; (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [revokedRow], rowCount: 1 }); const result = await repo.revoke(CREDENTIAL_ROW.credential_id); const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(sql).toContain('revoked'); expect(params).toContain(CREDENTIAL_ROW.credential_id); expect(result?.status).toBe('revoked'); expect(result?.revokedAt).toEqual(revokedAt); }); it('should return null when credential is not found', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await repo.revoke('nonexistent'); expect(result).toBeNull(); }); }); // ── revokeAllForAgent ───────────────────────────────────────────────────────── describe('revokeAllForAgent()', () => { it('should return the count of revoked credentials', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 3 }); const count = await repo.revokeAllForAgent(CREDENTIAL_ROW.client_id); const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(sql).toContain('revoked'); expect(params).toContain(CREDENTIAL_ROW.client_id); expect(count).toBe(3); }); it('should return 0 when no active credentials exist', async () => { (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: null }); const count = await repo.revokeAllForAgent('agent-no-creds'); expect(count).toBe(0); }); }); });