// POST /api/documents/token — contract ทางเข้า ticket ฉบับ 2026-11-04 + regression ทางเข้า x-api-key // route pure ล้วนหลังถอด hasVersionByUser (ไม่ import @/db/minio อีกต่อไป) จึง unit test // ได้โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า route // ไม่แตะ DB (เลิก "ตรวจเคยมี version" ของ requirement ขั้น 7 ฉบับ 2026-10-28) import { createHmac } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { POST } from "@/app/api/documents/token/route"; /** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */ const SECRET_DEFAULT = "super_secret"; const TOKEN_SECRET = "unit-test-token-secret"; const API_KEY = "unit-test-api-key"; const SAVED_ENV: Record = {}; function mintTicket( exp: number, overrides?: { fid?: string; uid?: string; uname?: string; tid?: string }, ): { ticketData: string; ticketHmac: string } { const ticket = { exp, fid: overrides?.fid ?? "F1", ...(overrides?.tid !== undefined ? { tid: overrides.tid } : { tid: "T12" }), uid: overrides?.uid ?? "oom", uname: overrides?.uname ?? "สรวิชญ์", }; const ticketData = JSON.stringify(ticket); const ticketHmac = createHmac("sha256", SECRET_DEFAULT).update(ticketData, "utf8").digest("hex"); return { ticketData, ticketHmac }; } function futureExp(): number { return Math.floor(Date.now() / 1000) + 3_600; } function makeRequest(body: BodyInit, headers?: Record): Request { return new Request("http://localhost/docedit/api/documents/token", { method: "POST", headers: { "content-type": "application/json", ...headers }, body, }); } function ticketBody(ticketData: string, ticketHmac: string): string { return JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }); } /** decode JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ assert claims/exp เท่านั้น */ function jwtPayload(token: string): Record { const payload = token.split(".")[1]; if (!payload) throw new Error("token ไม่มี payload"); return JSON.parse( Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"), ) as Record; } beforeEach(() => { for (const key of [ "ADDIN_TICKET_SECRET", "SECRET", "DOCVER_API_KEY", "DOCVER_TOKEN_SECRET", "DOCVER_TOKEN_TTL_SECONDS", ]) { SAVED_ENV[key] = process.env[key]; } process.env.DOCVER_TOKEN_SECRET = TOKEN_SECRET; delete process.env.ADDIN_TICKET_SECRET; // ใช้ default "super_secret" ของ addinTicketSecret delete process.env.SECRET; delete process.env.DOCVER_TOKEN_TTL_SECONDS; }); afterEach(() => { for (const [key, value] of Object.entries(SAVED_ENV)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); describe("POST /api/documents/token — ทางเข้า ticket (contract 2026-11-04)", () => { it("ticket ถูกทั้งลายเซ็น+อายุ → 200 + JWT claims จาก ticket + expires_in 604800 (default 7 วัน)", async () => { const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" }); const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); expect(response.status).toBe(200); const data = (await response.json()) as { access_token: string; token_type: string; expires_in: number }; expect(data.token_type).toBe("Bearer"); expect(data.expires_in).toBe(604_800); const payload = jwtPayload(data.access_token); expect(payload.userid).toBe("u1"); expect(payload.username).toBe("นายทดสอบ"); expect(payload.fid).toBe("F9"); expect(payload.template).toBe("T12"); expect(payload.purpose).toBe("docver"); expect((payload.exp as number) - (payload.iat as number)).toBe(604_800); }); it("body ไม่ใช่ JSON → 400", async () => { const response = await POST(makeRequest("not-json{")); expect(response.status).toBe(400); }); it("ไม่ส่ง ticket มาเลย / ค่าว่าง → 400 (ย้ายจาก 401 ตาม contract ฟอร์แม็ตพัง)", async () => { for (const body of ["{}", JSON.stringify({ ticket_data: "" , HmacSha256: "ab".repeat(32) })]) { const response = await POST(makeRequest(body)); expect(response.status, body).toBe(400); } }); it("field นอก allowlist / ผิดชนิด / ยาวเกิน cap → 400", async () => { for (const body of [ JSON.stringify({ ticket_data: "x", HmacSha256: "y", oops: 1 }), JSON.stringify({ ticket_data: 42, HmacSha256: "y" }), JSON.stringify({ ticket_data: "x".repeat(2049), HmacSha256: "y" }), ]) { const response = await POST(makeRequest(body)); expect(response.status, body).toBe(400); } }); it("ลายเซ็นผิด (แก้ ticket_data / digest เพี้ยน / secret ของ server ต่าง) → 401 ข้อความเดียวกันทุกเคส", async () => { const { ticketData, ticketHmac } = mintTicket(futureExp()); const tampered = ticketData.replace("F1", "F2"); // แก้ 1 ตัว — HMAC ไม่ตรง const cases: { label: string; body: string; secretEnv?: [string, string] }[] = [ { label: "แก้ ticket_data", body: ticketBody(tampered, ticketHmac) }, { label: "digest ไม่ใช่ hex", body: ticketBody(ticketData, "zz".repeat(32)) }, { label: "secret ฝั่ง server ต่าง", body: ticketBody(ticketData, ticketHmac), secretEnv: ["ADDIN_TICKET_SECRET", "different-secret"], }, ]; const messages = new Set(); for (const { label, body, secretEnv } of cases) { if (secretEnv) process.env[secretEnv[0]] = secretEnv[1]; const response = await POST(makeRequest(body)); expect(response.status, label).toBe(401); messages.add((await response.json()).error); if (secretEnv) delete process.env[secretEnv[0]]; } expect(messages.size).toBe(1); // ข้อความเดียว กัน oracle expect(Array.from(messages)[0]).toBe("ticket ไม่ถูกต้อง"); }); it("ลายเซ็นถูกแต่ exp อดีต → 403 \"ticket ของ e-Court หมดอายุ\" (ไม่ตรวจ DB — route ไม่มี DB)", async () => { const { ticketData, ticketHmac } = mintTicket(Math.floor(Date.now() / 1000) - 60); const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); expect(response.status).toBe(403); expect((await response.json()).error).toBe("ticket ของ e-Court หมดอายุ"); }); it("HMAC ถูกแต่ ticket_data ผิดกติกา (field หลุด allowlist) → 401", async () => { const invalid = JSON.stringify({ exp: futureExp(), fid: "f", uid: "u", uname: "n", oops: 1 }); const hmac = createHmac("sha256", SECRET_DEFAULT).update(invalid, "utf8").digest("hex"); const response = await POST(makeRequest(ticketBody(invalid, hmac))); expect(response.status).toBe(401); }); it("DOCVER_TOKEN_SECRET ไม่ตั้ง → 500 (ความผิด config ฝั่ง server ไม่ใช่ของผู้เรียก)", async () => { delete process.env.DOCVER_TOKEN_SECRET; const { ticketData, ticketHmac } = mintTicket(futureExp()); const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); expect(response.status).toBe(500); }); it("DOCVER_TOKEN_TTL_SECONDS=120 → expires_in 120 (env override default)", async () => { process.env.DOCVER_TOKEN_TTL_SECONDS = "120"; const { ticketData, ticketHmac } = mintTicket(futureExp()); const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac))); expect(response.status).toBe(200); expect(((await response.json()) as { expires_in: number }).expires_in).toBe(120); }); }); describe("POST /api/documents/token — ทางเข้า x-api-key (regression ไม่เปลี่ยน)", () => { it("key ถูก + body claims → 200 ได้ token อายุ default ใหม่ 604800", async () => { process.env.DOCVER_API_KEY = API_KEY; const response = await POST( makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1" }), { "x-api-key": API_KEY, }), ); expect(response.status).toBe(200); const data = (await response.json()) as { access_token: string; expires_in: number }; expect(data.expires_in).toBe(604_800); const payload = jwtPayload(data.access_token); expect(payload.userid).toBe("u1"); expect(payload.fid).toBe("F1"); expect(payload.purpose).toBe("docver"); }); it("key ผิด → 401", async () => { process.env.DOCVER_API_KEY = API_KEY; const response = await POST( makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), { "x-api-key": "wrong-key", }), ); expect(response.status).toBe(401); }); it("มี header x-api-key แต่ env ไม่ตั้ง → 401 fail closed", async () => { delete process.env.DOCVER_API_KEY; const response = await POST( makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), { "x-api-key": API_KEY, }), ); expect(response.status).toBe(401); }); });