Hyeonsang Cho 1792e4e432
fix(frontend): stop re-validating account emails the backend accepted (#7449)
* 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
2026-10-25 10:09:49 +08:00

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();
});
});