eCourt-Doc-Edit/addin/src/services/authService.test.ts
Suphonchai Phoonsawat f4b1c3e14b implement phase 4
2026-08-19 21:35:07 +07:00

283 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
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<void> {
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();
});
});