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>
246 lines
9.6 KiB
TypeScript
246 lines
9.6 KiB
TypeScript
/**
|
|
* 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,
|
|
InsufficientScopeError,
|
|
} 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<typeof TokenRepository>;
|
|
const MockCredentialRepo = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
|
|
const MockAgentRepo = AgentRepository as jest.MockedClass<typeof AgentRepository>;
|
|
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
|
|
|
|
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<TokenRepository>;
|
|
let credentialRepo: jest.Mocked<CredentialRepository>;
|
|
let agentRepo: jest.Mocked<AgentRepository>;
|
|
let auditService: jest.Mocked<AuditService>;
|
|
|
|
let plainSecret: string;
|
|
let credentialRow: ICredentialRow;
|
|
|
|
beforeEach(async () => {
|
|
jest.clearAllMocks();
|
|
|
|
tokenRepo = new MockTokenRepo({} as never, {} as never) as jest.Mocked<TokenRepository>;
|
|
credentialRepo = new MockCredentialRepo({} as never) as jest.Mocked<CredentialRepository>;
|
|
agentRepo = new MockAgentRepo({} as never) as jest.Mocked<AgentRepository>;
|
|
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
|
|
|
|
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 };
|
|
|
|
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 throw InsufficientScopeError if caller lacks tokens:read', async () => {
|
|
const noScopePayload = { ...callerPayload, scope: 'agents:read' };
|
|
await expect(
|
|
service.introspectToken(validToken, noScopePayload, IP, UA),
|
|
).rejects.toThrow(InsufficientScopeError);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|