eCourt-Doc-Edit/addin/src/services/authService.test.ts

284 lines
12 KiB
TypeScript
Raw Normal View History

2026-08-19 21:35:07 +07:00
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();
});
});