fix(tier): /me 临时失败时管理页显示权限确认失败和重试,不再误报无权访问
This commit is contained in:
10 files changed
+237
-8
No files matched your search
@@ -0,0 +1,89 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { humanizeError } from "@/lib/api";
|
||||||
|
import { RequireAdmin } from "./RequireAdmin";
|
||||||
|
import { RequireOwner } from "./RequireOwner";
|
||||||
|
|
||||||
|
const tier = vi.hoisted(() => ({
|
||||||
|
loading: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
|
identityError: null as unknown,
|
||||||
|
revalidate: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
|
||||||
|
|
||||||
|
const t = (key: string) => i18next.t(key);
|
||||||
|
const err503 = { status: 503, code: "unavailable", message: "database is restarting" };
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<RequireAdmin />}>
|
||||||
|
<Route path="/admin" element={<p>admin page</p>} />
|
||||||
|
</Route>
|
||||||
|
<Route element={<RequireOwner />}>
|
||||||
|
<Route path="/admin/users" element={<p>users page</p>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryButton = () => screen.getByRole("button", { name: t("common:try_again") });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.assign(tier, { loading: false, isAdmin: false, isOwner: false, identityError: null });
|
||||||
|
tier.revalidate.mockReset();
|
||||||
|
tier.revalidate.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
["RequireAdmin", "/admin", "admin page", "isAdmin"],
|
||||||
|
["RequireOwner", "/admin/users", "users page", "isOwner"],
|
||||||
|
] as const)("%s", (_name, path, page, flag) => {
|
||||||
|
it("says the access check failed, with why, when /me failed with anything but a 401", () => {
|
||||||
|
tier.identityError = err503;
|
||||||
|
renderAt(path);
|
||||||
|
|
||||||
|
expect(screen.getByText(t("common:access_unknown_title"))).toBeTruthy();
|
||||||
|
expect(screen.getByRole("alert").textContent).toBe(humanizeError(err503));
|
||||||
|
expect(retryButton()).toBeTruthy();
|
||||||
|
expect(screen.queryByText(t("common:not_authorized_title"))).toBeNull();
|
||||||
|
expect(screen.queryByText(page)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads /me in place from the retry, and holds the button until it answers", async () => {
|
||||||
|
tier.identityError = err503;
|
||||||
|
let answer!: () => void;
|
||||||
|
tier.revalidate.mockReturnValue(new Promise<void>((resolve) => (answer = resolve)));
|
||||||
|
renderAt(path);
|
||||||
|
|
||||||
|
fireEvent.click(retryButton());
|
||||||
|
expect(tier.revalidate).toHaveBeenCalledTimes(1);
|
||||||
|
expect((retryButton() as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
fireEvent.click(retryButton());
|
||||||
|
expect(tier.revalidate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => answer());
|
||||||
|
expect((retryButton() as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still says not authorized to a known account without the role", () => {
|
||||||
|
renderAt(path);
|
||||||
|
|
||||||
|
expect(screen.getByText(t("common:not_authorized_title"))).toBeTruthy();
|
||||||
|
expect(screen.queryByText(t("common:access_unknown_title"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the page for an account with the role", () => {
|
||||||
|
tier[flag] = true;
|
||||||
|
renderAt(path);
|
||||||
|
|
||||||
|
expect(screen.getByText(page)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { Loading, NotAuthorized } from "@/components/States";
|
import { AccessUnknown, Loading, NotAuthorized } from "@/components/States";
|
||||||
|
|
||||||
// RequireAdmin is the single route wrapper for BOTH /admin/* and /ops/* (the two
|
// RequireAdmin is the single route wrapper for BOTH /admin/* and /ops/* (the two
|
||||||
// admin-tier concerns). It is belt-and-suspenders: it spares non-admins a wall of
|
// admin-tier concerns). It is belt-and-suspenders: it spares non-admins a wall of
|
||||||
// 403s, but the server enforces the boundary independently on every data call.
|
// 403s, but the server enforces the boundary independently on every data call.
|
||||||
//
|
//
|
||||||
// While /me is still loading we show a spinner rather than NotAuthorized, so a
|
// While /me is still loading we show a spinner rather than NotAuthorized, so a
|
||||||
// genuine admin is never briefly told "not authorized" on a slow boot. Once
|
// genuine admin is never briefly told "not authorized" on a slow boot. A /me that
|
||||||
// settled, isAdmin is the fail-closed verdict (false on any /me failure).
|
// failed with anything but a 401 leaves the answer unknown, so the page says the
|
||||||
|
// check failed and offers a retry. Otherwise isAdmin is the fail-closed verdict.
|
||||||
export function RequireAdmin() {
|
export function RequireAdmin() {
|
||||||
const { isAdmin, loading } = useTier();
|
const { isAdmin, loading, identityError } = useTier();
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
if (loading) return <Loading label={t("checking_access")} />;
|
if (loading) return <Loading label={t("checking_access")} />;
|
||||||
|
if (identityError) return <AccessUnknown error={identityError} />;
|
||||||
return isAdmin ? <Outlet /> : <NotAuthorized />;
|
return isAdmin ? <Outlet /> : <NotAuthorized />;
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { NotAuthorized } from "@/components/States";
|
import { AccessUnknown, Loading, NotAuthorized } from "@/components/States";
|
||||||
import { Loading } from "@/components/States";
|
|
||||||
|
|
||||||
|
// RequireOwner gates user management, one level above admin. Like RequireAdmin
|
||||||
|
// it waits for /me, and a /me that failed with anything but a 401 gets a retry:
|
||||||
|
// the owner is not told "not authorized" on a flaky read.
|
||||||
export function RequireOwner() {
|
export function RequireOwner() {
|
||||||
const { loading, isOwner } = useTier();
|
const { loading, isOwner, identityError } = useTier();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
if (identityError) {
|
||||||
|
return <AccessUnknown error={identityError} />;
|
||||||
|
}
|
||||||
if (!isOwner) {
|
if (!isOwner) {
|
||||||
return <NotAuthorized />;
|
return <NotAuthorized />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { Loader2, AlertTriangle, Inbox, ShieldX, ShieldQuestion, Construction, Moon, SearchX, RefreshCw } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge";
|
import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge";
|
||||||
import { PowerButton } from "@/components/PowerButton";
|
import { PowerButton } from "@/components/PowerButton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { humanizeError } from "@/lib/api";
|
import { humanizeError } from "@/lib/api";
|
||||||
|
import { useTier } from "@/lib/tier";
|
||||||
import type { Phase, RetireState } from "@/lib/types";
|
import type { Phase, RetireState } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -94,6 +97,41 @@ export function NotAuthorized() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccessUnknown stands in for an admin or owner page while /me has failed with
|
||||||
|
// anything but a 401: whether this account may see the page is unknown, so it
|
||||||
|
// says the check failed and why, and retries in place (the app stays mounted).
|
||||||
|
export function AccessUnknown({ error }: { error: unknown }) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const { revalidate } = useTier();
|
||||||
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
setRetrying(true);
|
||||||
|
try {
|
||||||
|
await revalidate();
|
||||||
|
} finally {
|
||||||
|
setRetrying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
|
||||||
|
<ShieldQuestion className="h-8 w-8 text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("access_unknown_title")}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t("access_unknown_body")}</p>
|
||||||
|
<p role="alert" className="mt-2 text-xs text-muted-foreground/80">
|
||||||
|
{humanizeError(error)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void retry()} disabled={retrying}>
|
||||||
|
{retrying ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||||
|
{t("try_again")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotFoundProps {
|
export interface NotFoundProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"not_wired_yet": "Not wired yet",
|
"not_wired_yet": "Not wired yet",
|
||||||
"pending_backend_prefix": "pending backend: ",
|
"pending_backend_prefix": "pending backend: ",
|
||||||
"checking_access": "Checking access…",
|
"checking_access": "Checking access…",
|
||||||
|
"access_unknown_title": "Couldn't check your access",
|
||||||
|
"access_unknown_body": "Your account details didn't load, so this page stays hidden until they do.",
|
||||||
"loading_config": "Loading config…",
|
"loading_config": "Loading config…",
|
||||||
"brand_name": "Felis",
|
"brand_name": "Felis",
|
||||||
"brand_tagline": "K8s-native Minecraft orchestration",
|
"brand_tagline": "K8s-native Minecraft orchestration",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"not_wired_yet": "功能尚未接入",
|
"not_wired_yet": "功能尚未接入",
|
||||||
"pending_backend_prefix": "待接入接口:",
|
"pending_backend_prefix": "待接入接口:",
|
||||||
"checking_access": "正在验证权限…",
|
"checking_access": "正在验证权限…",
|
||||||
|
"access_unknown_title": "暂时无法确认你的权限",
|
||||||
|
"access_unknown_body": "账户信息没有读到,读到之前这一页先不显示。",
|
||||||
"loading_config": "正在加载配置…",
|
"loading_config": "正在加载配置…",
|
||||||
"brand_name": "Felis",
|
"brand_name": "Felis",
|
||||||
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
|
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ describe("deriveAuth", () => {
|
|||||||
expect(s.isAdmin).toBe(false);
|
expect(s.isAdmin).toBe(false);
|
||||||
expect(s.unauthenticated).toBe(false);
|
expect(s.unauthenticated).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a settled non-401 failure as identityError, so a gate can offer a retry", () => {
|
||||||
|
expect(deriveAuth(null, err500, false).identityError).toBe(err500);
|
||||||
|
const offline = new TypeError("Failed to fetch");
|
||||||
|
expect(deriveAuth(null, offline, false).identityError).toBe(offline);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no identityError for a 401, while loading, or once an identity is known", () => {
|
||||||
|
expect(deriveAuth(null, err401, false).identityError).toBeNull();
|
||||||
|
expect(deriveAuth(null, err500, true).identityError).toBeNull();
|
||||||
|
expect(deriveAuth(admin, err500, false).identityError).toBeNull();
|
||||||
|
expect(deriveAuth(null, null, false).identityError).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loginReturnPath", () => {
|
describe("loginReturnPath", () => {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export interface AuthState {
|
|||||||
/** True ONLY when /me returned 401 — no/expired session, route to /login. A
|
/** True ONLY when /me returned 401 — no/expired session, route to /login. A
|
||||||
* transient or 5xx failure leaves this false so the app keeps rendering. */
|
* transient or 5xx failure leaves this false so the app keeps rendering. */
|
||||||
unauthenticated: boolean;
|
unauthenticated: boolean;
|
||||||
|
/** The settled /me failure that left the identity unknown (anything but a
|
||||||
|
* 401), else null. The app keeps running, but an admin cannot be told from a
|
||||||
|
* user until a retry succeeds, so a gate says so and offers the retry instead
|
||||||
|
* of "not authorized". */
|
||||||
|
identityError: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** isUnauthorized reports whether a caught error is the request() 401 envelope —
|
/** isUnauthorized reports whether a caught error is the request() 401 envelope —
|
||||||
@@ -48,6 +53,7 @@ export function deriveAuth(
|
|||||||
loading,
|
loading,
|
||||||
isAdmin: identity?.is_admin === true,
|
isAdmin: identity?.is_admin === true,
|
||||||
unauthenticated: !loading && identity === null && isUnauthorized(error),
|
unauthenticated: !loading && identity === null && isUnauthorized(error),
|
||||||
|
identityError: !loading && identity === null && error != null && !isUnauthorized(error) ? error : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import { TierProvider, useTier } from "./tier";
|
||||||
|
import type { Identity } from "./types";
|
||||||
|
|
||||||
|
const calls = vi.hoisted(() => ({ me: vi.fn() }));
|
||||||
|
vi.mock("./api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./api")>();
|
||||||
|
return { ...actual, api: { ...actual.api, ...calls } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin: Identity = { user_id: "u1", email: "[email protected]", role: "admin", is_admin: true, is_owner: false };
|
||||||
|
const err503 = { status: 503, code: "unavailable", message: "restarting" };
|
||||||
|
|
||||||
|
let tier: ReturnType<typeof useTier>;
|
||||||
|
function Probe() {
|
||||||
|
tier = useTier();
|
||||||
|
return <p>{tier.isAdmin ? "admin" : "not admin"}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
render(
|
||||||
|
<TierProvider>
|
||||||
|
<Probe />
|
||||||
|
</TierProvider>,
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls.me.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TierProvider after a /me that failed with anything but a 401", () => {
|
||||||
|
it("exposes the failure, and a revalidate that succeeds clears it without a reload", async () => {
|
||||||
|
calls.me.mockRejectedValueOnce(err503);
|
||||||
|
await boot();
|
||||||
|
expect(tier.identityError).toBe(err503);
|
||||||
|
expect(tier.unauthenticated).toBe(false);
|
||||||
|
|
||||||
|
let answer!: (id: Identity) => void;
|
||||||
|
calls.me.mockReturnValueOnce(new Promise<Identity>((resolve) => (answer = resolve)));
|
||||||
|
let retry!: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
retry = tier.revalidate();
|
||||||
|
});
|
||||||
|
// Mid-check the app stays mounted: loading never comes back.
|
||||||
|
expect(tier.loading).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
answer(admin);
|
||||||
|
await retry;
|
||||||
|
});
|
||||||
|
expect(tier.identityError).toBeNull();
|
||||||
|
expect(screen.getByText("admin")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the failure when the retry fails the same way", async () => {
|
||||||
|
calls.me.mockRejectedValueOnce(err503);
|
||||||
|
await boot();
|
||||||
|
|
||||||
|
calls.me.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
|
await act(() => tier.revalidate());
|
||||||
|
|
||||||
|
expect(tier.identityError).toBe(err503);
|
||||||
|
expect(tier.unauthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,8 @@ import { deriveAuth, isUnauthorized, type AuthState } from "./auth";
|
|||||||
// principal whose /me momentarily fails still gets the full User-Side app; they
|
// principal whose /me momentarily fails still gets the full User-Side app; they
|
||||||
// simply don't see admin surfaces. (The backend 403s admin data calls
|
// simply don't see admin surfaces. (The backend 403s admin data calls
|
||||||
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
|
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
|
||||||
|
// The failure itself is `identityError`: the admin and owner gates show it
|
||||||
|
// with a retry, since "not authorized" would be a guess.
|
||||||
//
|
//
|
||||||
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
|
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
|
||||||
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
||||||
@@ -61,6 +63,7 @@ const TierContext = createContext<TierState>({
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
unauthenticated: false,
|
unauthenticated: false,
|
||||||
|
identityError: null,
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
revalidate: async () => {},
|
revalidate: async () => {},
|
||||||
sessionEnded: false,
|
sessionEnded: false,
|
||||||
|
|||||||
Reference in new issue
Block a user