Skip to content
Open
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
687 changes: 674 additions & 13 deletions src/app/admin/admin-review-client.tsx

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions src/app/api/admin/payment-proofs/[teamId]/file/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
enforceIpRateLimit: vi.fn(),
enforceUserRateLimit: vi.fn(),
getAdminPaymentProofDownloadUrl: vi.fn(),
getRouteAuthContext: vi.fn(),
hasAdminReviewAccess: vi.fn(),
}));

vi.mock("@/server/security/rate-limit", () => ({
enforceIpRateLimit: mocks.enforceIpRateLimit,
enforceUserRateLimit: mocks.enforceUserRateLimit,
}));

vi.mock("@/server/auth/context", () => ({
getRouteAuthContext: mocks.getRouteAuthContext,
}));

vi.mock("@/server/admin/review-access", () => ({
hasAdminReviewAccess: mocks.hasAdminReviewAccess,
}));

vi.mock("@/server/admin/payment-proofs", () => ({
getAdminPaymentProofDownloadUrl: mocks.getAdminPaymentProofDownloadUrl,
}));

const makeParams = (teamId: string) => ({
params: Promise.resolve({ teamId }),
});

describe("/api/admin/payment-proofs/[teamId]/file GET", () => {
beforeEach(() => {
vi.resetModules();
mocks.enforceIpRateLimit.mockReset();
mocks.enforceUserRateLimit.mockReset();
mocks.getAdminPaymentProofDownloadUrl.mockReset();
mocks.getRouteAuthContext.mockReset();
mocks.hasAdminReviewAccess.mockReset();

mocks.enforceIpRateLimit.mockResolvedValue(null);
mocks.enforceUserRateLimit.mockResolvedValue(null);
mocks.getRouteAuthContext.mockResolvedValue({
ok: true,
supabase: {},
user: { email: "admin@example.com", id: "user-1" },
});
mocks.hasAdminReviewAccess.mockResolvedValue(true);
mocks.getAdminPaymentProofDownloadUrl.mockResolvedValue({
data: { url: "https://example.com/admin-signed-proof" },
ok: true,
status: 200,
});
});

it("redirects admins to the signed proof URL", async () => {
const { GET } = await import("./route");
const response = await GET(
new NextRequest(
"http://localhost/api/admin/payment-proofs/11111111-1111-4111-8111-111111111111/file",
),
makeParams("11111111-1111-4111-8111-111111111111"),
);

expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(
"https://example.com/admin-signed-proof",
);
});

it("forwards unauthenticated responses", async () => {
mocks.getRouteAuthContext.mockResolvedValueOnce({
ok: false,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
});

const { GET } = await import("./route");
const response = await GET(
new NextRequest(
"http://localhost/api/admin/payment-proofs/11111111-1111-4111-8111-111111111111/file",
),
makeParams("11111111-1111-4111-8111-111111111111"),
);
const body = await response.json();

expect(response.status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
});
54 changes: 54 additions & 0 deletions src/app/api/admin/payment-proofs/[teamId]/file/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { type NextRequest, NextResponse } from "next/server";
import { UUID_PATTERN } from "@/lib/register-api";
import { getAdminPaymentProofDownloadUrl } from "@/server/admin/payment-proofs";
import { hasAdminReviewAccess } from "@/server/admin/review-access";
import { getRouteAuthContext } from "@/server/auth/context";
import { jsonError } from "@/server/http/response";
import {
enforceIpRateLimit,
enforceUserRateLimit,
} from "@/server/security/rate-limit";

type Params = { params: Promise<{ teamId: string }> };

export async function GET(request: NextRequest, { params }: Params) {
const ipRateLimitResponse = await enforceIpRateLimit({
policy: "payment_proof_view_ip",
request,
});
if (ipRateLimitResponse) {
return ipRateLimitResponse;
}

const context = await getRouteAuthContext();
if (!context.ok) {
return context.response;
}

if (!(await hasAdminReviewAccess(context.user.email))) {
return jsonError("Forbidden", 403);
}

const userRateLimitResponse = await enforceUserRateLimit({
policy: "payment_proof_view_user",
request,
userId: context.user.id,
});
if (userRateLimitResponse) {
return userRateLimitResponse;
}

const { teamId } = await params;
if (!UUID_PATTERN.test(teamId)) {
return jsonError("Team id is invalid.", 400);
}

const result = await getAdminPaymentProofDownloadUrl({ teamId });
if (!result.ok) {
return jsonError(result.error, result.status);
}

const response = NextResponse.redirect(result.data.url, { status: 302 });
response.headers.set("Cache-Control", "no-store");
return response;
}
126 changes: 126 additions & 0 deletions src/app/api/admin/payment-proofs/[teamId]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
enforceSameOrigin: vi.fn(),
getRouteAuthContext: vi.fn(),
hasAdminReviewAccess: vi.fn(),
updateAdminPaymentDecision: vi.fn(),
}));

vi.mock("@/server/security/csrf", () => ({
enforceSameOrigin: mocks.enforceSameOrigin,
}));

vi.mock("@/server/auth/context", () => ({
getRouteAuthContext: mocks.getRouteAuthContext,
}));

vi.mock("@/server/admin/review-access", () => ({
hasAdminReviewAccess: mocks.hasAdminReviewAccess,
}));

vi.mock("@/server/admin/payment-proofs", () => ({
updateAdminPaymentDecision: mocks.updateAdminPaymentDecision,
}));

const makeParams = (teamId: string) => ({
params: Promise.resolve({ teamId }),
});

describe("/api/admin/payment-proofs/[teamId] PATCH", () => {
beforeEach(() => {
vi.resetModules();
mocks.enforceSameOrigin.mockReset();
mocks.getRouteAuthContext.mockReset();
mocks.hasAdminReviewAccess.mockReset();
mocks.updateAdminPaymentDecision.mockReset();

mocks.enforceSameOrigin.mockReturnValue(null);
mocks.getRouteAuthContext.mockResolvedValue({
ok: true,
supabase: {},
user: { email: "admin@example.com", id: "user-1" },
});
mocks.hasAdminReviewAccess.mockResolvedValue(true);
mocks.updateAdminPaymentDecision.mockResolvedValue({
data: {
paymentRejectedReason: null,
paymentReviewedAt: "2026-03-07T10:05:00.000Z",
paymentStatus: "approved",
teamId: "11111111-1111-4111-8111-111111111111",
},
ok: true,
status: 200,
});
});

it("returns 403 when CSRF validation fails", async () => {
mocks.enforceSameOrigin.mockReturnValueOnce(
new Response(JSON.stringify({ error: "Forbidden" }), {
headers: { "content-type": "application/json" },
status: 403,
}),
);

const { PATCH } = await import("./route");
const response = await PATCH(
new NextRequest(
"http://localhost/api/admin/payment-proofs/11111111-1111-4111-8111-111111111111",
{
body: JSON.stringify({ decision: "approved" }),
headers: { "content-type": "application/json" },
method: "PATCH",
},
),
makeParams("11111111-1111-4111-8111-111111111111"),
);

expect(response.status).toBe(403);
expect(mocks.updateAdminPaymentDecision).not.toHaveBeenCalled();
});

it("rejects invalid payload shape", async () => {
const { PATCH } = await import("./route");
const response = await PATCH(
new NextRequest(
"http://localhost/api/admin/payment-proofs/11111111-1111-4111-8111-111111111111",
{
body: JSON.stringify({ decision: "rejected" }),
headers: { "content-type": "application/json" },
method: "PATCH",
},
),
makeParams("11111111-1111-4111-8111-111111111111"),
);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toMatch(/rejection reason/i);
expect(mocks.updateAdminPaymentDecision).not.toHaveBeenCalled();
});

it("returns service response on success", async () => {
const { PATCH } = await import("./route");
const response = await PATCH(
new NextRequest(
"http://localhost/api/admin/payment-proofs/11111111-1111-4111-8111-111111111111",
{
body: JSON.stringify({ decision: "approved" }),
headers: { "content-type": "application/json" },
method: "PATCH",
},
),
makeParams("11111111-1111-4111-8111-111111111111"),
);
const body = await response.json();

expect(response.status).toBe(200);
expect(mocks.updateAdminPaymentDecision).toHaveBeenCalledWith({
decision: "approved",
reason: undefined,
teamId: "11111111-1111-4111-8111-111111111111",
});
expect(body.paymentStatus).toBe("approved");
});
});
75 changes: 75 additions & 0 deletions src/app/api/admin/payment-proofs/[teamId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { UUID_PATTERN } from "@/lib/register-api";
import { updateAdminPaymentDecision } from "@/server/admin/payment-proofs";
import { hasAdminReviewAccess } from "@/server/admin/review-access";
import { getRouteAuthContext } from "@/server/auth/context";
import { isJsonRequest, parseJsonSafely } from "@/server/http/request";
import { jsonError, jsonNoStore } from "@/server/http/response";
import { enforceSameOrigin } from "@/server/security/csrf";

const updatePaymentDecisionSchema = z
.object({
decision: z.enum(["approved", "rejected"]),
reason: z.string().trim().optional(),
})
.superRefine((data, ctx) => {
if (data.decision === "rejected" && !data.reason?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Rejection reason is required.",
path: ["reason"],
});
}
});

type Params = { params: Promise<{ teamId: string }> };

export async function PATCH(request: NextRequest, { params }: Params) {
const csrfResponse = enforceSameOrigin(request);
if (csrfResponse) {
return csrfResponse;
}

const context = await getRouteAuthContext();
if (!context.ok) {
return context.response;
}

if (!(await hasAdminReviewAccess(context.user.email))) {
return jsonError("Forbidden", 403);
}

const { teamId } = await params;
if (!UUID_PATTERN.test(teamId)) {
return jsonError("Team id is invalid.", 400);
}

if (!isJsonRequest(request)) {
return jsonError("Content-Type must be application/json.", 415);
}

const body = await parseJsonSafely(request);
if (body === null) {
return jsonError("Invalid JSON payload.", 400);
}

const parsed = updatePaymentDecisionSchema.safeParse(body);
if (!parsed.success) {
return jsonError(
parsed.error.issues[0]?.message ?? "Invalid payload.",
400,
);
}

const result = await updateAdminPaymentDecision({
decision: parsed.data.decision,
reason: parsed.data.reason,
teamId,
});
if (!result.ok) {
return jsonError(result.error, result.status);
}

return jsonNoStore(result.data, result.status);
}
Loading