diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 7bc8f62..b02f022 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -22,6 +22,7 @@ import { useAuthStore } from "@/store/authStore"; import { useAccessStore } from "@/store/accessStore"; import { useProjectStore } from "@/store/projectStore"; import { apiFetch } from "@/lib/apiFetch"; +import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow"; import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft"; import { permissionCodes, resourcePermissions } from "@/lib/permissions"; import { config } from "@config/config"; @@ -153,25 +154,24 @@ const App = (props: React.PropsWithChildren) => { }); return { success: true }; }, - logout: async () => { - try { - await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ event: "logout" }), - projectHeaderMode: "omit", - skipAuthRedirect: true, - }); - } catch { - // Logout must still complete when audit storage is unavailable. - } - if (data?.user?.id) { - sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); - } - clearSessionRecoveryDrafts(); - window.location.assign("/api/auth/keycloak-logout"); - return { success: true }; - }, + logout: () => + completeLogout({ + reportAudit: () => + reportLogoutAudit({ + endpoint: `${config.BACKEND_URL}/api/v1/audit-events`, + accessToken: + typeof data?.accessToken === "string" + ? data.accessToken + : useAuthStore.getState().accessToken, + }), + clearLocalState: () => { + if (data?.user?.id) { + sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); + } + clearSessionRecoveryDrafts(); + }, + navigate: (path) => window.location.assign(path), + }), onError: async (error) => { return { error }; }, diff --git a/src/lib/logoutFlow.test.ts b/src/lib/logoutFlow.test.ts new file mode 100644 index 0000000..d437ef9 --- /dev/null +++ b/src/lib/logoutFlow.test.ts @@ -0,0 +1,79 @@ +import { completeLogout, reportLogoutAudit } from "./logoutFlow"; + +describe("reportLogoutAudit", () => { + it("starts an authenticated keepalive request", async () => { + const fetcher = jest.fn(async () => ({ ok: true }) as Response); + + await reportLogoutAudit({ + endpoint: "https://server.example/api/v1/audit-events", + accessToken: "access-token", + fetcher, + }); + + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, init] = fetcher.mock.calls[0]; + expect(url).toBe("https://server.example/api/v1/audit-events"); + expect(init).toMatchObject({ + method: "POST", + body: JSON.stringify({ event: "logout" }), + keepalive: true, + }); + expect(new Headers(init?.headers).get("Authorization")).toBe( + "Bearer access-token", + ); + }); +}); + +describe("completeLogout", () => { + it("navigates immediately without waiting for audit reporting", async () => { + const reportAudit = jest.fn( + () => new Promise(() => undefined), + ); + const clearLocalState = jest.fn(); + const navigate = jest.fn(); + + const result = completeLogout({ + reportAudit, + clearLocalState, + navigate, + }); + + await Promise.resolve(); + + expect(reportAudit).toHaveBeenCalledTimes(1); + expect(clearLocalState).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout"); + await expect(result).resolves.toEqual({ success: true }); + }); + + it("completes logout when audit reporting fails", async () => { + const reportAudit = jest.fn(() => Promise.reject(new Error("offline"))); + const clearLocalState = jest.fn(); + const navigate = jest.fn(); + + await expect( + completeLogout({ reportAudit, clearLocalState, navigate }), + ).resolves.toEqual({ success: true }); + + expect(clearLocalState).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout"); + }); + + it("completes logout when audit reporting throws synchronously", async () => { + const clearLocalState = jest.fn(); + const navigate = jest.fn(); + + await expect( + completeLogout({ + reportAudit: () => { + throw new Error("invalid request"); + }, + clearLocalState, + navigate, + }), + ).resolves.toEqual({ success: true }); + + expect(clearLocalState).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout"); + }); +}); diff --git a/src/lib/logoutFlow.ts b/src/lib/logoutFlow.ts new file mode 100644 index 0000000..b3c33f6 --- /dev/null +++ b/src/lib/logoutFlow.ts @@ -0,0 +1,47 @@ +const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout"; + +type CompleteLogoutOptions = { + reportAudit: () => Promise; + clearLocalState: () => void; + navigate: (path: string) => void; +}; + +type LogoutAuditOptions = { + endpoint: string; + accessToken?: string | null; + fetcher?: typeof fetch; +}; + +export const reportLogoutAudit = ({ + endpoint, + accessToken, + fetcher = fetch, +}: LogoutAuditOptions) => { + const headers = new Headers({ "Content-Type": "application/json" }); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + return fetcher(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ event: "logout" }), + keepalive: true, + }); +}; + +export const completeLogout = async ({ + reportAudit, + clearLocalState, + navigate, +}: CompleteLogoutOptions) => { + try { + void reportAudit().catch(() => undefined); + } catch { + // Synchronous reporting errors must not block logout either. + } + + clearLocalState(); + navigate(KEYCLOAK_LOGOUT_PATH); + return { success: true as const }; +};