Refactor document versioning to extract fields from uploaded .docx files on the server side

- Removed the `fields` parameter from the version upload form, as it is now extracted from the uploaded .docx file using the `extractContentControls` function.
- Updated related types and interfaces to reflect the new structure of version fields.
- Modified the OpenAPI specification to document the changes in the versioning process.
- Adjusted tests to ensure compliance with the new behavior, including validation for the absence of the `fields` parameter.
- Implemented a new function `extractDocumentSnapshot` to handle the extraction of content controls and document properties from .docx files.
- Updated metadata and versioning services to align with the new extraction logic.
This commit is contained in:
Suphonchai Phoonsawat 2026-10-31 10:21:17 +07:00
parent bc61f0fd2e
commit cdae1ceedd
17 changed files with 380 additions and 179 deletions

View file

@ -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", () => {

101
tests/lib/lib-tmpl.test.ts Normal file
View file

@ -0,0 +1,101 @@
// 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 } from "node:crypto";
import { describe, expect, it } from "vitest";
import PizzaZip from "pizzip";
import { extractDocumentSnapshot } from "@/lib/lib-tmpl";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** 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),
});
}
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();
});
});

View file

@ -2,7 +2,8 @@
// versioningService หลังปลด mock (2026-10-27) — คุมทั้งสองโหมด:
// - ของจริง: sign-in ผ่าน signInWithTicket ให้ได้ Bearer ก่อน แล้วตรวจ FormData ของ
// uploadVersion ตรง contract POST /api/documents/versions (ห้ามส่ง userId/userName/
// templateId — identity มาจาก Bearer หมด), getVersions แปลง DTO, getDownloadLink ใส่ ?format=
// templateId — identity มาจาก Bearer หมด · และห้ามส่ง fields — server แกะจากไฟล์เอง
// 2026-10-30), getVersions แปลง DTO, getDownloadLink ใส่ ?format=
// - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย)
import { createHmac, webcrypto } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
@ -83,12 +84,14 @@ describe("versioningService (real mode)", () => {
expect((form.get("file") as File).name).toBe("คำฟ้อง.docx");
expect((form.get("pdf") as File).name).toBe("document.pdf");
expect(form.get("clientVersionId")).toBe("client-vid-1");
expect(JSON.parse(form.get("fields") as string)).toEqual({ blackno: "ด1" });
expect(form.get("message")).toBe("แก้ไขย่อหน้า 3"); // trim แล้ว
// contract 2026-10-22: ห้ามมี field identity/template เหลืออยู่เลย
expect(form.has("userId")).toBe(false);
expect(form.has("userName")).toBe(false);
expect(form.has("templateId")).toBe(false);
// contract 2026-10-30: fields ก็ห้ามส่ง — server แกะจากไฟล์ .docx เอง
// (ตัวเดียวกับ /api/template/extract) แม้ caller จะ pass เข้ามาตาม signature เดิม
expect(form.has("fields")).toBe(false);
});
it("pdf ว่าง/ไม่แนบ = ไม่ append field pdf (ไฟล์เปล่า = version ไม่มี PDF)", async () => {
@ -105,6 +108,8 @@ describe("versioningService (real mode)", () => {
it("getVersions แปลง DocumentVersionDTO → VersionRecord ครบทุก field", async () => {
await signInReal();
// fields = ก้อนเดียวกับ return ของ /api/template/extract (server แกะจากไฟล์เอง)
const fields = { cc: { blackno: "ด1" }, bookmark: {}, properties: { custom: {} }, verify: {} };
const fetchMock = vi.fn(
async (_url: string, _init?: RequestInit) =>
new Response(
@ -116,7 +121,7 @@ describe("versioningService (real mode)", () => {
createdByUserId: "oom",
createdByUserName: "สรวิชญ์",
contentHash: "ab",
fields: { blackno: "ด1" },
fields,
message: null,
hasPdf: true,
},
@ -134,7 +139,7 @@ describe("versioningService (real mode)", () => {
createdByUserId: "oom",
createdByUserName: "สรวิชญ์",
contentHash: "ab",
fields: { blackno: "ด1" },
fields,
message: null,
hasPdf: true,
},