feat : limit each session to 1 hours when checkout
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkoutExpired, checkoutExpiresAt } from "./checkout-expiration";
|
||||
|
||||
describe("commission checkout window", () => {
|
||||
it("expires exactly one hour after each checkout was created", () => {
|
||||
const createdAt = new Date("2026-10-01T12:00:00.000Z");
|
||||
const expiresAt = checkoutExpiresAt(createdAt);
|
||||
expect(expiresAt).toBe(new Date("2026-10-01T13:00:00.000Z").getTime());
|
||||
expect(checkoutExpired(createdAt, expiresAt - 1)).toBe(false);
|
||||
expect(checkoutExpired(createdAt, expiresAt)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export const CHECKOUT_DURATION_MS = 60 * 60 * 1000;
|
||||
|
||||
export function checkoutNow(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function checkoutExpiresAt(createdAt: Date): number {
|
||||
return createdAt.getTime() + CHECKOUT_DURATION_MS;
|
||||
}
|
||||
|
||||
export function checkoutExpired(createdAt: Date, now = Date.now()): boolean {
|
||||
return now >= checkoutExpiresAt(createdAt);
|
||||
}
|
||||
@@ -13,8 +13,9 @@ vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ select }) }));
|
||||
vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => redis }));
|
||||
|
||||
const { authorizeMobileSlip, createMobileSlipLink } = await import("./mobile-slip");
|
||||
const checkout = { id: "checkout-1", userId: "customer-1", amountBaht: 150 };
|
||||
const { authorizeMobileSlip, createMobileSlipLink, mobileSlipStatus } = await import("./mobile-slip");
|
||||
const checkout = { id: "checkout-1", userId: "customer-1", amountBaht: 150,
|
||||
createdAt: new Date(Date.now() - 60_000) };
|
||||
|
||||
describe("commission mobile slip links", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); set.mockResolvedValue("OK"); });
|
||||
@@ -26,7 +27,7 @@ describe("commission mobile slip links", () => {
|
||||
});
|
||||
|
||||
it("issues a ten-minute link and rejects an expired or replaced token", async () => {
|
||||
limit.mockResolvedValueOnce([{ id: checkout.id }]).mockResolvedValueOnce([]);
|
||||
limit.mockResolvedValueOnce([checkout]).mockResolvedValueOnce([]);
|
||||
const { token, digest } = await createMobileSlipLink(checkout.id, checkout.userId);
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(digest).toBe(createHash("sha256").update(token).digest("hex"));
|
||||
@@ -49,4 +50,41 @@ describe("commission mobile slip links", () => {
|
||||
limit.mockResolvedValueOnce([checkout]).mockResolvedValueOnce([]);
|
||||
expect(await authorizeMobileSlip(token)).toEqual({ checkout, ticketId: null, digest });
|
||||
});
|
||||
|
||||
it("does not issue a link for an expired checkout", async () => {
|
||||
limit.mockResolvedValueOnce([{ ...checkout, createdAt: new Date(Date.now() - 3_600_000) }])
|
||||
.mockResolvedValueOnce([]);
|
||||
await expect(createMobileSlipLink(checkout.id, checkout.userId))
|
||||
.rejects.toMatchObject({ status: 410, message: "checkout-expired" });
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps a mobile link at the checkout deadline", async () => {
|
||||
limit.mockResolvedValueOnce([{ ...checkout, createdAt: new Date(Date.now() - 3_570_000) }])
|
||||
.mockResolvedValueOnce([]);
|
||||
const { expiresAt } = await createMobileSlipLink(checkout.id, checkout.userId);
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 30_000);
|
||||
expect(set).toHaveBeenCalledWith(expect.stringContaining(":token:"), expect.any(String), "EX", 30);
|
||||
});
|
||||
|
||||
it("rejects an active phone link once its checkout expires", async () => {
|
||||
const token = "x".repeat(43);
|
||||
const digest = createHash("sha256").update(token).digest("hex");
|
||||
get.mockResolvedValueOnce(JSON.stringify({ checkoutId: checkout.id, userId: checkout.userId }))
|
||||
.mockResolvedValueOnce(digest);
|
||||
limit.mockResolvedValueOnce([{ ...checkout, createdAt: new Date(Date.now() - 3_600_000) }])
|
||||
.mockResolvedValueOnce([]);
|
||||
await expect(authorizeMobileSlip(token))
|
||||
.rejects.toMatchObject({ status: 410, message: "checkout-expired" });
|
||||
});
|
||||
|
||||
it("reports expired unpaid checkouts while preserving completed tickets", async () => {
|
||||
const expired = { ...checkout, createdAt: new Date(Date.now() - 3_600_000) };
|
||||
limit.mockResolvedValueOnce([expired]).mockResolvedValueOnce([]);
|
||||
expect(await mobileSlipStatus(checkout.id, checkout.userId, "a".repeat(64)))
|
||||
.toEqual({ state: "expired" });
|
||||
limit.mockResolvedValueOnce([expired]).mockResolvedValueOnce([{ id: "ticket-1" }]);
|
||||
expect(await mobileSlipStatus(checkout.id, checkout.userId, "a".repeat(64)))
|
||||
.toEqual({ state: "complete", ticketId: "ticket-1" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionCheckouts, commissionTickets } from "@/db/schema";
|
||||
import { checkoutExpired, checkoutExpiresAt } from "@/lib/commission/checkout-expiration";
|
||||
import { getRedisClient, redisEventChannel } from "@/lib/redis/client";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
|
||||
@@ -15,19 +16,22 @@ const errorKey = (digest: string) => `${prefix()}:error:${digest}`;
|
||||
const digestToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
export async function createMobileSlipLink(checkoutId: string, userId: string) {
|
||||
const [checkout] = await getDb().select({ id: commissionCheckouts.id }).from(commissionCheckouts)
|
||||
const [checkout] = await getDb().select({ id: commissionCheckouts.id, createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts)
|
||||
.where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1);
|
||||
if (!checkout) throw new HttpError(404, "checkout-not-found");
|
||||
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
|
||||
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1);
|
||||
if (ticket) throw new HttpError(409, "checkout-already-paid");
|
||||
if (checkoutExpired(checkout.createdAt)) throw new HttpError(410, "checkout-expired");
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const digest = digestToken(token);
|
||||
const redis = await getRedisClient();
|
||||
await redis.set(tokenKey(digest), JSON.stringify({ checkoutId, userId }), "EX", TTL_SECONDS);
|
||||
await redis.set(activeKey(checkoutId), digest, "EX", TTL_SECONDS);
|
||||
return { token, digest, expiresAt: Date.now() + TTL_SECONDS * 1000 };
|
||||
const expiresAt = Math.min(Date.now() + TTL_SECONDS * 1000, checkoutExpiresAt(checkout.createdAt));
|
||||
const ttl = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
|
||||
await redis.set(tokenKey(digest), JSON.stringify({ checkoutId, userId }), "EX", ttl);
|
||||
await redis.set(activeKey(checkoutId), digest, "EX", ttl);
|
||||
return { token, digest, expiresAt };
|
||||
}
|
||||
|
||||
export async function authorizeMobileSlip(token: string) {
|
||||
@@ -44,16 +48,18 @@ export async function authorizeMobileSlip(token: string) {
|
||||
if (!checkout) throw new HttpError(404, "checkout-not-found");
|
||||
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
|
||||
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1);
|
||||
if (!ticket && checkoutExpired(checkout.createdAt)) throw new HttpError(410, "checkout-expired");
|
||||
return { checkout, ticketId: ticket?.id ?? null, digest };
|
||||
}
|
||||
|
||||
export async function mobileSlipStatus(checkoutId: string, userId: string, digest: string) {
|
||||
const [checkout] = await getDb().select({ id: commissionCheckouts.id }).from(commissionCheckouts)
|
||||
const [checkout] = await getDb().select({ id: commissionCheckouts.id, createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts)
|
||||
.where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1);
|
||||
if (!checkout) throw new HttpError(404, "checkout-not-found");
|
||||
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
|
||||
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1);
|
||||
if (ticket) return { state: "complete", ticketId: ticket.id };
|
||||
if (checkoutExpired(checkout.createdAt)) return { state: "expired" };
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) throw new HttpError(400, "invalid-upload-link");
|
||||
const redis = await getRedisClient();
|
||||
if (await redis.get(activeKey(checkoutId)) !== digest) return { state: "expired" };
|
||||
|
||||
@@ -4,9 +4,12 @@ const set = vi.fn().mockResolvedValue(null);
|
||||
const getRedisClient = vi.fn().mockResolvedValue({ set });
|
||||
const inspectImage = vi.fn();
|
||||
const verifyCommissionSlip = vi.fn();
|
||||
const limit = vi.fn();
|
||||
const evalRedis = vi.fn().mockResolvedValue(0);
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/redis/client", () => ({ getRedisClient }));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ select: () => ({ from: () => ({ where: () => ({ limit }) }) }) }) }));
|
||||
vi.mock("@/lib/media/inspect", () => ({ inspectImage }));
|
||||
vi.mock("@/lib/commission/payment", () => ({ verifyCommissionSlip }));
|
||||
|
||||
@@ -23,4 +26,29 @@ describe("commission slip submission lock", () => {
|
||||
expect(inspectImage).not.toHaveBeenCalled();
|
||||
expect(verifyCommissionSlip).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unpaid expired checkout before verifying its slip", async () => {
|
||||
set.mockResolvedValueOnce("OK");
|
||||
getRedisClient.mockResolvedValueOnce({ set, eval: evalRedis });
|
||||
limit.mockResolvedValueOnce([]);
|
||||
const checkout = { id: "checkout-2", userId: "customer-1", amountBaht: 150,
|
||||
createdAt: new Date(Date.now() - 3_600_000) };
|
||||
const file = new File(["image"], "slip.png", { type: "image/png" });
|
||||
await expect(verifyAndCreateTicket(checkout as Parameters<typeof verifyAndCreateTicket>[0], file))
|
||||
.rejects.toMatchObject({ status: 410, message: "checkout-expired" });
|
||||
expect(inspectImage).not.toHaveBeenCalled();
|
||||
expect(verifyCommissionSlip).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still returns an existing ticket after the checkout deadline", async () => {
|
||||
set.mockResolvedValueOnce("OK");
|
||||
getRedisClient.mockResolvedValueOnce({ set, eval: evalRedis });
|
||||
limit.mockResolvedValueOnce([{ id: "ticket-1" }]);
|
||||
const checkout = { id: "checkout-3", userId: "customer-1", amountBaht: 150,
|
||||
createdAt: new Date(Date.now() - 3_600_000) };
|
||||
const file = new File(["image"], "slip.png", { type: "image/png" });
|
||||
expect(await verifyAndCreateTicket(checkout as Parameters<typeof verifyAndCreateTicket>[0], file))
|
||||
.toEqual({ ticketId: "ticket-1", created: false });
|
||||
expect(verifyCommissionSlip).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { commissionCheckouts, commissionPayments, commissionTickets } from "@/db
|
||||
import { notifyPaidTicketDiscord } from "@/lib/commission/discord";
|
||||
import { verifyCommissionSlip } from "@/lib/commission/payment";
|
||||
import { notifyCommission } from "@/lib/commission/server";
|
||||
import { checkoutExpired } from "@/lib/commission/checkout-expiration";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { inspectImage } from "@/lib/media/inspect";
|
||||
import { getRedisClient } from "@/lib/redis/client";
|
||||
@@ -28,6 +29,7 @@ export async function verifyAndCreateTicket(checkout: Checkout, file: FormDataEn
|
||||
const [existing] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
|
||||
.where(eq(commissionTickets.checkoutId, checkout.id)).limit(1);
|
||||
if (existing) return { ticketId: existing.id, created: false };
|
||||
if (checkoutExpired(checkout.createdAt)) throw new HttpError(410, "checkout-expired");
|
||||
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp");
|
||||
|
||||
@@ -18,6 +18,7 @@ export function verificationError(code: string | undefined): string {
|
||||
case "slip-amount-or-date-mismatch": return "ยอดเงินหรือวันเวลาในสลิปไม่ตรงกับคำขอนี้ กรุณาใช้สลิปจากการชำระเงินครั้งนี้";
|
||||
case "payment-already-used": return "สลิปนี้ถูกใช้กับคำขออื่นแล้ว";
|
||||
case "slip-verification-in-progress": return "กำลังตรวจสอบสลิปอยู่ กรุณารอสักครู่";
|
||||
case "checkout-expired": return "หมดเวลาชำระเงินสำหรับคำขอนี้แล้ว กรุณาสร้างคำขอใหม่";
|
||||
default: return reason && /^\d{6}$/.test(reason)
|
||||
? `ตรวจสอบสลิปไม่ผ่าน (รหัส ${reason}) กรุณาแจ้งผู้ดูแลระบบ`
|
||||
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";
|
||||
|
||||
Reference in New Issue
Block a user