Probe /api/v1/admin/ping and refuse the shell on any non-OK outcome
The initial authorization probe called the nonexistent /api/v1/children and treated every non-403 outcome as success, so a 404 rendered the admin shell. Probe the admin plane's GET /api/v1/admin/ping instead and enter the shell only on a 2xx: 403 shows the refusal, 401 keeps the login flow, and any other status or network failure shows a new probe-error screen with Retry and Sign out. Task: f50338c8-8b48-4e3d-9064-e20d9b32fe34 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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 AppRoutes from "./AppRoutes";
|
||||||
import { storeTokens } from "./auth/oidc";
|
import { storeTokens } from "./auth/oidc";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
@@ -32,6 +32,32 @@ describe("AppRoutes auth gating", () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function probeCalls(fetchMock: ReturnType<typeof vi.fn>): 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(<AppRoutes />);
|
||||||
|
|
||||||
|
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 () => {
|
it("renders the four-tab shell after a successful authenticated probe", async () => {
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||||
@@ -45,6 +71,9 @@ describe("AppRoutes auth gating", () => {
|
|||||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
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 () => {
|
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.getByTestId("refusal-screen")).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(screen.getByText(/not in the nestquest-admins group/i)).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(<AppRoutes />);
|
||||||
|
|
||||||
|
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(<AppRoutes />);
|
||||||
|
|
||||||
|
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(<AppRoutes />);
|
||||||
|
|
||||||
|
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(<AppRoutes />);
|
||||||
|
|
||||||
|
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", () => {
|
it("shows the configuration error screen when the client id is missing", () => {
|
||||||
|
|||||||
+43
-8
@@ -12,7 +12,7 @@ import {
|
|||||||
handleCallback,
|
handleCallback,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
} from "./auth/oidc";
|
} from "./auth/oidc";
|
||||||
import { ApiForbiddenError, apiFetch } from "./api/client";
|
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client";
|
||||||
import AdminShell from "./App";
|
import AdminShell from "./App";
|
||||||
|
|
||||||
type Screen =
|
type Screen =
|
||||||
@@ -21,18 +21,28 @@ type Screen =
|
|||||||
| { kind: "config-error"; message: string }
|
| { kind: "config-error"; message: string }
|
||||||
| { kind: "auth-error"; message: string }
|
| { kind: "auth-error"; message: string }
|
||||||
| { kind: "refusal"; message: string }
|
| { kind: "refusal"; message: string }
|
||||||
|
| { kind: "probe-error"; message: string }
|
||||||
| { kind: "app" };
|
| { kind: "app" };
|
||||||
|
|
||||||
function currentPath(): string {
|
function currentPath(): string {
|
||||||
return window.location.pathname.replace(/\/+$/, "") || "/";
|
return window.location.pathname.replace(/\/+$/, "") || "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADMIN_PROBE_PATH = "/api/v1/admin/ping";
|
||||||
|
|
||||||
async function probeApi(setScreen: (screen: Screen) => void) {
|
async function probeApi(setScreen: (screen: Screen) => void) {
|
||||||
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
|
// The admin plane's authorization probe: only a 2xx enters the shell, so a
|
||||||
// right after login instead of a silent empty shell.
|
// non-admin (403) sees the explicit refusal and any other failure an error.
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/v1/children", {}, config);
|
const response = await apiFetch(ADMIN_PROBE_PATH, {}, config);
|
||||||
setScreen({ kind: "app" });
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof ConfigError) {
|
if (error instanceof ConfigError) {
|
||||||
setScreen({ kind: "config-error", message: error.message });
|
setScreen({ kind: "config-error", message: error.message });
|
||||||
@@ -42,9 +52,15 @@ async function probeApi(setScreen: (screen: Screen) => void) {
|
|||||||
setScreen({ kind: "refusal", message: error.message });
|
setScreen({ kind: "refusal", message: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Network or other failures still allow the shell to render; API screens
|
if (error instanceof ApiUnauthorizedError) {
|
||||||
// handle their own errors in later tasks.
|
// apiFetch has already started the login redirect.
|
||||||
setScreen({ kind: "app" });
|
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() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case "probe-error":
|
||||||
|
return (
|
||||||
|
<div className="admin-error" role="alert" data-testid="probe-error-screen">
|
||||||
|
<h1>Admin console unavailable</h1>
|
||||||
|
<p>{screen.message}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setScreen({ kind: "loading" });
|
||||||
|
void probeApi(setScreen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case "app":
|
case "app":
|
||||||
return <AdminShell />;
|
return <AdminShell />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user