eCourt-note/tests/lib/note-auth.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

204 lines
10 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.

import { describe, expect, it } from "vitest";
import {
devMintEnabled,
parseDevLinkBody,
parseLoginTokenBody,
parseSessionExchangeBody,
parseSsoLinkParams,
ssoBanner,
} from "@/lib/note-auth";
// PLAN-SSO.md §4 — pure module ของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ
// ผู้ใช้ "note ใช้แบบ add-in": parse ลิงก์ / body แลก token / แบนเนอร์) — ไฟล์นี้
// ตั้งใจไม่แตะ module ใดที่ลาก @/db หรือ node:crypto มาด้วย จึงรันได้โดยไม่ต้อง
// ตั้ง DATABASE_URL
describe("parseSsoLinkParams (D12)", () => {
const url = (query: string): URL => new URL(`https://x.go.th/docedit/note?${query}`);
it("มี t เดี่ยว = ทาง jwt + ส่ง fileUrl/typeId ผ่าน", () => {
const parsed = parseSsoLinkParams(url("fileUrl=https%3A%2F%2Ffile.example%2Fa.pdf&typeId=T12&t=abc.def.ghi"));
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.credential).toEqual({ kind: "jwt", token: "abc.def.ghi" });
expect(parsed.fileUrl).toBe("https://file.example/a.pdf");
expect(parsed.typeId).toBe("T12");
}
});
it("มี ticket ครบสองค่า (ไม่มี t) = ทาง ticket", () => {
const ticketData = encodeURIComponent('{"exp":1,"fid":"f","uid":"u","uname":"n"}');
const parsed = parseSsoLinkParams(url(`ticket_data=${ticketData}&HmacSha256=${"a".repeat(64)}`));
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.credential).toEqual({
kind: "ticket",
ticketData: '{"exp":1,"fid":"f","uid":"u","uname":"n"}',
hmacHex: "a".repeat(64),
});
expect(parsed.fileUrl).toBeUndefined();
}
});
it("มีทั้ง t และ ticket (แม้เหลือ HmacSha256 ตัวเดียว) = ok:false (fail closed)", () => {
expect(parseSsoLinkParams(url(`t=a.b.c&ticket_data=%7B%7D&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false);
expect(parseSsoLinkParams(url(`t=a.b.c&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false);
});
it("ticket ครึ่งเดียว / ไม่มีชุดไหนเลย = ok:false", () => {
expect(parseSsoLinkParams(url("ticket_data=%7B%7D")).ok).toBe(false);
expect(parseSsoLinkParams(url(`HmacSha256=${"a".repeat(64)}`)).ok).toBe(false);
expect(parseSsoLinkParams(url("fileUrl=https%3A%2F%2Fx")).ok).toBe(false);
expect(parseSsoLinkParams(url("")).ok).toBe(false);
});
it("ค่ายาวเกิน cap = ok:false", () => {
expect(parseSsoLinkParams(url(`t=${"a".repeat(8193)}`)).ok).toBe(false);
expect(parseSsoLinkParams(url(`ticket_data=${"a".repeat(2049)}&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false);
expect(parseSsoLinkParams(url(`t=x.y.z&fileUrl=${"a".repeat(2049)}`)).ok).toBe(false);
});
});
describe("parseSessionExchangeBody (POST /api/auth/session)", () => {
it("t เดี่ยว + fileUrl/typeId ผ่าน — ได้ credential ทาง jwt", () => {
const parsed = parseSessionExchangeBody({
t: "a.b.c",
fileUrl: "https://f.example/a.pdf",
typeId: "T12",
});
expect(parsed).toEqual({
ok: true,
credential: { kind: "jwt", token: "a.b.c" },
fileUrl: "https://f.example/a.pdf",
typeId: "T12",
});
});
it("ticket ครบสองค่า = ทาง ticket · ครึ่งเดียว/ไม่มีเลย = 400", () => {
const ticket = JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" });
expect(parseSessionExchangeBody({ ticket_data: ticket, HmacSha256: "a".repeat(64) })).toEqual({
ok: true,
credential: { kind: "ticket", ticketData: ticket, hmacHex: "a".repeat(64) },
});
expect(parseSessionExchangeBody({ ticket_data: ticket }).ok).toBe(false);
expect(parseSessionExchangeBody({ HmacSha256: "a".repeat(64) }).ok).toBe(false);
expect(parseSessionExchangeBody({}).ok).toBe(false);
});
it("สอง credential พร้อมกัน = 400 (D12) · field อื่น/ผิด type = 400 · cap ยาว = 400", () => {
expect(parseSessionExchangeBody({ t: "a.b.c", HmacSha256: "a".repeat(64) }).ok).toBe(false);
expect(parseSessionExchangeBody({ t: "a.b.c", oops: 1 }).ok).toBe(false);
expect(parseSessionExchangeBody({ t: 123 }).ok).toBe(false);
expect(parseSessionExchangeBody({ t: "a".repeat(8193) }).ok).toBe(false);
expect(parseSessionExchangeBody({ t: "a.b.c", fileUrl: "a".repeat(2049) }).ok).toBe(false);
expect(parseSessionExchangeBody(null).ok).toBe(false);
expect(parseSessionExchangeBody("text").ok).toBe(false);
expect(parseSessionExchangeBody([1, 2]).ok).toBe(false);
});
});
describe("parseLoginTokenBody (mint route §3.1)", () => {
it("ครบ 3 field = ได้ claims + ttl default 300 · ttl ในช่วงใช้ค่าที่ส่ง", () => {
const full = parseLoginTokenBody({ userid: "123", username: "สรวิชญ์", fid: "F1" });
expect(full).toEqual({ ok: true, claims: { userid: "123", username: "สรวิชญ์", fid: "F1" }, ttlSeconds: 300 });
const withTtl = parseLoginTokenBody({ userid: "123", username: "n", fid: "F1", ttl_seconds: 60 });
expect(withTtl).toEqual({ ok: true, claims: { userid: "123", username: "n", fid: "F1" }, ttlSeconds: 60 });
});
it("ttl_seconds นอกช่วง 60–600 / ไม่ใช่ integer = 400", () => {
for (const bad of [59, 601, 0, -1, 300.5, "300", null]) {
expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", ttl_seconds: bad }).ok).toBe(false);
}
});
it("ส่ง purpose หรือ field อื่นมา = 400 (login-token เอกสิทธิ์ — D4)", () => {
expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", purpose: "docver" }).ok).toBe(false);
expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", template: "T" }).ok).toBe(false);
});
it("ไม่ใช่ object / ขาด field / identity ว่าง = 400", () => {
expect(parseLoginTokenBody(null).ok).toBe(false);
expect(parseLoginTokenBody("text").ok).toBe(false);
expect(parseLoginTokenBody({ username: "n", fid: "F" }).ok).toBe(false);
expect(parseLoginTokenBody({ userid: " ", username: "n", fid: "F" }).ok).toBe(false);
expect(parseLoginTokenBody({ userid: "1", username: "n", fid: 3 }).ok).toBe(false);
});
it("identity มี & = + % = 400 — อักขระที่ตีความต่างกันตาม context ง่าย (2026-10-30)", () => {
for (const bad of ["a&b", "a=b", "สมหญิง+สุข", "a%41"]) {
expect(parseLoginTokenBody({ userid: bad, username: "n", fid: "F" }).ok, bad).toBe(false);
expect(parseLoginTokenBody({ userid: "1", username: bad, fid: "F" }).ok, bad).toBe(false);
}
// อักขระอื่นที่ใช้ได้ปกติ (ไทย/ขีด/เว้นวรรค) = ผ่าน
expect(parseLoginTokenBody({ userid: "1234-5", username: "สรวิชญ์ ญาติ", fid: "F" }).ok).toBe(true);
});
});
describe("ssoBanner (D8/D15)", () => {
it("ครบ 4 ค่ามีข้อความ · ค่าอื่น/undefined = null (ไม่แสดง)", () => {
for (const reason of ["expired", "invalid", "error", "required"]) {
const banner = ssoBanner(reason);
expect(banner).not.toBeNull();
expect(["warning", "error"]).toContain(banner!.intent);
expect(banner!.text.length).toBeGreaterThan(0);
}
expect(ssoBanner("weird")).toBeNull();
expect(ssoBanner(undefined)).toBeNull();
});
});
describe("parseDevLinkBody / devMintEnabled (dev-link)", () => {
it("ครบ field = ได้ claims + ttl ตามเกณฑ์เดียวกับ mint · mode default jwt · field ส่วนเสริมผ่านเข้ามาได้", () => {
const full = parseDevLinkBody({
userid: "oom",
username: "สรวิชญ์",
fid: "F123",
ttl_seconds: 120,
fileUrl: "/samples/a.pdf",
typeId: "T9",
mode: "ticket",
});
expect(full).toEqual({
ok: true,
claims: { userid: "oom", username: "สรวิชญ์", fid: "F123" },
ttlSeconds: 120,
fileUrl: "/samples/a.pdf",
typeId: "T9",
mode: "ticket",
});
const minimal = parseDevLinkBody({ userid: "u", username: "n", fid: "F" });
expect(minimal.ok).toBe(true);
if (minimal.ok) {
expect(minimal.mode).toBe("jwt");
expect(minimal.fileUrl).toBeUndefined();
expect(minimal.ttlSeconds).toBe(300);
}
});
it("identity/ttl ผิดเกณฑ์ = 400 ข้อความเดียวกับ parseLoginTokenBody · mode/fileUrl/typeId ผิดรูป = 400", () => {
expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", ttl_seconds: 601 }).ok).toBe(false);
expect(parseDevLinkBody({ userid: " ", username: "n", fid: "F" }).ok).toBe(false);
expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", mode: "basic" }).ok).toBe(false);
expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", fileUrl: "" }).ok).toBe(false);
expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", oops: 1 }).ok).toBe(false);
});
it("devMintEnabled: ต้องเป็น 1 เป๊ะ — ค่าอื่น = false · ใช้ได้ทุก NODE_ENV (2026-11-02)", () => {
const orig = process.env.NODE_ENV;
const writable = process.env as Record<string, string | undefined>;
process.env.NOTE_SSO_DEV_MINT = "1";
writable.NODE_ENV = "development";
expect(devMintEnabled()).toBe(true);
// production ที่ตั้ง env ไว้ก็เปิด (เครื่องทดสอบ) — gate เหลือ env ตัวเดียว
writable.NODE_ENV = "production";
expect(devMintEnabled()).toBe(true);
writable.NODE_ENV = orig ?? "test";
for (const value of [undefined, "", "0", "true", " yes "]) {
if (value === undefined) delete process.env.NOTE_SSO_DEV_MINT;
else process.env.NOTE_SSO_DEV_MINT = value;
expect(devMintEnabled(), String(value)).toBe(false);
}
delete process.env.NOTE_SSO_DEV_MINT;
});
});