Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/common/interceptors/audit-log.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
45 changes: 44 additions & 1 deletion src/common/interceptors/response.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
}),
Expand Down Expand Up @@ -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' }],
Expand Down
11 changes: 9 additions & 2 deletions src/modules/budgets/budget.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {}
101 changes: 101 additions & 0 deletions src/modules/budgets/pipes/agent-budget-validation.pipe.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 56 additions & 0 deletions src/modules/budgets/pipes/agent-budget-validation.pipe.ts
Original file line number Diff line number Diff line change
@@ -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<CreateTransactionInput> {
constructor(
@Inject(REQUEST) private readonly request: AuthenticatedRequest,
private readonly budgets: BudgetService,
private readonly policies: PolicyEvaluatorService,
) {}

async transform(value: CreateTransactionInput): Promise<CreateTransactionInput> {
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;
}
}
4 changes: 3 additions & 1 deletion src/modules/transactions/transaction.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 4 additions & 5 deletions src/modules/transactions/transaction.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down