// @vitest-environment jsdom // ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5) — คุมพฤติกรรม service layer: // happy-path ขอ token ครั้งเดียวต่อ fid, เปิดเอกสารเดิมซ้ำใช้ token เดิมไม่ยิง /token ซ้ำ, // memory fallback ของ session, และทุก fail path ต้องคืน result ไม่ใช่ throw // (jsdom ไม่มี crypto.subtle — ฉีด webcrypto ของ node แทน secure context ของ webview จริง) import { createHmac, webcrypto } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as authService from "@/wordaddin/services/authService"; import * as ticketService from "@/wordaddin/services/ticketService"; // จับ error ทุกทาง (window error / unhandled rejection) — flow ต้องไม่ปล่อยอะไรหลุดเลย const errors: unknown[] = []; function trapErrors() { window.addEventListener("error", (event) => errors.push(event.error ?? event.message)); window.addEventListener("unhandledrejection", (event) => errors.push(event.reason)); } function makeTicket(): { ticketData: string; ticketHmac: string } { const ticket = { exp: Math.floor(Date.now() / 1000) + 300, fid: "DBG_FID_001", tid: "T12", uid: "oom", uname: "สรวิชญ์", }; const ticketData = JSON.stringify(ticket); const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex"); return { ticketData, ticketHmac }; } afterEach(() => { window.localStorage.clear(); // memory fallback ของ session เป็น module state — เคลียร์ด้วยไม่งั้นค้างข้ามเคส ticketService.clearSession(); authService.signInForDemo(); // รีเซ็ต state ของ authService (username/fid/ticket) vi.restoreAllMocks(); vi.unstubAllGlobals(); errors.length = 0; }); describe("signInWithTicket (service layer)", () => { it("B: signInWithTicket happy-path (มี crypto.subtle + fetch ตอบ 200) ไม่ต้อง throw", async () => { trapErrors(); // jsdom ไม่มี crypto.subtle — ใส่ webcrypto ของ node แทน (secure context ของ webview จริงมีอยู่แล้ว) vi.stubGlobal("crypto", webcrypto); const { ticketData, ticketHmac } = makeTicket(); const fetchMock = vi.fn( async () => new Response( JSON.stringify({ access_token: [ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", Buffer.from( JSON.stringify({ userid: "oom", fid: "DBG_FID_001", exp: Math.floor(Date.now() / 1000) + 3600 }), ).toString("base64url"), "sig", ].join("."), token_type: "Bearer", expires_in: 3600, }), { status: 200, headers: { "content-type": "application/json" } }, ), ); vi.stubGlobal("fetch", fetchMock); const first = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(first.ok, JSON.stringify(first)).toBe(true); // เปิดเอกสารเดิมซ้ำ — ต้องไม่ยิง /token ซ้ำ (ใช้ token เดิมจาก localStorage) await authService.signInWithTicket({ ticketData, ticketHmac }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(errors).toEqual([]); expect(window.localStorage.getItem("wordaddin-auth")).toBeTruthy(); }); it("C: signInWithTicket แบบไม่มี crypto.subtle (webview เก่า) ต้องจบเป็น no-access ไม่ใช่ throw", async () => { trapErrors(); const noSubtle = { getRandomValues: () => new Uint8Array(0) }; vi.stubGlobal("crypto", noSubtle); // ไม่มี .subtle เลย const { ticketData, ticketHmac } = makeTicket(); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok).toBe(false); expect(errors).toEqual([]); }); it("D: ticket ผิดทุกแบบ (ขาด/hex เพี้ยน/parse ไม่ผ่าน/หมดอายุ/fetch 401) ต้องคืน result ไม่ใช่ throw", async () => { trapErrors(); vi.stubGlobal("crypto", webcrypto); vi.stubGlobal( "fetch", vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 })), ); const { ticketData, ticketHmac } = makeTicket(); const bad = [ { ticketData: undefined, ticketHmac: undefined }, { ticketData: "not-json", ticketHmac }, { ticketData, ticketHmac: "zz".repeat(32) }, { ticketData: JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), // หมดอายุ + HMAC ถูก ticketHmac: createHmac("sha256", "super_secret") .update(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), "utf8") .digest("hex"), }, { ticketData, ticketHmac }, // ถูก แต่ server 401 → token-request-failed ] satisfies { ticketData?: string; ticketHmac?: string }[]; for (const input of bad) { const result = await authService.signInWithTicket(input); expect(result.ok, JSON.stringify(input)).toBe(false); if (!result.ok) expect(typeof result.message).toBe("string"); } expect(errors).toEqual([]); }); });