import { createHash } from "crypto"; import * as authService from "./authService"; import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock"; const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token"; function base64Url(buffer: Buffer): string { return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } interface OfficeDialogMock { displayDialogAsync: jest.Mock; dialog: { close: jest.Mock }; capturedUrl: () => string; sendMessage: (payload: unknown) => void; sendDialogEvent: () => void; } /** mock Office.context.ui.displayDialogAsync + dialog event handlers (FR-8 flow ผ่าน Dialog API) */ function installOfficeDialogMock(): OfficeDialogMock { const globalAny = globalThis as unknown as Record; const messageHandlers: Array<(args: unknown) => void> = []; const eventHandlers: Array<(args: unknown) => void> = []; let capturedAuthUrl = ""; const dialog = { close: jest.fn(), addEventHandler: (eventType: string, handler: (args: unknown) => void) => { (eventType === "dialogMessageReceived" ? messageHandlers : eventHandlers).push(handler); }, }; const displayDialogAsync = jest.fn( (startAddress: string, _options: unknown, callback: (result: unknown) => void) => { capturedAuthUrl = startAddress; callback({ status: "succeeded", value: dialog }); } ); globalAny.Office = { EventType: { DialogMessageReceived: "dialogMessageReceived", DialogEventReceived: "dialogEventReceived", }, AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, context: { ui: { displayDialogAsync } }, } as unknown as typeof Office; return { displayDialogAsync, dialog: dialog as OfficeDialogMock["dialog"], capturedUrl: () => capturedAuthUrl, sendMessage: (payload: unknown) => { messageHandlers.forEach((handler) => handler({ message: JSON.stringify(payload) })); }, sendDialogEvent: () => { eventHandlers.forEach((handler) => handler({ error: 12006 })); }, }; } /** รอจนกว่า login จะเรียก displayDialogAsync จริง (ต้องผ่าน crypto.subtle.digest ก่อน — ในเครื่องโหลดอาจช้ากว่า setTimeout(0)) */ async function waitForDialog(office: OfficeDialogMock): Promise { for ( let attempt = 0; attempt < 200 && office.displayDialogAsync.mock.calls.length === 0; attempt++ ) { await new Promise((resolve) => setTimeout(resolve, 5)); } expect(office.displayDialogAsync).toHaveBeenCalled(); } beforeEach(() => { authService.signOut(); }); afterEach(() => { authService.signOut(); // เคลียร์ auto-refresh timer กัน handle ค้าง }); describe("PKCE", () => { it("code_verifier ยาว 43–128 chars และอยู่ในชุดตัวอักษร base64url ที่ RFC 7636 อนุญาต", () => { for (let i = 0; i < 20; i++) { const verifier = authService.createCodeVerifier(); expect(verifier.length).toBeGreaterThanOrEqual(43); expect(verifier.length).toBeLessThanOrEqual(128); expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/); expect(verifier).not.toContain("="); } }); it("code_challenge = BASE64URL(SHA256(verifier)) — ตรวจกับ test vector ของ RFC 7636 appendix B", async () => { // vector จาก RFC 7636 เอง const challenge = await authService.createS256Challenge( "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" ); expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); }); it("code_challenge ของ verifier สุ่มตรงกับที่คำนวณเองด้วย node crypto", async () => { const verifier = authService.createCodeVerifier(); const expected = base64Url(createHash("sha256").update(verifier).digest()); await expect(authService.createS256Challenge(verifier)).resolves.toBe(expected); }); }); describe("buildAuthorizationUrl", () => { it("ประกอบ authorization URL ครบทุกพารามิเตอร์ (client_id/redirect/response_type/scope/state/PKCE)", () => { const url = new URL( authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" }) ); expect(url.origin).toBe("http://localhost:8080"); expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth"); expect(url.searchParams.get("client_id")).toBe("word-addin-officejs"); expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html"); expect(url.searchParams.get("response_type")).toBe("code"); expect(url.searchParams.get("scope")).toBe("openid"); expect(url.searchParams.get("state")).toBe("the-state"); expect(url.searchParams.get("code_challenge")).toBe("the-challenge"); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); }); describe("decodeJwtExpiry", () => { it("อ่าน exp จาก JWT payload เป็น ms epoch", () => { const expiresAt = Date.now() + 300_000; expect(authService.decodeJwtExpiry(makeFakeJwt(expiresAt))).toBe( Math.floor(expiresAt / 1000) * 1000 ); }); it("token ไม่ใช่รูป JWT → null", () => { expect(authService.decodeJwtExpiry("not-a-jwt")).toBeNull(); }); }); describe("login (Office Dialog + token exchange)", () => { it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => { const office = installOfficeDialogMock(); let exchangedBody: URLSearchParams | null = null; const fetchMock = installFetchMock(); fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => { expect(String(input)).toBe(TOKEN_ENDPOINT); exchangedBody = new URLSearchParams(String(init?.body)); return mockResponse({ status: 200, body: { access_token: makeFakeJwt(Date.now() + 300_000), refresh_token: "refresh-1", expires_in: 300, }, }); }); const loginPromise = authService.login(); await waitForDialog(office); // authorization URL ที่ส่งเข้า dialog const authUrl = new URL(office.capturedUrl()); const challenge = authUrl.searchParams.get("code_challenge") ?? ""; const state = authUrl.searchParams.get("state") ?? ""; expect(challenge).not.toBe(""); expect(state).not.toBe(""); office.sendMessage({ code: "the-auth-code", state }); await expect(loginPromise).resolves.toEqual({ ok: true }); expect(authService.isTokenValid()).toBe(true); expect(office.dialog.close).toHaveBeenCalled(); // verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง) const verifier = exchangedBody?.get("code_verifier") ?? ""; expect(exchangedBody?.get("grant_type")).toBe("authorization_code"); expect(exchangedBody?.get("code")).toBe("the-auth-code"); expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs"); expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge); }); it("state ไม่ตรง (CSRF) → login fail และไม่ยิง token endpoint", async () => { const office = installOfficeDialogMock(); const fetchMock = installFetchMock(); const loginPromise = authService.login(); await waitForDialog(office); office.sendMessage({ code: "code", state: "tampered-state" }); const result = await loginPromise; expect(result).toEqual({ ok: false, error: expect.stringContaining("state") }); expect(fetchMock).not.toHaveBeenCalled(); expect(authService.isTokenValid()).toBe(false); }); it("ผู้ใช้ปิด dialog เอง (DialogEventReceived) → คืน error แบบ graceful", async () => { const office = installOfficeDialogMock(); installFetchMock(); const loginPromise = authService.login(); await waitForDialog(office); office.sendDialogEvent(); const result = await loginPromise; expect(result.ok).toBe(false); expect(authService.isTokenValid()).toBe(false); }); it("Keycloak ปฏิเสธ (error ใน callback) → ok:false พร้อมชื่อ error", async () => { const office = installOfficeDialogMock(); installFetchMock(); const loginPromise = authService.login(); await waitForDialog(office); office.sendMessage({ error: "access_denied", errorDescription: "user cancelled" }); const result = await loginPromise; expect(result).toEqual({ ok: false, error: expect.stringContaining("access_denied") }); }); it("เรียก login ซ้อนพร้อมกัน → เปิด dialog ครั้งเดียว ได้ผลเดียวกันทั้งสอง promise", async () => { const office = installOfficeDialogMock(); installFetchMock(async () => mockResponse({ status: 200, body: { access_token: makeFakeJwt(Date.now() + 300_000), expires_in: 300 }, }) ); const first = authService.login(); const second = authService.login(); await waitForDialog(office); const state = new URL(office.capturedUrl()).searchParams.get("state") ?? ""; office.sendMessage({ code: "code", state }); await expect(first).resolves.toEqual({ ok: true }); await expect(second).resolves.toEqual({ ok: true }); expect(office.displayDialogAsync).toHaveBeenCalledTimes(1); }); }); describe("silent refresh (AC-9)", () => { it("access token หมดอายุ + refresh token ใช้ได้ → getAccessToken refresh เงียบๆ แล้วคืน token ใหม่", async () => { authService.setSessionForTest("expired-access-token", "refresh-old", Date.now() - 60_000); const newToken = makeFakeJwt(Date.now() + 600_000); const fetchMock = installFetchMock(async () => mockResponse({ status: 200, body: { access_token: newToken, refresh_token: "refresh-new", expires_in: 600 }, }) ); const token = await authService.getAccessToken(); expect(token).toBe(newToken); expect(authService.isTokenValid()).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); const body = new URLSearchParams(String((fetchMock.mock.calls[0][1] as { body: string }).body)); expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh-old"); expect(body.get("client_id")).toBe("word-addin-officejs"); // Keycloak หมุน refresh token — เก็บอันใหม่ไว้ใช้รอบหน้า expect(authService.hasRefreshToken()).toBe(true); }); it("refresh token ตาย/ถูก revoke → getAccessToken คืน null ไม่ throw และล้าง session", async () => { authService.setSessionForTest("expired-access-token", "refresh-dead", Date.now() - 60_000); const fetchMock = installFetchMock(async () => mockResponse({ status: 400, text: "invalid_grant" }) ); await expect(authService.getAccessToken()).resolves.toBeNull(); expect(authService.isTokenValid()).toBe(false); expect(authService.hasRefreshToken()).toBe(false); // เรียกซ้ำหลัง session ถูกล้าง → null ทันทีโดยไม่ยิง fetch เพิ่ม await expect(authService.getAccessToken()).resolves.toBeNull(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("ไม่เคย login เลย → isTokenValid false, getAccessToken null", async () => { expect(authService.isTokenValid()).toBe(false); await expect(authService.getAccessToken()).resolves.toBeNull(); }); });