// @vitest-environment jsdom // ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5 — contract 2026-11-04) — คุมพฤติกรรม // service layer: pre-check ฝั่ง client (missing/ฟอร์แม็ต/หมดอายุ+ไม่มี session ไม่ยิง API), // ยิง /api/auth/token ทุกครั้งที่เปิดเอกสารแล้วแยกทางตาม status (200/400/401/403), // memory fallback ของ session, ทุก fail path ต้องคืน result ไม่ใช่ throw // (ไม่มีการตรวจ HMAC ฝั่ง client อีกต่อไป — PLAN-SSO.md D13: secret เป็นของ server // อย่างเดียว "ไฟล์ถูกแก้" ตรวจจาก 400/401 ของ server) import { createHmac } 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"; import { withBasePath } from "@/utils/base-path"; // จับ 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)); } const FID = "DBG_FID_001"; function makeTicket(overrides?: { exp?: number; fid?: string; uid?: string; uname?: string }): { ticketData: string; ticketHmac: string; } { const ticket = { exp: overrides?.exp ?? Math.floor(Date.now() / 1000) + 300, fid: overrides?.fid ?? FID, tid: "T12", uid: overrides?.uid ?? "oom", uname: overrides?.uname ?? "สรวิชญ์", }; const ticketData = JSON.stringify(ticket); // HMAC ยัง mint จริงตามตัว stamp (server ใน test อื่นจะเทียบค่านี้ — ฝั่ง client ไม่แตะแล้ว) const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex"); return { ticketData, ticketHmac }; } function tokenResponse(token: string): Response { return new Response( JSON.stringify({ access_token: token, token_type: "Bearer", expires_in: 3600, }), { status: 200, headers: { "content-type": "application/json" } }, ); } function errorResponse(status: number): Response { return new Response(JSON.stringify({ error: "stub" }), { status, headers: { "content-type": "application/json" }, }); } function seedSession(token: string, tokenExpSec: number, userId = "oom"): void { ticketService.saveSession({ userId, userName: "ชื่อเก่า", tokens: { [FID]: { token, exp: tokenExpSec } }, }); } 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 — pre-check ฝั่ง client (เตะโดยไม่ยิง API)", () => { it("ไม่มี ticket / ยาวเกิน 255 → reason missing", async () => { trapErrors(); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const { ticketData, ticketHmac } = makeTicket(); const bad = [ { ticketData: undefined, ticketHmac: undefined }, { ticketData: "", ticketHmac }, { ticketData: "x".repeat(256), ticketHmac }, { ticketData, ticketHmac: "x".repeat(256) }, ] 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(result.reason).toBe("missing"); } expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); it("parse ฟอร์แม็ตไม่ผ่าน → reason tampered ไม่ยิง fetch", async () => { trapErrors(); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const result = await authService.signInWithTicket({ ticketData: "not-json", ticketHmac: "aa".repeat(32) }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("tampered"); expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); it("ticket หมดอายุ + ไม่มี session → reason expired ไม่ยิง fetch", async () => { trapErrors(); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("expired"); expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); it("ticket หมดอายุ + session เป็น uid คนอื่น → reason expired ไม่ยิง fetch", async () => { trapErrors(); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); seedSession("other-token", Math.floor(Date.now() / 1000) + 1800, "someone-else"); const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("expired"); expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); it("ticket หมดอายุ + uid ตรงแต่ไม่มี entry ของ fid นี้ → reason expired ไม่ยิง fetch", async () => { trapErrors(); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); // session ของ oom แต่เก็บ token ของ fid อื่น ticketService.saveSession({ userId: "oom", userName: "สรวิชญ์", tokens: { OTHER_FID: { token: "other", exp: Math.floor(Date.now() / 1000) + 1800 } }, }); const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("expired"); expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); }); describe("signInWithTicket — ยิง /token ทุกครั้งที่เปิดเอกสาร", () => { it("happy-path: ไม่มี session + 200 → ok และสร้าง session ใหม่ครบ userId/userName/tokens[fid]", async () => { trapErrors(); const fetchMock = vi.fn(async (_url: string | URL | Request) => tokenResponse("token-1")); vi.stubGlobal("fetch", fetchMock); const { ticketData, ticketHmac } = makeTicket(); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok, JSON.stringify(result)).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); // endpoint ที่ยิงจริง = path สั้น /api/auth/token (เปลี่ยนจาก /api/documents/token 2026-11-05) expect(fetchMock.mock.calls[0][0]).toBe(withBasePath("/api/auth/token")); const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); expect(stored.userId).toBe("oom"); expect(stored.userName).toBe("สรวิชญ์"); expect(stored.tokens[FID].token).toBe("token-1"); expect(errors).toEqual([]); }); it("เปิดเอกสารเดิมซ้ำ → ยิง /token อีกครั้ง (ตรวจลายเซ็นที่ server ทุกครั้ง) แล้วแทน entry ด้วย token ใหม่", async () => { trapErrors(); const fetchMock = vi.fn(async () => tokenResponse("token-2")); vi.stubGlobal("fetch", fetchMock); const { ticketData, ticketHmac } = makeTicket(); await authService.signInWithTicket({ ticketData, ticketHmac }); await authService.signInWithTicket({ ticketData, ticketHmac }); expect(fetchMock).toHaveBeenCalledTimes(2); const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); expect(stored.tokens[FID].token).toBe("token-2"); expect(errors).toEqual([]); }); it("user ใหม่ (session uid ต่าง) + 200 → เคลียร์ session เก่าทั้งก้อนแล้วสร้างใหม่", async () => { trapErrors(); const fetchMock = vi.fn(async () => tokenResponse("token-new-user")); vi.stubGlobal("fetch", fetchMock); seedSession("old-user-token", Math.floor(Date.now() / 1000) + 1800, "someone-else"); const { ticketData, ticketHmac } = makeTicket(); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok, JSON.stringify(result)).toBe(true); const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); expect(stored.userId).toBe("oom"); expect(stored.tokens[FID].token).toBe("token-new-user"); expect(JSON.stringify(stored)).not.toContain("old-user-token"); // ของเดิมถูกเคลียร์ทิ้งจริง expect(errors).toEqual([]); }); it("คนเดิมแต่ uname เปลี่ยน → session.userName อัปเดตตามไฟล์ล่าสุด", async () => { trapErrors(); const fetchMock = vi.fn(async () => tokenResponse("token-keep")); vi.stubGlobal("fetch", fetchMock); await authService.signInWithTicket(makeTicket({ uname: "ชื่อเดิม" })); await authService.signInWithTicket(makeTicket({ uname: "ชื่อใหม่" })); const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); expect(stored.userName).toBe("ชื่อใหม่"); expect(errors).toEqual([]); }); }); describe("signInWithTicket — แยกทางตาม status ของ /token (contract 2026-11-04)", () => { it("400 (ฟอร์แม็ตพัง) → reason tampered", async () => { trapErrors(); const fetchMock = vi.fn(async () => errorResponse(400)); vi.stubGlobal("fetch", fetchMock); const result = await authService.signInWithTicket(makeTicket()); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("tampered"); expect(errors).toEqual([]); }); it("401 (ลายเซ็นไม่ถูก) → reason tampered", async () => { trapErrors(); vi.stubGlobal("fetch", vi.fn(async () => errorResponse(401))); const result = await authService.signInWithTicket(makeTicket()); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("tampered"); expect(result.message).toContain("ลายเซ็น"); } expect(errors).toEqual([]); }); it("403 (ลายเซ็นถูกแต่หมดอายุ) + มี JWT ของ fid ใน session ที่ยังไม่หมด → ใช้ entry เดิมต่อ ไม่แทนที่", async () => { trapErrors(); const fetchMock = vi.fn(async () => errorResponse(403)); vi.stubGlobal("fetch", fetchMock); seedSession("cached-token", Math.floor(Date.now() / 1000) + 1800); const { ticketData, ticketHmac } = makeTicket({ exp: 1, uname: "ชื่อใหม่" }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok, JSON.stringify(result)).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); expect(stored.tokens[FID].token).toBe("cached-token"); // ไม่ถูกแทนที่ expect(stored.userName).toBe("ชื่อใหม่"); // refresh ชื่อตามไฟล์ล่าสุด expect(errors).toEqual([]); }); it("403 + entry ของ fid หมดอายุแล้ว → reason expired", async () => { trapErrors(); const fetchMock = vi.fn(async () => errorResponse(403)); vi.stubGlobal("fetch", fetchMock); seedSession("token-stale", Math.floor(Date.now() / 1000) - 60); const { ticketData, ticketHmac } = makeTicket({ exp: 1 }); const result = await authService.signInWithTicket({ ticketData, ticketHmac }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("expired"); expect(fetchMock).toHaveBeenCalledTimes(1); // pre-check ปล่อยผ่าน (มี entry) แล้ว server ตอบ 403 expect(errors).toEqual([]); }); it("500 / network / 200 แต่ body เสีย → reason token-request-failed (ไม่ใช่ความผิดของไฟล์)", async () => { trapErrors(); const cases: { label: string; mock: () => unknown }[] = [ { label: "500", mock: () => vi.fn(async () => errorResponse(500)) }, { label: "network", mock: () => vi.fn(async () => Promise.reject(new Error("offline"))) }, { label: "malformed", mock: () => vi.fn(async () => new Response(JSON.stringify({ nope: true }), { status: 200, headers: { "content-type": "application/json" } })), }, ]; for (const { label, mock } of cases) { window.localStorage.clear(); ticketService.clearSession(); vi.stubGlobal("fetch", mock()); const result = await authService.signInWithTicket(makeTicket()); expect(result.ok, label).toBe(false); if (!result.ok) expect(result.reason, label).toBe("token-request-failed"); expect(errors, label).toEqual([]); } }); }); describe("signInWithTicket — resilience", () => { it("localStorage โดน block (webview/private mode) → ผ่านด้วย memory fallback ไม่ throw", async () => { trapErrors(); const blocked = () => { throw new DOMException("blocked", "SecurityError"); }; vi.spyOn(Storage.prototype, "getItem").mockImplementation(blocked); vi.spyOn(Storage.prototype, "setItem").mockImplementation(blocked); vi.spyOn(Storage.prototype, "removeItem").mockImplementation(blocked); vi.stubGlobal("fetch", vi.fn(async () => tokenResponse("token-memory"))); const result = await authService.signInWithTicket(makeTicket()); expect(result.ok, JSON.stringify(result)).toBe(true); // token ใช้ได้ภายใน task pane instance นี้ผ่าน memory fallback expect(await authService.getAccessToken()).toBe("token-memory"); expect(errors).toEqual([]); }); }); describe("renewTokenFromTicket", () => { it("200 → true และ session ได้ token ใหม่", async () => { trapErrors(); const fetchMock = vi.fn(async () => tokenResponse("token-renewed")); vi.stubGlobal("fetch", fetchMock); const signIn = await authService.signInWithTicket(makeTicket()); expect(signIn.ok, JSON.stringify(signIn)).toBe(true); fetchMock.mockClear(); const renewed = await authService.renewTokenFromTicket(); expect(renewed).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}"); expect(stored.tokens[FID].token).toBe("token-renewed"); expect(errors).toEqual([]); }); it("ticket หมดอายุ (server ตอบ 403) / 401 / network → false ไม่ throw", async () => { trapErrors(); const cases: { label: string; mock: () => unknown }[] = [ { label: "403", mock: () => vi.fn(async () => errorResponse(403)) }, { label: "401", mock: () => vi.fn(async () => errorResponse(401)) }, { label: "network", mock: () => vi.fn(async () => Promise.reject(new Error("offline"))) }, ]; for (const { label, mock } of cases) { window.localStorage.clear(); ticketService.clearSession(); authService.signInForDemo(); vi.stubGlobal("fetch", vi.fn(async () => tokenResponse("seed-token"))); const signIn = await authService.signInWithTicket(makeTicket()); expect(signIn.ok, label).toBe(true); vi.stubGlobal("fetch", mock()); const renewed = await authService.renewTokenFromTicket(); expect(renewed, label).toBe(false); expect(errors, label).toEqual([]); } }); it("ยังไม่ได้ sign in (ไม่มี currentTicket) → false ไม่ยิง fetch", async () => { trapErrors(); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); // authService.signInForDemo() ใน afterEach/reset ตั้ง currentTicket = null อยู่แล้ว expect(await authService.renewTokenFromTicket()).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); });