/** * Unit tests for src/services/OAuth2Service.ts */ import crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { OAuth2Service } from '../../../src/services/OAuth2Service'; import { TokenRepository } from '../../../src/repositories/TokenRepository'; import { CredentialRepository } from '../../../src/repositories/CredentialRepository'; import { AgentRepository } from '../../../src/repositories/AgentRepository'; import { AuditService } from '../../../src/services/AuditService'; import { AuthenticationError, AuthorizationError, FreeTierLimitError, } from '../../../src/utils/errors'; import { IAgent, ICredential, ICredentialRow, ITokenPayload } from '../../../src/types/index'; import { hashSecret, generateClientSecret } from '../../../src/utils/crypto'; jest.mock('../../../src/repositories/TokenRepository'); jest.mock('../../../src/repositories/CredentialRepository'); jest.mock('../../../src/repositories/AgentRepository'); jest.mock('../../../src/services/AuditService'); const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); const MockTokenRepo = TokenRepository as jest.MockedClass; const MockCredentialRepo = CredentialRepository as jest.MockedClass; const MockAgentRepo = AgentRepository as jest.MockedClass; const MockAuditService = AuditService as jest.MockedClass; const MOCK_AGENT_ID = uuidv4(); const MOCK_AGENT: IAgent = { agentId: MOCK_AGENT_ID, email: 'agent@sentryagent.ai', agentType: 'screener', version: '1.0.0', capabilities: ['agents:read'], owner: 'team-a', deploymentEnv: 'production', status: 'active', createdAt: new Date(), updatedAt: new Date(), }; const IP = '127.0.0.1'; const UA = 'test/1.0'; describe('OAuth2Service', () => { let service: OAuth2Service; let tokenRepo: jest.Mocked; let credentialRepo: jest.Mocked; let agentRepo: jest.Mocked; let auditService: jest.Mocked; let plainSecret: string; let credentialRow: ICredentialRow; beforeEach(async () => { jest.clearAllMocks(); tokenRepo = new MockTokenRepo({} as never, {} as never) as jest.Mocked; credentialRepo = new MockCredentialRepo({} as never) as jest.Mocked; agentRepo = new MockAgentRepo({} as never) as jest.Mocked; auditService = new MockAuditService({} as never) as jest.Mocked; service = new OAuth2Service( tokenRepo, credentialRepo, agentRepo, auditService, privateKey, publicKey, ); plainSecret = generateClientSecret(); const secretHash = await hashSecret(plainSecret); const credId = uuidv4(); const mockCredential: ICredential = { credentialId: credId, clientId: MOCK_AGENT_ID, status: 'active', createdAt: new Date(), expiresAt: null, revokedAt: null, }; credentialRow = { ...mockCredential, secretHash, vaultPath: null }; credentialRepo.findByAgentId.mockResolvedValue({ credentials: [mockCredential], total: 1 }); credentialRepo.findById.mockResolvedValue(credentialRow); auditService.logEvent.mockResolvedValue({} as never); }); // ──────────────────────────────────────────────────────────────── // issueToken // ──────────────────────────────────────────────────────────────── describe('issueToken()', () => { beforeEach(() => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); tokenRepo.getMonthlyCount.mockResolvedValue(0); tokenRepo.incrementMonthlyCount.mockResolvedValue(1); }); it('should issue a token for valid credentials', async () => { const result = await service.issueToken( MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA, ); expect(result.token_type).toBe('Bearer'); expect(result.expires_in).toBe(3600); expect(result.access_token).toBeTruthy(); }); it('should throw AuthenticationError for unknown agent', async () => { agentRepo.findById.mockResolvedValue(null); await expect( service.issueToken('unknown', plainSecret, 'agents:read', IP, UA), ).rejects.toThrow(AuthenticationError); }); it('should throw AuthenticationError for wrong secret', async () => { await expect( service.issueToken(MOCK_AGENT_ID, 'wrong_secret', 'agents:read', IP, UA), ).rejects.toThrow(AuthenticationError); }); it('should throw AuthorizationError for suspended agent', async () => { agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'suspended' }); await expect( service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA), ).rejects.toThrow(AuthorizationError); }); it('should throw AuthorizationError for decommissioned agent', async () => { agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' }); await expect( service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA), ).rejects.toThrow(AuthorizationError); }); it('should throw FreeTierLimitError when monthly limit reached', async () => { tokenRepo.getMonthlyCount.mockResolvedValue(10000); await expect( service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA), ).rejects.toThrow(FreeTierLimitError); }); }); // ──────────────────────────────────────────────────────────────── // introspectToken // ──────────────────────────────────────────────────────────────── describe('introspectToken()', () => { let validToken: string; let callerPayload: ITokenPayload; beforeEach(async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); tokenRepo.getMonthlyCount.mockResolvedValue(0); tokenRepo.incrementMonthlyCount.mockResolvedValue(1); const issued = await service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read tokens:read', IP, UA); validToken = issued.access_token; const { verifyToken } = await import('../../../src/utils/jwt'); callerPayload = verifyToken(validToken, publicKey); }); it('should return active: true for a valid token', async () => { tokenRepo.isRevoked.mockResolvedValue(false); const result = await service.introspectToken(validToken, callerPayload, IP, UA); expect(result.active).toBe(true); expect(result.sub).toBe(MOCK_AGENT_ID); }); it('should return active: false for a revoked token', async () => { tokenRepo.isRevoked.mockResolvedValue(true); const result = await service.introspectToken(validToken, callerPayload, IP, UA); expect(result.active).toBe(false); }); it('should introspect successfully regardless of caller scope (tokens:read enforced by OPA middleware)', async () => { // Scope enforcement for tokens:read has been moved to OpaMiddleware. // The service introspects any token presented to it once the request has // passed the middleware layer. tokenRepo.isRevoked.mockResolvedValue(false); const noScopePayload = { ...callerPayload, scope: 'agents:read' }; const result = await service.introspectToken(validToken, noScopePayload, IP, UA); expect(result.active).toBe(true); }); it('should return active: false for an expired token', async () => { const result = await service.introspectToken('invalid.jwt.token', callerPayload, IP, UA); expect(result.active).toBe(false); }); }); // ──────────────────────────────────────────────────────────────── // revokeToken // ──────────────────────────────────────────────────────────────── describe('revokeToken()', () => { let validToken: string; let callerPayload: ITokenPayload; beforeEach(async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); tokenRepo.getMonthlyCount.mockResolvedValue(0); tokenRepo.incrementMonthlyCount.mockResolvedValue(1); const issued = await service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA); validToken = issued.access_token; const { verifyToken } = await import('../../../src/utils/jwt'); callerPayload = verifyToken(validToken, publicKey); tokenRepo.addToRevocationList.mockResolvedValue(); }); it('should revoke a token successfully', async () => { await expect( service.revokeToken(validToken, callerPayload, IP, UA), ).resolves.toBeUndefined(); expect(tokenRepo.addToRevocationList).toHaveBeenCalled(); }); it('should throw AuthorizationError if revoking another agent token', async () => { const otherPayload = { ...callerPayload, sub: uuidv4() }; await expect( service.revokeToken(validToken, otherPayload, IP, UA), ).rejects.toThrow(AuthorizationError); }); it('should succeed silently for a malformed token (RFC 7009)', async () => { await expect( service.revokeToken('not.a.valid.token', callerPayload, IP, UA), ).resolves.toBeUndefined(); }); }); });