// extractDocumentSnapshot — หัวใจของ "fields ของ version = return ของ /api/template/extract" // (2026-10-30): คุมว่าก้อน {cc, bookmark, properties, verify} แกะได้ครบและ verify ทำงาน // ตาม secret ที่ผู้เรียกส่ง · ไฟล์นี้ตั้งใจไม่แตะ module ที่ลาก @/db หรือ minio มาด้วย // (pattern document-versioning.test.ts — รันได้ไม่ต้องตั้ง DATABASE_URL/MINIO_*) import { createHmac, generateKeyPairSync } from "node:crypto"; import { describe, expect, it } from "vitest"; import PizzaZip from "pizzip"; import { verifyAddinTicketHmac } from "@/lib/addin-ticket"; import { verifyEd25519 } from "@/lib/lib-utils"; import { buildStampedTicketData, extractDocumentSnapshot, getCustomDocProps, stampTicketProps, } from "@/lib/lib-tmpl"; import { parseDocverTicket } from "@/utils/docver-ticket"; const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const CUSTOM_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; const VT_NS = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; function escapeXml(unsafe: string): string { return unsafe.replace(/&/g, "&").replace(//g, ">"); } /** docx จิ๋วในหน่วยความจำ: CC 1 ตัว (tag blackno) + bookmark sec1 ครอบ 2 run */ function documentXml(): string { return ( `\n` + `` + `` + `ด1/2569` + `` + `hello world` + `` + `` ); } function customPropsXml(ticketData: string, hmacHex: string): string { const prop = (pid: number, name: string, value: string) => `` + `${escapeXml(value)}`; return ( `\n` + `` + prop(2, "ticket_data", ticketData) + prop(3, "HmacSha256", hmacHex) + `` ); } function buildDocx(entries: Record): Buffer { const zip = new PizzaZip(); for (const [name, content] of Object.entries(entries)) { zip.file(name, content); } return Buffer.from(zip.generate({ type: "nodebuffer" })); } const TICKET_DATA = JSON.stringify({ exp: 2_000_000_000, fid: "F1", uid: "u1", uname: "ทดสอบ" }); const TICKET_HMAC = createHmac("sha256", "super_secret").update(TICKET_DATA, "utf8").digest("hex"); function stampedDocx(): Buffer { return buildDocx({ "word/document.xml": documentXml(), "docProps/custom.xml": customPropsXml(TICKET_DATA, TICKET_HMAC), }); } // setCustomDocProps (ทาง stampTicketProps) บังคับให้ zip มี _rels/.rels และ // [Content_Types].xml ก่อน (ensureCustomRelationship/ensureCustomContentType) const RELS_XML = `\n` + `` + `` + ``; const CONTENT_TYPES_XML = `\n` + `` + `` + `` + ``; /** docx จิ๋วที่ stamp ได้ — ครบส่วนที่ setCustomDocProps บังคับ */ function stampableDocx(extra: Record = {}): Buffer { return buildDocx({ "word/document.xml": documentXml(), "_rels/.rels": RELS_XML, "[Content_Types].xml": CONTENT_TYPES_XML, ...extra, }); } describe("extractDocumentSnapshot", () => { it("แกะครบทั้งก้อน {cc, bookmark, properties, verify} เหมือน /api/template/extract", () => { const snapshot = extractDocumentSnapshot(stampedDocx(), { secret: "super_secret" }); expect(snapshot.cc).toEqual({ blackno: "ด1/2569" }); expect(snapshot.bookmark).toEqual({ sec1: "hello world" }); expect(snapshot.properties.custom?.ticket_data).toBe(TICKET_DATA); expect(snapshot.properties.custom?.HmacSha256).toBe(TICKET_HMAC); expect(snapshot.verify.HmacSha256).toBe(true); // exp (unix sec) ฟอร์แมต th-TH เป็นปี พ.ศ. — 2000000000 = 18 พ.ค. ค.ศ. 2033 = พ.ศ. 2576 expect(typeof snapshot.verify.exp).toBe("string"); expect(snapshot.verify.exp).toContain("2576"); // ไม่ส่ง public key = ไม่มี key Ed25519 ใน verify (เหมือน extract ที่ไม่ส่ง public_key) expect(snapshot.verify.Ed25519).toBeUndefined(); }); it("secret ผิด = HmacSha256 false · ไม่ส่ง secret = ไม่มี key HmacSha256 เลย", () => { expect(extractDocumentSnapshot(stampedDocx(), { secret: "wrong-secret" }).verify.HmacSha256).toBe(false); const noSecret = extractDocumentSnapshot(stampedDocx()); expect(noSecret.verify.HmacSha256).toBeUndefined(); // exp ยังอ่านได้แม้ไม่ตรวจ HMAC (มาจาก ticket_data ตรง ๆ — ปี พ.ศ. 2576) expect(noSecret.verify.exp).toContain("2576"); }); it("ไฟล์ไม่มี custom properties = verify ว่าง แต่ cc/bookmark ยังแกะได้", () => { const bare = buildDocx({ "word/document.xml": documentXml() }); const snapshot = extractDocumentSnapshot(bare, { secret: "super_secret" }); expect(snapshot.verify).toEqual({}); // getDocProps เติม custom เป็น {} เสมอ (ไม่มี core/app ใน zip จิ๋วนี้) expect(snapshot.properties).toEqual({ custom: {} }); expect(snapshot.cc).toEqual({ blackno: "ด1/2569" }); }); it("ไฟล์เสีย (ไม่ใช่ ZIP ที่มี word/document.xml) = throw ให้ route ตอบ 400/500 ตามกลุ่ม", () => { expect(() => extractDocumentSnapshot(Buffer.from("PK\x03\x04-not-a-zip"))).toThrow(); }); }); // buildStampedTicketData/stampTicketProps — ฝั่ง stamp ของ GET /api/documents/latest // (recipe เดียวกับ applyTemplate แต่ ticket มาจาก query param และห้ามกลืน error) describe("buildStampedTicketData", () => { it("เติม exp = floor(now/1000)+ttl เรียง key ตามตัวอักษร — nowMs กำหนดได้ให้ deterministic", () => { const nowMs = 1_793_000_000_000; const result = buildStampedTicketData({ fid: "F1", uid: "u1", uname: "น" }, 300, nowMs); expect(result).toBe( JSON.stringify({ exp: 1_793_000_300, fid: "F1", uid: "u1", uname: "น" }), ); // ลำดับ key ขาเข้าไม่มีผล — เรียงเป็น exp,fid,tid,uid,uname เสมอ (tid มีก็เข้าที่) const shuffled = buildStampedTicketData( { uname: "น", uid: "u1", tid: "T12", fid: "F1" }, 300, nowMs, ); expect(Object.keys(JSON.parse(shuffled))).toEqual(["exp", "fid", "tid", "uid", "uname"]); }); it("ผลลัพธ์ redeem ได้จริง — parseDocverTicket (ฝั่ง add-in) แกะผ่าน", () => { const ticketData = buildStampedTicketData( { fid: "F1", uid: "u1", uname: "ทดสอบ" }, 300, Date.now(), ); expect(parseDocverTicket(ticketData).ok).toBe(true); }); it("ค่า null/undefined ถูกตัดออก — parseTicketDataParam ใช้ null แทน tid ที่ไม่มี แต่ parseDocverTicket ไม่ยอม tid:null", () => { const result = buildStampedTicketData( { fid: "F1", uid: "u1", uname: "ทดสอบ", tid: null }, 300, 1_793_000_000_000, ); expect(result).toBe( JSON.stringify({ exp: 1_793_000_300, fid: "F1", uid: "u1", uname: "ทดสอบ" }), ); expect(parseDocverTicket(result).ok).toBe(true); }); }); describe("stampTicketProps", () => { const SECRET = "super_secret"; /** seed 32 byte (hex) + public key 32 byte (hex) จาก node:crypto — เข้ารูปที่ * signEd25519/verifyEd25519 ของ lib-utils รับ */ function ed25519KeyPair(): { seedHex: string; publicHex: string } { const { privateKey, publicKey } = generateKeyPairSync("ed25519"); const privDer = privateKey.export({ type: "pkcs8", format: "der" }) as Buffer; const pubDer = publicKey.export({ type: "spki", format: "der" }) as Buffer; return { seedHex: privDer.subarray(privDer.length - 32).toString("hex"), publicHex: pubDer.subarray(pubDer.length - 32).toString("hex"), }; } it("stamp ticket_data+HmacSha256 ลงไฟล์เปล่า · ไม่ให้ private key = ไม่มี Ed25519", () => { const ticketData = buildStampedTicketData( { fid: "F1", uid: "u1", uname: "ทดสอบ" }, 300, Date.now(), ); const bare = stampableDocx(); const result = stampTicketProps(bare, ticketData, { secret: SECRET }); expect(result.ed25519).toBeNull(); expect(result.hmacSha256).toBe( createHmac("sha256", SECRET).update(ticketData, "utf8").digest("hex"), ); const props = getCustomDocProps(result.buffer); expect(props.ticket_data).toBe(ticketData); expect(props.HmacSha256).toBe(result.hmacSha256); expect(props.Ed25519).toBeUndefined(); // non-destructive — buffer ต้นฉบับไม่ถูกแตะ (route ห้ามเขียนกลับ MinIO) expect(bare.equals(stampableDocx())).toBe(true); }); it("ให้ private key = มี Ed25519 และ verify ผ่านด้วย public key คู่กัน", () => { const { seedHex, publicHex } = ed25519KeyPair(); const ticketData = buildStampedTicketData( { fid: "F1", uid: "u1", uname: "ทดสอบ" }, 300, Date.now(), ); const result = stampTicketProps(stampableDocx(), ticketData, { secret: SECRET, privateKeyHex: seedHex, }); expect(result.ed25519).not.toBeNull(); expect(verifyEd25519(ticketData, result.ed25519!, publicHex)).toBe(true); expect(getCustomDocProps(result.buffer).Ed25519).toBe(result.ed25519); }); it("chain จบถึง end: verifyAddinTicketHmac (ทางเข้า /api/documents/token) ผ่าน", () => { const ticketData = buildStampedTicketData( { fid: "F1", uid: "u1", uname: "ทดสอบ" }, 300, Date.now(), ); const result = stampTicketProps(stampableDocx(), ticketData, { secret: SECRET, }); const verified = verifyAddinTicketHmac(ticketData, result.hmacSha256, SECRET); expect(verified.ok).toBe(true); // secret ผิด = ไม่ผ่าน (HMAC ผูกกับ secret ของ add-in จริง) expect(verifyAddinTicketHmac(ticketData, result.hmacSha256, "wrong").ok).toBe(false); }); it("stamp ทับไฟล์ที่มี ticket เดิม = ค่าถูกแทนทั้งก้อน (exp เก่าหาย)", () => { const fresh = buildStampedTicketData( { fid: "F2", uid: "u2", uname: "ใหม่" }, 300, Date.now(), ); const result = stampTicketProps( stampableDocx({ "docProps/custom.xml": customPropsXml(TICKET_DATA, TICKET_HMAC) }), fresh, { secret: SECRET }, ); const props = getCustomDocProps(result.buffer); expect(props.ticket_data).toBe(fresh); expect(String(props.ticket_data)).not.toContain("2000000000"); // exp เก่าของ TICKET_DATA }); it("ไฟล์เสีย = throw (route ตอบ 500 — ห้ามกลืนแบบ applyTemplate)", () => { expect(() => stampTicketProps(Buffer.from("not-a-zip"), "{}", { secret: SECRET }), ).toThrow(); }); });