feat: add GET /api/documents/latest returning latest version file with stamped ticket
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>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-31 11:09:42 +07:00
parent 5bc8d9a817
commit 0b6ee8cc84
12 changed files with 806 additions and 9 deletions

View file

@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
issueDocverToken,
parseTokenRequestBody,
requireDocumentApiKey,
requireDocverApiKey,
requireDocverBearer,
verifyDocverToken,
@ -30,12 +31,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;
});
@ -175,6 +180,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>();