From 73782051aace2d72a52a9078781bd2b2f3abf5d6 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Thu, 24 Sep 2026 00:05:22 -0400 Subject: [PATCH] Task f50338c8: Fix the admin auth probe path and refusal state (#212) --- admin/src/AppRoutes.test.tsx | 122 ++++++++++++++++++++++++++++++++++- admin/src/AppRoutes.tsx | 51 ++++++++++++--- 2 files changed, 164 insertions(+), 9 deletions(-) diff --git a/admin/src/AppRoutes.test.tsx b/admin/src/AppRoutes.test.tsx index abb0fc4..5ba0287 100644 --- a/admin/src/AppRoutes.test.tsx +++ b/admin/src/AppRoutes.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import AppRoutes from "./AppRoutes"; import { storeTokens } from "./auth/oidc"; import { config } from "./config"; @@ -32,6 +32,32 @@ describe("AppRoutes auth gating", () => { ).toBeTruthy(); }); + function probeCalls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes("/api/")); + } + + it("probes exactly GET /api/v1/admin/ping and no non-admin path", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + const calls = probeCalls(fetchMock); + expect(calls).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]); + const [, init] = fetchMock.mock.calls[0]; + expect(init.method ?? "GET").toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + for (const url of calls) { + expect(new URL(url, "http://localhost").pathname.startsWith("/api/v1/admin/")).toBe(true); + } + }); + it("renders the four-tab shell after a successful authenticated probe", async () => { storeTokens({ access_token: "at-123", expires_in: 600 }); const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); @@ -45,6 +71,9 @@ describe("AppRoutes auth gating", () => { for (const label of ["Today", "Tasks", "Schedule", "History"]) { expect(screen.getByRole("tab", { name: label })).toBeTruthy(); } + expect(String(fetchMock.mock.calls[0][0])).toBe( + `${config.apiBaseUrl}/api/v1/admin/ping`, + ); }); it("renders the explicit refusal when the API answers 403", async () => { @@ -58,6 +87,97 @@ describe("AppRoutes auth gating", () => { expect(screen.getByTestId("refusal-screen")).toBeTruthy(); }); expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + }); + + it("keeps the login flow when the probe answers 401", async () => { + storeTokens({ access_token: "stale", expires_in: 600 }); + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const fetchMock = vi.fn().mockImplementation(async (url: string) => + String(url).includes("/api/v1/admin/ping") + ? new Response("", { status: 401 }) + : jsonResponse({ + issuer: config.issuer, + authorization_endpoint: "https://auth.example.com/authorize/", + token_endpoint: "https://auth.example.com/token/", + userinfo_endpoint: "https://auth.example.com/userinfo/", + end_session_endpoint: "https://auth.example.com/end-session/", + jwks_uri: "https://auth.example.com/jwks/", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("login-screen")).toBeTruthy(); + }); + expect(assign).toHaveBeenCalledTimes(1); + const redirect = new URL(assign.mock.calls[0][0] as string); + expect(redirect.origin + redirect.pathname).toBe("https://auth.example.com/authorize/"); + expect(probeCalls(fetchMock)).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + expect(screen.queryByTestId("probe-error-screen")).toBeNull(); + }); + + for (const status of [404, 500]) { + it(`shows the error screen, not the shell, when the probe answers ${status}`, async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status })); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + expect(screen.getByText(new RegExp(`answered ${status}`))).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + expect(screen.queryByTestId("refusal-screen")).toBeNull(); + }); + } + + it("shows the error screen, not the shell, when the probe request fails", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + expect(screen.getByText(/could not be reached/i)).toBeTruthy(); + expect(screen.queryByRole("tab", { name: "Today" })).toBeNull(); + }); + + it("re-probes on retry and enters the shell once the probe succeeds", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + // After the retry the ping succeeds; the shell's own tab loads stay pending. + let pings = 0; + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (!String(url).endsWith("/api/v1/admin/ping")) return new Promise(() => {}); + pings += 1; + return Promise.resolve(new Response("", { status: pings === 1 ? 500 : 200 })); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("probe-error-screen")).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Today" })).toBeTruthy(); + }); + expect(String(fetchMock.mock.calls[1][0])).toBe( + `${config.apiBaseUrl}/api/v1/admin/ping`, + ); }); it("shows the configuration error screen when the client id is missing", () => { diff --git a/admin/src/AppRoutes.tsx b/admin/src/AppRoutes.tsx index 5d6b1ab..9959c19 100644 --- a/admin/src/AppRoutes.tsx +++ b/admin/src/AppRoutes.tsx @@ -12,7 +12,7 @@ import { handleCallback, isAuthenticated, } from "./auth/oidc"; -import { ApiForbiddenError, apiFetch } from "./api/client"; +import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client"; import AdminShell from "./App"; type Screen = @@ -21,18 +21,28 @@ type Screen = | { kind: "config-error"; message: string } | { kind: "auth-error"; message: string } | { kind: "refusal"; message: string } + | { kind: "probe-error"; message: string } | { kind: "app" }; function currentPath(): string { return window.location.pathname.replace(/\/+$/, "") || "/"; } +const ADMIN_PROBE_PATH = "/api/v1/admin/ping"; + async function probeApi(setScreen: (screen: Screen) => void) { - // A cheap authenticated probe so a 403 surfaces as an explicit refusal - // right after login instead of a silent empty shell. + // The admin plane's authorization probe: only a 2xx enters the shell, so a + // non-admin (403) sees the explicit refusal and any other failure an error. try { - await apiFetch("/api/v1/children", {}, config); - setScreen({ kind: "app" }); + const response = await apiFetch(ADMIN_PROBE_PATH, {}, config); + if (response.ok) { + setScreen({ kind: "app" }); + return; + } + setScreen({ + kind: "probe-error", + message: `The admin API answered ${response.status}. The admin console cannot load.`, + }); } catch (error) { if (error instanceof ConfigError) { setScreen({ kind: "config-error", message: error.message }); @@ -42,9 +52,15 @@ async function probeApi(setScreen: (screen: Screen) => void) { setScreen({ kind: "refusal", message: error.message }); return; } - // Network or other failures still allow the shell to render; API screens - // handle their own errors in later tasks. - setScreen({ kind: "app" }); + if (error instanceof ApiUnauthorizedError) { + // apiFetch has already started the login redirect. + setScreen({ kind: "login" }); + return; + } + setScreen({ + kind: "probe-error", + message: "The admin API could not be reached. Check your connection and try again.", + }); } } @@ -170,6 +186,25 @@ export default function AppRoutes() { ); + case "probe-error": + return ( +
+

Admin console unavailable

+

{screen.message}

+ + +
+ ); case "app": return ; }