2026-11-09 14:30:29 +07:00
|
|
|
|
// POST /api/auth/token — ทางเข้า token หลักของระบบ (2026-11-09 รอบสอง: ถอด
|
|
|
|
|
|
// /api/note/auth/token — ทาง mint login-token รวมมาที่นี่ผ่าน purpose:"login"
|
|
|
|
|
|
// ส่วน /api/documents/token ของกลุ่ม docver **คงไว้** เป็น alias re-export ของ
|
|
|
|
|
|
// เส้นนี้) · contract ทางเข้า ticket ฉบับ 2026-11-04 + ทาง x-api-key แยกตาม
|
|
|
|
|
|
// body purpose · route pure ล้วน (ไม่ import @/db/minio) จึง unit test ได้โดย
|
|
|
|
|
|
// ไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า
|
|
|
|
|
|
// route ไม่แตะ DB
|
2026-11-04 21:18:19 +07:00
|
|
|
|
import { createHmac } from "node:crypto";
|
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
|
|
|
2026-11-09 14:30:29 +07:00
|
|
|
|
import { POST } from "@/app/api/auth/token/route";
|
|
|
|
|
|
import { POST as documentsTokenPOST } from "@/app/api/documents/token/route";
|
2026-11-04 21:18:19 +07:00
|
|
|
|
|
|
|
|
|
|
/** 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 {
|
2026-11-09 14:30:29 +07:00
|
|
|
|
return new Request("http://localhost/docedit/api/auth/token", {
|
2026-11-04 21:18:19 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-09 14:30:29 +07:00
|
|
|
|
describe("POST /api/auth/token — ทางเข้า ticket (contract 2026-11-04)", () => {
|
2026-11-04 21:18:19 +07:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-09 14:30:29 +07:00
|
|
|
|
describe("POST /api/auth/token — ทางเข้า x-api-key แบบ docver (contract เดิมของ /api/documents/token)", () => {
|
2026-11-04 21:18:19 +07:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-11-09 14:30:29 +07:00
|
|
|
|
|
|
|
|
|
|
describe("POST /api/auth/token — ทางเข้า purpose:'login' (mint login-token ลิงก์ SSO ของ note)", () => {
|
|
|
|
|
|
it("x-api-key + purpose:login + ttl_seconds → 200 login-token อายุตาม ttl (template:null)", async () => {
|
|
|
|
|
|
process.env.DOCVER_API_KEY = API_KEY;
|
|
|
|
|
|
const response = await POST(
|
|
|
|
|
|
makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1", ttl_seconds: 120, purpose: "login" }), {
|
|
|
|
|
|
"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(120);
|
|
|
|
|
|
const payload = jwtPayload(data.access_token);
|
|
|
|
|
|
expect(payload.purpose).toBe("login");
|
|
|
|
|
|
expect(payload.template).toBeNull();
|
|
|
|
|
|
expect((payload.exp as number) - (payload.iat as number)).toBe(120);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("purpose:login ไม่ส่ง ttl_seconds → default 300 (D5)", async () => {
|
|
|
|
|
|
process.env.DOCVER_API_KEY = API_KEY;
|
|
|
|
|
|
const response = await POST(
|
|
|
|
|
|
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1", purpose: "login" }), {
|
|
|
|
|
|
"x-api-key": API_KEY,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
|
expect(((await response.json()) as { expires_in: number }).expires_in).toBe(300);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("purpose ค่าอื่น (docver/ตัวเลข) → 400 (จากทาง x-api-key ไม่ใช่ทาง ticket)", async () => {
|
|
|
|
|
|
process.env.DOCVER_API_KEY = API_KEY;
|
|
|
|
|
|
for (const purpose of ["docver", "admin", 42]) {
|
|
|
|
|
|
const response = await POST(
|
|
|
|
|
|
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1", purpose }), {
|
|
|
|
|
|
"x-api-key": API_KEY,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(response.status, `purpose=${JSON.stringify(purpose)}`).toBe(400);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("purpose:login + template / ttl นอกช่วง 60–600 → 400 (allowlist ของ parseLoginTokenBody)", async () => {
|
|
|
|
|
|
process.env.DOCVER_API_KEY = API_KEY;
|
|
|
|
|
|
for (const body of [
|
|
|
|
|
|
JSON.stringify({ userid: "u1", username: "n", fid: "F1", template: "T12", purpose: "login" }),
|
|
|
|
|
|
JSON.stringify({ userid: "u1", username: "n", fid: "F1", ttl_seconds: 601, purpose: "login" }),
|
|
|
|
|
|
JSON.stringify({ userid: "u1", username: "n", fid: "F1", ttl_seconds: 59, purpose: "login" }),
|
|
|
|
|
|
]) {
|
|
|
|
|
|
const response = await POST(
|
|
|
|
|
|
makeRequest(body, { "x-api-key": API_KEY }),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(response.status, body).toBe(400);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("alias /api/documents/token (กลุ่ม docver คงไว้ — กัน copy กลับมา drift อีก)", () => {
|
|
|
|
|
|
it("export function เดียวกันกับ /api/auth/token (re-export)", () => {
|
|
|
|
|
|
expect(documentsTokenPOST).toBe(POST);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("body เดิมของ e-Court (ไม่มี purpose) ผ่านเส้นเก่ายังได้ docver-token ตาม contract เดิม", async () => {
|
|
|
|
|
|
process.env.DOCVER_API_KEY = API_KEY;
|
|
|
|
|
|
const response = await documentsTokenPOST(
|
|
|
|
|
|
new Request("http://localhost/docedit/api/documents/token", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "content-type": "application/json", "x-api-key": API_KEY },
|
|
|
|
|
|
body: JSON.stringify({ userid: "u1", username: "n", fid: "F1", template: "T12" }),
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
|
const payload = jwtPayload(((await response.json()) as { access_token: string }).access_token);
|
|
|
|
|
|
expect(payload.purpose).toBe("docver");
|
|
|
|
|
|
expect(payload.template).toBe("T12");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|