Harden CMS security and theme contrast
This commit is contained in:
1 parent
2465ff2170
commit
4a1e1115b3
57 files changed
+1023
-231
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
authorizeTopupCapture,
|
||||
claimTopupDelivery,
|
||||
recordCreatedTopup,
|
||||
} from "@/lib/services/paypal-topup";
|
||||
|
||||
describe("authorizeTopupCapture", () => {
|
||||
it("rejects an order created by a different user", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 41,
|
||||
status: "CREATED",
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
|
||||
code: "ORDER_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an order that has already left the created state", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 99,
|
||||
status: "COMPLETED",
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
|
||||
code: "ORDER_ALREADY_PROCESSED",
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes credit delivery for a captured order without capturing it again", async () => {
|
||||
const findOrder = async () => ({
|
||||
userId: 99,
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
amount: 12.5,
|
||||
});
|
||||
|
||||
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).resolves.toMatchObject({
|
||||
action: "DELIVER_CREDITS",
|
||||
amount: 12.5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordCreatedTopup", () => {
|
||||
it("stores the PayPal order id with its owner before approval", async () => {
|
||||
let saved: Parameters<typeof record>[0] | undefined;
|
||||
const record = async (data: {
|
||||
userId: number;
|
||||
transactionId: string;
|
||||
status: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) => {
|
||||
saved = data;
|
||||
};
|
||||
|
||||
await recordCreatedTopup(
|
||||
{ userId: 99, orderId: "ORDER-123", amount: 12.5, currency: "EUR", credits: 1250 },
|
||||
record,
|
||||
);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
userId: 99,
|
||||
transactionId: "ORDER-123",
|
||||
status: "CREATED",
|
||||
amount: 12.5,
|
||||
currency: "EUR",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimTopupDelivery", () => {
|
||||
it("rejects delivery when another request already claimed the order", async () => {
|
||||
const claim = async () => ({ count: 0 });
|
||||
|
||||
await expect(claimTopupDelivery(claim)).rejects.toMatchObject({
|
||||
code: "DELIVERY_ALREADY_CLAIMED",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows delivery after atomically claiming the pending order", async () => {
|
||||
const claim = async () => ({ count: 1 });
|
||||
|
||||
await expect(claimTopupDelivery(claim)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface PendingTopup {
|
||||
userId: number;
|
||||
status: string | null;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export type AuthorizedTopup = PendingTopup & { action: "CAPTURE" | "DELIVER_CREDITS" };
|
||||
|
||||
export type FindTopupOrder = (orderId: string) => Promise<PendingTopup | null>;
|
||||
|
||||
export interface CreatedTopupRecord {
|
||||
userId: number;
|
||||
transactionId: string;
|
||||
status: "CREATED";
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type CreateTopupRecord = (data: CreatedTopupRecord) => Promise<unknown>;
|
||||
|
||||
export class TopupCaptureError extends Error {
|
||||
constructor(public readonly code: "ORDER_NOT_FOUND" | "ORDER_ALREADY_PROCESSED") {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export class TopupDeliveryError extends Error {
|
||||
readonly code = "DELIVERY_ALREADY_CLAIMED";
|
||||
|
||||
constructor() {
|
||||
super("DELIVERY_ALREADY_CLAIMED");
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimTopupDelivery(
|
||||
claim: () => Promise<{ count: number }>,
|
||||
): Promise<void> {
|
||||
const result = await claim();
|
||||
if (result.count !== 1) throw new TopupDeliveryError();
|
||||
}
|
||||
|
||||
export async function authorizeTopupCapture(
|
||||
userId: number,
|
||||
orderId: string,
|
||||
findOrder: FindTopupOrder,
|
||||
): Promise<AuthorizedTopup> {
|
||||
const order = await findOrder(orderId);
|
||||
if (!order || order.userId !== userId) {
|
||||
throw new TopupCaptureError("ORDER_NOT_FOUND");
|
||||
}
|
||||
if (order.status === "CAPTURED_PENDING_CREDIT" && Number.isFinite(order.amount)) {
|
||||
return { ...order, action: "DELIVER_CREDITS" };
|
||||
}
|
||||
if (order.status !== "CREATED") {
|
||||
throw new TopupCaptureError("ORDER_ALREADY_PROCESSED");
|
||||
}
|
||||
return { ...order, action: "CAPTURE" };
|
||||
}
|
||||
|
||||
export async function recordCreatedTopup(
|
||||
topup: {
|
||||
userId: number;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
credits: number;
|
||||
},
|
||||
createRecord: CreateTopupRecord,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await createRecord({
|
||||
userId: topup.userId,
|
||||
transactionId: topup.orderId,
|
||||
status: "CREATED",
|
||||
description: `Top-up: ${topup.credits} credits`,
|
||||
amount: topup.amount,
|
||||
currency: topup.currency,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
|
||||
describe("createOwnedTicketReply", () => {
|
||||
it("does not create a reply for a ticket owned by another user", async () => {
|
||||
let created = false;
|
||||
const db = {
|
||||
findTicket: async () => ({ id: 5n, userId: 7 }),
|
||||
createReply: async () => {
|
||||
created = true;
|
||||
throw new Error("must not run");
|
||||
},
|
||||
touchTicket: async () => undefined,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createOwnedTicketReply(db, { ticketId: 5n, userId: 99, content: "Help" }),
|
||||
).resolves.toBeNull();
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
|
||||
it("creates the reply and updates ticket activity as one operation", async () => {
|
||||
let touchedAt: Date | undefined;
|
||||
const db = {
|
||||
findTicket: async () => ({ id: 5n, userId: 99 }),
|
||||
createReply: async (data: { createdAt: Date }) => ({
|
||||
id: 8n,
|
||||
userId: 99,
|
||||
content: "Help",
|
||||
createdAt: data.createdAt,
|
||||
}),
|
||||
touchTicket: async (_ticketId: bigint, updatedAt: Date) => {
|
||||
touchedAt = updatedAt;
|
||||
},
|
||||
};
|
||||
|
||||
const reply = await createOwnedTicketReply(db, {
|
||||
ticketId: 5n,
|
||||
userId: 99,
|
||||
content: "Help",
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({ id: 8n, userId: 99, content: "Help" });
|
||||
expect(touchedAt).toBe(reply?.createdAt);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface TicketReplyRecord {
|
||||
id: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
export interface TicketReplyDb {
|
||||
findTicket(ticketId: bigint): Promise<{ id: bigint; userId: number | null } | null>;
|
||||
createReply(data: {
|
||||
ticketId: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Promise<TicketReplyRecord>;
|
||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function createOwnedTicketReply(
|
||||
db: TicketReplyDb,
|
||||
input: { ticketId: bigint; userId: number; content: string },
|
||||
): Promise<TicketReplyRecord | null> {
|
||||
const ticket = await db.findTicket(input.ticketId);
|
||||
if (!ticket || ticket.userId !== input.userId) return null;
|
||||
|
||||
const now = new Date();
|
||||
const reply = await db.createReply({ ...input, createdAt: now, updatedAt: now });
|
||||
await db.touchTicket(input.ticketId, now);
|
||||
return reply;
|
||||
}
|
||||
Reference in new issue
Block a user