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 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
Server-to-server endpoint for the e-Court backend (x-api-key against DOCVER_API_KEY or EXTERNAL_API_KEY): - type=pdf returns the latest version's PDF bytes (404 when absent) - type=docx stamps fresh ticket custom properties (exp + HmacSha256 via addinTicketSecret(), Ed25519 when PRIVATE_KEY_HEX is set) into an in-memory copy before returning — MinIO object is never modified - docId comes from fid inside the ticket_data query param, which must include fid/uid/uname so the stamped file stays redeemable by the add-in at /api/documents/token (buildStampedTicketData drops null values because parseDocverTicket rejects tid:null) - adds parseTicketDataParam, parseLatestTypeParam, requireDocumentApiKey, findLatestVersionForDoc, getVersionObjectBytes + unit tests (320 pass) - syncs PLAN-DOCVER.md, OpenAPI spec and .env.example Co-Authored-By: Claude Code <noreply@anthropic.com>
200 lines
9.2 KiB
TypeScript
200 lines
9.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
isTicketExpired,
|
|
normalizeHexDigest,
|
|
parseDocverTicket,
|
|
parseTicketDataParam,
|
|
timingSafeEqualHex,
|
|
} from "@/utils/docver-ticket";
|
|
|
|
// PLAN-ADDIN-AUTH.md §4 — pure ล้วนไม่แตะ env/DB อะไรเลย
|
|
// รูป ticket_data อ้างอิงจากตัว stamp จริงของกลุ่ม template (app/api/template/
|
|
// render/route.ts applyTemplate — key เรียงตามตัวอักษรหลังเติม exp)
|
|
|
|
const VALID = JSON.stringify({
|
|
exp: 1793067433,
|
|
fid: "T12_FD155_3s-long_ZZZ",
|
|
tid: "T12",
|
|
uid: "oom",
|
|
uname: "สรวิชญ์",
|
|
});
|
|
|
|
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
|
expect(result.ok).toBe(true);
|
|
}
|
|
|
|
describe("parseDocverTicket", () => {
|
|
it("ticket ครบทุก field = แกะได้ (trim ค่า string)", () => {
|
|
const result = parseDocverTicket(VALID);
|
|
ok(result);
|
|
expect(result.ticket).toEqual({
|
|
exp: 1793067433,
|
|
fid: "T12_FD155_3s-long_ZZZ",
|
|
tid: "T12",
|
|
uid: "oom",
|
|
uname: "สรวิชญ์",
|
|
});
|
|
});
|
|
|
|
it("tid optional — ไม่ส่ง/ส่งค่าว่าง = null", () => {
|
|
const noTid = parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }));
|
|
ok(noTid);
|
|
expect(noTid.ticket.tid).toBeNull();
|
|
const blankTid = parseDocverTicket(
|
|
JSON.stringify({ exp: 1, fid: "f", tid: " ", uid: "u", uname: "n" }),
|
|
);
|
|
ok(blankTid);
|
|
expect(blankTid.ticket.tid).toBeNull();
|
|
});
|
|
|
|
it("JSON ไม่ผ่าน / ไม่ใช่ object (array/string/null) = ไม่ผ่าน", () => {
|
|
for (const raw of ["not json", "[1,2]", '"text"', "null", "{}"]) {
|
|
expect(parseDocverTicket(raw).ok, raw).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("field อื่นนอก allowlist = ไม่ผ่าน (กัน typo/field หลุดจาก spec)", () => {
|
|
const result = parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", oops: 1 }));
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain("oops");
|
|
});
|
|
|
|
it("exp ไม่ใช่จำนวนเต็ม / หาย / uid-uname-fid หายหรือไม่ใช่ string = ไม่ผ่าน", () => {
|
|
for (const ticket of [
|
|
{ fid: "f", uid: "u", uname: "n" }, // ไม่มี exp
|
|
{ exp: "1793067433", fid: "f", uid: "u", uname: "n" }, // exp เป็น string
|
|
{ exp: 1.5, fid: "f", uid: "u", uname: "n" }, // exp ไม่ใช่ integer
|
|
{ exp: 1, uid: "u", uname: "n" }, // ไม่มี fid
|
|
{ exp: 1, fid: "f", uname: "n" }, // ไม่มี uid
|
|
{ exp: 1, fid: "f", uid: "u" }, // ไม่มี uname
|
|
{ exp: 1, fid: "", uid: "u", uname: "n" }, // fid ว่าง
|
|
{ exp: 1, fid: 3, uid: "u", uname: "n" }, // fid เป็น number
|
|
{ exp: 1, fid: "f", uid: "u", uname: "n", tid: 12 }, // tid ไม่ใช่ string
|
|
]) {
|
|
expect(parseDocverTicket(JSON.stringify(ticket)).ok, JSON.stringify(ticket)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("ความยาวเกิน cap: uid > 100 / uname > 200 / fid > 200 / tid > 100 = ไม่ผ่าน", () => {
|
|
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u".repeat(101), uname: "n" })).ok).toBe(false);
|
|
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n".repeat(201) })).ok).toBe(false);
|
|
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false);
|
|
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isTicketExpired", () => {
|
|
it("exp อนาคต = ยังไม่หมด · exp ตรง now พอดี/อดีต = หมดแล้ว", () => {
|
|
const now = 1_793_000_000;
|
|
expect(isTicketExpired(now + 1, now)).toBe(false);
|
|
expect(isTicketExpired(now, now)).toBe(true);
|
|
expect(isTicketExpired(now - 1, now)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("normalizeHexDigest", () => {
|
|
const HEX64 = "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9";
|
|
|
|
it("hex lowercase ครบ 64 = ผ่าน · trim และ uppercase ก็ normalize ให้", () => {
|
|
expect(normalizeHexDigest(HEX64)).toBe(HEX64);
|
|
expect(normalizeHexDigest(` ${HEX64} `)).toBe(HEX64);
|
|
expect(normalizeHexDigest(HEX64.toUpperCase())).toBe(HEX64);
|
|
});
|
|
|
|
it("รูปไม่ใช่ (สั้น/ยาว/ไม่ใช่ hex/ว่าง) = null", () => {
|
|
for (const bad of ["", "abc", `${HEX64}00`, `g${HEX64.slice(1)}`, " "]) {
|
|
expect(normalizeHexDigest(bad), bad).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("timingSafeEqualHex", () => {
|
|
it("ค่าเท่ากัน = true ต่างกัน = false · ความยาวไม่เท่า = false ทันที", () => {
|
|
const a = "ab".repeat(32);
|
|
expect(timingSafeEqualHex(a, a)).toBe(true);
|
|
expect(timingSafeEqualHex(a, "cd".repeat(32))).toBe(false);
|
|
expect(timingSafeEqualHex(a, "ab")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// parseTicketDataParam — query param `ticket_data` ของ GET /api/documents/latest:
|
|
// ticket ก่อนเติม exp (server เติมตอน stamp) แต่บังคับ fid/uid/uname เพราะ
|
|
// parseDocverTicket บังคับทั้งสามตอน add-in redeem — ขาด = ไฟล์ที่คืนไปเปิดไม่ได้
|
|
describe("parseTicketDataParam", () => {
|
|
it("fid+uid+uname น้อยสุด = ผ่าน (trim ค่า) · +tid ก็ได้ · ไม่มี exp ก็ผ่าน", () => {
|
|
const minimal = parseTicketDataParam(
|
|
JSON.stringify({ fid: " F1 ", uid: " u1 ", uname: " ทดสอบ " }),
|
|
);
|
|
ok(minimal);
|
|
expect(minimal.ticket).toEqual({ fid: "F1", uid: "u1", uname: "ทดสอบ", tid: null });
|
|
|
|
const withTid = parseTicketDataParam(
|
|
JSON.stringify({ fid: "F1", tid: "T12", uid: "u1", uname: "ทดสอบ" }),
|
|
);
|
|
ok(withTid);
|
|
expect(withTid.ticket.tid).toBe("T12");
|
|
});
|
|
|
|
it("tid ส่งค่าว่าง = null · exp ถ้าส่งมาเป็นจำนวนเต็ม = รับ (เติมทับตอน stamp)", () => {
|
|
const blankTid = parseTicketDataParam(
|
|
JSON.stringify({ fid: "F1", tid: " ", uid: "u1", uname: "n" }),
|
|
);
|
|
ok(blankTid);
|
|
expect(blankTid.ticket.tid).toBeNull();
|
|
|
|
const withExp = parseTicketDataParam(
|
|
JSON.stringify({ exp: 1_793_067_433, fid: "F1", uid: "u1", uname: "n" }),
|
|
);
|
|
ok(withExp);
|
|
});
|
|
|
|
it("ไม่ส่ง / ว่าง / ยาวเกิน 2048 = ไม่ผ่าน", () => {
|
|
expect(parseTicketDataParam(null).ok).toBe(false);
|
|
expect(parseTicketDataParam(" ").ok).toBe(false);
|
|
expect(parseTicketDataParam("x".repeat(2049)).ok).toBe(false);
|
|
});
|
|
|
|
it("JSON ไม่ผ่าน / ไม่ใช่ object (array/string/null/ตัวเลข) = ไม่ผ่าน", () => {
|
|
for (const raw of ["not json", "[1,2]", '"text"', "null", "42"]) {
|
|
expect(parseTicketDataParam(raw).ok, raw).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("field อื่นนอก allowlist = ไม่ผ่าน (error ระบุชื่อ field)", () => {
|
|
const result = parseTicketDataParam(
|
|
JSON.stringify({ fid: "f", uid: "u", uname: "n", oops: 1 }),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain("oops");
|
|
});
|
|
|
|
it("exp ส่งมาผิดชนิด (string/ทศนิยม) = ไม่ผ่าน แม้จะไม่ได้ใช้ค่าเดิม", () => {
|
|
for (const exp of ["1793067433", 1.5]) {
|
|
const result = parseTicketDataParam(
|
|
JSON.stringify({ exp, fid: "f", uid: "u", uname: "n" }),
|
|
);
|
|
expect(result.ok, String(exp)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("ขาด/ผิดชนิด fid·uid·uname รายตัว = ไม่ผ่าน — บังคับครบเหมือน parseDocverTicket", () => {
|
|
for (const ticket of [
|
|
{ uid: "u", uname: "n" }, // ไม่มี fid
|
|
{ fid: "f", uname: "n" }, // ไม่มี uid
|
|
{ fid: "f", uid: "u" }, // ไม่มี uname
|
|
{ fid: "", uid: "u", uname: "n" }, // fid ว่าง
|
|
{ fid: 3, uid: "u", uname: "n" }, // fid เป็น number
|
|
{ fid: "f", uid: "u", uname: "n", tid: 12 }, // tid ไม่ใช่ string
|
|
]) {
|
|
expect(parseTicketDataParam(JSON.stringify(ticket)).ok, JSON.stringify(ticket)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("ความยาวเกิน cap: uid > 100 / uname > 200 / fid > 200 / tid > 100 = ไม่ผ่าน", () => {
|
|
expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u".repeat(101), uname: "n" })).ok).toBe(false);
|
|
expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u", uname: "n".repeat(201) })).ok).toBe(false);
|
|
expect(parseTicketDataParam(JSON.stringify({ fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false);
|
|
expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false);
|
|
});
|
|
});
|