- POST /api/documents/token issues JWT HS256 (claims userid/username/fid/template) after x-api-key check against new env DOCVER_API_KEY (fail-closed, timing-safe, uniform 401) - all docver endpoints now require Authorization: Bearer, verified with env DOCVER_TOKEN_SECRET (TTL via DOCVER_TOKEN_TTL_SECONDS, default 3600); secret unset = 401 on verify / 500 on issue - identity (userId/userName) + templateId extracted from token claims instead of request payload; removed from multipart form (legacy fields now 400) - lib/docver-token.ts: hand-rolled JWT via node:crypto only — alg pinned to HS256 (anti alg-confusion), timing-safe compare with both sides hashed, exp enforced, identical 401 for all bearer failure modes - openapi spec: /api/documents/token path + DocverBearer scheme; PLAN-DOCVER.md / CLAUDE.md / .env.example updated - tests: new tests/lib/docver-token.test.ts + adjusted parseVersionUploadForm tests (258 pass, no DB env needed) - package-lock.json: sync minio (^8.0.7) that was missing from the earlier docver commit Co-Authored-By: Claude Code <noreply@anthropic.com>
240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
|
||
import {
|
||
DOCVER_MESSAGE_MAX,
|
||
contentDispositionValue,
|
||
docverError,
|
||
isDocxBytes,
|
||
isPdfBytes,
|
||
parseDocId,
|
||
parseDownloadFormat,
|
||
parseFieldsJson,
|
||
parseVersionIdParam,
|
||
parseVersionUploadForm,
|
||
sanitizeDisplayFileName,
|
||
sanitizeFilePartName,
|
||
} from "@/lib/document-versioning";
|
||
|
||
// docver (PLAN-DOCVER.md) — validation ล้วนของ /api/documents/*: ไฟล์นี้ตั้งใจไม่แตะ
|
||
// module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ external-api-auth.test.ts
|
||
// — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ตั้ง DATABASE_URL/MINIO_*)
|
||
|
||
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
||
expect(result.ok).toBe(true);
|
||
}
|
||
|
||
describe("parseDocId", () => {
|
||
it("รับ GUID / รหัส e-Court / charset ที่อนุญาตทั้งหมด และ trim รอบ ๆ", () => {
|
||
for (const raw of [
|
||
"123e4567-e89b-12d3-a456-426614174000",
|
||
"ecourt:123",
|
||
"DOC_2026.10",
|
||
" doc1 ",
|
||
"a".repeat(200),
|
||
]) {
|
||
const result = parseDocId(raw);
|
||
ok(result);
|
||
expect(result.docId).toBe(raw.trim());
|
||
}
|
||
});
|
||
|
||
it("ตกเมื่อว่าง / เกิน 200 / มีช่องว่างกลาง / ไทย / slash / '..'", () => {
|
||
for (const raw of ["", " ", "a".repeat(201), "doc 1", "เอกสาร1", "a/b", "..", "a\\b"]) {
|
||
expect(parseDocId(raw).ok).toBe(false);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("parseVersionIdParam", () => {
|
||
it("รับ UUID ทั้งพิมพ์ใหญ่/เล็ก (normalize ลงตัวพิมพ์เล็ก) + trim", () => {
|
||
const result = parseVersionIdParam(" 6F9619FF-8B86-D011-B42D-00C04FC964FF ");
|
||
ok(result);
|
||
expect(result.versionId).toBe("6f9619ff-8b86-d011-b42d-00c04fc964ff");
|
||
});
|
||
|
||
it("ตกเมื่อไม่ใช่รูป UUID", () => {
|
||
for (const raw of ["not-a-uuid", "123", "{6f9619ff-8b86-d011-b42d-00c04fc964ff}", ""]) {
|
||
expect(parseVersionIdParam(raw).ok).toBe(false);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("parseDownloadFormat", () => {
|
||
it("ไม่ส่ง/ว่าง/ข้างในมี whitespace = docx (default ตาม contract)", () => {
|
||
for (const raw of [null, "", " ", " docx "]) {
|
||
const result = parseDownloadFormat(raw);
|
||
ok(result);
|
||
expect(result.format).toBe("docx");
|
||
}
|
||
});
|
||
|
||
it("รับ pdf ตรง ๆ และตกเมื่อค่าอื่น (รวมพิมพ์ใหญ่)", () => {
|
||
const pdf = parseDownloadFormat("pdf");
|
||
ok(pdf);
|
||
expect(pdf.format).toBe("pdf");
|
||
for (const raw of ["exe", "PDF", "docxx"]) {
|
||
expect(parseDownloadFormat(raw).ok).toBe(false);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("isDocxBytes / isPdfBytes", () => {
|
||
it("ผ่านเมื่อ magic bytes ตรง (แม้ buffer สั้นกว่าที่อ่าน)", () => {
|
||
expect(isDocxBytes(Buffer.from("PK\x03\x04rest-of-zip"))).toBe(true);
|
||
expect(isPdfBytes(Buffer.from("%PDF-1.7\n..."))).toBe(true);
|
||
});
|
||
|
||
it("ตกเมื่อ signature ผิดหรือ buffer ว่าง/สั้นกว่า signature", () => {
|
||
expect(isDocxBytes(Buffer.from("%PDF-1.7"))).toBe(false);
|
||
expect(isDocxBytes(Buffer.from("PK"))).toBe(false);
|
||
expect(isDocxBytes(Buffer.alloc(0))).toBe(false);
|
||
expect(isPdfBytes(Buffer.from("PK\x03\x04"))).toBe(false);
|
||
expect(isPdfBytes(Buffer.from("%PD"))).toBe(false);
|
||
expect(isPdfBytes(Buffer.alloc(0))).toBe(false);
|
||
});
|
||
});
|
||
|
||
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)
|
||
function baseForm(): FormData {
|
||
const form = new FormData();
|
||
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
|
||
return form;
|
||
}
|
||
|
||
it("happy path: แยกทุก field + trim ค่าว่างเป็น null + fileName ตัด path ที่ฝังมา", () => {
|
||
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);
|
||
expect(result.form).toEqual({
|
||
clientVersionId: "11111111-1111-4111-8111-111111111111",
|
||
message: null,
|
||
fileName: "คำสั่งเรียก.docx",
|
||
fields: { blackno: "1" },
|
||
});
|
||
});
|
||
|
||
it("ตกเมื่อมี field ที่ไม่รู้จัก (กัน typo เงียบ ๆ)", () => {
|
||
const form = baseForm();
|
||
form.append("oops", "1");
|
||
const result = parseVersionUploadForm(form);
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) expect(result.error).toContain("oops");
|
||
});
|
||
|
||
it("ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field (identity มาจาก token)", () => {
|
||
for (const legacy of ["userId", "userName", "templateId"]) {
|
||
const form = baseForm();
|
||
form.append(legacy, "x");
|
||
const result = parseVersionUploadForm(form);
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) expect(result.error).toContain(legacy);
|
||
}
|
||
});
|
||
|
||
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
|
||
const form = baseForm();
|
||
form.append("message", new File(["x"], "m.txt"));
|
||
const result = parseVersionUploadForm(form);
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) expect(result.error).toContain("message");
|
||
});
|
||
|
||
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {
|
||
const edge = baseForm();
|
||
edge.append("message", "a".repeat(DOCVER_MESSAGE_MAX));
|
||
ok(parseVersionUploadForm(edge));
|
||
const over = baseForm();
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {
|
||
it("ตัด path ฝังมา (ทั้ง / และ \\) + trim ว่าง = null + cap 200", () => {
|
||
expect(sanitizeDisplayFileName("C:\\Users\\judge\\a.docx")).toBe("a.docx");
|
||
expect(sanitizeDisplayFileName("/tmp/b.pdf")).toBe("b.pdf");
|
||
expect(sanitizeDisplayFileName(" ")).toBeNull();
|
||
expect(sanitizeDisplayFileName(null)).toBeNull();
|
||
expect(sanitizeDisplayFileName(`${"a".repeat(250)}.docx`)).toHaveLength(200);
|
||
});
|
||
|
||
it("File.name ที่ undici ถอดเป็น latin1 (ชื่อไทยเพี้ยนแบบ N4) กู้คืนเป็น UTF-8 ได้", () => {
|
||
const original = "คำสั่งเรียกเอกสาร.docx";
|
||
const mangled = Buffer.from(original, "utf8").toString("latin1");
|
||
expect(mangled).not.toBe(original); // ต้องเพี้ยนจริงก่อน ไม่ใช่ทดสอบตัวเอง
|
||
expect(sanitizeFilePartName(mangled)).toBe(original);
|
||
// ชื่อ ASCII ไม่มีตัว U+0080–U+00FF จึงไม่ถูกแตะ
|
||
expect(sanitizeFilePartName("plain.docx")).toBe("plain.docx");
|
||
});
|
||
});
|
||
|
||
describe("docverError", () => {
|
||
it("สร้าง Response JSON ไทยพร้อม status ที่ขอ", async () => {
|
||
const res = docverError("ไม่พบ version นี้ของเอกสาร", 404);
|
||
expect(res.status).toBe(404);
|
||
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
||
expect(await res.json()).toEqual({ error: "ไม่พบ version นี้ของเอกสาร" });
|
||
});
|
||
});
|
||
|
||
describe("contentDispositionValue", () => {
|
||
it("ไม่มีชื่อ = ชื่อ default อย่างเดียว", () => {
|
||
expect(contentDispositionValue(null, "docx")).toBe('attachment; filename="document.docx"');
|
||
expect(contentDispositionValue(" ", "pdf")).toBe('attachment; filename="document.pdf"');
|
||
});
|
||
|
||
it("ชื่อ ASCII: เติม extension ที่ขาด / คงชื่อที่มีอยู่แล้ว / ตัด quote-backslash", () => {
|
||
expect(contentDispositionValue("Report Final", "docx")).toBe(
|
||
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final',
|
||
);
|
||
expect(contentDispositionValue("Report Final.docx", "docx")).toBe(
|
||
'attachment; filename="Report Final.docx"; filename*=UTF-8\'\'Report%20Final.docx',
|
||
);
|
||
expect(contentDispositionValue('bad"name\\', "pdf")).toBe(
|
||
'attachment; filename="badname.pdf"; filename*=UTF-8\'\'bad%22name%5C',
|
||
);
|
||
});
|
||
|
||
it("ชื่อไทยล้วน = ASCII fallback เป็นชื่อ default แต่ filename* ยังชื่อเต็ม", () => {
|
||
expect(contentDispositionValue("คำสั่งเรียก", "docx")).toBe(
|
||
'attachment; filename="document.docx"; filename*=UTF-8\'\''
|
||
+ encodeURIComponent("คำสั่งเรียก"),
|
||
);
|
||
});
|
||
|
||
it("ชื่อผสมไทย+เลข = ส่วน ASCII ที่เหลือใช้เป็น fallback ได้", () => {
|
||
expect(contentDispositionValue("รายงาน 2026", "pdf")).toBe(
|
||
'attachment; filename="2026.pdf"; filename*=UTF-8\'\''
|
||
+ encodeURIComponent("รายงาน 2026"),
|
||
);
|
||
});
|
||
});
|