Introduces full multi-tenant organization model to AgentIdP: Schema: - 6 migrations: organizations + organization_members tables; organization_id FK added to agents, credentials, audit_logs; PostgreSQL RLS policies on all three tables; system org seed + backfill API: - 6 new /api/v1/organizations endpoints (CRUD + members) gated by admin:orgs scope - OPA scopes.json updated with 6 new org endpoint → admin:orgs mappings Implementation: - OrgRepository, OrgService, OrgController, createOrgsRouter - OrgContextMiddleware: sets app.organization_id session variable so RLS enforces per-request org isolation at the database layer - JWT payload extended with organization_id claim; auth.ts backfills org_system for backward-compatible tokens - New error classes: OrgNotFoundError, OrgHasActiveAgentsError, AlreadyMemberError Tests: 373 passing, 80.64% branch coverage, zero any types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
/**
|
|
* Unit tests for src/services/CredentialService.ts
|
|
*/
|
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { CredentialService } from '../../../src/services/CredentialService';
|
|
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
|
|
import { AgentRepository } from '../../../src/repositories/AgentRepository';
|
|
import { AuditService } from '../../../src/services/AuditService';
|
|
import { VaultClient } from '../../../src/vault/VaultClient';
|
|
import {
|
|
AgentNotFoundError,
|
|
CredentialNotFoundError,
|
|
CredentialAlreadyRevokedError,
|
|
CredentialError,
|
|
} from '../../../src/utils/errors';
|
|
import { IAgent, ICredential, ICredentialRow } from '../../../src/types/index';
|
|
|
|
jest.mock('../../../src/repositories/CredentialRepository');
|
|
jest.mock('../../../src/repositories/AgentRepository');
|
|
jest.mock('../../../src/services/AuditService');
|
|
jest.mock('../../../src/vault/VaultClient');
|
|
|
|
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 MockVaultClient = VaultClient as jest.MockedClass<typeof VaultClient>;
|
|
|
|
const AGENT_ID = uuidv4();
|
|
const CREDENTIAL_ID = uuidv4();
|
|
|
|
const MOCK_AGENT: IAgent = {
|
|
agentId: AGENT_ID,
|
|
organizationId: 'org_system',
|
|
email: 'agent@sentryagent.ai',
|
|
agentType: 'screener',
|
|
version: '1.0.0',
|
|
capabilities: ['resume:read'],
|
|
owner: 'team-a',
|
|
deploymentEnv: 'production',
|
|
status: 'active',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const MOCK_CREDENTIAL: ICredential = {
|
|
credentialId: CREDENTIAL_ID,
|
|
clientId: AGENT_ID,
|
|
status: 'active',
|
|
createdAt: new Date(),
|
|
expiresAt: null,
|
|
revokedAt: null,
|
|
};
|
|
|
|
const MOCK_CREDENTIAL_ROW: ICredentialRow = {
|
|
...MOCK_CREDENTIAL,
|
|
secretHash: '$2b$10$somehashvalue',
|
|
vaultPath: null,
|
|
};
|
|
|
|
const IP = '127.0.0.1';
|
|
const UA = 'test/1.0';
|
|
|
|
describe('CredentialService', () => {
|
|
let service: CredentialService;
|
|
let credentialRepo: jest.Mocked<CredentialRepository>;
|
|
let agentRepo: jest.Mocked<AgentRepository>;
|
|
let auditService: jest.Mocked<AuditService>;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
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 CredentialService(credentialRepo, agentRepo, auditService);
|
|
auditService.logEvent.mockResolvedValue({} as never);
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// generateCredential
|
|
// ────────────────────────────────────────────────────────────────
|
|
describe('generateCredential()', () => {
|
|
it('should generate and return a credential with a one-time secret', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.create.mockResolvedValue(MOCK_CREDENTIAL);
|
|
|
|
const result = await service.generateCredential(AGENT_ID, {}, IP, UA);
|
|
expect(result.credentialId).toBe(CREDENTIAL_ID);
|
|
expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('should throw AgentNotFoundError for unknown agent', async () => {
|
|
agentRepo.findById.mockResolvedValue(null);
|
|
await expect(service.generateCredential('unknown', {}, IP, UA)).rejects.toThrow(
|
|
AgentNotFoundError,
|
|
);
|
|
});
|
|
|
|
it('should throw CredentialError for suspended agent', async () => {
|
|
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'suspended' });
|
|
await expect(service.generateCredential(AGENT_ID, {}, IP, UA)).rejects.toThrow(
|
|
CredentialError,
|
|
);
|
|
});
|
|
|
|
it('should throw CredentialError for decommissioned agent', async () => {
|
|
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
|
|
await expect(service.generateCredential(AGENT_ID, {}, IP, UA)).rejects.toThrow(
|
|
CredentialError,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// listCredentials
|
|
// ────────────────────────────────────────────────────────────────
|
|
describe('listCredentials()', () => {
|
|
it('should return a paginated list', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findByAgentId.mockResolvedValue({
|
|
credentials: [MOCK_CREDENTIAL],
|
|
total: 1,
|
|
});
|
|
|
|
const result = await service.listCredentials(AGENT_ID, { page: 1, limit: 20 });
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.total).toBe(1);
|
|
});
|
|
|
|
it('should throw AgentNotFoundError for unknown agent', async () => {
|
|
agentRepo.findById.mockResolvedValue(null);
|
|
await expect(
|
|
service.listCredentials('unknown', { page: 1, limit: 20 }),
|
|
).rejects.toThrow(AgentNotFoundError);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// rotateCredential
|
|
// ────────────────────────────────────────────────────────────────
|
|
describe('rotateCredential()', () => {
|
|
it('should rotate and return a new secret', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW);
|
|
credentialRepo.updateHash.mockResolvedValue(MOCK_CREDENTIAL);
|
|
|
|
const result = await service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA);
|
|
expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('should throw AgentNotFoundError for unknown agent', async () => {
|
|
agentRepo.findById.mockResolvedValue(null);
|
|
await expect(
|
|
service.rotateCredential('unknown', CREDENTIAL_ID, {}, IP, UA),
|
|
).rejects.toThrow(AgentNotFoundError);
|
|
});
|
|
|
|
it('should throw CredentialNotFoundError for unknown credential', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findById.mockResolvedValue(null);
|
|
await expect(
|
|
service.rotateCredential(AGENT_ID, 'unknown', {}, IP, UA),
|
|
).rejects.toThrow(CredentialNotFoundError);
|
|
});
|
|
|
|
it('should throw CredentialAlreadyRevokedError for revoked credential', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findById.mockResolvedValue({
|
|
...MOCK_CREDENTIAL_ROW,
|
|
status: 'revoked',
|
|
revokedAt: new Date(),
|
|
});
|
|
await expect(
|
|
service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA),
|
|
).rejects.toThrow(CredentialAlreadyRevokedError);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// revokeCredential
|
|
// ────────────────────────────────────────────────────────────────
|
|
describe('revokeCredential()', () => {
|
|
it('should revoke the credential', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW);
|
|
credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });
|
|
|
|
await expect(
|
|
service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('should throw AgentNotFoundError for unknown agent', async () => {
|
|
agentRepo.findById.mockResolvedValue(null);
|
|
await expect(
|
|
service.revokeCredential('unknown', CREDENTIAL_ID, IP, UA),
|
|
).rejects.toThrow(AgentNotFoundError);
|
|
});
|
|
|
|
it('should throw CredentialAlreadyRevokedError for already-revoked credential', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findById.mockResolvedValue({
|
|
...MOCK_CREDENTIAL_ROW,
|
|
status: 'revoked',
|
|
revokedAt: new Date(),
|
|
});
|
|
await expect(
|
|
service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA),
|
|
).rejects.toThrow(CredentialAlreadyRevokedError);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Vault-path tests ──────────────────────────────────────────────────────
|
|
|
|
describe('CredentialService — Vault path (Phase 2)', () => {
|
|
let service: CredentialService;
|
|
let credentialRepo: jest.Mocked<CredentialRepository>;
|
|
let agentRepo: jest.Mocked<AgentRepository>;
|
|
let auditService: jest.Mocked<AuditService>;
|
|
let vaultClient: jest.Mocked<VaultClient>;
|
|
|
|
const VAULT_PATH = `secret/data/agentidp/agents/${AGENT_ID}/credentials/${CREDENTIAL_ID}`;
|
|
|
|
const MOCK_VAULT_CREDENTIAL_ROW: ICredentialRow = {
|
|
...MOCK_CREDENTIAL,
|
|
secretHash: '',
|
|
vaultPath: VAULT_PATH,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
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>;
|
|
vaultClient = new MockVaultClient('http://localhost:8200', 'token') as jest.Mocked<VaultClient>;
|
|
service = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient);
|
|
auditService.logEvent.mockResolvedValue({} as never);
|
|
});
|
|
|
|
describe('generateCredential() with Vault', () => {
|
|
it('writes secret to Vault and stores the vault_path in the DB', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
vaultClient.writeSecret.mockResolvedValue(VAULT_PATH);
|
|
credentialRepo.createWithVaultPath.mockResolvedValue(MOCK_CREDENTIAL);
|
|
|
|
const result = await service.generateCredential(AGENT_ID, {}, IP, UA);
|
|
|
|
expect(vaultClient.writeSecret).toHaveBeenCalledWith(
|
|
AGENT_ID,
|
|
expect.any(String),
|
|
expect.any(String),
|
|
);
|
|
expect(credentialRepo.createWithVaultPath).toHaveBeenCalled();
|
|
expect(credentialRepo.create).not.toHaveBeenCalled();
|
|
expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
|
|
});
|
|
});
|
|
|
|
describe('rotateCredential() with Vault', () => {
|
|
it('writes new Vault version and updates vault_path in the DB', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findById.mockResolvedValue(MOCK_VAULT_CREDENTIAL_ROW);
|
|
vaultClient.writeSecret.mockResolvedValue(VAULT_PATH);
|
|
credentialRepo.updateVaultPath.mockResolvedValue(MOCK_CREDENTIAL);
|
|
|
|
const result = await service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA);
|
|
|
|
expect(vaultClient.writeSecret).toHaveBeenCalledWith(
|
|
AGENT_ID,
|
|
CREDENTIAL_ID,
|
|
expect.any(String),
|
|
);
|
|
expect(credentialRepo.updateVaultPath).toHaveBeenCalled();
|
|
expect(credentialRepo.updateHash).not.toHaveBeenCalled();
|
|
expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
|
|
});
|
|
});
|
|
|
|
describe('revokeCredential() with Vault', () => {
|
|
it('revokes DB record and deletes Vault secret', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findById.mockResolvedValue(MOCK_VAULT_CREDENTIAL_ROW);
|
|
credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });
|
|
vaultClient.deleteSecret.mockResolvedValue();
|
|
|
|
await service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA);
|
|
|
|
expect(credentialRepo.revoke).toHaveBeenCalledWith(CREDENTIAL_ID);
|
|
expect(vaultClient.deleteSecret).toHaveBeenCalledWith(AGENT_ID, CREDENTIAL_ID);
|
|
});
|
|
|
|
it('does not call Vault delete when credential has no vault_path (bcrypt credential)', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW); // vaultPath: null
|
|
credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });
|
|
|
|
await service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA);
|
|
|
|
expect(vaultClient.deleteSecret).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|