mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(frontend): stop re-validating account emails the backend accepted userSchema checked email with zod's email(), which rejects many addresses pydantic EmailStr registers (a&b@example.com, a@b.c, Unicode). /auth/me for such an account parsed as malformed: SSR answered gateway_unavailable, the offline banner never recovered, and the admin user list failed for every administrator. The backend owns address validation; keep a non-empty string. * docs(changelog): reference #7449 for the account email schema fix
368 lines
9.6 KiB
TypeScript
368 lines
9.6 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",
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── email contract ─────────────────────────────────────────────────
|
|
|
|
// Each address is one the backend registers with EmailStr and returns from
|
|
// /auth/me as shown (IDN domains come back in Unicode).
|
|
const BACKEND_ACCEPTED_EMAILS = [
|
|
"a&b@example.com",
|
|
"a@b.c",
|
|
"a~b@example.com",
|
|
"josé@example.com",
|
|
"user@exämple.com",
|
|
];
|
|
|
|
describe("getServerSideUser — accepts every address the backend accepts", () => {
|
|
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");
|
|
});
|
|
|
|
test.each(BACKEND_ACCEPTED_EMAILS)(
|
|
"authenticates a session for %s",
|
|
async (email) => {
|
|
const me = {
|
|
id: "u-1",
|
|
email,
|
|
system_role: "user",
|
|
needs_setup: false,
|
|
oauth_provider: null,
|
|
permissions: null,
|
|
};
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(
|
|
async () =>
|
|
new Response(JSON.stringify(me), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toMatchObject({
|
|
tag: "authenticated",
|
|
user: { email },
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("userSchema email", () => {
|
|
test.each(BACKEND_ACCEPTED_EMAILS)("keeps %s verbatim", (email) => {
|
|
const parsed = userSchema.parse({
|
|
id: "1",
|
|
email,
|
|
system_role: "user",
|
|
});
|
|
|
|
expect(parsed.email).toBe(email);
|
|
});
|
|
|
|
test("still rejects an empty or missing email", () => {
|
|
expect(() =>
|
|
userSchema.parse({ id: "1", email: "", system_role: "user" }),
|
|
).toThrow();
|
|
expect(() => userSchema.parse({ id: "1", system_role: "user" })).toThrow();
|
|
});
|
|
});
|
|
|
|
// ── 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();
|
|
});
|
|
});
|