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
12 changes: 3 additions & 9 deletions __tests__/lib/stellar/pool-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe("generatePoolAssetCode", () => {
const poolId = "507f1f77bcf86cd799439abc"
const assetCode = generatePoolAssetCode(poolId, "KEKE")

expect(assetCode).toBe("KEKE9439ABC")
expect(assetCode).toBe("KEKE439ABC")
expect(assetCode).toEqual(assetCode.toUpperCase())
})
})
Expand Down Expand Up @@ -110,8 +110,8 @@ describe("validateAssetCode", () => {

describe("createPoolAsset", () => {
const mockPoolId = "507f1f77bcf86cd799439011"
const mockIssuerKey = "GAJVUHQV5ZBQ6XMVLXL4QHXFZVBVBXM4PEX2XCZMN7H6VWLJDP3I6YHX"
const mockDistributionKey = "GBVXSZQHQJLVH7QKGVBR2XDVJ6VJM4LKXNQVHXZBXVJ7CKZW5QJVHXZB"
const mockIssuerKey = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H"
const mockDistributionKey = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA"

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -165,9 +165,7 @@ describe("createPoolAsset", () => {
issuerPublicKey: mockIssuerKey,
distributionPublicKey: mockDistributionKey,
contractId: "",
explorerBaseUrl: "https://stellar.expert/explorer/testnet",
mock: false,
demoPublicKey: "DEMO",
})

const result = await createPoolAsset({
Expand Down Expand Up @@ -234,9 +232,7 @@ describe("createPoolAsset", () => {
issuerPublicKey: "",
distributionPublicKey: mockDistributionKey,
contractId: "",
explorerBaseUrl: "https://stellar.expert/explorer/testnet",
mock: false,
demoPublicKey: "DEMO",
})

await expect(createPoolAsset({ poolId: mockPoolId })).rejects.toThrow(
Expand Down Expand Up @@ -266,9 +262,7 @@ describe("createPoolAsset", () => {
issuerPublicKey: mockIssuerKey,
distributionPublicKey: "",
contractId: "",
explorerBaseUrl: "https://stellar.expert/explorer/testnet",
mock: false,
demoPublicKey: "DEMO",
})

await expect(createPoolAsset({ poolId: mockPoolId })).rejects.toThrow(
Expand Down
7 changes: 3 additions & 4 deletions app/api/admin/kyc-requests/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { NextResponse } from "next/server"

import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard"
import { finalizeAuthenticatedResponse } from "@/lib/api/route-guard"
import { authorizeRequest } from "@/lib/authorization/route"
import dbConnect from "@/lib/dbConnect"
import User from "@/models/User"

export async function GET(request: Request) {
try {
await dbConnect()

const authContext = await requireAuthenticatedUser(request, ["admin"], {
forbiddenMessage: "Admin access required",
})
const authContext = await authorizeRequest(request, "kyc:review", { type: "kyc" })
if ("response" in authContext) return authContext.response

const kycRequests = await User.find({ role: { $in: ["driver", "investor"] } })
Expand Down
13 changes: 5 additions & 8 deletions app/api/admin/reports/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import mongoose from "mongoose"
import { NextResponse } from "next/server"

import dbConnect from "@/lib/dbConnect"
import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user"
import { withSessionRefresh } from "@/lib/auth/current-user"
import { authorizeRequest } from "@/lib/authorization/route"
import DriverPayment from "@/models/DriverPayment"
import HirePurchaseContract from "@/models/HirePurchaseContract"
import Investment from "@/models/Investment"
Expand Down Expand Up @@ -85,13 +86,9 @@ function collectObjectIds(values: unknown[]) {

export async function GET(request: Request) {
try {
const { user, shouldRefreshSession } = await getAuthenticatedUser(request)
if (!user) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (user.role !== "admin") {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const auth = await authorizeRequest(request, "admin:report", { type: "report" })
if ("response" in auth) return auth.response
const { user, shouldRefreshSession } = auth

await dbConnect()

Expand Down
9 changes: 5 additions & 4 deletions app/api/admin/users/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"

import dbConnect from "@/lib/dbConnect"
import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user"
import { withSessionRefresh } from "@/lib/auth/current-user"
import { authorizeRequest } from "@/lib/authorization/route"
import User from "@/models/User"

function csvEscape(value: unknown): string {
Expand All @@ -14,9 +15,9 @@ function csvEscape(value: unknown): string {

export async function GET(request: Request) {
try {
const { user, shouldRefreshSession } = await getAuthenticatedUser(request)
if (!user) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
if (user.role !== "admin") return NextResponse.json({ message: "Forbidden" }, { status: 403 })
const auth = await authorizeRequest(request, "admin:report", { type: "report" })
if ("response" in auth) return auth.response
const { user, shouldRefreshSession } = auth

await dbConnect()

Expand Down
25 changes: 13 additions & 12 deletions app/api/driver/payments/initialize/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server"

import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user"
import { withSessionRefresh } from "@/lib/auth/current-user"
import { authorizeRequest } from "@/lib/authorization/route"
import dbConnect from "@/lib/dbConnect"
import { createDriverPayment, markDriverPaymentFailed } from "@/lib/services/driver-contracts.service"
import HirePurchaseContract from "@/models/HirePurchaseContract"

function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
Expand All @@ -19,25 +22,23 @@ export async function POST(request: Request) {
}

try {
const { user, shouldRefreshSession } = await getAuthenticatedUser(request)
if (!user) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}

if (user.role !== "driver") {
return NextResponse.json({ message: "Only drivers can initialize repayments." }, { status: 403 })
}

await dbConnect()
const body = await request.json().catch(() => ({}))
const contractId = typeof body.contractId === "string" ? body.contractId.trim() : ""
const amountNgn = Number(body.amountNgn ?? body.amount)
const providedEmail = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""
const payerEmail = (providedEmail || user.email || "").trim().toLowerCase()

if (!contractId) {
return NextResponse.json({ message: "Contract ID is required." }, { status: 400 })
}

const contract = await HirePurchaseContract.findById(contractId).select("driverUserId status").lean()
const auth = await authorizeRequest(request, "repayment:record", {
type: "repayment", ownerId: contract?.driverUserId?.toString(), state: contract?.status, exists: Boolean(contract),
})
if ("response" in auth) return auth.response
const { user, shouldRefreshSession } = auth
const payerEmail = (user.email || "").trim().toLowerCase()

if (!Number.isFinite(amountNgn) || amountNgn <= 0) {
return NextResponse.json({ message: "A valid amount is required." }, { status: 400 })
}
Expand Down
19 changes: 9 additions & 10 deletions app/api/investments/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"
import { z } from "zod"

import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard"
import { finalizeAuthenticatedResponse } from "@/lib/api/route-guard"
import { authorizeRequest } from "@/lib/authorization/route"
import { parseSearchParams } from "@/lib/api/validation"
import dbConnect from "@/lib/dbConnect"
import Investment from "@/models/Investment"
Expand All @@ -12,20 +13,18 @@ const querySchema = z.object({

export async function GET(request: Request) {
try {
const authContext = await requireAuthenticatedUser(request, ["admin", "investor"], {
forbiddenMessage: "Investor or admin access required",
})
if ("response" in authContext) return authContext.response

const query = parseSearchParams(request, querySchema)
if ("response" in query) return query.response

await dbConnect()

const investorId =
authContext.user.role === "admin" && query.data.investorId
? query.data.investorId
: authContext.user._id.toString()
const authContext = await authorizeRequest(request, "investment:read", user => Promise.resolve({
type: "investment",
ownerId: user.role === "admin" && query.data.investorId ? query.data.investorId : user._id.toString(),
}))
if ("response" in authContext) return authContext.response

const investorId = authContext.user.role === "admin" && query.data.investorId ? query.data.investorId : authContext.user._id.toString()

const filter = authContext.user.role === "admin" && !query.data.investorId ? {} : { investorId }

Expand Down
38 changes: 14 additions & 24 deletions app/api/kyc-documents/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"
import { z } from "zod"

import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard"
import { finalizeAuthenticatedResponse } from "@/lib/api/route-guard"
import { authorizeRequest } from "@/lib/authorization/route"
import { parseSearchParams } from "@/lib/api/validation"
import dbConnect from "@/lib/dbConnect"
import {
Expand All @@ -22,39 +23,28 @@ function sanitizeFilename(filename: string) {

export async function GET(request: Request) {
try {
const authContext = await requireAuthenticatedUser(request, ["admin", "driver", "investor"])
if ("response" in authContext) return authContext.response

const rateLimit = consumeRateLimit({
key: buildRateLimitKey("kyc-document", authContext.user._id.toString(), getClientIpAddress(request)),
limit: 60,
windowMs: 10 * 60 * 1000,
})
if (!rateLimit.allowed) {
return rateLimitExceededResponse(rateLimit)
}

const query = parseSearchParams(request, querySchema)
if ("response" in query) return query.response

const reference = query.data.ref

await dbConnect()

const documentOwnerExists = await User.exists({
const documentOwner = await User.findOne({
kycDocuments: reference,
})
}).select("_id").lean()

if (!documentOwnerExists) {
return NextResponse.json({ message: "Document not found." }, { status: 404 })
}
const authContext = await authorizeRequest(request, "kyc:document:read", {
type: "kyc", ownerId: documentOwner?._id?.toString(), exists: Boolean(documentOwner),
})
if ("response" in authContext) return authContext.response

if (authContext.user.role !== "admin") {
const ownsDocument = Array.isArray(authContext.user.kycDocuments) && authContext.user.kycDocuments.includes(reference)
if (!ownsDocument) {
return NextResponse.json({ message: "You do not have access to this document." }, { status: 403 })
}
}
const rateLimit = consumeRateLimit({
key: buildRateLimitKey("kyc-document", authContext.user._id.toString(), getClientIpAddress(request)),
limit: 60,
windowMs: 10 * 60 * 1000,
})
if (!rateLimit.allowed) return rateLimitExceededResponse(rateLimit)

const secureReference = parseKycDocumentReference(reference)
const rawBlobUrl = secureReference?.url || reference
Expand Down
56 changes: 18 additions & 38 deletions app/api/loans/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import mongoose from "mongoose"
import { NextResponse } from "next/server"

import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user"
import { withSessionRefresh } from "@/lib/auth/current-user"
import { authorizeRequest } from "@/lib/authorization/route"
import dbConnect from "@/lib/dbConnect"
import { logAuditEvent } from "@/lib/security/audit-log"
import { getClientIpAddress } from "@/lib/security/rate-limit"
Expand All @@ -14,10 +15,6 @@ function isObjectId(value: unknown): value is string {
return typeof value === "string" && mongoose.Types.ObjectId.isValid(value)
}

function isApprovedDriver(user: any) {
return user?.role === "driver" && (user?.kycStatus === "approved_stage2" || user?.isKycVerified === true || user?.kycVerified === true)
}

function formatLoan(loan: any) {
return {
...loan.toObject(),
Expand All @@ -36,19 +33,15 @@ export async function GET(request: Request) {
try {
await dbConnect()

const { user, shouldRefreshSession } = await getAuthenticatedUser(request)
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

if (user.role !== "admin" && user.role !== "driver") {
return NextResponse.json({ error: "Access denied" }, { status: 403 })
}

const { searchParams } = new URL(request.url)
const requestedUserId = searchParams.get("userId")
const requestedStatus = searchParams.get("status")
const query: Record<string, unknown> = {}
const auth = await authorizeRequest(request, "loan:read", user => Promise.resolve({
type: "loan", ownerId: user.role === "admin" && requestedUserId ? requestedUserId : user._id.toString(),
}))
if ("response" in auth) return auth.response
const { user, shouldRefreshSession } = auth

if (user.role === "admin") {
if (requestedUserId) {
Expand Down Expand Up @@ -85,17 +78,11 @@ export async function POST(request: Request) {
try {
await dbConnect()

const { user, shouldRefreshSession } = await getAuthenticatedUser(request)
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

if (!isApprovedDriver(user)) {
return NextResponse.json({ error: "Only KYC-approved drivers can create loan applications." }, { status: 403 })
}
const auth = await authorizeRequest(request, "loan:create", { type: "loan" })
if ("response" in auth) return auth.response
const { user, shouldRefreshSession } = auth

const body = await request.json().catch(() => ({}))
const requestedDriverId = typeof body.driverId === "string" ? body.driverId : undefined
const vehicleId = typeof body.vehicleId === "string" ? body.vehicleId : ""
const requestedAmount = toPositiveNumber(body.requestedAmount)
const loanTerm = toPositiveNumber(body.loanTerm)
Expand All @@ -107,9 +94,7 @@ export async function POST(request: Request) {
const collateral = typeof body.collateral === "string" ? body.collateral.trim() : ""
const riskAssessment = body.riskAssessment === "Low" || body.riskAssessment === "High" ? body.riskAssessment : "Medium"

if (requestedDriverId && requestedDriverId !== user._id.toString()) {
return NextResponse.json({ error: "You can only submit a loan for your own account." }, { status: 403 })
}
// Request-supplied driver IDs are never trusted; ownership always comes from the principal.

if (!isObjectId(vehicleId) || !requestedAmount || !loanTerm || !monthlyPayment || !weeklyPayment || !interestRate) {
return NextResponse.json({ error: "Missing or invalid required fields" }, { status: 400 })
Expand Down Expand Up @@ -194,15 +179,6 @@ export async function PUT(request: Request) {
try {
await dbConnect()

const { user, shouldRefreshSession } = await getAuthenticatedUser(request)
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

if (user.role !== "admin") {
return NextResponse.json({ error: "Admin access required" }, { status: 403 })
}

const body = await request.json().catch(() => ({}))
const loanId = typeof body.loanId === "string" ? body.loanId : ""
const status = typeof body.status === "string" ? body.status : ""
Expand All @@ -213,9 +189,13 @@ export async function PUT(request: Request) {
}

const loan = await Loan.findById(loanId)
if (!loan) {
return NextResponse.json({ error: "Loan not found" }, { status: 404 })
}
const auth = await authorizeRequest(request, "loan:approve", {
type: "loan", state: loan?.status, exists: Boolean(loan),
})
if ("response" in auth) return auth.response
const { user, shouldRefreshSession } = auth
if (!loan) return NextResponse.json({ error: "Loan not found" }, { status: 404 })
if (status !== "Approved" && status !== "Rejected") return NextResponse.json({ error: "Only approval or rejection is allowed from the review workflow." }, { status: 409 })

loan.status = status
loan.adminNotes = adminNotes || undefined
Expand Down
Loading
Loading