feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):
- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
{ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
wordaddin-auth keyed by fid, same-origin token request), authService
rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
metadataService reads ticket_data/HmacSha256, no-access messages per
cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
request bodies for /token), CLAUDE.md, .env.example
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 11:21:15 +07:00
|
|
|
// @vitest-environment jsdom
|
2026-10-27 11:54:05 +07:00
|
|
|
// 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 จริง)
|
feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):
- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
{ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
wordaddin-auth keyed by fid, same-origin token request), authService
rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
metadataService reads ticket_data/HmacSha256, no-access messages per
cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
request bodies for /token), CLAUDE.md, .env.example
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 11:21:15 +07:00
|
|
|
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";
|
|
|
|
|
|
2026-10-27 11:54:05 +07:00
|
|
|
// จับ error ทุกทาง (window error / unhandled rejection) — flow ต้องไม่ปล่อยอะไรหลุดเลย
|
feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):
- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
{ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
wordaddin-auth keyed by fid, same-origin token request), authService
rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
metadataService reads ticket_data/HmacSha256, no-access messages per
cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
request bodies for /token), CLAUDE.md, .env.example
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 11:21:15 +07:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-27 11:54:05 +07:00
|
|
|
describe("signInWithTicket (service layer)", () => {
|
feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):
- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
{ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
wordaddin-auth keyed by fid, same-origin token request), authService
rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
metadataService reads ticket_data/HmacSha256, no-access messages per
cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
request bodies for /token), CLAUDE.md, .env.example
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 11:21:15 +07:00
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
});
|