From 7b90e7445b2c8b3ba51cdc36dec7e01013d62f44 Mon Sep 17 00:00:00 2001 From: MissBlue00 Date: Fri, 24 Apr 2026 23:13:00 +0100 Subject: [PATCH] feat: enforce auth and state checks for commitment settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ownership validation (403 Forbidden when caller is not owner) - Add read-before-write to validate eligibility - Enforce actor matches owner (session principal) - Handle invalid states (VIOLATED, EARLY_EXIT → 400 BadRequest) - Already settled returns 409 Conflict - Add tests for 401, 403, 409, 400, 200 scenarios --- src/app/api/commitments/[id]/settle/route.ts | 42 +++- tests/api/settle.test.ts | 199 +++++++++++++++++++ 2 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 tests/api/settle.test.ts diff --git a/src/app/api/commitments/[id]/settle/route.ts b/src/app/api/commitments/[id]/settle/route.ts index 4f96fa18..5b9ec7f4 100644 --- a/src/app/api/commitments/[id]/settle/route.ts +++ b/src/app/api/commitments/[id]/settle/route.ts @@ -3,13 +3,13 @@ import { z } from 'zod'; import { checkRateLimit } from '@/lib/backend/rateLimit'; import { withApiHandler } from '@/lib/backend/withApiHandler'; import { ok } from '@/lib/backend/apiResponse'; -import { TooManyRequestsError, ValidationError, NotFoundError, ConflictError } from '@/lib/backend/errors'; -import { settleCommitmentOnChain } from '@/lib/backend/services/contracts'; +import { TooManyRequestsError, ValidationError, NotFoundError, ConflictError, ForbiddenError, UnauthorizedError, BadRequestError } from '@/lib/backend/errors'; +import { settleCommitmentOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; import { logCommitmentSettled } from '@/lib/backend/logger'; // Request validation schema const SettleRequestSchema = z.object({ - callerAddress: z.string().optional(), + callerAddress: z.string().min(5, 'Invalid caller address').optional(), }); interface Params { @@ -35,7 +35,7 @@ export const POST = withApiHandler(async (req: NextRequest, { params }: Params) let body; try { body = await req.json(); - } catch (error) { + } catch { throw new ValidationError('Invalid JSON in request body'); } @@ -46,7 +46,37 @@ export const POST = withApiHandler(async (req: NextRequest, { params }: Params) const { callerAddress } = validation.data; + // Authenticate: callerAddress is required for settlement + if (!callerAddress) { + throw new UnauthorizedError('Authentication required. Provide callerAddress in request body.'); + } + try { + // Read-before-write to validate eligibility + const commitment = await getCommitmentFromChain(id); + + if (!commitment || commitment.status === 'UNKNOWN') { + throw new NotFoundError('Commitment', { commitmentId: id }); + } + + // Enforce actor matches owner (session principal) + if (commitment.ownerAddress && commitment.ownerAddress !== callerAddress) { + throw new ForbiddenError('You do not have permission to settle this commitment.'); + } + + // Handle invalid states - commitment must be in valid state to settle + if (commitment.status === 'SETTLED') { + throw new ConflictError('Commitment has already been settled.'); + } + + if (commitment.status === 'VIOLATED') { + throw new BadRequestError('Commitment has been violated and cannot be settled.'); + } + + if (commitment.status === 'EARLY_EXIT') { + throw new BadRequestError('Commitment has exited early and cannot be settled.'); + } + // Call the settlement function const settlementResult = await settleCommitmentOnChain({ commitmentId: id, @@ -86,7 +116,9 @@ export const POST = withApiHandler(async (req: NextRequest, { params }: Params) if ( error instanceof ValidationError || error instanceof NotFoundError || - error instanceof ConflictError + error instanceof ConflictError || + error instanceof ForbiddenError || + error instanceof UnauthorizedError ) { throw error; } diff --git a/tests/api/settle.test.ts b/tests/api/settle.test.ts new file mode 100644 index 00000000..92e2423f --- /dev/null +++ b/tests/api/settle.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { POST } from '@/app/api/commitments/[id]/settle/route' +import { createMockRequest, parseResponse } from './helpers' +import * as contractsModule from '@/lib/backend/services/contracts' + +vi.mock('@/lib/backend/services/contracts', async () => { + const actual = await vi.importActual('@/lib/backend/services/contracts') + return { + ...actual, + settleCommitmentOnChain: vi.fn(), + getCommitmentFromChain: vi.fn(), + } +}) + +vi.mock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn().mockResolvedValue(true), +})) + +describe('POST /api/commitments/[id]/settle', () => { + const mockCommitmentId = 'test-commitment-123' + const mockOwnerAddress = 'GD5TIP5CKNSV7QZP2FGV6BOB7ZHQG4T4S5R6K4YZJ2MJJQ6XZM4XJQ5Z' + const mockCallerAddress = 'GD5TIP5CKNSV7QZP2FGV6BOB7ZHQG4T4S5R6K4YZJ2MJJQ6XZM4XJQ5Z' + const otherAddress = 'GCZ7Z7K5X5D4X3A2B1C0D9E8F7A6B5C4D3E2F1G0H9I8J7K6L5M4' + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return 200 and settle commitment when authorized owner calls', async () => { + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue({ + id: mockCommitmentId, + ownerAddress: mockOwnerAddress, + status: 'ACTIVE', + expiresAt: '2020-01-01T00:00:00.000Z', + }) + + vi.mocked(contractsModule.settleCommitmentOnChain).mockResolvedValue({ + settlementAmount: '1000.50', + finalStatus: 'SETTLED', + txHash: 'abc123hash', + }) + + const request = createMockRequest( + `http://localhost:3000/api/commitments/${mockCommitmentId}/settle`, + { + method: 'POST', + body: { callerAddress: mockCallerAddress }, + } + ) + + const response = await POST(request, { params: { id: mockCommitmentId } }) + const result = await parseResponse(response) + + expect(result.status).toBe(200) + expect(result.data.success).toBe(true) + expect(result.data.data).toHaveProperty('commitmentId', mockCommitmentId) + expect(result.data.data).toHaveProperty('settlementAmount', '1000.50') + expect(result.data.data).toHaveProperty('finalStatus', 'SETTLED') + }) + + it('should return 401 when callerAddress is not provided', async () => { + const request = createMockRequest( + `http://localhost:3000/api/commitments/${mockCommitmentId}/settle`, + { + method: 'POST', + body: {}, + } + ) + + const response = await POST(request, { params: { id: mockCommitmentId } }) + const result = await parseResponse(response) + + expect(result.status).toBe(401) + expect(result.data.success).toBe(false) + expect(result.data.error).toHaveProperty('code', 'UNAUTHORIZED') + }) + + it('should return 403 when caller is not the owner', async () => { + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue({ + id: mockCommitmentId, + ownerAddress: mockOwnerAddress, + status: 'ACTIVE', + expiresAt: '2020-01-01T00:00:00.000Z', + }) + + const request = createMockRequest( + `http://localhost:3000/api/commitments/${mockCommitmentId}/settle`, + { + method: 'POST', + body: { callerAddress: otherAddress }, + } + ) + + const response = await POST(request, { params: { id: mockCommitmentId } }) + const result = await parseResponse(response) + + expect(result.status).toBe(403) + expect(result.data.success).toBe(false) + expect(result.data.error).toHaveProperty('code', 'FORBIDDEN') + }) + + it('should return 409 when commitment is already settled', async () => { + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue({ + id: mockCommitmentId, + ownerAddress: mockOwnerAddress, + status: 'SETTLED', + expiresAt: '2020-01-01T00:00:00.000Z', + }) + + const request = createMockRequest( + `http://localhost:3000/api/commitments/${mockCommitmentId}/settle`, + { + method: 'POST', + body: { callerAddress: mockCallerAddress }, + } + ) + + const response = await POST(request, { params: { id: mockCommitmentId } }) + const result = await parseResponse(response) + + expect(result.status).toBe(409) + expect(result.data.success).toBe(false) + expect(result.data.error).toHaveProperty('code', 'CONFLICT') + expect(result.data.error.message).toContain('already been settled') + }) + + it('should return 400 when commitment status is VIOLATED', async () => { + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue({ + id: mockCommitmentId, + ownerAddress: mockOwnerAddress, + status: 'VIOLATED', + expiresAt: '2020-01-01T00:00:00.000Z', + }) + + const request = createMockRequest( + `http://localhost:3000/api/commitments/${mockCommitmentId}/settle`, + { + method: 'POST', + body: { callerAddress: mockCallerAddress }, + } + ) + + const response = await POST(request, { params: { id: mockCommitmentId } }) + const result = await parseResponse(response) + + expect(result.status).toBe(400) + expect(result.data.success).toBe(false) + expect(result.data.error).toHaveProperty('code', 'BAD_REQUEST') + expect(result.data.error.message).toContain('violated') + }) + + it('should return 400 when commitment status is EARLY_EXIT', async () => { + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue({ + id: mockCommitmentId, + ownerAddress: mockOwnerAddress, + status: 'EARLY_EXIT', + expiresAt: '2020-01-01T00:00:00.000Z', + }) + + const request = createMockRequest( + `http://localhost:3000/api/commitments/${mockCommitmentId}/settle`, + { + method: 'POST', + body: { callerAddress: mockCallerAddress }, + } + ) + + const response = await POST(request, { params: { id: mockCommitmentId } }) + const result = await parseResponse(response) + + expect(result.status).toBe(400) + expect(result.data.success).toBe(false) + expect(result.data.error).toHaveProperty('code', 'BAD_REQUEST') + expect(result.data.error.message).toContain('early') + }) + + it('should return 404 when commitment not found', async () => { + vi.mocked(contractsModule.getCommitmentFromChain).mockResolvedValue({ + id: mockCommitmentId, + ownerAddress: '', + status: 'UNKNOWN', + } as any) + + const request = createMockRequest( + `http://localhost:3000/api/commitments/${mockCommitmentId}/settle`, + { + method: 'POST', + body: { callerAddress: mockCallerAddress }, + } + ) + + const response = await POST(request, { params: { id: mockCommitmentId } }) + const result = await parseResponse(response) + + expect(result.status).toBe(404) + expect(result.data.success).toBe(false) + expect(result.data.error).toHaveProperty('code', 'NOT_FOUND') + }) +}) \ No newline at end of file