mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +00:00
* 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).
284 lines
7.4 KiB
TypeScript
284 lines
7.4 KiB
TypeScript
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
test,
|
|
rs,
|
|
} from "@rstest/core";
|
|
|
|
import { AUTH_DISABLED_USER } from "@/core/auth/auth-disabled-user";
|
|
import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
|
|
import { userSchema } from "@/core/auth/types";
|
|
|
|
rs.mock("next/headers", () => ({
|
|
cookies: rs.fn(() => {
|
|
throw new Error("cookies should not be read in static website mode");
|
|
}),
|
|
}));
|
|
|
|
const ENV_KEYS = [
|
|
"DEER_FLOW_AUTH_DISABLED",
|
|
"DEER_FLOW_ENV",
|
|
"ENVIRONMENT",
|
|
"NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
|
|
] as const;
|
|
|
|
type EnvSnapshot = Partial<
|
|
Record<(typeof ENV_KEYS)[number], string | undefined>
|
|
>;
|
|
|
|
function snapshotEnv(): EnvSnapshot {
|
|
const snapshot: EnvSnapshot = {};
|
|
for (const key of ENV_KEYS) {
|
|
snapshot[key] = process.env[key];
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
|
|
const env = process.env as Record<string, string | undefined>;
|
|
if (value === undefined) {
|
|
delete env[key];
|
|
} else {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
|
|
function restoreEnv(snapshot: EnvSnapshot) {
|
|
for (const key of ENV_KEYS) {
|
|
setEnv(key, snapshot[key]);
|
|
}
|
|
}
|
|
|
|
async function loadFreshServerAuth() {
|
|
rs.resetModules();
|
|
return await import("@/core/auth/server");
|
|
}
|
|
|
|
describe("getServerSideUser", () => {
|
|
let saved: EnvSnapshot;
|
|
|
|
beforeEach(() => {
|
|
saved = snapshotEnv();
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
|
|
setEnv("DEER_FLOW_ENV", undefined);
|
|
setEnv("ENVIRONMENT", undefined);
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreEnv(saved);
|
|
rs.unstubAllGlobals();
|
|
});
|
|
|
|
test("bypasses gateway auth in static website mode", async () => {
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", "true");
|
|
const fetchSpy = rs.fn(() => {
|
|
throw new Error("fetch should not be called in static website mode");
|
|
});
|
|
rs.stubGlobal("fetch", fetchSpy);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "authenticated",
|
|
user: STATIC_WEBSITE_USER,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("bypasses gateway auth in auth-disabled mode", async () => {
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
|
|
const fetchSpy = rs.fn(() => {
|
|
throw new Error("fetch should not be called in auth-disabled mode");
|
|
});
|
|
rs.stubGlobal("fetch", fetchSpy);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "authenticated",
|
|
user: AUTH_DISABLED_USER,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("does not enable auth-disabled mode in explicit production environments", async () => {
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
|
|
setEnv("DEER_FLOW_ENV", "production");
|
|
|
|
const { isAuthDisabledMode } =
|
|
await import("@/core/auth/auth-disabled-user");
|
|
|
|
expect(isAuthDisabledMode()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getServerSideUser — gateway_unavailable contract (issue #3493)", () => {
|
|
let saved: EnvSnapshot;
|
|
|
|
beforeEach(() => {
|
|
saved = snapshotEnv();
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreEnv(saved);
|
|
rs.unstubAllGlobals();
|
|
rs.doUnmock("next/headers");
|
|
});
|
|
|
|
test("returns gateway_unavailable when /auth/me fetch rejects (e.g. AbortError)", async () => {
|
|
rs.doMock("next/headers", () => ({
|
|
cookies: rs.fn(async () => ({
|
|
get: (name: string) =>
|
|
name === "access_token" ? { value: "stub-token" } : undefined,
|
|
})),
|
|
}));
|
|
const abortErr = new DOMException("Aborted", "AbortError");
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(() => Promise.reject(abortErr)),
|
|
);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "gateway_unavailable",
|
|
});
|
|
});
|
|
|
|
test("returns gateway_unavailable when /auth/me responds with a 5xx", async () => {
|
|
rs.doMock("next/headers", () => ({
|
|
cookies: rs.fn(async () => ({
|
|
get: (name: string) =>
|
|
name === "access_token" ? { value: "stub-token" } : undefined,
|
|
})),
|
|
}));
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(() =>
|
|
Promise.resolve(
|
|
new Response("upstream error", {
|
|
status: 503,
|
|
statusText: "Service Unavailable",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "gateway_unavailable",
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── account_disabled contract (RFC #4063 gap 3) ────────────────────
|
|
|
|
describe("getServerSideUser — account_disabled contract", () => {
|
|
let saved: EnvSnapshot;
|
|
|
|
beforeEach(() => {
|
|
saved = snapshotEnv();
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
|
|
rs.doMock("next/headers", () => ({
|
|
cookies: rs.fn(async () => ({
|
|
get: (name: string) =>
|
|
name === "access_token" ? { value: "stub-token" } : undefined,
|
|
})),
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreEnv(saved);
|
|
rs.unstubAllGlobals();
|
|
rs.doUnmock("next/headers");
|
|
});
|
|
|
|
function stubMeResponse(body: unknown, status = 401) {
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(
|
|
async () =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
test("maps a 401 account_disabled session to the account_disabled tag", async () => {
|
|
stubMeResponse({
|
|
detail: { code: "account_disabled", message: "Account disabled" },
|
|
});
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "account_disabled",
|
|
});
|
|
});
|
|
|
|
test("keeps every other 401 a plain unauthenticated result", async () => {
|
|
stubMeResponse({
|
|
detail: { code: "token_expired", message: "Token expired" },
|
|
});
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "unauthenticated",
|
|
});
|
|
});
|
|
|
|
test("a non-JSON 401 body still degrades to unauthenticated", async () => {
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(async () => new Response("nope", { status: 401 })),
|
|
);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "unauthenticated",
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── system_role contract (RFC #4063 gap 2) ─────────────────────────
|
|
|
|
describe("userSchema system_role", () => {
|
|
test("accepts a provider-configured custom role verbatim", () => {
|
|
const parsed = userSchema.parse({
|
|
id: "1",
|
|
email: "g@example.com",
|
|
system_role: "guest",
|
|
needs_setup: false,
|
|
oauth_provider: null,
|
|
permissions: null,
|
|
});
|
|
|
|
expect(parsed.system_role).toBe("guest");
|
|
});
|
|
|
|
test("still rejects an empty role string", () => {
|
|
expect(() =>
|
|
userSchema.parse({
|
|
id: "1",
|
|
email: "g@example.com",
|
|
system_role: "",
|
|
needs_setup: false,
|
|
disabled: false,
|
|
oauth_provider: null,
|
|
permissions: null,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|