implement phase 4
This commit is contained in:
parent
25c2ce53cd
commit
f4b1c3e14b
27 changed files with 6929 additions and 48 deletions
283
addin/src/services/authService.test.ts
Normal file
283
addin/src/services/authService.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue