deer-flow/frontend/tests/unit/core/api/fetcher.dom.test.ts
hataa 2f4f4d4b6c
feat(authz): admin user-management UI and disabled-account copy (#4063 gap 3) (#6641)
* feat(authz): admin user-management UI and disabled-account copy (#4063 gap 3)

Frontend slice of the account lifecycle backend (#6566):

- Settings gains an admin-only Users page (core/auth/admin-users.ts ->
  GET/PATCH /api/v1/admin/users): email, read-only role, disabled state,
  and a suspend/restore toggle per row; the Gateway's rejection detail
  (last-active-admin 409) renders verbatim. The nav entry is hidden for
  non-admins and the page guards independently; ?settings=users joins the
  deep-link whitelist (now an exhaustive Record over SettingsSection).
- A session that turns out disabled maps account_disabled to a login
  redirect that states the reason on all three surfaces: the SSR
  AuthResult tag (consumed exhaustively by the workspace/artifacts/auth
  layouts), the shared fetcher's 401 interception, and
  AuthProvider.refreshUser. No next target is preserved - a disabled
  account cannot sign back in. Login failures surface the localized
  account_disabled copy when the code is present.
- userSchema stays disabled-optional (absent = not disabled); zh/en
  locales and docs updated in the same change.

* fix(authz): address review on admin user UI and disabled copy (#4063 gap 3)

Review follow-ups on PR #6641:

- Direct /login visits with a suspended session now surface the SSR
  account_disabled verdict through AuthDisabledContext instead of
  discarding it (willem-bd): the auth layout wraps pages with the
  provider and the login form seeds its reason from it — no redirect,
  so no loop. Regression tests cover both verdict states.
- admin-users.ts parses both endpoints against userSchema /
  z.array(userSchema) (zhfeng): malformed payloads fail the load
  instead of rendering phantom rows; toggle error copy is scoped to
  GatewayApiError with a localized fallback for anything else.
- The localized account_disabled copy is login-only (zhfeng): sign-up
  keeps the backend message verbatim so a suspended account's email
  cannot be probed through the register surface.
- Settings page folds the load banner and toggle error into a single
  alert region (zhfeng); adds coded-envelope 409, malformed-row, and
  load-failure tests; fetcher.ts documents the 401 body-consumption
  contract.
- prettier across touched files (CI format gate).

The admin-users query key keeps user?.id, matching the
model-settings-page precedent (converge later per review).
2026-10-11 12:54:51 +08:00

61 lines
2.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { fetch as apiFetch } from "@/core/api/fetcher";
describe("api fetcher unauthorized redirect", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
globalThis.fetch = rs.fn(
async () => new Response("", { status: 401 }),
) as unknown as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns the caller to the full URL, query string included", async () => {
window.history.replaceState(
{},
"",
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
);
// The wrapper redirects and then throws UnauthorizedError; the redirect
// target is what this test is about.
await expect(
apiFetch("/api/threads/t-1/artifacts/mnt/user-data/outputs/report.md"),
).rejects.toThrow();
expect(window.location.href).toContain("/login?next=");
const next = new URL(
window.location.href,
"http://localhost",
).searchParams.get("next");
expect(next).toBe(
"/artifacts/view?path=%2Fmnt%2Fuser-data%2Foutputs%2Freport.md&thread_id=t-1",
);
});
it("redirects an account_disabled 401 to a login page that states the reason", async () => {
globalThis.fetch = rs.fn(
async () =>
new Response(
JSON.stringify({
detail: { code: "account_disabled", message: "Account disabled" },
}),
{ status: 401, headers: { "Content-Type": "application/json" } },
),
) as unknown as typeof globalThis.fetch;
window.history.replaceState({}, "", "/workspace");
await expect(apiFetch("/api/v1/auth/me")).rejects.toThrow();
expect(window.location.href).toContain("/login?error=account_disabled");
// A disabled account cannot sign back in, so no return target is kept.
expect(window.location.href).not.toContain("next=");
});
});