deer-flow/frontend/tests/unit/core/auth/admin-users.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

139 lines
3.7 KiB
TypeScript

import {
afterEach,
beforeEach,
describe,
expect,
test,
rs,
} from "@rstest/core";
import { GatewayApiError } from "@/core/api/errors";
import { loadAdminUsers, updateUserAccount } from "@/core/auth/admin-users";
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
describe("admin user management client", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("loadAdminUsers requests the admin list endpoint", async () => {
const fetchSpy = rs.fn(async () =>
jsonResponse([
{
id: "u-1",
email: "a@example.com",
system_role: "admin",
needs_setup: false,
oauth_provider: null,
disabled: true,
},
]),
);
globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch;
const users = await loadAdminUsers();
expect(fetchSpy).toHaveBeenCalledWith(
"/api/v1/admin/users",
expect.objectContaining({ method: "GET" }),
);
expect(users).toEqual([
{
id: "u-1",
email: "a@example.com",
system_role: "admin",
needs_setup: false,
oauth_provider: null,
disabled: true,
},
]);
});
test("updateUserAccount PATCHes a JSON body with only the changed fields", async () => {
const fetchSpy = rs.fn(async () =>
jsonResponse({
id: "u-1",
email: "a@example.com",
system_role: "admin",
needs_setup: false,
oauth_provider: null,
disabled: true,
}),
);
globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch;
await updateUserAccount("u 1/id", { disabled: true });
const [url, init] = rs.mocked(fetchSpy).mock.calls[0] as unknown as [
string,
RequestInit & { body: string },
];
expect(url).toBe("/api/v1/admin/users/u%201%2Fid");
expect(init.method).toBe("PATCH");
expect(init.headers).toMatchObject({ "Content-Type": "application/json" });
expect(JSON.parse(init.body)).toEqual({ disabled: true });
});
test("a 409 conflict surfaces the gateway detail as the error message", async () => {
globalThis.fetch = rs.fn(async () =>
jsonResponse(
{ detail: "cannot disable the last remaining active admin" },
409,
),
) as unknown as typeof globalThis.fetch;
const error = await updateUserAccount("u-1", { disabled: true }).catch(
(err: unknown) => err,
);
expect(error).toBeInstanceOf(GatewayApiError);
expect(error).toMatchObject({
status: 409,
message: "cannot disable the last remaining active admin",
});
});
test("a coded-envelope 409 still surfaces its message verbatim", async () => {
globalThis.fetch = rs.fn(async () =>
jsonResponse(
{
detail: {
code: "last_active_admin",
message: "cannot disable the last remaining active admin",
},
},
409,
),
) as unknown as typeof globalThis.fetch;
const error = await updateUserAccount("u-1", { disabled: true }).catch(
(err: unknown) => err,
);
expect(error).toBeInstanceOf(GatewayApiError);
expect(error).toMatchObject({
status: 409,
message: "cannot disable the last remaining active admin",
});
});
test("a malformed row rejects instead of rendering a phantom user", async () => {
globalThis.fetch = rs.fn(async () =>
jsonResponse([{ email: "no-id@example.com" }]),
) as unknown as typeof globalThis.fetch;
await expect(loadAdminUsers()).rejects.toThrow();
});
});