fix(tier): /me 临时失败时管理页显示权限确认失败和重试,不再误报无权访问

This commit is contained in:
Lemon-miaow committed 2026-09-27 06:17:31 +08:00
1 parent e519549c8a
commit 4291d08e2b
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();
});
});
+6 -4
View File
@@ -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 />;
} }
+8 -3
View File
@@ -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 />;
} }
+39 -1
View File
@@ -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 管理平台",
+13
View File
@@ -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", () => {
+6
View File
@@ -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,
}; };
} }
+69
View File
@@ -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);
});
});
+3
View File
@@ -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,