Merge branch 'forgejo-dev' into working
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Resolve CLAUDE.md: คำอธิบาย lib/document-versioning.ts เอาของ forgejo-dev (field `fields` ถอดออก 2026-10-30) ส่วน lib/docver-token.ts เอาของ working (purpose login / verifyDocverLoginToken — โค้ดฝั่ง forgejo-dev มีครบแล้ว แค่คำอธิบายล้าหลัง) Verify: npx tsc --noEmit ผ่าน · vitest 23 files / 338 tests ผ่าน Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
commit
a288e5643c
31 changed files with 1445 additions and 278 deletions
|
|
@ -7,7 +7,6 @@ import {
|
|||
isDocxBytes,
|
||||
isPdfBytes,
|
||||
parseDownloadFormat,
|
||||
parseFieldsJson,
|
||||
parseVersionIdParam,
|
||||
parseVersionUploadForm,
|
||||
sanitizeDisplayFileName,
|
||||
|
|
@ -71,23 +70,6 @@ describe("isDocxBytes / isPdfBytes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("parseFieldsJson", () => {
|
||||
it("แยก JSON object ล้วนได้ และ `{}` = null (ถือว่าไม่ส่ง)", () => {
|
||||
const result = parseFieldsJson('{"blackno":"คดีหมายเลขดำที่"}');
|
||||
ok(result);
|
||||
expect(result.fields).toEqual({ blackno: "คดีหมายเลขดำที่" });
|
||||
const empty = parseFieldsJson("{}");
|
||||
ok(empty);
|
||||
expect(empty.fields).toBeNull();
|
||||
});
|
||||
|
||||
it("ตกเมื่อ JSON เพี้ยน / ไม่ใช่ object / value ไม่ใช่ string", () => {
|
||||
for (const raw of ["ไม่ใช่ JSON", "[1,2]", '"string"', "null", '{"a":1}']) {
|
||||
expect(parseFieldsJson(raw).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVersionUploadForm", () => {
|
||||
// identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก
|
||||
// Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts)
|
||||
|
|
@ -101,7 +83,6 @@ describe("parseVersionUploadForm", () => {
|
|||
const form = baseForm();
|
||||
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
|
||||
form.append("message", " ");
|
||||
form.append("fields", '{"blackno":"1"}');
|
||||
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
|
||||
const result = parseVersionUploadForm(form);
|
||||
ok(result);
|
||||
|
|
@ -109,7 +90,6 @@ describe("parseVersionUploadForm", () => {
|
|||
clientVersionId: "11111111-1111-4111-8111-111111111111",
|
||||
message: null,
|
||||
fileName: "คำสั่งเรียก.docx",
|
||||
fields: { blackno: "1" },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -131,6 +111,14 @@ describe("parseVersionUploadForm", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("ส่ง fields ใน form = 400 (2026-10-30 — server แกะจากไฟล์เองแบบ /api/template/extract)", () => {
|
||||
const form = baseForm();
|
||||
form.append("fields", '{"blackno":"1"}');
|
||||
const result = parseVersionUploadForm(form);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain("fields");
|
||||
});
|
||||
|
||||
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
|
||||
const form = baseForm();
|
||||
form.append("message", new File(["x"], "m.txt"));
|
||||
|
|
@ -147,31 +135,6 @@ describe("parseVersionUploadForm", () => {
|
|||
over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1));
|
||||
expect(parseVersionUploadForm(over).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("fields ใน form ผิดกติกาส่งต่อ error ของ parseFieldsJson", () => {
|
||||
const form = baseForm();
|
||||
form.append("fields", '{"a":1}');
|
||||
expect(parseVersionUploadForm(form).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("ค่าของ fields ไม่จำกัดความยาว (เอกสาร 100–200 หน้า — 2026-10-27) · key รับได้ถึง 1,000", () => {
|
||||
const big = baseForm();
|
||||
big.append("fields", JSON.stringify({ detail1: "พ".repeat(1_000_000) }));
|
||||
expect(parseVersionUploadForm(big).ok).toBe(true);
|
||||
|
||||
const manyKeys = baseForm();
|
||||
manyKeys.append(
|
||||
"fields",
|
||||
JSON.stringify(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [`tag${i}`, "x"]))),
|
||||
);
|
||||
expect(parseVersionUploadForm(manyKeys).ok).toBe(true);
|
||||
const over = baseForm();
|
||||
over.append(
|
||||
"fields",
|
||||
JSON.stringify(Object.fromEntries(Array.from({ length: 1_001 }, (_, i) => [`tag${i}`, "x"]))),
|
||||
);
|
||||
expect(parseVersionUploadForm(over).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
import {
|
||||
issueDocverToken,
|
||||
parseTokenRequestBody,
|
||||
requireDocumentApiKey,
|
||||
requireDocverApiKey,
|
||||
requireDocverBearer,
|
||||
verifyDocverLoginToken,
|
||||
|
|
@ -31,12 +32,16 @@ function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T
|
|||
// --- env save/restore — require* อ่าน env ตอนเรียก ไม่ใช่ตอน module load
|
||||
const HAD_API_KEY = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_API_KEY");
|
||||
const ORIG_API_KEY = process.env.DOCVER_API_KEY;
|
||||
const HAD_EXTERNAL_KEY = Object.prototype.hasOwnProperty.call(process.env, "EXTERNAL_API_KEY");
|
||||
const ORIG_EXTERNAL_KEY = process.env.EXTERNAL_API_KEY;
|
||||
const HAD_SECRET = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_TOKEN_SECRET");
|
||||
const ORIG_SECRET = process.env.DOCVER_TOKEN_SECRET;
|
||||
|
||||
afterEach(() => {
|
||||
if (HAD_API_KEY) process.env.DOCVER_API_KEY = ORIG_API_KEY;
|
||||
else delete process.env.DOCVER_API_KEY;
|
||||
if (HAD_EXTERNAL_KEY) process.env.EXTERNAL_API_KEY = ORIG_EXTERNAL_KEY;
|
||||
else delete process.env.EXTERNAL_API_KEY;
|
||||
if (HAD_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET;
|
||||
else delete process.env.DOCVER_TOKEN_SECRET;
|
||||
});
|
||||
|
|
@ -176,6 +181,59 @@ describe("requireDocverApiKey", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// requireDocumentApiKey — auth ของ GET /api/documents/latest: x-api-key เทียบ
|
||||
// DOCVER_API_KEY หรือ EXTERNAL_API_KEY ตัวใดตัวหนึ่ง (ต่างจาก /token)
|
||||
describe("requireDocumentApiKey", () => {
|
||||
function setKeys(docver: string | undefined, external: string | undefined): void {
|
||||
if (docver === undefined) delete process.env.DOCVER_API_KEY;
|
||||
else process.env.DOCVER_API_KEY = docver;
|
||||
if (external === undefined) delete process.env.EXTERNAL_API_KEY;
|
||||
else process.env.EXTERNAL_API_KEY = external;
|
||||
}
|
||||
|
||||
it("ทั้งคู่ไม่ตั้ง / ว่าง / ช่องว่างล้วน = 401 fail closed ทุกกรณี", async () => {
|
||||
for (const [docver, external] of [
|
||||
[undefined, undefined],
|
||||
["", ""],
|
||||
[" ", " "],
|
||||
] as const) {
|
||||
setKeys(docver, external);
|
||||
const res = requireDocumentApiKey(reqWithKey("test-key"));
|
||||
expect(res?.status, `${docver}/${external}`).toBe(401);
|
||||
expect(await bodyOf(res!)).toEqual({ error: "ระบบยังไม่ได้ตั้งค่า API key" });
|
||||
}
|
||||
});
|
||||
|
||||
it("ตั้งอันเดียวตัวไหนก็ผ่านเมื่อ key ตรงตัวนั้น · ตั้งทั้งคู่ตรงอันที่สองก็ผ่าน", () => {
|
||||
setKeys("docver-key", undefined);
|
||||
expect(requireDocumentApiKey(reqWithKey("docver-key"))).toBeNull();
|
||||
expect(requireDocumentApiKey(reqWithKey("external-key"))?.status).toBe(401);
|
||||
|
||||
setKeys(undefined, "external-key");
|
||||
expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull();
|
||||
expect(requireDocumentApiKey(reqWithKey("docver-key"))?.status).toBe(401);
|
||||
|
||||
setKeys("docver-key", "external-key");
|
||||
expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull();
|
||||
});
|
||||
|
||||
it("ไม่ใส่ header กับ key ผิด ต้องได้ 401 ข้อความเดียวกันเป๊ะ (กัน enumerate)", async () => {
|
||||
setKeys("docver-key", "external-key");
|
||||
const missing = requireDocumentApiKey(reqWithKey());
|
||||
const wrong = requireDocumentApiKey(reqWithKey("not-any-key"));
|
||||
expect(missing?.status).toBe(401);
|
||||
expect(wrong?.status).toBe(401);
|
||||
expect(await bodyOf(missing!)).toEqual(await bodyOf(wrong!));
|
||||
});
|
||||
|
||||
it("env มี whitespace รอบ ๆ ก็ trim ให้เทียบได้ทั้งสองตัว", () => {
|
||||
setKeys(" docver-key ", undefined);
|
||||
expect(requireDocumentApiKey(reqWithKey("docver-key"))).toBeNull();
|
||||
setKeys(undefined, " external-key ");
|
||||
expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireDocverBearer", () => {
|
||||
it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => {
|
||||
const deniedBodies = new Set<string>();
|
||||
|
|
|
|||
264
tests/lib/lib-tmpl.test.ts
Normal file
264
tests/lib/lib-tmpl.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
// 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, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** docx จิ๋วในหน่วยความจำ: CC 1 ตัว (tag blackno) + bookmark sec1 ครอบ 2 run */
|
||||
function documentXml(): string {
|
||||
return (
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
||||
`<w:document xmlns:w="${WORD_NS}"><w:body>` +
|
||||
`<w:sdt><w:sdtPr><w:tag w:val="blackno"/></w:sdtPr>` +
|
||||
`<w:sdtContent><w:p><w:r><w:t>ด1/2569</w:t></w:r></w:p></w:sdtContent></w:sdt>` +
|
||||
`<w:p><w:bookmarkStart w:id="7" w:name="sec1"/>` +
|
||||
`<w:r><w:t>hello </w:t></w:r><w:r><w:t>world</w:t></w:r>` +
|
||||
`<w:bookmarkEnd w:id="7"/></w:p>` +
|
||||
`</w:body></w:document>`
|
||||
);
|
||||
}
|
||||
|
||||
function customPropsXml(ticketData: string, hmacHex: string): string {
|
||||
const prop = (pid: number, name: string, value: string) =>
|
||||
`<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="${pid}" name="${name}">` +
|
||||
`<vt:lpwstr>${escapeXml(value)}</vt:lpwstr></property>`;
|
||||
return (
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
||||
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}">` +
|
||||
prop(2, "ticket_data", ticketData) +
|
||||
prop(3, "HmacSha256", hmacHex) +
|
||||
`</Properties>`
|
||||
);
|
||||
}
|
||||
|
||||
function buildDocx(entries: Record<string, string>): 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 =
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
|
||||
`</Relationships>`;
|
||||
|
||||
const CONTENT_TYPES_XML =
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
||||
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||
`<Default Extension="xml" ContentType="application/xml"/>` +
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
|
||||
`</Types>`;
|
||||
|
||||
/** docx จิ๋วที่ stamp ได้ — ครบส่วนที่ setCustomDocProps บังคับ */
|
||||
function stampableDocx(extra: Record<string, string> = {}): 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue