eCourt-note/tests/lib/note-auth.test.ts
Your Name d37bc2a3f0 feat(sso): switch note session from cookies to token
เปลี่ยน session ของแอป note จาก cookie คู่ oacCase/oacCaseSig มาเป็น
access_token แบบ add-in ตามคำขอผู้ใช้ (2026-10-30):

- POST /api/note/auth/session แลก credential ของลิงก์ e-Court
  (JWT ทาง A / ticket ทาง B) เป็น access_token purpose=login อายุ 8 ชม.
  พร้อมลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server (fileId จาก fid)
- token เก็บ localStorage "note-auth" (lib/note-session.ts) + Bearer
  ผ่าน noteAuthFetch · Server Actions ทุกตัวรับ token เป็น param แรก
  และแกะ identity เอง (lib/note-token.ts) — ไม่เชื่อ userId จาก client
- หน้า protected เป็น shell + client gate (NoteHome bootstrap ที่ /note,
  AnnotateView + GET /api/note/files/[id]/bootstrap)
- ถอด: middleware.ts, lib/session.ts, utils/user.ts, action logout,
  GET /api/note/auth/e-court, entry /note/annotate?fileUrl=
- ทาง ticket ปฏิเสธ secret default "super_secret" เฉพาะ production
- GET /api/note/files/open: ลิงก์เปิดไฟล์ตัวอย่างแบบ /tmpl (path ใน
  เครื่อง /samples เท่านั้น)
- หน้า /note/dev: ลิงก์ A Link Get(new) ที่ mint credential แล้วยิง
  เข้า flow SSO จริง (JWT/ticket สลับได้) + section Flow + เรียบเรียง
  ภาษาให้อ่านง่าย · หน้า /note เป็นหน้าข้อมูลระบบ
- แก้ตาม code review: redo closure เรียก action ด้วย signature เก่า,
  bootstrap timeout + strip credential ที่ผิดรูป, แยก no-session ออก
  จาก network error, ห้ามอักขระ & = + % ใน identity, ตรวจ purpose
  ก่อน exp ของ login-token, audit log strip อักขระควบคุม
- เอกสาร: docs/PLAN-SSO.md §0.5 (ฉบับ token), CLAUDE.md, .env.example

ทดสอบ: tsc ผ่าน · vitest ผ่านทั้งชุด · ทดสอบจริงผ่าน Playwright
(SSO → viewer → เปลี่ยนสี annotation → undo → redo ครบวงจร)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 18:05:36 +07:00

203 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/note/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 เป๊ะและไม่ใช่ production — ค่าอื่น/production = false", () => {
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);
writable.NODE_ENV = "production";
expect(devMintEnabled()).toBe(false);
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;
});
});