diff --git a/frontend/editor/src/core/api/signing.ts b/frontend/editor/src/core/api/signing.ts new file mode 100644 index 0000000000..c58fad8aac --- /dev/null +++ b/frontend/editor/src/core/api/signing.ts @@ -0,0 +1,21 @@ +import apiClient from "@app/services/apiClient"; +import type { + SignRequestSummary, + SessionSummary, +} from "@app/types/signingSession"; + +export interface SigningSessions { + signRequests: SignRequestSummary[]; + mySessions: SessionSummary[]; +} + +/** The two lists the signing UI always needs together. */ +export async function fetchSigningSessions(): Promise { + const [requests, sessions] = await Promise.all([ + apiClient.get( + "/api/v1/security/cert-sign/sign-requests", + ), + apiClient.get("/api/v1/security/cert-sign/sessions"), + ]); + return { signRequests: requests.data, mySessions: sessions.data }; +} diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx b/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx new file mode 100644 index 0000000000..14e473e33c --- /dev/null +++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { baseQueryOptions } from "@app/query/queryClient"; +import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; +import { useSigningSessions } from "@app/hooks/signing/useSigningSessions"; +import { fetchSigningSessions } from "@app/api/signing"; +import { alert } from "@app/components/toast"; +import { expectConsole } from "@app/tests/failOnConsole"; + +vi.mock("@app/api/signing", () => ({ fetchSigningSessions: vi.fn() })); +vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_k: string, fallback?: string) => fallback ?? _k, + }), +})); + +const mockFetch = vi.mocked(fetchSigningSessions); +const mockAlert = vi.mocked(alert); + +const EMPTY = { signRequests: [], mySessions: [] }; + +function setVisibility(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); + // Bubbles, as the real event does: query-core listens for it on window. + document.dispatchEvent(new Event("visibilitychange", { bubbles: true })); +} + +describe("useSigningSessions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue(EMPTY); + }); + + afterEach(() => { + vi.useRealTimers(); + setVisibility("visible"); + }); + + it("dedupes concurrent observers of the same key", async () => { + const { result } = renderHook( + () => ({ + badge: useSigningSessions({ + enabled: true, + autoRefreshInterval: 60000, + }), + launcher: useSigningSessions({ enabled: true }), + controller: useSigningSessions({ + enabled: true, + autoRefreshInterval: 15000, + }), + }), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => expect(result.current.badge.loading).toBe(false)); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not fetch while disabled", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: false, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + await act(async () => { + vi.advanceTimersByTime(60000); + }); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.current.signRequests).toEqual([]); + }); + + it("starts fetching when enabled flips on", async () => { + const { result, rerender } = renderHook( + ({ on }: { on: boolean }) => useSigningSessions({ enabled: on }), + { wrapper: TestQueryProvider, initialProps: { on: false } }, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + rerender({ on: true }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("polls on the interval", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + expect(result.current.loading).toBe(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does not raise the spinner while a background poll is in flight", async () => { + // Real timers, a held-open poll, and every render recorded. Asserting on + // result.current alone is not enough: waitFor returns as soon as the fetch + // count moves, before React has re-rendered, so a spinner that did flip on + // would be missed. + const seen: boolean[] = []; + const { result } = renderHook( + () => { + const state = useSigningSessions({ + enabled: true, + autoRefreshInterval: 50, + }); + seen.push(state.loading); + return state; + }, + { wrapper: TestQueryProvider }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Marked before the poll: waitFor flushes renders, so recording after it + // would skip straight past the in-flight one. + const fromPollStart = seen.length; + + let release: (v: unknown) => void = () => {}; + mockFetch.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }) as never, + ); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + + // Give React room to render the in-flight state, if it produces one. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + + // Mid-poll: this is what the old `silent` flag bought. + expect(seen.slice(fromPollStart)).not.toContain(true); + expect(result.current.loading).toBe(false); + + await act(async () => { + release(EMPTY); + }); + }); + + it("shows the spinner for a user-initiated refresh, not a background poll", async () => { + // Real timers: the in-flight window has to be observable, which is exactly + // what a fake-timer act() hides. + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let release: (v: unknown) => void = () => {}; + mockFetch.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }) as never, + ); + + let done: Promise; + act(() => { + done = result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(true)); + + await act(async () => { + release(EMPTY); + await done; + }); + expect(result.current.loading).toBe(false); + }); + + it("toasts a first-load failure", async () => { + expectConsole.error(/Failed to fetch signing data/); + mockFetch.mockRejectedValue(new Error("down")); + + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(mockAlert).toHaveBeenCalledTimes(1); + }); + + it("stays silent when a background poll fails after a success", async () => { + vi.useFakeTimers(); + mockFetch.mockResolvedValueOnce(EMPTY); + + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockAlert).not.toHaveBeenCalled(); + + mockFetch.mockRejectedValue(new Error("flaky")); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAlert).not.toHaveBeenCalled(); + }); + + it("toasts an explicit refetch failure even with data on screen", async () => { + expectConsole.error(/Failed to fetch signing data/); + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockAlert).not.toHaveBeenCalled(); + + mockFetch.mockRejectedValue(new Error("nope")); + await act(async () => { + await result.current.refetch(); + }); + + expect(mockAlert).toHaveBeenCalledTimes(1); + }); + + it("stops polling while the tab is hidden", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("hidden"); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + // Four intervals elapsed with the tab in the background. + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("visible"); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(mockFetch.mock.calls.length).toBeGreaterThan(1); + }); + + it("refetches on becoming visible rather than waiting out the interval", async () => { + vi.useFakeTimers(); + // The app client turns focus refetching off globally; TestQueryProvider + // does not, and would pass this on the library default alone. + const client = new QueryClient({ + defaultOptions: { + queries: { ...baseQueryOptions, retry: false, gcTime: Infinity }, + }, + }); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("hidden"); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("visible"); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("stops polling once unmounted", async () => { + vi.useFakeTimers(); + const { unmount } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts index 785e792414..e001af5441 100644 --- a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts +++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts @@ -1,9 +1,14 @@ -import { useState, useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import apiClient from "@app/services/apiClient"; +import { fetchSigningSessions } from "@app/api/signing"; +import { qk } from "@app/query/keys"; import { alert } from "@app/components/toast"; import { SignRequestSummary, SessionSummary } from "@app/types/signingSession"; +const EMPTY_REQUESTS: SignRequestSummary[] = []; +const EMPTY_SESSIONS: SessionSummary[] = []; + export interface UseSigningSessionsOptions { enabled?: boolean; autoRefreshInterval?: number; // milliseconds, 0 to disable @@ -18,8 +23,8 @@ export interface UseSigningSessionsResult { } /** - * Hook to fetch signing sessions data (sign requests and user's sessions). - * Supports auto-refresh for real-time updates. + * Signing sessions. Background polls never raise the spinner or a toast; only a + * first load or an explicit refetch does. */ export const useSigningSessions = ( options: UseSigningSessionsOptions = {}, @@ -27,83 +32,64 @@ export const useSigningSessions = ( const { enabled = true, autoRefreshInterval = 0 } = options; const { t } = useTranslation(); - const [signRequests, setSignRequests] = useState([]); - const [mySessions, setMySessions] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { data, isLoading, isLoadingError, error, refetch } = useQuery({ + queryKey: qk.signingSessions(), + queryFn: fetchSigningSessions, + enabled, + staleTime: 0, + refetchInterval: autoRefreshInterval > 0 ? autoRefreshInterval : false, + refetchIntervalInBackground: false, + // The interval pauses while unfocused, so returning has to catch up: the + // client-wide default of false would hold stale data until the next tick. + refetchOnWindowFocus: autoRefreshInterval > 0, + }); - const fetchData = useCallback( - async (opts?: { silent?: boolean }) => { - if (!enabled) return; + const notifyFailure = useCallback(() => { + console.error("Failed to fetch signing data"); + alert({ + alertType: "warning", + title: t("common.error"), + body: t("certSign.fetchFailed", "Failed to load signing data"), + expandable: false, + durationMs: 2500, + }); + }, [t]); - // Background auto-refreshes pass { silent: true } to skip the loading spinner - // and failure toasts; only the initial load and explicit refetch surface errors. - const silent = opts?.silent ?? false; - - if (!silent) setLoading(true); - setError(null); - - try { - const [requestsResponse, sessionsResponse] = await Promise.all([ - apiClient.get( - "/api/v1/security/cert-sign/sign-requests", - ), - apiClient.get( - "/api/v1/security/cert-sign/sessions", - ), - ]); - - setSignRequests(requestsResponse.data); - setMySessions(sessionsResponse.data); - } catch (err) { - const errorObj = - err instanceof Error - ? err - : new Error("Failed to fetch signing data"); - setError(errorObj); - console.error("Failed to fetch signing data:", err); - - if (!silent) { - alert({ - alertType: "warning", - title: t("common.error"), - body: t("certSign.fetchFailed", "Failed to load signing data"), - expandable: false, - durationMs: 2500, - }); - } - } finally { - if (!silent) setLoading(false); - } - }, - [enabled, t], - ); - - // Initial fetch + // isLoadingError is "failed with nothing cached", i.e. a first load. A poll + // that fails after a success keeps the old data and stays silent. + const reportedRef = useRef(false); useEffect(() => { - if (enabled) { - fetchData(); - } - }, [enabled, fetchData]); - - // Auto-refresh - useEffect(() => { - if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) { + if (!isLoadingError) { + reportedRef.current = false; return; } + if (reportedRef.current) return; + reportedRef.current = true; + notifyFailure(); + }, [isLoadingError, notifyFailure]); - const interval = setInterval(() => { - fetchData({ silent: true }); - }, autoRefreshInterval); + // Neither isLoading nor isFetching alone matches the old `silent` flag: a + // user-initiated refresh showed the spinner even with data on screen, a + // background poll never did. isFetching cannot tell them apart, so track it. + const [refreshing, setRefreshing] = useState(false); - return () => clearInterval(interval); - }, [enabled, autoRefreshInterval, fetchData]); + const explicitRefetch = useCallback(async () => { + setRefreshing(true); + try { + const result = await refetch(); + // Reported here rather than by the effect: a user-initiated refresh + // should say so even when stale data is already on screen. + if (result.error && !reportedRef.current) notifyFailure(); + } finally { + setRefreshing(false); + } + }, [refetch, notifyFailure]); return { - signRequests, - mySessions, - loading, - error, - refetch: fetchData, + signRequests: data?.signRequests ?? EMPTY_REQUESTS, + mySessions: data?.mySessions ?? EMPTY_SESSIONS, + loading: isLoading || refreshing, + error: (error as Error | null) ?? null, + refetch: explicitRefetch, }; }; diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index d95f674011..3e44395de0 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -8,6 +8,7 @@ export const qk = { ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, + signingSessions: () => ["editor", "signingSessions"] as const, /** Keyed on the asking identity: two users must never share one answer. */ portalAccess: (userId: string | null) => ["editor", "portalAccess", userId] as const,