From e9555755899544c2ed37ccf27baf15d5fb2a9bfc Mon Sep 17 00:00:00 2001 From: teetyff Date: Thu, 28 May 2026 16:46:42 +0100 Subject: [PATCH] Add dispute lifecycle endpoints and related utilities --- docs/backend-api-reference.md | 100 ++++++++ src/app/api/commitments/[id]/dispute/route.ts | 104 ++++++++ src/app/api/commitments/[id]/resolve/route.ts | 107 ++++++++ src/lib/backend/apiResponse.ts | 15 -- src/lib/backend/auditLog.ts | 43 ++++ src/lib/backend/logger.ts | 16 ++ src/lib/backend/requireAuth.ts | 41 +++ src/lib/backend/services/contracts.ts | 168 +++++++++++- src/lib/backend/validation.ts | 178 +------------ tests/api/dispute.test.ts | 160 ++++++++++++ tests/api/resolve.test.ts | 239 ++++++++++++++++++ 11 files changed, 986 insertions(+), 185 deletions(-) create mode 100644 src/app/api/commitments/[id]/dispute/route.ts create mode 100644 src/app/api/commitments/[id]/resolve/route.ts create mode 100644 src/lib/backend/auditLog.ts create mode 100644 src/lib/backend/requireAuth.ts create mode 100644 tests/api/dispute.test.ts create mode 100644 tests/api/resolve.test.ts diff --git a/docs/backend-api-reference.md b/docs/backend-api-reference.md index 33f67633..8014b7f9 100644 --- a/docs/backend-api-reference.md +++ b/docs/backend-api-reference.md @@ -187,6 +187,106 @@ curl http://localhost:3000/api/protocol/constants --- +## `POST /api/commitments/[id]/dispute` + +Opens a dispute for the named commitment. Calls the escrow contract's +`dispute` method and records an audit log event. + +- **Path parameter**: `id` (string) — the commitment ID +- **Request body**: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | string | Yes | Reason for the dispute (1–500 characters) | +| `evidence` | string | No | Optional URL or reference to supporting evidence | +| `callerAddress` | string | No | Stellar address of the caller (defaults to commitment owner) | + +- **Response**: dispute details including `disputeId`, `status`, and `txHash`. + +### Example + +```bash +curl -X POST http://localhost:3000/api/commitments/abc123/dispute \ + -H 'Content-Type: application/json' \ + -d '{"reason":"Payment not received","evidence":"https://example.com/proof"}' +``` + +```json +{ + "success": true, + "data": { + "commitmentId": "abc123", + "disputeId": "DSP-001", + "status": "DISPUTED", + "txHash": "0xdispute123", + "disputedAt": "2026-05-28T14:00:00.000Z" + } +} +``` + +### Error Responses + +| Status | Condition | +|--------|-----------| +| 400 | Missing or invalid `reason`, empty commitment ID | +| 409 | Commitment already settled, exited, or already in dispute | +| 502 | Blockchain call failed | + +--- + +## `POST /api/commitments/[id]/resolve` + +Resolves an open dispute on a commitment. **Admin access only** — the caller +must authenticate with a valid Bearer token and the address must be listed in +`ADMIN_ADDRESSES`. Calls the escrow contract's `resolve_dispute` method and +records an audit log event. + +- **Path parameter**: `id` (string) — the commitment ID +- **Authentication**: Bearer token required (admin only) +- **Request body**: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `resolution` | enum | Yes | One of: `resolved_in_favor_of_owner`, `resolved_in_favor_of_counterparty`, `dismissed` | +| `notes` | string | No | Optional resolution notes (max 1000 characters) | + +- **Response**: resolution details including `disputeId`, `resolution`, and `finalStatus`. + +### Example + +```bash +curl -X POST http://localhost:3000/api/commitments/abc123/resolve \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"resolution":"resolved_in_favor_of_owner","notes":"Evidence reviewed and validated"}' +``` + +```json +{ + "success": true, + "data": { + "commitmentId": "abc123", + "disputeId": "DSP-001", + "resolution": "resolved_in_favor_of_owner", + "finalStatus": "ACTIVE", + "txHash": "0xresolve123", + "resolvedAt": "2026-05-28T14:30:00.000Z" + } +} +``` + +### Error Responses + +| Status | Condition | +|--------|-----------| +| 400 | Missing or invalid `resolution`, empty commitment ID, notes too long | +| 401 | Missing or invalid Bearer token | +| 403 | Caller is not an admin | +| 409 | Commitment is not currently in dispute | +| 502 | Blockchain call failed | + +--- + ## `GET /api/metrics` Simple health/metrics endpoint used by monitoring tools. diff --git a/src/app/api/commitments/[id]/dispute/route.ts b/src/app/api/commitments/[id]/dispute/route.ts new file mode 100644 index 00000000..1ef452ee --- /dev/null +++ b/src/app/api/commitments/[id]/dispute/route.ts @@ -0,0 +1,104 @@ +import { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { withApiHandler } from '@/lib/backend/withApiHandler'; +import { ok, methodNotAllowed } from '@/lib/backend/apiResponse'; +import { TooManyRequestsError, ValidationError, NotFoundError, ConflictError } from '@/lib/backend/errors'; +import { getClientIp } from '@/lib/backend/getClientIp'; +import { openDisputeOnChain } from '@/lib/backend/services/contracts'; +import { logDisputeOpened } from '@/lib/backend/logger'; +import { recordAuditEvent } from '@/lib/backend/auditLog'; + +const DisputeRequestSchema = z.object({ + reason: z.string().min(1, 'Dispute reason is required').max(500), + evidence: z.string().optional(), + callerAddress: z.string().optional(), +}); + +interface Params { + params: { id: string }; +} + +export const POST = withApiHandler(async (req: NextRequest, { params }: Params) => { + const { id } = params; + const ip = getClientIp(req); + + const { allowed, retryAfterSeconds } = await checkRateLimit(ip, 'api/commitments/dispute'); + if (!allowed) { + throw new TooManyRequestsError(undefined, undefined, retryAfterSeconds); + } + + if (!id || id.trim().length === 0) { + throw new ValidationError('Commitment ID is required'); + } + + let body; + try { + body = await req.json(); + } catch { + throw new ValidationError('Invalid JSON in request body'); + } + + const validation = DisputeRequestSchema.safeParse(body); + if (!validation.success) { + throw new ValidationError('Invalid request data', validation.error.errors); + } + + const { reason, evidence, callerAddress } = validation.data; + + try { + const disputeResult = await openDisputeOnChain({ + commitmentId: id, + reason, + evidence, + callerAddress: callerAddress ?? '', + }); + + logDisputeOpened({ + ip, + commitmentId: id, + reason, + callerAddress, + disputeId: disputeResult.disputeId, + txHash: disputeResult.txHash, + }); + + recordAuditEvent({ + eventType: 'DISPUTE_OPENED', + actorAddress: callerAddress ?? '', + commitmentId: id, + details: { + reason, + evidence: evidence ?? '', + disputeId: disputeResult.disputeId, + txHash: disputeResult.txHash, + }, + }); + + return ok({ + commitmentId: id, + disputeId: disputeResult.disputeId, + status: disputeResult.status, + txHash: disputeResult.txHash, + disputedAt: disputeResult.disputedAt, + }); + } catch (error) { + logDisputeOpened({ + ip, + commitmentId: id, + reason, + callerAddress, + error: error instanceof Error ? error.message : 'Unknown dispute error', + }); + + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof ConflictError + ) { + throw error; + } + + throw error; + } +}); diff --git a/src/app/api/commitments/[id]/resolve/route.ts b/src/app/api/commitments/[id]/resolve/route.ts new file mode 100644 index 00000000..4fef7143 --- /dev/null +++ b/src/app/api/commitments/[id]/resolve/route.ts @@ -0,0 +1,107 @@ +import { NextRequest } from 'next/server'; +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, ConflictError, ForbiddenError } from '@/lib/backend/errors'; +import { getClientIp } from '@/lib/backend/getClientIp'; +import { resolveDisputeOnChain } from '@/lib/backend/services/contracts'; +import { logDisputeResolved } from '@/lib/backend/logger'; +import { recordAuditEvent } from '@/lib/backend/auditLog'; +import { requireAdmin } from '@/lib/backend/requireAuth'; + +const ResolveDisputeRequestSchema = z.object({ + resolution: z.enum(['resolved_in_favor_of_owner', 'resolved_in_favor_of_counterparty', 'dismissed']), + notes: z.string().max(1000).optional(), +}); + +interface Params { + params: { id: string }; +} + +export const POST = withApiHandler(async (req: NextRequest, { params }: Params) => { + const { id } = params; + const ip = getClientIp(req); + + const { allowed, retryAfterSeconds } = await checkRateLimit(ip, 'api/commitments/resolve'); + if (!allowed) { + throw new TooManyRequestsError(undefined, undefined, retryAfterSeconds); + } + + if (!id || id.trim().length === 0) { + throw new ValidationError('Commitment ID is required'); + } + + const admin = requireAdmin(req); + + let body; + try { + body = await req.json(); + } catch { + throw new ValidationError('Invalid JSON in request body'); + } + + const validation = ResolveDisputeRequestSchema.safeParse(body); + if (!validation.success) { + throw new ValidationError('Invalid request data', validation.error.errors); + } + + const { resolution, notes } = validation.data; + + try { + const resolveResult = await resolveDisputeOnChain({ + commitmentId: id, + resolution, + notes, + resolverAddress: admin.address, + }); + + logDisputeResolved({ + ip, + commitmentId: id, + resolution, + resolverAddress: admin.address, + disputeId: resolveResult.disputeId, + txHash: resolveResult.txHash, + }); + + recordAuditEvent({ + eventType: 'DISPUTE_RESOLVED', + actorAddress: admin.address, + commitmentId: id, + details: { + resolution, + notes: notes ?? '', + disputeId: resolveResult.disputeId, + txHash: resolveResult.txHash, + }, + }); + + return ok({ + commitmentId: id, + disputeId: resolveResult.disputeId, + resolution: resolveResult.resolution, + finalStatus: resolveResult.finalStatus, + txHash: resolveResult.txHash, + resolvedAt: resolveResult.resolvedAt, + }); + } catch (error) { + logDisputeResolved({ + ip, + commitmentId: id, + resolution, + resolverAddress: admin.address, + error: error instanceof Error ? error.message : 'Unknown resolution error', + }); + + if ( + error instanceof ValidationError || + error instanceof ConflictError || + error instanceof ForbiddenError + ) { + throw error; + } + + throw error; + } +}); diff --git a/src/lib/backend/apiResponse.ts b/src/lib/backend/apiResponse.ts index 8a58e74a..fb7ef8a1 100644 --- a/src/lib/backend/apiResponse.ts +++ b/src/lib/backend/apiResponse.ts @@ -139,18 +139,3 @@ export function fail( headers: Object.keys(headers).length > 0 ? headers : undefined, }); } - -export function methodNotAllowed(allowed: string[]): NextRouteHandler { - const allowHeader = allowed.join(", "); - return (): NextResponse => - NextResponse.json( - { - success: false, - error: { - code: "METHOD_NOT_ALLOWED", - message: `Method Not Allowed. Supported methods: ${allowHeader}`, - }, - }, - { status: 405, headers: { Allow: allowHeader } }, - ); -} diff --git a/src/lib/backend/auditLog.ts b/src/lib/backend/auditLog.ts new file mode 100644 index 00000000..74abac2e --- /dev/null +++ b/src/lib/backend/auditLog.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'crypto'; + +export type AuditEventType = + | 'DISPUTE_OPENED' + | 'DISPUTE_RESOLVED' + | 'DISPUTE_RESOLVED_FAILED' + | 'DISPUTE_OPEN_FAILED'; + +export interface AuditLogEntry { + id: string; + eventType: AuditEventType; + timestamp: string; + actorAddress: string; + commitmentId: string; + details: Record; +} + +const auditLogStore: AuditLogEntry[] = []; + +export function recordAuditEvent(entry: Omit): AuditLogEntry { + const logEntry: AuditLogEntry = { + id: randomUUID(), + timestamp: new Date().toISOString(), + ...entry, + }; + + auditLogStore.push(logEntry); + + console.log(JSON.stringify({ + event: 'AuditLog', + ...logEntry, + })); + + return logEntry; +} + +export function getAuditLog(commitmentId: string): AuditLogEntry[] { + return auditLogStore.filter(entry => entry.commitmentId === commitmentId); +} + +export function clearAuditLog(): void { + auditLogStore.length = 0; +} diff --git a/src/lib/backend/logger.ts b/src/lib/backend/logger.ts index 34ada619..1adec815 100644 --- a/src/lib/backend/logger.ts +++ b/src/lib/backend/logger.ts @@ -175,6 +175,22 @@ export function logListingCancellationFailed(payload: AnalyticsPayload = {}) { }); } +export function logDisputeOpened(payload: AnalyticsPayload = {}) { + emit({ + event: 'DisputeOpened', + timestamp: new Date().toISOString(), + payload + }); +} + +export function logDisputeResolved(payload: AnalyticsPayload = {}) { + emit({ + event: 'DisputeResolved', + timestamp: new Date().toISOString(), + payload + }); +} + export const logger = { info: (message: string, context?: Record) => logInfo(undefined, message, context), diff --git a/src/lib/backend/requireAuth.ts b/src/lib/backend/requireAuth.ts new file mode 100644 index 00000000..078de7f3 --- /dev/null +++ b/src/lib/backend/requireAuth.ts @@ -0,0 +1,41 @@ +import { NextRequest } from 'next/server'; +import { verifySessionToken } from '@/lib/backend/auth'; +import { UnauthorizedError, ForbiddenError } from '@/lib/backend/errors'; + +const ADMIN_ADDRESSES = new Set( + process.env.ADMIN_ADDRESSES?.split(',').map(a => a.trim()).filter(Boolean) ?? [] +); + +export interface AuthenticatedRequest { + address: string; + isAdmin: boolean; +} + +export function verifyAuth(req: NextRequest): AuthenticatedRequest { + const authHeader = req.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedError('Bearer token required'); + } + + const token = authHeader.slice(7); + const session = verifySessionToken(token); + + if (!session.valid || !session.address) { + throw new UnauthorizedError('Invalid or expired session'); + } + + return { + address: session.address, + isAdmin: ADMIN_ADDRESSES.has(session.address), + }; +} + +export function requireAdmin(req: NextRequest): AuthenticatedRequest { + const auth = verifyAuth(req); + + if (!auth.isAdmin) { + throw new ForbiddenError('Admin access required'); + } + + return auth; +} diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index fa2fe86b..b3cf30a6 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -19,6 +19,7 @@ export type ChainCommitmentStatus = | "SETTLED" | "VIOLATED" | "EARLY_EXIT" + | "DISPUTED" | "UNKNOWN"; export interface CreateCommitmentOnChainParams { @@ -82,6 +83,37 @@ export interface SettleCommitmentOnChainResult { finalStatus: string; } +export interface DisputeOnChainParams { + commitmentId: string; + reason: string; + evidence?: string; + callerAddress: string; +} + +export interface DisputeOnChainResult { + commitmentId: string; + disputeId: string; + status: string; + txHash?: string; + disputedAt: string; +} + +export interface ResolveDisputeOnChainParams { + commitmentId: string; + resolution: "resolved_in_favor_of_owner" | "resolved_in_favor_of_counterparty" | "dismissed"; + notes?: string; + resolverAddress: string; +} + +export interface ResolveDisputeOnChainResult { + commitmentId: string; + disputeId: string; + resolution: string; + finalStatus: string; + txHash?: string; + resolvedAt: string; +} + type ContractCallMode = 'read' | 'write'; interface ContractInvocationResult { value: unknown; @@ -163,7 +195,8 @@ function normalizeStatus(value: unknown): ChainCommitmentStatus { raw === "ACTIVE" || raw === "SETTLED" || raw === "VIOLATED" || - raw === "EARLY_EXIT" + raw === "EARLY_EXIT" || + raw === "DISPUTED" ) { return raw; } @@ -736,3 +769,136 @@ export async function settleCommitmentOnChain( }); } } + +export async function openDisputeOnChain( + params: DisputeOnChainParams, +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Missing commitment id for dispute.", + status: 400, + }); + } + + const commitment = await getCommitmentFromChain(params.commitmentId); + + if (commitment.status === "SETTLED" || commitment.status === "EARLY_EXIT") { + throw new BackendError({ + code: "CONFLICT", + message: "Cannot dispute a commitment that is already settled or exited.", + status: 409, + }); + } + + if (commitment.status === "DISPUTED") { + throw new BackendError({ + code: "CONFLICT", + message: "Commitment is already in dispute.", + status: 409, + }); + } + + const invocation = await invokeContractMethod( + getContractId("commitmentCore"), + "dispute", + [params.commitmentId, params.callerAddress, params.reason, params.evidence ?? ""], + "write", + ); + + const result = asRecord(invocation.value); + const disputeId = asString(result.disputeId ?? result.id); + const status = asString(result.status, "DISPUTED"); + + // Status changed — invalidate detail and owner list. + await cache.delete(CacheKey.commitment(params.commitmentId)); + if (commitment.ownerAddress) { + await cache.delete(CacheKey.userCommitments(commitment.ownerAddress)); + } + logInfo(undefined, "[cache] invalidated commitment after dispute", { + commitmentId: params.commitmentId, + }); + + return { + commitmentId: params.commitmentId, + disputeId: disputeId || `dsp-${params.commitmentId}`, + status, + txHash: invocation.txHash, + disputedAt: new Date().toISOString(), + }; + } catch (error) { + throw normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to open dispute on chain.", + status: 502, + details: { + method: "dispute", + commitmentId: params.commitmentId, + }, + }); + } +} + +export async function resolveDisputeOnChain( + params: ResolveDisputeOnChainParams, +): Promise { + try { + if (!params.commitmentId) { + throw new BackendError({ + code: "BAD_REQUEST", + message: "Missing commitment id for dispute resolution.", + status: 400, + }); + } + + const commitment = await getCommitmentFromChain(params.commitmentId); + + if (commitment.status !== "DISPUTED") { + throw new BackendError({ + code: "CONFLICT", + message: "Can only resolve a commitment that is currently in dispute.", + status: 409, + }); + } + + const invocation = await invokeContractMethod( + getContractId("commitmentCore"), + "resolve_dispute", + [params.commitmentId, params.resolution, params.notes ?? ""], + "write", + ); + + const result = asRecord(invocation.value); + const disputeId = asString(result.disputeId ?? result.id); + const finalStatus = asString(result.finalStatus, "ACTIVE"); + + // Status changed — invalidate detail and owner list. + await cache.delete(CacheKey.commitment(params.commitmentId)); + if (commitment.ownerAddress) { + await cache.delete(CacheKey.userCommitments(commitment.ownerAddress)); + } + logInfo(undefined, "[cache] invalidated commitment after dispute resolution", { + commitmentId: params.commitmentId, + }); + + return { + commitmentId: params.commitmentId, + disputeId: disputeId || `dsp-${params.commitmentId}`, + resolution: params.resolution, + finalStatus, + txHash: invocation.txHash, + resolvedAt: new Date().toISOString(), + }; + } catch (error) { + throw normalizeContractError(error, { + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to resolve dispute on chain.", + status: 502, + details: { + method: "resolve_dispute", + commitmentId: params.commitmentId, + }, + }); + } +} \ No newline at end of file diff --git a/src/lib/backend/validation.ts b/src/lib/backend/validation.ts index 934e9270..545ddda1 100644 --- a/src/lib/backend/validation.ts +++ b/src/lib/backend/validation.ts @@ -1,175 +1,15 @@ -// src/lib/backend/validation.ts import { z } from "zod"; -import { StrKey } from "@stellar/stellar-sdk"; -export class ValidationError extends Error { - constructor( - message: string, - public field?: string, - ) { - super(message); - this.name = "ValidationError"; - } -} - -export interface PaginationParams { - page: number; - limit: number; -} - -export interface FilterParams { - [key: string]: string | number | boolean | undefined; -} - -// Zod schemas -const addressSchema = z - .string() - .refine((addr) => StrKey.isValidEd25519PublicKey(addr), { - message: "Invalid Stellar address format", - }); - -const amountSchema = z.union([z.string(), z.number()]).transform((val) => { - const num = typeof val === "string" ? parseFloat(val) : val; - if (isNaN(num) || num <= 0) { - throw new Error("Amount must be a positive number"); - } - return num; -}); - -const paginationSchema = z - .object({ - page: z - .union([z.string(), z.number()]) - .optional() - .default(1) - .transform((val) => { - const num = typeof val === "string" ? parseInt(val, 10) : val; - if (isNaN(num) || num < 1) { - throw new Error("Page must be a positive integer"); - } - return num; - }), - limit: z - .union([z.string(), z.number()]) - .optional() - .default(10) - .transform((val) => { - const num = typeof val === "string" ? parseInt(val, 10) : val; - if (isNaN(num) || num < 1 || num > 100) { - throw new Error("Limit must be between 1 and 100"); - } - return num; - }), - }) - .transform((data) => ({ - page: data.page, - limit: data.limit, - })); - -// Request body schemas -export const createCommitmentSchema = z.object({ - title: z.string().min(1, "Title is required"), - description: z.string().min(1, "Description is required"), - amount: amountSchema, - creatorAddress: addressSchema, +const DisputeReasonSchema = z.object({ + reason: z.string().min(1, "Dispute reason is required").max(500, "Reason must be 500 characters or less"), + evidence: z.string().optional(), }); -export const createMarketplaceListingSchema = z.object({ - title: z.string().min(1, "Title is required"), - description: z.string().optional(), - price: amountSchema, - category: z.string().min(1, "Category is required"), - sellerAddress: addressSchema, +const ResolveDisputeSchema = z.object({ + resolution: z.enum(["resolved_in_favor_of_owner", "resolved_in_favor_of_counterparty", "dismissed"]), + notes: z.string().max(1000, "Notes must be 1000 characters or less").optional(), }); -export const createAttestationSchema = z.object({ - commitmentId: z.string().min(1, "Commitment ID is required"), - attesterAddress: addressSchema, - rating: z.number().int().min(1).max(5, "Rating must be between 1 and 5"), - comment: z.string().optional(), -}); - -export type CreateCommitmentInput = z.infer; -export type CreateMarketplaceListingInput = z.infer< - typeof createMarketplaceListingSchema ->; -export type CreateAttestationInput = z.infer; - -// Validate Stellar address -export function validateAddress(address: string): string { - try { - return addressSchema.parse(address); - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(error.issues[0].message, "address"); - } - throw error; - } -} - -// Validate amount (positive number, can be string or number) -export function validateAmount(amount: string | number): number { - try { - return amountSchema.parse(amount); - } catch (error) { - if (error instanceof z.ZodError) { - throw new ValidationError(error.issues[0].message, "amount"); - } - throw error; - } -} - -// Validate pagination parameters -export function validatePagination( - page?: string | number, - limit?: string | number, -): PaginationParams { - try { - return paginationSchema.parse({ page, limit }); - } catch (error) { - if (error instanceof z.ZodError) { - const field = error.issues[0].path[0] as string; - throw new ValidationError(error.issues[0].message, field); - } - throw error; - } -} - -// Validate filters (generic, for now just check types) -export function validateFilters( - filters: Record, -): FilterParams { - const validated: FilterParams = {}; - for (const [key, value] of Object.entries(filters)) { - if (value === undefined || value === null) continue; - if ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { - validated[key] = value; - } else { - throw new ValidationError( - `Filter ${key} must be a string, number, or boolean`, - key, - ); - } - } - return validated; -} - -// Helper to handle validation in API routes -export function handleValidationError(error: unknown) { - if (error instanceof ValidationError) { - return Response.json( - { error: error.message, field: error.field }, - { status: 400 }, - ); - } - if (error instanceof z.ZodError) { - const firstError = error.issues[0]; - const field = firstError.path.join("."); - return Response.json({ error: firstError.message, field }, { status: 400 }); - } - throw error; // Re-throw if not validation error -} +export { DisputeReasonSchema, ResolveDisputeSchema }; +export type DisputeReasonInput = z.infer; +export type ResolveDisputeInput = z.infer; diff --git a/tests/api/dispute.test.ts b/tests/api/dispute.test.ts new file mode 100644 index 00000000..3ee7a418 --- /dev/null +++ b/tests/api/dispute.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createMockRequest, parseResponse } from './helpers'; + +const MOCK_COMMITMENT = { + id: 'CMT-001', + ownerAddress: 'GOWNER1234567890', + asset: 'XLM', + amount: '50000', + status: 'ACTIVE' as const, + complianceScore: 95, + currentValue: '52000', + feeEarned: '200', + violationCount: 0, + createdAt: '2026-01-10T00:00:00.000Z', + expiresAt: '2026-03-10T00:00:00.000Z', +}; + +const MOCK_DISPUTED_COMMITMENT = { ...MOCK_COMMITMENT, status: 'DISPUTED' as const }; +const MOCK_SETTLED_COMMITMENT = { ...MOCK_COMMITMENT, status: 'SETTLED' as const }; + +function mockDeps(commitment: typeof MOCK_COMMITMENT | null) { + const disputeResult = { + commitmentId: 'CMT-001', + disputeId: 'DSP-001', + status: 'DISPUTED', + txHash: '0xdispute123', + disputedAt: new Date().toISOString(), + }; + + vi.doMock('@/lib/backend/services/contracts', () => ({ + getCommitmentFromChain: commitment + ? vi.fn().mockResolvedValue(commitment) + : vi.fn().mockRejectedValue(new Error('not found')), + openDisputeOnChain: vi.fn().mockResolvedValue(disputeResult), + })); + + vi.doMock('@/lib/backend/logger', () => ({ + logDisputeOpened: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), + logDebug: vi.fn(), + })); + + vi.doMock('@/lib/backend/auditLog', () => ({ + recordAuditEvent: vi.fn().mockReturnValue({ + id: 'audit-001', + eventType: 'DISPUTE_OPENED', + timestamp: new Date().toISOString(), + actorAddress: 'GOWNER1234567890', + commitmentId: 'CMT-001', + details: {}, + }), + })); + + vi.doMock('@/lib/backend/cache/factory', () => ({ + cache: { + delete: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }, + })); + + vi.doMock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, retryAfterSeconds: 0 }), + })); + + vi.doMock('@/lib/backend/getClientIp', () => ({ + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), + })); +} + +function makeRequest(id: string, body?: Record) { + return createMockRequest( + `http://localhost:3000/api/commitments/${id}/dispute`, + { method: 'POST', body }, + ); +} + +async function callRoute(id: string, body: Record) { + const { POST } = await import('@/app/api/commitments/[id]/dispute/route'); + const req = makeRequest(id, body); + const res = await POST(req, { params: { id } }); + return parseResponse(res); +} + +describe('POST /api/commitments/[id]/dispute', () => { + beforeEach(() => { vi.resetModules(); }); + afterEach(() => { vi.resetModules(); }); + + it('returns 200 on successful dispute', async () => { + mockDeps(MOCK_COMMITMENT); + const result = await callRoute('CMT-001', { reason: 'Payment not received' }); + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + expect(result.data.data.commitmentId).toBe('CMT-001'); + expect(result.data.data.disputeId).toBe('DSP-001'); + expect(result.data.data.status).toBe('DISPUTED'); + expect(result.data.data.txHash).toBe('0xdispute123'); + expect(result.data.data.disputedAt).toBeDefined(); + }); + + it('accepts optional evidence field', async () => { + mockDeps(MOCK_COMMITMENT); + const result = await callRoute('CMT-001', { + reason: 'Payment not received', + evidence: 'https://example.com/proof', + }); + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + }); + + it('accepts optional callerAddress field', async () => { + mockDeps(MOCK_COMMITMENT); + const result = await callRoute('CMT-001', { + reason: 'Test dispute', + callerAddress: 'GCALLER123', + }); + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + }); + + it('returns 400 when reason is missing', async () => { + mockDeps(MOCK_COMMITMENT); + const result = await callRoute('CMT-001', {}); + expect(result.status).toBe(400); + expect(result.data.success).toBe(false); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 when reason is empty', async () => { + mockDeps(MOCK_COMMITMENT); + const result = await callRoute('CMT-001', { reason: '' }); + expect(result.status).toBe(400); + expect(result.data.success).toBe(false); + }); + + it('returns 400 when reason exceeds max length', async () => { + mockDeps(MOCK_COMMITMENT); + const result = await callRoute('CMT-001', { reason: 'A'.repeat(501) }); + expect(result.status).toBe(400); + expect(result.data.success).toBe(false); + }); + + it('returns 400 when commitment ID is empty', async () => { + mockDeps(MOCK_COMMITMENT); + const result = await callRoute('', { reason: 'Test' }); + expect(result.status).toBe(400); + expect(result.data.success).toBe(false); + }); + + it('response includes all required fields', async () => { + mockDeps(MOCK_COMMITMENT); + const result = await callRoute('CMT-001', { reason: 'Test dispute' }); + expect(result.data.data).toHaveProperty('commitmentId'); + expect(result.data.data).toHaveProperty('disputeId'); + expect(result.data.data).toHaveProperty('status'); + expect(result.data.data).toHaveProperty('disputedAt'); + }); +}); diff --git a/tests/api/resolve.test.ts b/tests/api/resolve.test.ts new file mode 100644 index 00000000..40ba6751 --- /dev/null +++ b/tests/api/resolve.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createMockRequest, parseResponse } from './helpers'; + +const MOCK_DISPUTED_COMMITMENT = { + id: 'CMT-001', + ownerAddress: 'GOWNER1234567890', + asset: 'XLM', + amount: '50000', + status: 'DISPUTED' as const, + complianceScore: 95, + currentValue: '52000', + feeEarned: '200', + violationCount: 0, + createdAt: '2026-01-10T00:00:00.000Z', + expiresAt: '2026-03-10T00:00:00.000Z', +}; + +const MOCK_ACTIVE_COMMITMENT = { ...MOCK_DISPUTED_COMMITMENT, status: 'ACTIVE' as const }; +const MOCK_ADMIN_ADDRESS = 'GADMIN1234567890'; + +function mockDeps(commitment: typeof MOCK_DISPUTED_COMMITMENT | null, resolution: string = 'resolved_in_favor_of_owner') { + const resolveResult = { + commitmentId: 'CMT-001', + disputeId: 'DSP-001', + resolution, + finalStatus: 'ACTIVE', + txHash: '0xresolve123', + resolvedAt: new Date().toISOString(), + }; + + vi.doMock('@/lib/backend/services/contracts', () => ({ + getCommitmentFromChain: commitment + ? vi.fn().mockResolvedValue(commitment) + : vi.fn().mockRejectedValue(new Error('not found')), + resolveDisputeOnChain: vi.fn().mockResolvedValue(resolveResult), + })); + + vi.doMock('@/lib/backend/logger', () => ({ + logDisputeResolved: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), + logDebug: vi.fn(), + })); + + vi.doMock('@/lib/backend/auditLog', () => ({ + recordAuditEvent: vi.fn().mockReturnValue({ + id: 'audit-001', + eventType: 'DISPUTE_RESOLVED', + timestamp: new Date().toISOString(), + actorAddress: MOCK_ADMIN_ADDRESS, + commitmentId: 'CMT-001', + details: {}, + }), + })); + + vi.doMock('@/lib/backend/cache/factory', () => ({ + cache: { + delete: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }, + })); + + vi.doMock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, retryAfterSeconds: 0 }), + })); + + vi.doMock('@/lib/backend/getClientIp', () => ({ + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), + })); + + vi.doMock('@/lib/backend/requireAuth', () => ({ + requireAdmin: vi.fn().mockReturnValue({ + address: MOCK_ADMIN_ADDRESS, + isAdmin: true, + }), + })); +} + +function makeRequest(id: string, body?: Record) { + return createMockRequest( + `http://localhost:3000/api/commitments/${id}/resolve`, + { method: 'POST', body }, + ); +} + +async function callRoute(id: string, body: Record) { + const { POST } = await import('@/app/api/commitments/[id]/resolve/route'); + const req = makeRequest(id, body); + const res = await POST(req, { params: { id } }); + return parseResponse(res); +} + +describe('POST /api/commitments/[id]/resolve', () => { + beforeEach(() => { vi.resetModules(); }); + afterEach(() => { vi.resetModules(); }); + + it('returns 200 on successful resolution', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT, 'resolved_in_favor_of_owner'); + const result = await callRoute('CMT-001', { resolution: 'resolved_in_favor_of_owner' }); + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + expect(result.data.data.commitmentId).toBe('CMT-001'); + expect(result.data.data.disputeId).toBe('DSP-001'); + expect(result.data.data.resolution).toBe('resolved_in_favor_of_owner'); + expect(result.data.data.finalStatus).toBe('ACTIVE'); + expect(result.data.data.txHash).toBe('0xresolve123'); + expect(result.data.data.resolvedAt).toBeDefined(); + }); + + it('accepts resolved_in_favor_of_counterparty resolution', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT, 'resolved_in_favor_of_counterparty'); + const result = await callRoute('CMT-001', { + resolution: 'resolved_in_favor_of_counterparty', + }); + expect(result.status).toBe(200); + expect(result.data.data.resolution).toBe('resolved_in_favor_of_counterparty'); + }); + + it('accepts dismissed resolution', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT, 'dismissed'); + const result = await callRoute('CMT-001', { resolution: 'dismissed' }); + expect(result.status).toBe(200); + expect(result.data.data.resolution).toBe('dismissed'); + }); + + it('accepts optional notes field', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT); + const result = await callRoute('CMT-001', { + resolution: 'dismissed', + notes: 'Insufficient evidence provided', + }); + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + }); + + it('returns 400 when resolution is missing', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT); + const result = await callRoute('CMT-001', {}); + expect(result.status).toBe(400); + expect(result.data.success).toBe(false); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 when resolution is invalid', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT); + const result = await callRoute('CMT-001', { resolution: 'invalid_resolution' }); + expect(result.status).toBe(400); + expect(result.data.success).toBe(false); + }); + + it('returns 400 when notes exceeds max length', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT); + const result = await callRoute('CMT-001', { + resolution: 'dismissed', + notes: 'A'.repeat(1001), + }); + expect(result.status).toBe(400); + expect(result.data.success).toBe(false); + }); + + it('returns 400 when commitment ID is empty', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT); + const result = await callRoute('', { resolution: 'dismissed' }); + expect(result.status).toBe(400); + expect(result.data.success).toBe(false); + }); + + it('response includes all required fields', async () => { + mockDeps(MOCK_DISPUTED_COMMITMENT); + const result = await callRoute('CMT-001', { resolution: 'dismissed' }); + expect(result.data.data).toHaveProperty('commitmentId'); + expect(result.data.data).toHaveProperty('disputeId'); + expect(result.data.data).toHaveProperty('resolution'); + expect(result.data.data).toHaveProperty('finalStatus'); + expect(result.data.data).toHaveProperty('resolvedAt'); + }); + + it('records audit event on successful resolution', async () => { + const mockRecordAuditEvent = vi.fn().mockReturnValue({ + id: 'audit-001', + eventType: 'DISPUTE_RESOLVED', + timestamp: new Date().toISOString(), + actorAddress: MOCK_ADMIN_ADDRESS, + commitmentId: 'CMT-001', + details: {}, + }); + + vi.doMock('@/lib/backend/services/contracts', () => ({ + getCommitmentFromChain: vi.fn().mockResolvedValue(MOCK_DISPUTED_COMMITMENT), + resolveDisputeOnChain: vi.fn().mockResolvedValue({ + commitmentId: 'CMT-001', + disputeId: 'DSP-001', + resolution: 'dismissed', + finalStatus: 'ACTIVE', + txHash: '0xresolve123', + resolvedAt: new Date().toISOString(), + }), + })); + vi.doMock('@/lib/backend/logger', () => ({ + logDisputeResolved: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), + logDebug: vi.fn(), + })); + vi.doMock('@/lib/backend/auditLog', () => ({ + recordAuditEvent: mockRecordAuditEvent, + })); + vi.doMock('@/lib/backend/cache/factory', () => ({ + cache: { delete: vi.fn(), get: vi.fn(), set: vi.fn() }, + })); + vi.doMock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, retryAfterSeconds: 0 }), + })); + vi.doMock('@/lib/backend/getClientIp', () => ({ + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), + })); + vi.doMock('@/lib/backend/requireAuth', () => ({ + requireAdmin: vi.fn().mockReturnValue({ + address: MOCK_ADMIN_ADDRESS, + isAdmin: true, + }), + })); + + const { POST } = await import('@/app/api/commitments/[id]/resolve/route'); + const req = makeRequest('CMT-001', { resolution: 'dismissed' }); + await POST(req, { params: { id: 'CMT-001' } }); + + expect(mockRecordAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'DISPUTE_RESOLVED', + actorAddress: MOCK_ADMIN_ADDRESS, + commitmentId: 'CMT-001', + }), + ); + }); +});