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

+13
View File
@@ -68,6 +68,19 @@ describe("deriveAuth", () => {
expect(s.isAdmin).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", () => {
+6
View File
@@ -20,6 +20,11 @@ export interface AuthState {
/** 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. */
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 —
@@ -48,6 +53,7 @@ export function deriveAuth(
loading,
isAdmin: identity?.is_admin === true,
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
// simply don't see admin surfaces. (The backend 403s admin data calls
// 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()`
// 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,
isOwner: false,
unauthenticated: false,
identityError: null,
refresh: async () => {},
revalidate: async () => {},
sessionEnded: false,