import { createHmac } from "node:crypto"; import { describe, expect, it } from "vitest"; import { addinTicketSecret, parseTicketTokenBody, ticketToDocverClaims, verifyAddinTicket, } from "@/lib/addin-ticket"; // PLAN-ADDIN-AUTH.md §3 — pure ล้วน (verifyAddinTicket รับ secret ทาง parameter) // ไม่แตะ @/db/minio — รันได้โดยไม่ต้องตั้ง env อะไร (เหมือน docver-token.test.ts) const SECRET = "super_secret"; /** ตัว ticket ตัวอย่าง — key เรียงตามตัวอักษรเหมือนตัว stamp จริง (sortObjectKeys ของ lib-utils) */ const TICKET_DATA = JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 300, fid: "T12_FD155_3s-long_ZZZ", tid: "T12", uid: "oom", uname: "สรวิชญ์", }); function sign(message: string, secret: string = SECRET): string { return createHmac("sha256", secret).update(message, "utf8").digest("hex"); } function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T { expect(result.ok).toBe(true); } describe("verifyAddinTicket", () => { it("ticket ถูกต้อง + HMAC ตรง = ผ่าน ได้ ticket แกะแล้ว", () => { const result = verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA), SECRET); ok(result); expect(result.ticket.fid).toBe("T12_FD155_3s-long_ZZZ"); expect(result.ticket.uid).toBe("oom"); expect(result.ticket.tid).toBe("T12"); }); it("HMAC ผิด (แก้ ticket 1 ตัวอักษร / secret ผิด / digest เพี้ยน) = ไม่ผ่านทุกกรณี", () => { const digest = sign(TICKET_DATA); const tampered = `${TICKET_DATA.slice(0, -2)}xx}`; expect(verifyAddinTicket(tampered, digest, SECRET).ok).toBe(false); expect(verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA, "wrong-secret"), SECRET).ok).toBe(false); const flipped = `${digest.slice(0, -1)}${digest.endsWith("0") ? "1" : "0"}`; expect(verifyAddinTicket(TICKET_DATA, flipped, SECRET).ok).toBe(false); }); it("digest รูปไม่ใช่ (ไม่ใช่ hex / สั้น / ว่าง) = ไม่ผ่าน · uppercase hex normalize แล้วผ่านได้", () => { expect(verifyAddinTicket(TICKET_DATA, "zz".repeat(32), SECRET).ok).toBe(false); expect(verifyAddinTicket(TICKET_DATA, "abcd", SECRET).ok).toBe(false); expect(verifyAddinTicket(TICKET_DATA, "", SECRET).ok).toBe(false); expect(verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA).toUpperCase(), SECRET).ok).toBe(true); }); it("ticket หมดอายุ (exp อดีต / เท่ากับ now) = ไม่ผ่าน แม้ HMAC ถูก", () => { const now = Math.floor(Date.now() / 1000); for (const exp of [now - 1, now]) { const expired = JSON.stringify({ exp, fid: "f", uid: "u", uname: "n" }); expect(verifyAddinTicket(expired, sign(expired), SECRET, now).ok, `exp=${exp}`).toBe(false); } const alive = JSON.stringify({ exp: now + 1, fid: "f", uid: "u", uname: "n" }); expect(verifyAddinTicket(alive, sign(alive), SECRET, now).ok).toBe(true); }); it("HMAC ถูกแต่ ticket ผิดกติกา (field หลุด allowlist / exp ไม่ใช่ integer) = ไม่ผ่าน", () => { const extra = JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 60, fid: "f", uid: "u", uname: "n", oops: 1 }); expect(verifyAddinTicket(extra, sign(extra), SECRET).ok).toBe(false); const badExp = JSON.stringify({ exp: "123", fid: "f", uid: "u", uname: "n" }); expect(verifyAddinTicket(badExp, sign(badExp), SECRET).ok).toBe(false); }); }); describe("ticketToDocverClaims", () => { it("แปลง uid→userid, uname→username, tid→template · tid ไม่มี = null", () => { expect( ticketToDocverClaims({ exp: 1, fid: "F1", tid: "T12", uid: "oom", uname: "สรวิชญ์" }), ).toEqual({ userid: "oom", username: "สรวิชญ์", fid: "F1", template: "T12" }); expect( ticketToDocverClaims({ exp: 1, fid: "F1", tid: null, uid: "oom", uname: "สรวิชญ์" }), ).toEqual({ userid: "oom", username: "สรวิชญ์", fid: "F1", template: null }); }); }); describe("parseTicketTokenBody", () => { it("ครบทั้งคู่ = ได้ค่าตรง ๆ (ไม่แตะค่า — HMAC ต้องคำนวณบน string ดิบ)", () => { const digest = sign(TICKET_DATA); const result = parseTicketTokenBody({ ticket_data: TICKET_DATA, HmacSha256: digest }); ok(result); expect(result.ticketData).toBe(TICKET_DATA); expect(result.hmacHex).toBe(digest); }); it("ไม่ใช่ object / มี field อื่นปน = invalid (→ 400)", () => { for (const raw of [[], "text", null, { ticket_data: "x", HmacSha256: "y", extra: 1 }]) { const result = parseTicketTokenBody(raw); expect(result.ok, JSON.stringify(raw)).toBe(false); if (!result.ok) expect(result.kind).toBe("invalid"); } }); it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 401 กัน oracle)", () => { for (const raw of [{}, { ticket_data: "" }, { HmacSha256: "y" }, { ticket_data: null, HmacSha256: "y" }]) { const result = parseTicketTokenBody(raw); expect(result.ok, JSON.stringify(raw)).toBe(false); if (!result.ok) expect(result.kind).toBe("missing"); } }); it("field ไม่ใช่ string / ยาวเกิน cap = invalid", () => { for (const raw of [ { ticket_data: 123, HmacSha256: "y" }, { ticket_data: "x", HmacSha256: "y".repeat(129) }, { ticket_data: "x".repeat(2049), HmacSha256: "y" }, ]) { const result = parseTicketTokenBody(raw); expect(result.ok, JSON.stringify(raw)).toBe(false); if (!result.ok) expect(result.kind).toBe("invalid"); } }); }); describe("addinTicketSecret", () => { // env save/restore — addinTicketSecret อ่านตอนเรียก const ORIG_ADDIN = process.env.ADDIN_TICKET_SECRET; const ORIG_SECRET = process.env.SECRET; it("ลำดับ ADDIN_TICKET_SECRET → SECRET → default \"super_secret\" (trim ทุกตัว)", () => { delete process.env.ADDIN_TICKET_SECRET; delete process.env.SECRET; expect(addinTicketSecret()).toBe("super_secret"); process.env.SECRET = " tmpl-secret "; expect(addinTicketSecret()).toBe("tmpl-secret"); process.env.ADDIN_TICKET_SECRET = " addin-secret "; expect(addinTicketSecret()).toBe("addin-secret"); delete process.env.ADDIN_TICKET_SECRET; process.env.SECRET = " "; // ว่างล้วน = ข้ามไปตัวถัดไป expect(addinTicketSecret()).toBe("super_secret"); process.env.ADDIN_TICKET_SECRET = ORIG_ADDIN; process.env.SECRET = ORIG_SECRET; }); });