eCourt-note/tests/app/api/auth/token.test.ts
Your Name 5121970f31
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Failing after 30s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Failing after 1s
refactor(auth): consolidate auth under /api/auth/* endpoints
- ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session)
  เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key
  docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in)
  + POST /api/auth/session (แลก credential + register N8 + N11 fid-only)
- POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court
  เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา)
- ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session
  (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy)
- utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/
  localStorage store + memory fallback) — lib/note-session ใช้ร่วม
  (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin
  ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง)
- นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ
  auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป
  /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401)
- openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path
  เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec
- เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev
- tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) +
  e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-09 14:30:29 +07:00

295 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
import { createHmac } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { POST } from "@/app/api/auth/token/route";
import { POST as documentsTokenPOST } 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/auth/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/auth/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/auth/token — ทางเข้า x-api-key แบบ docver (contract เดิมของ /api/documents/token)", () => {
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);
});
});
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");
});
});