216 lines
9.7 KiB
TypeScript
216 lines
9.7 KiB
TypeScript
|
|
// POST /api/documents/token — contract ทางเข้า ticket ฉบับ 2026-11-04 + regression ทางเข้า x-api-key
|
||
|
|
// route pure ล้วนหลังถอด hasVersionByUser (ไม่ import @/db/minio อีกต่อไป) จึง unit test
|
||
|
|
// ได้โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า route
|
||
|
|
// ไม่แตะ DB (เลิก "ตรวจเคยมี version" ของ requirement ขั้น 7 ฉบับ 2026-10-28)
|
||
|
|
import { createHmac } from "node:crypto";
|
||
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||
|
|
|
||
|
|
import { POST } from "@/app/api/documents/token/route";
|
||
|
|
|
||
|
|
/** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */
|
||
|
|
const SECRET_DEFAULT = "super_secret";
|
||
|
|
const TOKEN_SECRET = "unit-test-token-secret";
|
||
|
|
const API_KEY = "unit-test-api-key";
|
||
|
|
|
||
|
|
const SAVED_ENV: Record<string, string | undefined> = {};
|
||
|
|
|
||
|
|
function mintTicket(
|
||
|
|
exp: number,
|
||
|
|
overrides?: { fid?: string; uid?: string; uname?: string; tid?: string },
|
||
|
|
): { ticketData: string; ticketHmac: string } {
|
||
|
|
const ticket = {
|
||
|
|
exp,
|
||
|
|
fid: overrides?.fid ?? "F1",
|
||
|
|
...(overrides?.tid !== undefined ? { tid: overrides.tid } : { tid: "T12" }),
|
||
|
|
uid: overrides?.uid ?? "oom",
|
||
|
|
uname: overrides?.uname ?? "สรวิชญ์",
|
||
|
|
};
|
||
|
|
const ticketData = JSON.stringify(ticket);
|
||
|
|
const ticketHmac = createHmac("sha256", SECRET_DEFAULT).update(ticketData, "utf8").digest("hex");
|
||
|
|
return { ticketData, ticketHmac };
|
||
|
|
}
|
||
|
|
|
||
|
|
function futureExp(): number {
|
||
|
|
return Math.floor(Date.now() / 1000) + 3_600;
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeRequest(body: BodyInit, headers?: Record<string, string>): Request {
|
||
|
|
return new Request("http://localhost/docedit/api/documents/token", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "content-type": "application/json", ...headers },
|
||
|
|
body,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function ticketBody(ticketData: string, ticketHmac: string): string {
|
||
|
|
return JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac });
|
||
|
|
}
|
||
|
|
|
||
|
|
/** decode JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ assert claims/exp เท่านั้น */
|
||
|
|
function jwtPayload(token: string): Record<string, unknown> {
|
||
|
|
const payload = token.split(".")[1];
|
||
|
|
if (!payload) throw new Error("token ไม่มี payload");
|
||
|
|
return JSON.parse(
|
||
|
|
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
|
||
|
|
) as Record<string, unknown>;
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
for (const key of [
|
||
|
|
"ADDIN_TICKET_SECRET",
|
||
|
|
"SECRET",
|
||
|
|
"DOCVER_API_KEY",
|
||
|
|
"DOCVER_TOKEN_SECRET",
|
||
|
|
"DOCVER_TOKEN_TTL_SECONDS",
|
||
|
|
]) {
|
||
|
|
SAVED_ENV[key] = process.env[key];
|
||
|
|
}
|
||
|
|
process.env.DOCVER_TOKEN_SECRET = TOKEN_SECRET;
|
||
|
|
delete process.env.ADDIN_TICKET_SECRET; // ใช้ default "super_secret" ของ addinTicketSecret
|
||
|
|
delete process.env.SECRET;
|
||
|
|
delete process.env.DOCVER_TOKEN_TTL_SECONDS;
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
for (const [key, value] of Object.entries(SAVED_ENV)) {
|
||
|
|
if (value === undefined) delete process.env[key];
|
||
|
|
else process.env[key] = value;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("POST /api/documents/token — ทางเข้า ticket (contract 2026-11-04)", () => {
|
||
|
|
it("ticket ถูกทั้งลายเซ็น+อายุ → 200 + JWT claims จาก ticket + expires_in 604800 (default 7 วัน)", async () => {
|
||
|
|
const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" });
|
||
|
|
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
const data = (await response.json()) as { access_token: string; token_type: string; expires_in: number };
|
||
|
|
expect(data.token_type).toBe("Bearer");
|
||
|
|
expect(data.expires_in).toBe(604_800);
|
||
|
|
|
||
|
|
const payload = jwtPayload(data.access_token);
|
||
|
|
expect(payload.userid).toBe("u1");
|
||
|
|
expect(payload.username).toBe("นายทดสอบ");
|
||
|
|
expect(payload.fid).toBe("F9");
|
||
|
|
expect(payload.template).toBe("T12");
|
||
|
|
expect(payload.purpose).toBe("docver");
|
||
|
|
expect((payload.exp as number) - (payload.iat as number)).toBe(604_800);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("body ไม่ใช่ JSON → 400", async () => {
|
||
|
|
const response = await POST(makeRequest("not-json{"));
|
||
|
|
expect(response.status).toBe(400);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("ไม่ส่ง ticket มาเลย / ค่าว่าง → 400 (ย้ายจาก 401 ตาม contract ฟอร์แม็ตพัง)", async () => {
|
||
|
|
for (const body of ["{}", JSON.stringify({ ticket_data: "" , HmacSha256: "ab".repeat(32) })]) {
|
||
|
|
const response = await POST(makeRequest(body));
|
||
|
|
expect(response.status, body).toBe(400);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("field นอก allowlist / ผิดชนิด / ยาวเกิน cap → 400", async () => {
|
||
|
|
for (const body of [
|
||
|
|
JSON.stringify({ ticket_data: "x", HmacSha256: "y", oops: 1 }),
|
||
|
|
JSON.stringify({ ticket_data: 42, HmacSha256: "y" }),
|
||
|
|
JSON.stringify({ ticket_data: "x".repeat(2049), HmacSha256: "y" }),
|
||
|
|
]) {
|
||
|
|
const response = await POST(makeRequest(body));
|
||
|
|
expect(response.status, body).toBe(400);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("ลายเซ็นผิด (แก้ ticket_data / digest เพี้ยน / secret ของ server ต่าง) → 401 ข้อความเดียวกันทุกเคส", async () => {
|
||
|
|
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||
|
|
const tampered = ticketData.replace("F1", "F2"); // แก้ 1 ตัว — HMAC ไม่ตรง
|
||
|
|
|
||
|
|
const cases: { label: string; body: string; secretEnv?: [string, string] }[] = [
|
||
|
|
{ label: "แก้ ticket_data", body: ticketBody(tampered, ticketHmac) },
|
||
|
|
{ label: "digest ไม่ใช่ hex", body: ticketBody(ticketData, "zz".repeat(32)) },
|
||
|
|
{
|
||
|
|
label: "secret ฝั่ง server ต่าง",
|
||
|
|
body: ticketBody(ticketData, ticketHmac),
|
||
|
|
secretEnv: ["ADDIN_TICKET_SECRET", "different-secret"],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
const messages = new Set<string>();
|
||
|
|
for (const { label, body, secretEnv } of cases) {
|
||
|
|
if (secretEnv) process.env[secretEnv[0]] = secretEnv[1];
|
||
|
|
const response = await POST(makeRequest(body));
|
||
|
|
expect(response.status, label).toBe(401);
|
||
|
|
messages.add((await response.json()).error);
|
||
|
|
if (secretEnv) delete process.env[secretEnv[0]];
|
||
|
|
}
|
||
|
|
expect(messages.size).toBe(1); // ข้อความเดียว กัน oracle
|
||
|
|
expect(Array.from(messages)[0]).toBe("ticket ไม่ถูกต้อง");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("ลายเซ็นถูกแต่ exp อดีต → 403 \"ticket ของ e-Court หมดอายุ\" (ไม่ตรวจ DB — route ไม่มี DB)", async () => {
|
||
|
|
const { ticketData, ticketHmac } = mintTicket(Math.floor(Date.now() / 1000) - 60);
|
||
|
|
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||
|
|
|
||
|
|
expect(response.status).toBe(403);
|
||
|
|
expect((await response.json()).error).toBe("ticket ของ e-Court หมดอายุ");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("HMAC ถูกแต่ ticket_data ผิดกติกา (field หลุด allowlist) → 401", async () => {
|
||
|
|
const invalid = JSON.stringify({ exp: futureExp(), fid: "f", uid: "u", uname: "n", oops: 1 });
|
||
|
|
const hmac = createHmac("sha256", SECRET_DEFAULT).update(invalid, "utf8").digest("hex");
|
||
|
|
const response = await POST(makeRequest(ticketBody(invalid, hmac)));
|
||
|
|
expect(response.status).toBe(401);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("DOCVER_TOKEN_SECRET ไม่ตั้ง → 500 (ความผิด config ฝั่ง server ไม่ใช่ของผู้เรียก)", async () => {
|
||
|
|
delete process.env.DOCVER_TOKEN_SECRET;
|
||
|
|
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||
|
|
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||
|
|
expect(response.status).toBe(500);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("DOCVER_TOKEN_TTL_SECONDS=120 → expires_in 120 (env override default)", async () => {
|
||
|
|
process.env.DOCVER_TOKEN_TTL_SECONDS = "120";
|
||
|
|
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||
|
|
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
expect(((await response.json()) as { expires_in: number }).expires_in).toBe(120);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("POST /api/documents/token — ทางเข้า x-api-key (regression ไม่เปลี่ยน)", () => {
|
||
|
|
it("key ถูก + body claims → 200 ได้ token อายุ default ใหม่ 604800", async () => {
|
||
|
|
process.env.DOCVER_API_KEY = API_KEY;
|
||
|
|
const response = await POST(
|
||
|
|
makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1" }), {
|
||
|
|
"x-api-key": API_KEY,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
const data = (await response.json()) as { access_token: string; expires_in: number };
|
||
|
|
expect(data.expires_in).toBe(604_800);
|
||
|
|
const payload = jwtPayload(data.access_token);
|
||
|
|
expect(payload.userid).toBe("u1");
|
||
|
|
expect(payload.fid).toBe("F1");
|
||
|
|
expect(payload.purpose).toBe("docver");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("key ผิด → 401", async () => {
|
||
|
|
process.env.DOCVER_API_KEY = API_KEY;
|
||
|
|
const response = await POST(
|
||
|
|
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), {
|
||
|
|
"x-api-key": "wrong-key",
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
expect(response.status).toBe(401);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("มี header x-api-key แต่ env ไม่ตั้ง → 401 fail closed", async () => {
|
||
|
|
delete process.env.DOCVER_API_KEY;
|
||
|
|
const response = await POST(
|
||
|
|
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), {
|
||
|
|
"x-api-key": API_KEY,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
expect(response.status).toBe(401);
|
||
|
|
});
|
||
|
|
});
|