import { createHmac } from "node:crypto"; import { afterEach, describe, expect, it } from "vitest"; import { issueDocverToken, parseTokenRequestBody, requireDocverApiKey, requireDocverBearer, verifyDocverToken, type DocverTokenClaims, } from "@/lib/docver-token"; // docver token (PLAN-DOCVER.md §auth) — validation/auth ล้วนของ /api/documents/*: // ไฟล์นี้ตั้งใจไม่แตะ module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ // external-api-auth.test.ts — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ต้องตั้ง // DATABASE_URL/MINIO_*) const SECRET = "test-secret-docver"; const CLAIMS: DocverTokenClaims = { userid: "123", username: "Sorawit Bholsithi", fid: "3", template: "T12", }; function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T { expect(result.ok).toBe(true); } // --- 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_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_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET; else delete process.env.DOCVER_TOKEN_SECRET; }); function reqWithKey(key?: string): Request { return new Request("http://localhost/docedit/api/documents/token", { headers: key === undefined ? {} : { "x-api-key": key }, }); } function reqWithBearer(authorization: string | null): Request { return new Request("http://localhost/docedit/api/documents/doc1/versions", { headers: authorization === null ? {} : { authorization }, }); } async function bodyOf(res: Response): Promise<{ error: string }> { expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8"); return (await res.json()) as { error: string }; } /** เซ็น payload ใด ๆ ด้วย SECRET ผ่านมือ — ใช้ประกอบเคส claims ผิดรูปที่ issueDocverToken สร้างเองไม่ได้ */ function signPayload(payload: Record): string { const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString( "base64url", ); const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); const signature = createHmac("sha256", SECRET) .update(`${header}.${body}`) .digest("base64url"); return `${header}.${body}.${signature}`; } describe("issueDocverToken / verifyDocverToken", () => { it("issue → verify ได้ claims ครบทุกตัว (userid/username/fid/template)", () => { const verified = verifyDocverToken(issueDocverToken(CLAIMS, SECRET, 60), SECRET); ok(verified); expect(verified.claims).toEqual(CLAIMS); }); it("template เป็นทางเลือก (null ได้) แต่ fid บังคับ — token ไม่มี fid = ไม่ผ่าน", () => { const verified = verifyDocverToken( issueDocverToken({ userid: "u1", username: "ชื่อ", fid: "7", template: null }, SECRET, 60), SECRET, ); ok(verified); expect(verified.claims).toEqual({ userid: "u1", username: "ชื่อ", fid: "7", template: null }); const noFid = signPayload({ userid: "u1", username: "n", template: "T1", iat: 0, exp: Math.floor(Date.now() / 1000) + 60, }); expect(verifyDocverToken(noFid, SECRET).ok).toBe(false); }); it("แก้ payload แล้วใช้ signature เดิม (tampered) หรือ secret ผิด = ไม่ผ่าน", () => { const token = issueDocverToken(CLAIMS, SECRET, 60); const [header, , signature] = token.split("."); const tamperedPayload = Buffer.from( JSON.stringify({ ...CLAIMS, userid: "999" }), "utf8", ).toString("base64url"); expect(verifyDocverToken(`${header}.${tamperedPayload}.${signature}`, SECRET).ok).toBe(false); expect(verifyDocverToken(token, "wrong-secret").ok).toBe(false); }); it("header alg อื่น (none / HS512) = ไม่ผ่าน แม้จะเซ็นด้วย secret ถูก (กัน alg-confusion)", () => { const body = Buffer.from( JSON.stringify({ ...CLAIMS, iat: 0, exp: Math.floor(Date.now() / 1000) + 60 }), "utf8", ).toString("base64url"); for (const alg of ["none", "HS512"]) { const header = Buffer.from(JSON.stringify({ alg, typ: "JWT" }), "utf8").toString("base64url"); const signature = createHmac("sha256", SECRET) .update(`${header}.${body}`) .digest("base64url"); expect(verifyDocverToken(`${header}.${body}.${signature}`, SECRET).ok).toBe(false); } }); it("token หมดอายุ (ttl ติดลบ) หรือไม่มี exp = ไม่ผ่าน", () => { expect(verifyDocverToken(issueDocverToken(CLAIMS, SECRET, -10), SECRET).ok).toBe(false); const noExp = signPayload({ ...CLAIMS, iat: Math.floor(Date.now() / 1000) }); expect(verifyDocverToken(noExp, SECRET).ok).toBe(false); }); it("claims ไม่ครบรูปร่าง (แม้เซ็นถูก) = ไม่ผ่าน — กัน token เซ็นเองหลุดไปใช้", () => { const now = Math.floor(Date.now() / 1000) + 60; for (const bad of [ { username: "n", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี userid { userid: "u", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี username { userid: "", username: "n", fid: "3", template: null, iat: 0, exp: now }, // userid ว่าง { userid: "u", username: "n", fid: 3, template: null, iat: 0, exp: now }, // fid เป็น number { userid: "u", username: "n", fid: "", template: null, iat: 0, exp: now }, // fid ว่าง { userid: "u", username: "n", template: null, iat: 0, exp: now }, // ไม่มี fid (บังคับแล้ว) { userid: "u", username: "n", fid: null, template: null, iat: 0, exp: now }, // fid null ]) { expect(verifyDocverToken(signPayload(bad), SECRET).ok).toBe(false); } }); it("รูป token ผิด (ไม่ใช่ 3 segment / segment เป็น base64 ปกติไม่ใช่ base64url) = ไม่ผ่าน", () => { expect(verifyDocverToken("not-a-jwt", SECRET).ok).toBe(false); expect(verifyDocverToken("a.b.c.d", SECRET).ok).toBe(false); // base64 ปกติมี + / ซึ่ง base64url decode แล้วได้ byte ผิด → header/payload พัง expect(verifyDocverToken("???.???.???", SECRET).ok).toBe(false); }); }); describe("requireDocverApiKey", () => { it("env ไม่ตั้ง / ว่าง / ช่องว่างล้วน = 401 fail closed ทุกกรณี", async () => { for (const value of [undefined, "", " "]) { if (value === undefined) delete process.env.DOCVER_API_KEY; else process.env.DOCVER_API_KEY = value; const res = requireDocverApiKey(reqWithKey("test-key")); expect(res?.status).toBe(401); expect(await bodyOf(res!)).toEqual({ error: "ระบบยังไม่ได้ตั้งค่า API key" }); } }); it("ไม่ใส่ header กับ key ผิด ต้องได้ 401 ข้อความเดียวกันเป๊ะ (กัน enumerate)", async () => { process.env.DOCVER_API_KEY = "test-key"; const missing = requireDocverApiKey(reqWithKey()); const wrong = requireDocverApiKey(reqWithKey("not-the-key")); expect(missing?.status).toBe(401); expect(wrong?.status).toBe(401); expect(await bodyOf(missing!)).toEqual(await bodyOf(wrong!)); }); it("key ตรง = ผ่าน (คืน null) · env มี whitespace รอบ ๆ ก็ trim ให้เทียบได้", () => { process.env.DOCVER_API_KEY = "test-key"; expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull(); process.env.DOCVER_API_KEY = " test-key "; expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull(); }); }); describe("requireDocverBearer", () => { it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => { const deniedBodies = new Set(); const cases: { label: string; secret: string | undefined; authorization: string | null }[] = [ { label: "ไม่มี Authorization", secret: SECRET, authorization: null }, { label: "scheme อื่น (Basic)", secret: SECRET, authorization: "Basic dXNlcjpwYXNz" }, { label: "Bearer ว่างเปล่า", secret: SECRET, authorization: "Bearer " }, { label: "token เน่า", secret: SECRET, authorization: "Bearer garbage.token.here" }, { label: "token หมดอายุ", secret: SECRET, authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, -10)}`, }, { label: "เซ็นด้วย secret ผิด", secret: SECRET, authorization: `Bearer ${issueDocverToken(CLAIMS, "other-secret", 60)}`, }, { label: "secret ไม่ตั้ง (fail closed)", secret: undefined, authorization: null }, { label: "secret ไม่ตั้งแม้จะมี token ถูก", secret: undefined, authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`, }, ]; for (const testCase of cases) { if (testCase.secret === undefined) delete process.env.DOCVER_TOKEN_SECRET; else process.env.DOCVER_TOKEN_SECRET = testCase.secret; const auth = requireDocverBearer(reqWithBearer(testCase.authorization)); expect(auth.ok, testCase.label).toBe(false); if (!auth.ok) { expect(auth.response.status, testCase.label).toBe(401); deniedBodies.add(JSON.stringify(await bodyOf(auth.response))); } } expect(deniedBodies.size).toBe(1); // ข้อความ 401 เดียวกันหมด — ไม่ใช้ endpoint เป็น oracle }); it("Bearer ที่ verify ผ่าน = ได้ claims · คำว่า bearer พิมพ์เล็กก็รับ (case-insensitive)", () => { process.env.DOCVER_TOKEN_SECRET = SECRET; const token = issueDocverToken(CLAIMS, SECRET, 60); const auth = requireDocverBearer(reqWithBearer(`Bearer ${token}`)); ok(auth); expect(auth.claims).toEqual(CLAIMS); const lower = requireDocverBearer(reqWithBearer(`bearer ${token}`)); ok(lower); expect(lower.claims).toEqual(CLAIMS); }); it("env มี whitespace รอบ secret ก็ trim แล้ว verify ได้", () => { process.env.DOCVER_TOKEN_SECRET = ` ${SECRET} `; const auth = requireDocverBearer( reqWithBearer(`Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`), ); ok(auth); expect(auth.claims).toEqual(CLAIMS); }); }); describe("parseTokenRequestBody", () => { it("ครบทุก field = ได้ claims (trim ค่า) · template ไม่ส่ง = null (fid บังคับ)", () => { const full = parseTokenRequestBody({ userid: " 123 ", username: " Sorawit Bholsithi ", fid: " 3 ", template: " T12 ", }); ok(full); expect(full.claims).toEqual({ userid: "123", username: "Sorawit Bholsithi", fid: "3", template: "T12" }); const minimal = parseTokenRequestBody({ userid: "123", username: "Sorawit", fid: "3" }); ok(minimal); expect(minimal.claims).toEqual({ userid: "123", username: "Sorawit", fid: "3", template: null }); }); it("template ส่งมาเป็นช่องว่าง = null (ถือว่าไม่ส่ง)", () => { const result = parseTokenRequestBody({ userid: "123", username: "n", fid: "3", template: " " }); ok(result); expect(result.claims.template).toBeNull(); }); it("ไม่ใช่ object (array / string / null) = 400", () => { for (const raw of [[], "text", null]) { const result = parseTokenRequestBody(raw); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("body ต้องเป็น JSON object"); } }); it("key อื่นนอก allowlist = 400 ไม่รู้จัก field (กัน typo เงียบ ๆ)", () => { const result = parseTokenRequestBody({ userid: "1", username: "n", oops: 1 }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain("oops"); }); it("ขาด userid หรือ username / ส่งมาไม่ใช่ string = 400 ข้อความเฉพาะ field", () => { for (const raw of [ { username: "n" }, { userid: " ", username: "n" }, { userid: 123, username: "n" }, { userid: "1" }, { userid: "1", username: "" }, ]) { const result = parseTokenRequestBody(raw); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/ต้องระบุ (userid|username)/); } } }); it("fid ขาด/null/ว่าง/ไม่ใช่ string (number) หรือ template ไม่ใช่ string = 400", () => { const missing = parseTokenRequestBody({ userid: "1", username: "n" }); expect(missing.ok).toBe(false); if (!missing.ok) expect(missing.error).toContain("fid"); for (const raw of [ { userid: "1", username: "n", fid: null }, { userid: "1", username: "n", fid: 3 }, { userid: "1", username: "n", fid: "" }, { userid: "1", username: "n", fid: " " }, { userid: "1", username: "n", template: 12 }, ]) { expect(parseTokenRequestBody(raw).ok).toBe(false); } }); it("ความยาวเกิน cap: userid > 100 / username > 200 / fid > 200 / template > 100 = 400", () => { expect(parseTokenRequestBody({ userid: "a".repeat(101), username: "n" }).ok).toBe(false); expect( parseTokenRequestBody({ userid: "1", username: "a".repeat(201) }).ok, ).toBe(false); expect( parseTokenRequestBody({ userid: "1", username: "n", fid: "a".repeat(201) }).ok, ).toBe(false); expect( parseTokenRequestBody({ userid: "1", username: "n", template: "a".repeat(101) }).ok, ).toBe(false); }); });