Restore the auth-token dialog when an installed PWA receives a daemon 401 before React mounts. - Latch daemon authentication failures for mount-time recovery. - Clear the latch when the authentication token changes. - Cover cold-start recovery and successful-response behavior with dashboard tests. Files changed: .changeset/fn-8301-pwa-auth-recovery.md | 7 +++ packages/dashboard/app/__tests__/auth.test.ts | 40 ++++++++++++++ packages/dashboard/app/auth.ts | 8 +++ .../hooks/__tests__/useAuthTokenRecovery.test.ts | 61 +++++++++++++++++++++- .../dashboard/app/hooks/useAuthTokenRecovery.ts | 10 ++-- 5 files changed, 121 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8301 Fusion-Task-Lineage: b35236f4-af3d-4313-8b7d-6cd59a237144 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import {
|
|
AUTH_TOKEN_RECOVERY_REQUIRED_EVENT,
|
|
clearAuthToken,
|
|
hasDaemonAuthFailure,
|
|
installAuthFetch,
|
|
setAuthToken,
|
|
} from "../../auth";
|
|
import { useAuthTokenRecovery } from "../useAuthTokenRecovery";
|
|
|
|
const originalFetch = window.fetch;
|
|
|
|
function waitForDaemonAuthRecoveryEvent(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const handleRecovery = () => {
|
|
window.removeEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery);
|
|
resolve();
|
|
};
|
|
window.addEventListener(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, handleRecovery);
|
|
});
|
|
}
|
|
|
|
describe("useAuthTokenRecovery", () => {
|
|
beforeEach(() => {
|
|
clearAuthToken();
|
|
window.localStorage.clear();
|
|
window.history.replaceState({}, "", "/");
|
|
window.fetch = originalFetch;
|
|
delete (window as Window & { __fnAuthFetchInstalled?: boolean }).__fnAuthFetchInstalled;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
it("opens on daemon auth-failure events and stays open for duplicate signals", () => {
|
|
const { result } = renderHook(() => useAuthTokenRecovery());
|
|
|
|
expect(result.current.open).toBe(false);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT));
|
|
});
|
|
|
|
expect(result.current.open).toBe(true);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT));
|
|
window.dispatchEvent(new Event(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT));
|
|
});
|
|
|
|
expect(result.current.open).toBe(true);
|
|
});
|
|
it("opens when a daemon-auth 401 latched before the hook mounts", async () => {
|
|
window.fetch = vi.fn(async () => new Response(
|
|
JSON.stringify({ error: "Unauthorized", message: "Valid bearer token required" }),
|
|
{ status: 401, headers: { "content-type": "application/json" } },
|
|
)) as unknown as typeof window.fetch;
|
|
installAuthFetch();
|
|
|
|
const recoveryEvent = waitForDaemonAuthRecoveryEvent();
|
|
await fetch("/api/tasks");
|
|
await recoveryEvent;
|
|
|
|
expect(hasDaemonAuthFailure()).toBe(true);
|
|
const { result } = renderHook(() => useAuthTokenRecovery());
|
|
expect(result.current.open).toBe(true);
|
|
});
|
|
|
|
it("does not open for a successful API response with a valid token", async () => {
|
|
setAuthToken("valid-token");
|
|
window.fetch = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})) as unknown as typeof window.fetch;
|
|
installAuthFetch();
|
|
|
|
await fetch("/api/tasks");
|
|
|
|
expect(hasDaemonAuthFailure()).toBe(false);
|
|
const { result } = renderHook(() => useAuthTokenRecovery());
|
|
expect(result.current.open).toBe(false);
|
|
});
|
|
|
|
it("removes the daemon auth-failure listener on unmount", () => {
|
|
const addSpy = vi.spyOn(window, "addEventListener");
|
|
const removeSpy = vi.spyOn(window, "removeEventListener");
|
|
const { unmount } = renderHook(() => useAuthTokenRecovery());
|
|
|
|
const addedCall = addSpy.mock.calls.find(
|
|
([type]) => type === AUTH_TOKEN_RECOVERY_REQUIRED_EVENT,
|
|
);
|
|
expect(addedCall).toBeTruthy();
|
|
const addedHandler = addedCall![1] as EventListener;
|
|
|
|
unmount();
|
|
|
|
expect(removeSpy).toHaveBeenCalledWith(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT, addedHandler);
|
|
});
|
|
});
|