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
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:
parent
2e951f99f4
commit
c0833c00f7
24 changed files with 858 additions and 460 deletions
215
tests/app/api/documents/token.test.ts
Normal file
215
tests/app/api/documents/token.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
// POST /api/documents/token — contract ทางเข้า ticket ฉบับ 2026-11-04 + regression ทางเข้า x-api-key
|
||||
// route pure ล้วนหลังถอด hasVersionByUser (ไม่ import @/db/minio อีกต่อไป) จึง unit test
|
||||
// ได้โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* — เคส 403 ผ่านโดยไม่มี DB เลย = พิสูจน์ว่า route
|
||||
// ไม่แตะ DB (เลิก "ตรวจเคยมี version" ของ requirement ขั้น 7 ฉบับ 2026-10-28)
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { POST } from "@/app/api/documents/token/route";
|
||||
|
||||
/** secret ตาม chain ของ addinTicketSecret — ลบ env ทั้งสองตัวใน setup เพื่อใช้ค่า default */
|
||||
const SECRET_DEFAULT = "super_secret";
|
||||
const TOKEN_SECRET = "unit-test-token-secret";
|
||||
const API_KEY = "unit-test-api-key";
|
||||
|
||||
const SAVED_ENV: Record<string, string | undefined> = {};
|
||||
|
||||
function mintTicket(
|
||||
exp: number,
|
||||
overrides?: { fid?: string; uid?: string; uname?: string; tid?: string },
|
||||
): { ticketData: string; ticketHmac: string } {
|
||||
const ticket = {
|
||||
exp,
|
||||
fid: overrides?.fid ?? "F1",
|
||||
...(overrides?.tid !== undefined ? { tid: overrides.tid } : { tid: "T12" }),
|
||||
uid: overrides?.uid ?? "oom",
|
||||
uname: overrides?.uname ?? "สรวิชญ์",
|
||||
};
|
||||
const ticketData = JSON.stringify(ticket);
|
||||
const ticketHmac = createHmac("sha256", SECRET_DEFAULT).update(ticketData, "utf8").digest("hex");
|
||||
return { ticketData, ticketHmac };
|
||||
}
|
||||
|
||||
function futureExp(): number {
|
||||
return Math.floor(Date.now() / 1000) + 3_600;
|
||||
}
|
||||
|
||||
function makeRequest(body: BodyInit, headers?: Record<string, string>): Request {
|
||||
return new Request("http://localhost/docedit/api/documents/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
function ticketBody(ticketData: string, ticketHmac: string): string {
|
||||
return JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac });
|
||||
}
|
||||
|
||||
/** decode JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ assert claims/exp เท่านั้น */
|
||||
function jwtPayload(token: string): Record<string, unknown> {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) throw new Error("token ไม่มี payload");
|
||||
return JSON.parse(
|
||||
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [
|
||||
"ADDIN_TICKET_SECRET",
|
||||
"SECRET",
|
||||
"DOCVER_API_KEY",
|
||||
"DOCVER_TOKEN_SECRET",
|
||||
"DOCVER_TOKEN_TTL_SECONDS",
|
||||
]) {
|
||||
SAVED_ENV[key] = process.env[key];
|
||||
}
|
||||
process.env.DOCVER_TOKEN_SECRET = TOKEN_SECRET;
|
||||
delete process.env.ADDIN_TICKET_SECRET; // ใช้ default "super_secret" ของ addinTicketSecret
|
||||
delete process.env.SECRET;
|
||||
delete process.env.DOCVER_TOKEN_TTL_SECONDS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(SAVED_ENV)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
describe("POST /api/documents/token — ทางเข้า ticket (contract 2026-11-04)", () => {
|
||||
it("ticket ถูกทั้งลายเซ็น+อายุ → 200 + JWT claims จาก ticket + expires_in 604800 (default 7 วัน)", async () => {
|
||||
const { ticketData, ticketHmac } = mintTicket(futureExp(), { fid: "F9", uid: "u1", uname: "นายทดสอบ" });
|
||||
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = (await response.json()) as { access_token: string; token_type: string; expires_in: number };
|
||||
expect(data.token_type).toBe("Bearer");
|
||||
expect(data.expires_in).toBe(604_800);
|
||||
|
||||
const payload = jwtPayload(data.access_token);
|
||||
expect(payload.userid).toBe("u1");
|
||||
expect(payload.username).toBe("นายทดสอบ");
|
||||
expect(payload.fid).toBe("F9");
|
||||
expect(payload.template).toBe("T12");
|
||||
expect(payload.purpose).toBe("docver");
|
||||
expect((payload.exp as number) - (payload.iat as number)).toBe(604_800);
|
||||
});
|
||||
|
||||
it("body ไม่ใช่ JSON → 400", async () => {
|
||||
const response = await POST(makeRequest("not-json{"));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("ไม่ส่ง ticket มาเลย / ค่าว่าง → 400 (ย้ายจาก 401 ตาม contract ฟอร์แม็ตพัง)", async () => {
|
||||
for (const body of ["{}", JSON.stringify({ ticket_data: "" , HmacSha256: "ab".repeat(32) })]) {
|
||||
const response = await POST(makeRequest(body));
|
||||
expect(response.status, body).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("field นอก allowlist / ผิดชนิด / ยาวเกิน cap → 400", async () => {
|
||||
for (const body of [
|
||||
JSON.stringify({ ticket_data: "x", HmacSha256: "y", oops: 1 }),
|
||||
JSON.stringify({ ticket_data: 42, HmacSha256: "y" }),
|
||||
JSON.stringify({ ticket_data: "x".repeat(2049), HmacSha256: "y" }),
|
||||
]) {
|
||||
const response = await POST(makeRequest(body));
|
||||
expect(response.status, body).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("ลายเซ็นผิด (แก้ ticket_data / digest เพี้ยน / secret ของ server ต่าง) → 401 ข้อความเดียวกันทุกเคส", async () => {
|
||||
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||||
const tampered = ticketData.replace("F1", "F2"); // แก้ 1 ตัว — HMAC ไม่ตรง
|
||||
|
||||
const cases: { label: string; body: string; secretEnv?: [string, string] }[] = [
|
||||
{ label: "แก้ ticket_data", body: ticketBody(tampered, ticketHmac) },
|
||||
{ label: "digest ไม่ใช่ hex", body: ticketBody(ticketData, "zz".repeat(32)) },
|
||||
{
|
||||
label: "secret ฝั่ง server ต่าง",
|
||||
body: ticketBody(ticketData, ticketHmac),
|
||||
secretEnv: ["ADDIN_TICKET_SECRET", "different-secret"],
|
||||
},
|
||||
];
|
||||
const messages = new Set<string>();
|
||||
for (const { label, body, secretEnv } of cases) {
|
||||
if (secretEnv) process.env[secretEnv[0]] = secretEnv[1];
|
||||
const response = await POST(makeRequest(body));
|
||||
expect(response.status, label).toBe(401);
|
||||
messages.add((await response.json()).error);
|
||||
if (secretEnv) delete process.env[secretEnv[0]];
|
||||
}
|
||||
expect(messages.size).toBe(1); // ข้อความเดียว กัน oracle
|
||||
expect(Array.from(messages)[0]).toBe("ticket ไม่ถูกต้อง");
|
||||
});
|
||||
|
||||
it("ลายเซ็นถูกแต่ exp อดีต → 403 \"ticket ของ e-Court หมดอายุ\" (ไม่ตรวจ DB — route ไม่มี DB)", async () => {
|
||||
const { ticketData, ticketHmac } = mintTicket(Math.floor(Date.now() / 1000) - 60);
|
||||
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).error).toBe("ticket ของ e-Court หมดอายุ");
|
||||
});
|
||||
|
||||
it("HMAC ถูกแต่ ticket_data ผิดกติกา (field หลุด allowlist) → 401", async () => {
|
||||
const invalid = JSON.stringify({ exp: futureExp(), fid: "f", uid: "u", uname: "n", oops: 1 });
|
||||
const hmac = createHmac("sha256", SECRET_DEFAULT).update(invalid, "utf8").digest("hex");
|
||||
const response = await POST(makeRequest(ticketBody(invalid, hmac)));
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("DOCVER_TOKEN_SECRET ไม่ตั้ง → 500 (ความผิด config ฝั่ง server ไม่ใช่ของผู้เรียก)", async () => {
|
||||
delete process.env.DOCVER_TOKEN_SECRET;
|
||||
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||||
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("DOCVER_TOKEN_TTL_SECONDS=120 → expires_in 120 (env override default)", async () => {
|
||||
process.env.DOCVER_TOKEN_TTL_SECONDS = "120";
|
||||
const { ticketData, ticketHmac } = mintTicket(futureExp());
|
||||
const response = await POST(makeRequest(ticketBody(ticketData, ticketHmac)));
|
||||
expect(response.status).toBe(200);
|
||||
expect(((await response.json()) as { expires_in: number }).expires_in).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/token — ทางเข้า x-api-key (regression ไม่เปลี่ยน)", () => {
|
||||
it("key ถูก + body claims → 200 ได้ token อายุ default ใหม่ 604800", async () => {
|
||||
process.env.DOCVER_API_KEY = API_KEY;
|
||||
const response = await POST(
|
||||
makeRequest(JSON.stringify({ userid: "u1", username: "นายทดสอบ", fid: "F1" }), {
|
||||
"x-api-key": API_KEY,
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const data = (await response.json()) as { access_token: string; expires_in: number };
|
||||
expect(data.expires_in).toBe(604_800);
|
||||
const payload = jwtPayload(data.access_token);
|
||||
expect(payload.userid).toBe("u1");
|
||||
expect(payload.fid).toBe("F1");
|
||||
expect(payload.purpose).toBe("docver");
|
||||
});
|
||||
|
||||
it("key ผิด → 401", async () => {
|
||||
process.env.DOCVER_API_KEY = API_KEY;
|
||||
const response = await POST(
|
||||
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), {
|
||||
"x-api-key": "wrong-key",
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("มี header x-api-key แต่ env ไม่ตั้ง → 401 fail closed", async () => {
|
||||
delete process.env.DOCVER_API_KEY;
|
||||
const response = await POST(
|
||||
makeRequest(JSON.stringify({ userid: "u1", username: "n", fid: "F1" }), {
|
||||
"x-api-key": API_KEY,
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
|
@ -75,8 +75,7 @@ describe("verifyAddinTicket", () => {
|
|||
});
|
||||
|
||||
// verifyAddinTicketHmac = ตัวเดียวกันแต่ไม่ตรวจ exp — route ใช้ตรวจ HMAC ก่อน
|
||||
// แล้วตัดสิน "ticket หมดอายุ + เอกสารนี้เคยทำงานโดย uid เดียวกัน" จาก DB เอง
|
||||
// (requirement ขั้น 7 — แก้บั๊ก 2026-10-28)
|
||||
// แล้วตัดสิน exp เองภายหลัง (ลายเซ็นถูกแต่หมดอายุ = 403 — contract 2026-11-04)
|
||||
describe("verifyAddinTicketHmac", () => {
|
||||
it("exp อดีต + HMAC ถูก = ผ่าน (ได้ ticket ครบ) — ต่างจาก verifyAddinTicket ที่ไม่ผ่าน", () => {
|
||||
const expired = JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" });
|
||||
|
|
@ -122,7 +121,7 @@ describe("parseTicketTokenBody", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 401 กัน oracle)", () => {
|
||||
it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 400 — ฟอร์แม็ตพัง, contract 2026-11-04)", () => {
|
||||
for (const raw of [{}, { ticket_data: "" }, { HmacSha256: "y" }, { ticket_data: null, HmacSha256: "y" }]) {
|
||||
const result = parseTicketTokenBody(raw);
|
||||
expect(result.ok, JSON.stringify(raw)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
normalizeHexDigest,
|
||||
parseDocverTicket,
|
||||
parseTicketDataParam,
|
||||
timingSafeEqualHex,
|
||||
} from "@/utils/docver-ticket";
|
||||
|
||||
// PLAN-ADDIN-AUTH.md §4 — pure ล้วนไม่แตะ env/DB อะไรเลย
|
||||
|
|
@ -116,15 +115,6 @@ describe("normalizeHexDigest", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("timingSafeEqualHex", () => {
|
||||
it("ค่าเท่ากัน = true ต่างกัน = false · ความยาวไม่เท่า = false ทันที", () => {
|
||||
const a = "ab".repeat(32);
|
||||
expect(timingSafeEqualHex(a, a)).toBe(true);
|
||||
expect(timingSafeEqualHex(a, "cd".repeat(32))).toBe(false);
|
||||
expect(timingSafeEqualHex(a, "ab")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// parseTicketDataParam — query param `ticket_data` ของ GET /api/documents/latest:
|
||||
// ticket ก่อนเติม exp (server เติมตอน stamp) แต่บังคับ fid/uid/uname เพราะ
|
||||
// parseDocverTicket บังคับทั้งสามตอน add-in redeem — ขาด = ไฟล์ที่คืนไปเปิดไม่ได้
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// templateId — identity มาจาก Bearer หมด · และห้ามส่ง fields — server แกะจากไฟล์เอง
|
||||
// 2026-10-30), getVersions แปลง DTO, getDownloadLink ใส่ ?format=
|
||||
// - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย)
|
||||
import { createHmac, webcrypto } from "node:crypto";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import * as authService from "@/wordaddin/services/authService";
|
||||
|
|
@ -20,7 +20,6 @@ const TOKEN = "header.payload.sig"; // ค่าหยง ๆ ก็ได้
|
|||
|
||||
/** sign-in จริงผ่าน signInWithTicket (fetch ตอบ token 1 ครั้ง) — real mode + มี Bearer ใน session */
|
||||
async function signInReal(): Promise<void> {
|
||||
vi.stubGlobal("crypto", webcrypto); // jsdom ไม่มี crypto.subtle
|
||||
const ticket = {
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
fid: "REAL_FID_001",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue