feat : limit each session to 1 hours when checkout

This commit is contained in:
2026-10-01 22:37:52 +07:00 Unverified
parent 61dfc9c9e5
commit d85687c332
11 changed files with 163 additions and 27 deletions
@@ -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);
});
});
+13
View File
@@ -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);
}
+41 -3
View File
@@ -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" });
});
});
+11 -5
View File
@@ -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" };
+28
View File
@@ -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();
});
});
+2
View File
@@ -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");
+1
View File
@@ -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}) กรุณาแจ้งผู้ดูแลระบบ`
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";