Refactor authentication flow and ticket handling in add-in
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s

- Removed client-side HMAC verification; server now handles all HMAC checks.
- Updated ticket processing logic to improve UX and security.
- Adjusted API request handling to return discriminated results based on HTTP status.
- Enhanced error messages for better clarity on authentication failures.
- Added unit tests for the new token endpoint to ensure compliance with updated contract.
- Cleaned up unused code and comments related to previous HMAC handling.
This commit is contained in:
Suphonchai Phoonsawat 2026-11-04 21:18:19 +07:00
parent 2e951f99f4
commit c0833c00f7
24 changed files with 858 additions and 460 deletions

View file

@ -1,11 +1,11 @@
// @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 และ
// ticket หมดอายุ + เอกสารที่เคยทำงาน (requirement ขั้น 7 — บั๊ก 2026-10-28:
// client ยิงขอ token เสมอ server เป็นคนตัดสินเรื่อง "เคยทำงาน" จาก DB)
// (jsdom ไม่มี crypto.subtle — ฉีด webcrypto ของ node แทน secure context ของ webview จริง)
import { createHmac, webcrypto } from "node:crypto";
// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5 — contract 2026-11-04) — คุมพฤติกรรม
// service layer: pre-check ฝั่ง client (missing/ฟอร์แม็ต/หมดอายุ+ไม่มี session ไม่ยิง API),
// ยิง /api/documents/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";
@ -18,110 +18,25 @@ function trapErrors() {
window.addEventListener("unhandledrejection", (event) => errors.push(event.reason));
}
function makeTicket(expSec?: number): { ticketData: string; ticketHmac: string } {
const FID = "DBG_FID_001";
function makeTicket(overrides?: { exp?: number; fid?: string; uid?: string; uname?: string }): {
ticketData: string;
ticketHmac: string;
} {
const ticket = {
exp: expSec ?? Math.floor(Date.now() / 1000) + 300,
fid: "DBG_FID_001",
exp: overrides?.exp ?? Math.floor(Date.now() / 1000) + 300,
fid: overrides?.fid ?? FID,
tid: "T12",
uid: "oom",
uname: "สรวิชญ์",
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 };
}
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([]);
});
});
// ─── ticket หมดอายุ + เอกสารที่เคยทำงาน (requirement ขั้น 7 — บั๊ก 2026-10-28) ──
// เกณฑ์: uid ตรงกัน + เคยสร้าง token ของ fid เดิม → ไม่ต้องสน expire ของ ticket
// ฝั่ง client ยิงขอ token เสมอแล้วให้ server ตัดสิน (200 = เคยทำงาน / 401 = ไม่เคย)
const FID = "DBG_FID_001";
function tokenResponse(token: string): Response {
return new Response(
JSON.stringify({
@ -133,95 +48,323 @@ function tokenResponse(token: string): Response {
);
}
function seedSession(token: string, tokenExpSec: number): void {
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: "oom",
userName: "สรวิชญ์",
userId,
userName: "ชื่อเก่า",
tokens: { [FID]: { token, exp: tokenExpSec } },
});
}
describe("ticket หมดอายุ + เอกสารที่เคยทำงาน", () => {
it("session เดิม + token ยังไม่หมด → ผ่านเลย ไม่ยิง /token", async () => {
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();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
seedSession("cached-token", Math.floor(Date.now() / 1000) + 1800);
const { ticketData, ticketHmac } = makeTicket();
const { ticketData, ticketHmac } = makeTicket(1); // ticket หมดอายุตั้งแต่ 1970
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(result.ok, JSON.stringify(result)).toBe(true);
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("session เดิม + token หมดด้วย → ยิงขอใหม่ (server ตัดสิน) ได้ token ใหม่", async () => {
it("parse ฟอร์แม็ตไม่ผ่าน → reason tampered ไม่ยิง fetch", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn(async () => tokenResponse("token-fresh"));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
seedSession("token-stale", Math.floor(Date.now() / 1000) - 60);
const { ticketData, ticketHmac } = makeTicket(1);
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("token-fresh");
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("ไม่มี session (localStorage หาย) + server 401 (ยังไม่เคยทำงาน) → expired ไม่ใช่ crash", async () => {
it("ticket หมดอายุ + ไม่มี session → reason expired ไม่ยิง fetch", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 })));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { ticketData, ticketHmac } = makeTicket(1);
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 () => 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);
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("expired");
expect(typeof result.message).toBe("string");
expect(result.reason).toBe("tampered");
expect(result.message).toContain("ลายเซ็น");
}
expect(errors).toEqual([]);
});
it("ไม่มี session + เคยทำงาน (server 200) → สร้าง session ใหม่ได้แม้ ticket หมดอายุ", async () => {
it("403 (ลายเซ็นถูกแต่หมดอายุ) + มี JWT ของ fid ใน session ที่ยังไม่หมด → ใช้ entry เดิมต่อ ไม่แทนที่", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn(async () => tokenResponse("token-rebuilt"));
const fetchMock = vi.fn(async () => errorResponse(403));
vi.stubGlobal("fetch", fetchMock);
seedSession("cached-token", Math.floor(Date.now() / 1000) + 1800);
const { ticketData, ticketHmac } = makeTicket(1);
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.userId).toBe("oom");
expect(stored.tokens[FID].token).toBe("token-rebuilt");
expect(stored.tokens[FID].token).toBe("cached-token"); // ไม่ถูกแทนที่
expect(stored.userName).toBe("ชื่อใหม่"); // refresh ชื่อตามไฟล์ล่าสุด
expect(errors).toEqual([]);
});
it("renewTokenFromTicket กับ ticket หมดอายุ + server 200 → ยังยิงขอ (server ตัดสิน) คืน true", async () => {
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();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn(async () => tokenResponse("token-renewed"));
vi.stubGlobal("fetch", fetchMock);
// sign-in ครั้งแรกด้วย ticket หมดอายุผ่านทาง server 200 → currentTicket เป็นตัวหมดอายุ
const { ticketData, ticketHmac } = makeTicket(1);
const signIn = await authService.signInWithTicket({ ticketData, ticketHmac });
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([]);
});
});