fix(tier): /me 临时失败时管理页显示权限确认失败和重试,不再误报无权访问
This commit is contained in:
10 files changed
+237
-8
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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,
|
||||
|
||||
Reference in new issue
Block a user