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
37 changes: 37 additions & 0 deletions __tests__/jobs/outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,43 @@ describe('Transactional Outbox', () => {
notifQueue.add = originalAdd;
});

it('should stop retrying after the bounded retry limit is reached', async () => {
const userId = 'user-retry-limit-test';
const notifQueue = (Queue as any).instances['notification-queue'];
const originalAdd = notifQueue.add;
notifQueue.add = async () => {
throw new Error('Retry limit triggered');
};

await db.insert(outboxEvents).values({
id: 'evt-retry-limit',
type: 'notification',
payload: JSON.stringify({
userId,
title: 'Retry Limit',
message: 'This should not retry forever.',
type: 'warning',
}),
status: 'FAILED',
attempts: 3,
lastError: 'Retry limit triggered',
createdAt: new Date(),
});

await processOutbox();

const [dbEvent] = await db
.select()
.from(outboxEvents)
.where(eq(outboxEvents.id, 'evt-retry-limit'));

expect(dbEvent.status).toBe('FAILED');
expect(dbEvent.attempts).toBe(3);
expect(notifQueue.jobs).toHaveLength(0);

notifQueue.add = originalAdd;
});

it('should process jobs through the consumers (workers)', async () => {
const userId = 'user-worker-test';

Expand Down
133 changes: 106 additions & 27 deletions app/api/commitments/[id]/actions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,66 @@
*/

import { NextResponse } from "next/server";
import { getUser } from "@/lib/auth";
import type {
Commitment,
CommitmentActionRequest,
CommitmentActionResponse,
CommitmentStatus,
} from "@/types/commitment";
import { COMMITMENT_STATE_MACHINE } from "@/types/commitment";

const BORROWER_WALLET = "G" + "A".repeat(55);
const LENDER_WALLET = "G" + "B".repeat(55);
const VALID_COMMITMENT_ID = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,63}$/;

const MOCK_COMMITMENTS: Record<string, Commitment> = {
"commitment-123": {
id: "commitment-123",
status: "active",
borrower: BORROWER_WALLET,
lender: LENDER_WALLET,
asset: "XLM",
amount: 10000,
interestRate: 12.5,
duration: 30,
collateralAsset: "USDC",
collateralAmount: 15000,
fundedAmount: 10000,
outstandingDebt: 10104.17,
createdAt: new Date(Date.now() - 86400000 * 5).toISOString(),
updatedAt: new Date(Date.now() - 3600000).toISOString(),
maturityDate: new Date(Date.now() + 86400000 * 25).toISOString(),
transactionHash: "a".repeat(64),
},
"valid-id": {
id: "valid-id",
status: "pending",
borrower: BORROWER_WALLET,
lender: LENDER_WALLET,
asset: "XLM",
amount: 5000,
interestRate: 11.25,
duration: 14,
collateralAsset: "USDC",
collateralAmount: 8000,
fundedAmount: 0,
outstandingDebt: 0,
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 3600000).toISOString(),
maturityDate: new Date(Date.now() + 86400000 * 9).toISOString(),
transactionHash: "b".repeat(64),
},
};

function normalizeCommitmentId(rawId: unknown): string {
return typeof rawId === "string" ? rawId.trim() : "";
}

function isValidCommitmentId(id: string): boolean {
return typeof id === "string" && id.length > 1 && VALID_COMMITMENT_ID.test(id);
}

/**
* Simulate transaction processing delay
*/
Expand All @@ -27,29 +80,63 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const body: CommitmentActionRequest = await request.json();
const user = await getUser();
if (!user || !user.walletAddress) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id: routeId } = await params;
const routeCommitmentId = normalizeCommitmentId(routeId);

const { action } = body;
if (!isValidCommitmentId(routeCommitmentId)) {
return NextResponse.json({ error: "Invalid commitment id" }, { status: 400 });
}

const body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Invalid action payload" }, { status: 400 });
}

const payload = body as Partial<CommitmentActionRequest>;
const action = payload.action;

if (typeof payload.commitmentId !== "string") {
return NextResponse.json({ error: "Invalid action payload" }, { status: 400 });
}

const commitmentId = normalizeCommitmentId(payload.commitmentId);
if (commitmentId !== routeCommitmentId) {
return NextResponse.json({ error: "Commitment id mismatch" }, { status: 400 });
}

if (!action || !["fund", "dispute", "early_exit", "settle"].includes(action)) {
return NextResponse.json(
{
success: false,
error: {
code: "INVALID_ACTION",
message: "Invalid action type",
},
},
{ status: 400 },
);
return NextResponse.json({ error: "Invalid action payload" }, { status: 400 });
}

if (
payload.metadata !== undefined &&
(typeof payload.metadata !== "object" || Array.isArray(payload.metadata) || payload.metadata === null)
) {
return NextResponse.json({ error: "Invalid action payload" }, { status: 400 });
}

if (
payload.signedEnvelopeXdr !== undefined &&
typeof payload.signedEnvelopeXdr !== "string"
) {
return NextResponse.json({ error: "Invalid action payload" }, { status: 400 });
}

const commitment = MOCK_COMMITMENTS[routeCommitmentId] ?? null;
if (!commitment) {
return NextResponse.json({ error: "Commitment not found" }, { status: 404 });
}

// In production, fetch current commitment state from database
// For now, we'll simulate based on the action
const currentStatus: CommitmentStatus = "active"; // Mock current state
if (user.walletAddress !== commitment.borrower && user.walletAddress !== commitment.lender) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

// Validate action is allowed in current state
const currentStatus: CommitmentStatus = commitment.status;
const allowedActions = COMMITMENT_STATE_MACHINE[currentStatus] || [];
if (!allowedActions.includes(action)) {
return NextResponse.json(
Expand All @@ -64,10 +151,8 @@ export async function POST(
);
}

// Simulate transaction processing
await delay(1000 + Math.random() * 2000); // 1-3 second delay
await delay(1000 + Math.random() * 2000);

// Determine new status based on action
let newStatus: CommitmentStatus;
switch (action) {
case "fund":
Expand All @@ -86,15 +171,9 @@ export async function POST(
newStatus = currentStatus;
}

// Generate mock transaction hash
const transactionHash = `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`.padEnd(
64,
"0",
);

const response: CommitmentActionResponse = {
success: true,
transactionHash,
transactionHash: "c".repeat(64),
newStatus,
};

Expand Down
97 changes: 97 additions & 0 deletions app/api/commitments/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from './route';
import { POST } from './actions/route';
import { getUser } from '@/lib/auth';

vi.mock('@/lib/auth', () => ({
getUser: vi.fn(),
}));

const mockGetUser = vi.mocked(getUser);

const borrowerWallet = 'G' + 'A'.repeat(55);
const lenderWallet = 'G' + 'B'.repeat(55);

function makeParams(id: string): { params: Promise<{ id: string }> } {
return { params: Promise.resolve({ id }) };
}

describe('commitment route security boundary', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('requires an authenticated user for GET detail requests', async () => {
mockGetUser.mockResolvedValueOnce(null);

const req = new NextRequest('http://localhost/api/commitments/valid-id');
const res = await GET(req, makeParams('valid-id'));

expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ error: 'Unauthorized' });
});

it('rejects invalid commitment ids before loading mock data', async () => {
mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: borrowerWallet } as any);

const req = new NextRequest('http://localhost/api/commitments/../../admin');
const res = await GET(req, makeParams('../../admin'));

expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: 'Invalid commitment id' });
});

it('forbids access when the authenticated wallet is not a party to the commitment', async () => {
mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: 'G' + 'C'.repeat(55) } as any);

const req = new NextRequest('http://localhost/api/commitments/commitment-123');
const res = await GET(req, makeParams('commitment-123'));

expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ error: 'Forbidden' });
});

it('requires the same commitment id in the action payload as the route id', async () => {
mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: borrowerWallet } as any);

const req = new NextRequest('http://localhost/api/commitments/commitment-123/actions', {
method: 'POST',
body: JSON.stringify({ action: 'dispute', commitmentId: 'other-id' }),
headers: { 'Content-Type': 'application/json' },
});

const res = await POST(req, makeParams('commitment-123'));

expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: 'Commitment id mismatch' });
});

it('rejects malformed action payloads and unauthorized wallets', async () => {
mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: borrowerWallet } as any);

const req = new NextRequest('http://localhost/api/commitments/commitment-123/actions', {
method: 'POST',
body: JSON.stringify({ action: 'dispute', commitmentId: 'commitment-123', metadata: 'not-an-object' }),
headers: { 'Content-Type': 'application/json' },
});

const res = await POST(req, makeParams('commitment-123'));

expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: 'Invalid action payload' });
});

it('returns only safe commitment data for the authenticated wallet', async () => {
mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: borrowerWallet } as any);

const req = new NextRequest('http://localhost/api/commitments/commitment-123');
const res = await GET(req, makeParams('commitment-123'));

expect(res.status).toBe(200);
const data = await res.json();
expect(data.commitment.id).toBe('commitment-123');
expect(data.commitment.borrower).toBe(borrowerWallet);
expect(data.commitment.transactionHash).toMatch(/^[0-9a-fA-F]{64}$/);
});
});
Loading