diff --git a/src/common/interceptors/audit-log.interceptor.spec.ts b/src/common/interceptors/audit-log.interceptor.spec.ts index cb8cdc0b..47f78607 100644 --- a/src/common/interceptors/audit-log.interceptor.spec.ts +++ b/src/common/interceptors/audit-log.interceptor.spec.ts @@ -329,6 +329,32 @@ describe('AuditLogInterceptor', () => { nested: { refreshToken: REDACTED_VALUE, note: 'keep me' }, }); // The original request body must be untouched. + expect(originalBody).toEqual({ + username: 'john', + password: 'secret-pass', + apiKey: 'abc123', + token: 'jwt-token', + passkey: 'cred-1', + stellarSecretKey: 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + webhook: { signature: 'sig-here', url: 'https://example.com/hook' }, + nested: { refreshToken: 'rt-1', note: 'keep me' }, + }); + }); + + it('masks sensitive keys case-insensitively and across separators', () => { + expect(isSensitiveKey('password')).toBe(true); + expect(isSensitiveKey('PasswordHash')).toBe(true); + expect(isSensitiveKey('apiKey')).toBe(true); + expect(isSensitiveKey('api_key')).toBe(true); + expect(isSensitiveKey('x-api-key')).toBe(true); + expect(isSensitiveKey('accessToken')).toBe(true); + expect(isSensitiveKey('passkey')).toBe(true); + expect(isSensitiveKey('signature')).toBe(true); + expect(isSensitiveKey('privateKey')).toBe(true); + expect(isSensitiveKey('stellarSecretKey')).toBe(true); + expect(isSensitiveKey('username')).toBe(false); + expect(isSensitiveKey('name')).toBe(false); + expect(isSensitiveKey('amount')).toBe(false); expect(originalBody.password).toBe('secret-pass'); expect(originalBody.apiKey).toBe('abc123'); }); diff --git a/src/common/interceptors/response.interceptor.spec.ts b/src/common/interceptors/response.interceptor.spec.ts index a5e3e39a..db44e4b5 100644 --- a/src/common/interceptors/response.interceptor.spec.ts +++ b/src/common/interceptors/response.interceptor.spec.ts @@ -12,13 +12,14 @@ describe('ResponseInterceptor', () => { interceptor = new ResponseInterceptor(); }); - const createMockContext = (requestId?: string): ExecutionContext => { + const createMockContext = (requestId?: string, response?: { statusCode: number }): ExecutionContext => { return { switchToHttp: () => ({ getRequest: () => ({ headers: requestId ? { [REQUEST_ID_HEADER]: requestId } : {}, }), getResponse: () => ({ + ...(response ?? { statusCode: 200 }), setHeader: () => undefined, }), }), @@ -60,6 +61,48 @@ describe('ResponseInterceptor', () => { }); }); + it('wraps arrays without changing their contents', async () => { + const items = [{ id: '1' }, { id: '2' }]; + const result = await interceptor + .intercept(createMockContext('array-request'), createMockHandler(items)) + .toPromise(); + + expect(result).toEqual({ + success: true, + data: items, + meta: {}, + requestId: 'array-request', + }); + }); + + it('wraps primitive values as data', async () => { + const result = await interceptor + .intercept(createMockContext('primitive-request'), createMockHandler('accepted')) + .toPromise(); + + expect(result).toEqual({ + success: true, + data: 'accepted', + meta: {}, + requestId: 'primitive-request', + }); + }); + + it('preserves a custom HTTP status set by the controller', async () => { + const response = { statusCode: 202 }; + const result = await interceptor + .intercept(createMockContext('accepted-request', response), createMockHandler({ queued: true })) + .toPromise(); + + expect(response.statusCode).toBe(202); + expect(result).toEqual({ + success: true, + data: { queued: true }, + meta: {}, + requestId: 'accepted-request', + }); + }); + it('extracts items and meta from Paginated responses', async () => { const paginated = new Paginated( [{ id: '1' }, { id: '2' }], diff --git a/src/modules/budgets/budget.module.ts b/src/modules/budgets/budget.module.ts index 87f99b20..fd1d51e4 100644 --- a/src/modules/budgets/budget.module.ts +++ b/src/modules/budgets/budget.module.ts @@ -4,6 +4,7 @@ import { BudgetService } from './budget.service'; import { BudgetRepository } from './budget.repository'; import { PolicyEvaluatorService } from './services/policy-evaluator.service'; import { RollingWindowBudgetService } from './services/rolling-window-budget.service'; +import { AgentBudgetValidationPipe } from './pipes/agent-budget-validation.pipe'; /** * Budget module. Exports the service so the transactions pipeline can enforce @@ -16,7 +17,13 @@ import { RollingWindowBudgetService } from './services/rolling-window-budget.ser */ @Module({ controllers: [BudgetController], - providers: [BudgetService, BudgetRepository, PolicyEvaluatorService, RollingWindowBudgetService], - exports: [BudgetService, PolicyEvaluatorService, RollingWindowBudgetService], + providers: [ + BudgetService, + BudgetRepository, + PolicyEvaluatorService, + RollingWindowBudgetService, + AgentBudgetValidationPipe, + ], + exports: [BudgetService, PolicyEvaluatorService, RollingWindowBudgetService, AgentBudgetValidationPipe], }) export class BudgetModule {} diff --git a/src/modules/budgets/pipes/agent-budget-validation.pipe.spec.ts b/src/modules/budgets/pipes/agent-budget-validation.pipe.spec.ts new file mode 100644 index 00000000..1c7b1812 --- /dev/null +++ b/src/modules/budgets/pipes/agent-budget-validation.pipe.spec.ts @@ -0,0 +1,101 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { CreateTransactionInput } from '../../transactions/transaction.dto'; +import { BudgetService } from '../budget.service'; +import { PolicyEvaluatorService } from '../services/policy-evaluator.service'; +import { AgentBudgetValidationPipe } from './agent-budget-validation.pipe'; + +describe('AgentBudgetValidationPipe', () => { + const transaction = { + walletId: 'wallet-id', + budgetId: 'budget-id', + asset: 'USDC', + amount: '12.5', + recipientAddress: 'GABC', + metadata: {}, + } as CreateTransactionInput; + + it('allows a transaction when its budget has sufficient headroom', async () => { + const assertWithinBudget = vi.fn().mockResolvedValue(undefined); + const pipe = new AgentBudgetValidationPipe( + { user: { organizationId: 'org-id' } } as never, + { assertWithinBudget } as unknown as BudgetService, + { evaluate: vi.fn() } as unknown as PolicyEvaluatorService, + ); + + await expect(pipe.transform(transaction)).resolves.toBe(transaction); + expect(assertWithinBudget).toHaveBeenCalledWith('org-id', 'budget-id', 12.5); + }); + + it('rejects when the selected budget check fails', async () => { + const assertWithinBudget = vi.fn().mockRejectedValue(new Error('Budget limit exceeded')); + const pipe = new AgentBudgetValidationPipe( + { user: { organizationId: 'org-id' } } as never, + { assertWithinBudget } as unknown as BudgetService, + { evaluate: vi.fn() } as unknown as PolicyEvaluatorService, + ); + + await expect(pipe.transform(transaction)).rejects.toThrow('Budget limit exceeded'); + }); + + it('does not require a budget lookup when no budget is selected', async () => { + const assertWithinBudget = vi.fn(); + const pipe = new AgentBudgetValidationPipe( + { user: { organizationId: 'org-id' } } as never, + { assertWithinBudget } as unknown as BudgetService, + { evaluate: vi.fn() } as unknown as PolicyEvaluatorService, + ); + const unbudgetedTransaction = { ...transaction, budgetId: undefined }; + + await expect(pipe.transform(unbudgetedTransaction)).resolves.toBe(unbudgetedTransaction); + expect(assertWithinBudget).not.toHaveBeenCalled(); + }); + + it('allows an agent transaction when active spending policies pass', async () => { + const evaluate = vi.fn().mockResolvedValue({ allowed: true }); + const pipe = new AgentBudgetValidationPipe( + { user: { organizationId: 'org-id' } } as never, + { assertWithinBudget: vi.fn() } as unknown as BudgetService, + { evaluate } as unknown as PolicyEvaluatorService, + ); + const agentTransaction = { ...transaction, budgetId: undefined, agentId: 'agent-id' }; + + await expect(pipe.transform(agentTransaction)).resolves.toBe(agentTransaction); + expect(evaluate).toHaveBeenCalledWith({ + organizationId: 'org-id', + agentId: 'agent-id', + walletId: 'wallet-id', + asset: 'USDC', + amount: '12.5', + recipientAddress: 'GABC', + }); + }); + + it('rejects an agent transaction blocked by an active spending policy', async () => { + const pipe = new AgentBudgetValidationPipe( + { user: { organizationId: 'org-id' } } as never, + { assertWithinBudget: vi.fn() } as unknown as BudgetService, + { + evaluate: vi.fn().mockResolvedValue({ + allowed: false, + reason: 'Daily limit exceeded', + remainingLimit: '0.0000000', + }), + } as unknown as PolicyEvaluatorService, + ); + + await expect( + pipe.transform({ ...transaction, budgetId: undefined, agentId: 'agent-id' }), + ).rejects.toThrow('Daily limit exceeded'); + }); + + it('requires authenticated organization context for a budgeted transaction', async () => { + const pipe = new AgentBudgetValidationPipe( + {} as never, + { assertWithinBudget: vi.fn() } as unknown as BudgetService, + { evaluate: vi.fn() } as unknown as PolicyEvaluatorService, + ); + + await expect(pipe.transform(transaction)).rejects.toBeInstanceOf(UnauthorizedException); + }); +}); diff --git a/src/modules/budgets/pipes/agent-budget-validation.pipe.ts b/src/modules/budgets/pipes/agent-budget-validation.pipe.ts new file mode 100644 index 00000000..5fdafb42 --- /dev/null +++ b/src/modules/budgets/pipes/agent-budget-validation.pipe.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable, PipeTransform, Scope, UnauthorizedException } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; +import { PolicyViolationException } from '../../../common/exceptions/domain.exception'; +import { AuthenticatedUser } from '../../../common/interfaces/authenticated-user.interface'; +import { CreateTransactionInput } from '../../transactions/transaction.dto'; +import { BudgetService } from '../budget.service'; +import { PolicyEvaluatorService } from '../services/policy-evaluator.service'; + +type AuthenticatedRequest = Request & { user?: AuthenticatedUser }; + +/** Checks a transaction's selected budget before governance work begins. */ +@Injectable({ scope: Scope.REQUEST }) +export class AgentBudgetValidationPipe implements PipeTransform { + constructor( + @Inject(REQUEST) private readonly request: AuthenticatedRequest, + private readonly budgets: BudgetService, + private readonly policies: PolicyEvaluatorService, + ) {} + + async transform(value: CreateTransactionInput): Promise { + if (!value.budgetId && !value.agentId) { + return value; + } + + const organizationId = this.request.user?.organizationId; + if (!organizationId) { + throw new UnauthorizedException( + 'An authenticated organization is required for budget validation', + ); + } + + if (value.budgetId) { + await this.budgets.assertWithinBudget(organizationId, value.budgetId, Number(value.amount)); + } + + if (value.agentId) { + const result = await this.policies.evaluate({ + organizationId, + agentId: value.agentId, + walletId: value.walletId, + asset: value.asset, + amount: value.amount, + recipientAddress: value.recipientAddress, + }); + if (!result.allowed) { + throw new PolicyViolationException( + result.reason ?? 'Transaction is blocked by an active spending policy', + { agentId: value.agentId, remainingLimit: result.remainingLimit }, + ); + } + } + + return value; + } +} diff --git a/src/modules/transactions/transaction.controller.ts b/src/modules/transactions/transaction.controller.ts index 774380a7..1710d247 100644 --- a/src/modules/transactions/transaction.controller.ts +++ b/src/modules/transactions/transaction.controller.ts @@ -33,6 +33,7 @@ import { SlidingWindowThrottlerGuard, SlidingWindowLimit, } from '../../common/guards/sliding-window-throttler.guard'; +import { AgentBudgetValidationPipe } from '../budgets/pipes/agent-budget-validation.pipe'; @ApiTags('transactions') @ApiBearerAuth('access-token') @@ -90,7 +91,8 @@ export class TransactionController { }) create( @CurrentUser() user: AuthenticatedUser, - @Body(new ZodValidationPipe(createTransactionSchema)) body: CreateTransactionInput, + @Body(new ZodValidationPipe(createTransactionSchema), AgentBudgetValidationPipe) + body: CreateTransactionInput, ) { const actorId = user.isApiKey ? user.createdById ?? user.id : user.id; return this.transactionService.create(user.organizationId, actorId, body); diff --git a/src/modules/transactions/transaction.service.ts b/src/modules/transactions/transaction.service.ts index 5466a3e9..de14148f 100644 --- a/src/modules/transactions/transaction.service.ts +++ b/src/modules/transactions/transaction.service.ts @@ -121,11 +121,6 @@ export class TransactionService { { actorId }, ); - // 5. Budget headroom (no mutation yet). - if (input.budgetId) { - await this.budgets.assertWithinBudget(organizationId, input.budgetId, amount); - } - const requiresApproval = policyResult.requiresApproval || !assessment.canAutoExecute; // 6. Persist the transaction row. @@ -188,6 +183,10 @@ export class TransactionService { const wallet = await this.wallets.getOrThrow(organizationId, tx.walletId); this.assertWalletSpendable(wallet); + if (tx.budgetId) { + await this.budgets.assertWithinBudget(organizationId, tx.budgetId, Number(tx.amount)); + } + await this.repository.update(tx.id, { status: TransactionStatus.SUBMITTED }); await this.eventBus.emit( DomainEventName.TransactionSubmitted,