mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 14:10:37 +00:00
fix(frontend): isolate workspace query caches by authenticated owner (#7146)
* fix(frontend): isolate query caches by authenticated owner * fix(frontend): share auth owner across isolation boundaries * docs(frontend): keep agent guidance within size budget --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
ee02e51df9
commit
1ae50826b1
@ -108,16 +108,15 @@ Users create **threads** (conversations), send messages, set thread-scoped `/goa
|
||||
|
||||
### Source Layout (`src/`)
|
||||
|
||||
- `app/` — Next.js routes: chat/workspace, agents, auth, showcase, artifact views,
|
||||
localized docs/blog, and API handlers.
|
||||
- `components/` — generated `ui/` and `ai-elements/`; application components under
|
||||
`workspace/`, `landing/`, and `docs/`.
|
||||
- `core/` — domain logic, API clients, state/hooks, i18n, and Markdown renderers.
|
||||
- `hooks/`, `lib/`, `typings/` — shared hooks, utilities, and ambient types.
|
||||
- `content/`, `styles/` — MDX content and Tailwind styles.
|
||||
- `env.js`, `mdx-components.ts` — environment validation and MDX component map.
|
||||
`AuthProvider` publishes the query owner (user ID, confirmed logout, or unknown
|
||||
auth). `QueryClientProvider` owns a mount-local client and remounts its consumers
|
||||
when that owner changes, clearing the old cache and cancelling old queries.
|
||||
Confirmed logout unmounts query consumers immediately; public and unknown-auth
|
||||
routes still render with an independent client. Keep auth outside the query
|
||||
provider. Superseded auth refreshes cannot restore an earlier owner.
|
||||
|
||||
Read [src/AGENTS.md](src/AGENTS.md) for component and domain ownership.
|
||||
See [project structure](README.md#project-structure) for the directory map and
|
||||
[src/AGENTS.md](src/AGENTS.md) for component and domain ownership.
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
Like the original DeerFlow 1.0, we would love to give the community a minimalistic and easy-to-use web interface with a more modern and flexible architecture.
|
||||
|
||||
Switching accounts reloads projects, custom agents, models, and other cached workspace data for the new account. Logging out immediately hides the previous account's data; refreshing the same account preserves its cache.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [Next.js 16](https://nextjs.org/) with [App Router](https://nextjs.org/docs/app)
|
||||
|
||||
@ -4,14 +4,37 @@ import {
|
||||
QueryClient,
|
||||
QueryClientProvider as TanStackQueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
import { AuthQueryScopeContext } from "@/core/auth/query-scope";
|
||||
|
||||
export function QueryClientProvider({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const owner = useContext(AuthQueryScopeContext);
|
||||
// A confirmed logout must not start fresh reads with the old cookie while
|
||||
// the logout POST is still pending. Public/unknown-auth routes still render.
|
||||
if (owner === null) return null;
|
||||
|
||||
// Remount consumers as well as the client, so no observer/local state can
|
||||
// retain an old account's data for the new account's first render.
|
||||
return (
|
||||
<ScopedQueryClientProvider key={owner}>
|
||||
{children}
|
||||
</ScopedQueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopedQueryClientProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
return (
|
||||
<TanStackQueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { AuthQueryScopeContext } from "@/core/auth/query-scope";
|
||||
import { userSchema, type User } from "@/core/auth/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
@ -27,6 +28,7 @@ export function GatewayOfflineBanner({
|
||||
}: GatewayOfflineBannerProps) {
|
||||
const { t } = useI18n();
|
||||
const { user, applyUser, refreshUser, logout } = useAuth();
|
||||
const authScope = useContext(AuthQueryScopeContext);
|
||||
// Guard against piling up probe calls while the gateway is still slow.
|
||||
const inFlightRef = useRef(false);
|
||||
// Count consecutive 401s so we can distinguish "transient warm-up 401"
|
||||
@ -35,11 +37,14 @@ export function GatewayOfflineBanner({
|
||||
|
||||
useEffect(() => {
|
||||
if (!gatewayUnavailable) return;
|
||||
// Logout from unknown auth keeps user=null, but changes this scope.
|
||||
if (authScope === null) return;
|
||||
// Once AuthProvider has a user again the banner has served its
|
||||
// purpose; tear down the polling so we don't keep probing every 10s
|
||||
// for the entire lifetime of the page (gatewayUnavailable is a
|
||||
// server-rendered prop and stays true until a full reload).
|
||||
if (user !== null) return;
|
||||
let disposed = false;
|
||||
|
||||
const probe = async () => {
|
||||
if (inFlightRef.current) return;
|
||||
@ -71,8 +76,9 @@ export function GatewayOfflineBanner({
|
||||
console.warn("[gateway-offline-banner] probe failed:", err);
|
||||
errored = true;
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
if (!disposed) inFlightRef.current = false;
|
||||
}
|
||||
if (disposed) return;
|
||||
|
||||
const action = decideProbeAction(
|
||||
authFailuresRef.current,
|
||||
@ -98,9 +104,11 @@ export function GatewayOfflineBanner({
|
||||
void probe();
|
||||
}, OFFLINE_BANNER_RETRY_INTERVAL_MS);
|
||||
return () => {
|
||||
disposed = true;
|
||||
inFlightRef.current = false;
|
||||
window.clearInterval(handle);
|
||||
};
|
||||
}, [gatewayUnavailable, user, applyUser, refreshUser]);
|
||||
}, [gatewayUnavailable, user, authScope, applyUser, refreshUser]);
|
||||
|
||||
if (!shouldShowOfflineBanner(user, gatewayUnavailable)) {
|
||||
return null;
|
||||
|
||||
@ -14,6 +14,7 @@ import { useArtifactDraftOwner } from "@/core/artifacts/draft-context";
|
||||
|
||||
import { isStaticWebsiteOnly } from "../static-mode";
|
||||
|
||||
import { AuthQueryScopeContext } from "./query-scope";
|
||||
import {
|
||||
ACCOUNT_DISABLED_LOGIN_URL,
|
||||
type User,
|
||||
@ -70,8 +71,11 @@ export function AuthProvider({
|
||||
}: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(initialUser);
|
||||
const [authKnown, setAuthKnown] = useState(!initialAuthUnknown);
|
||||
useArtifactDraftOwner(authKnown ? (user?.id ?? null) : undefined);
|
||||
const authQueryScope = authKnown ? (user?.id ?? null) : undefined;
|
||||
useArtifactDraftOwner(authQueryScope);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// Logout and externally confirmed users supersede pending auth refreshes.
|
||||
const authRequestRef = React.useRef(0);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const staticMode = isStaticWebsiteOnly();
|
||||
@ -84,8 +88,10 @@ export function AuthProvider({
|
||||
* so consumers don't reach into React internals.
|
||||
*/
|
||||
const applyUser = useCallback((next: User | null) => {
|
||||
authRequestRef.current++;
|
||||
setUser(next);
|
||||
setAuthKnown(true);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@ -94,15 +100,18 @@ export function AuthProvider({
|
||||
*/
|
||||
const refreshUser = useCallback(async () => {
|
||||
if (staticMode) return;
|
||||
const requestId = ++authRequestRef.current;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch("/api/v1/auth/me", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (requestId !== authRequestRef.current) return;
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (requestId !== authRequestRef.current) return;
|
||||
setUser(data);
|
||||
setAuthKnown(true);
|
||||
} else if (res.status === 401) {
|
||||
@ -112,6 +121,7 @@ export function AuthProvider({
|
||||
.json()
|
||||
.then((data) => parseAuthError(data).code)
|
||||
.catch(() => null);
|
||||
if (requestId !== authRequestRef.current) return;
|
||||
if (code === "account_disabled") {
|
||||
// Operator-disabled account (#4063 gap 3): the login page states
|
||||
// the reason. Unlike an expired session there is nothing to come
|
||||
@ -124,11 +134,12 @@ export function AuthProvider({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== authRequestRef.current) return;
|
||||
console.error("Failed to refresh user:", err);
|
||||
setUser(null);
|
||||
setAuthKnown(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (requestId === authRequestRef.current) setIsLoading(false);
|
||||
}
|
||||
}, [staticMode, pathname, router]);
|
||||
|
||||
@ -145,8 +156,10 @@ export function AuthProvider({
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
// Immediately clear local state to prevent UI flicker
|
||||
authRequestRef.current++;
|
||||
setUser(null);
|
||||
setAuthKnown(true);
|
||||
setIsLoading(false);
|
||||
|
||||
if (staticMode) {
|
||||
router.push("/");
|
||||
@ -208,7 +221,13 @@ export function AuthProvider({
|
||||
applyUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
<AuthQueryScopeContext.Provider value={authQueryScope}>
|
||||
{children}
|
||||
</AuthQueryScopeContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
8
frontend/src/core/auth/query-scope.ts
Normal file
8
frontend/src/core/auth/query-scope.ts
Normal file
@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
// undefined: public route or unknown auth; null: confirmed signed out.
|
||||
export const AuthQueryScopeContext = createContext<string | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@ -0,0 +1,338 @@
|
||||
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StrictMode, type ReactNode } from "react";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { useAgents } from "@/core/agents/hooks";
|
||||
import { AuthProvider, useAuth } from "@/core/auth/AuthProvider";
|
||||
import type { User } from "@/core/auth/types";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useProjects } from "@/core/projects/hooks";
|
||||
import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
|
||||
rs.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: rs.fn() }),
|
||||
usePathname: () => "/workspace",
|
||||
}));
|
||||
rs.mock("@/core/settings/user-preferences", () => ({
|
||||
startUserPreferences: () => () => undefined,
|
||||
}));
|
||||
rs.mock("@/core/static-mode", () => ({
|
||||
isStaticWebsiteOnly: rs.fn(() => false),
|
||||
}));
|
||||
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
|
||||
|
||||
const userA: User = {
|
||||
id: "user-A",
|
||||
email: "a@example.test",
|
||||
system_role: "user",
|
||||
needs_setup: false,
|
||||
};
|
||||
const userB: User = { ...userA, id: "user-B", email: "b@example.test" };
|
||||
let auth: ReturnType<typeof useAuth>;
|
||||
let client: QueryClient;
|
||||
let owner: "A" | "B";
|
||||
let failReads: boolean;
|
||||
let holdAReads: boolean;
|
||||
let pendingReads: Array<() => void>;
|
||||
let snapshots: Array<{ user: string | undefined; data: string }>;
|
||||
const clients = new Set<QueryClient>();
|
||||
const requests = rs.fn();
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => (resolve = done));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function response(url: string, account: string) {
|
||||
if (url.endsWith("/api/projects?status=active")) {
|
||||
return Response.json({
|
||||
projects: [
|
||||
{ id: `${account}-project`, name: `${account} private project` },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/agents")) {
|
||||
return Response.json({ agents: [{ name: `${account}-private-agent` }] });
|
||||
}
|
||||
if (url.endsWith("/api/models")) {
|
||||
return Response.json({ models: [{ name: `${account}-private-model` }] });
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}
|
||||
|
||||
function AuthProbe() {
|
||||
auth = useAuth();
|
||||
return <output data-testid="user">{auth.user?.id ?? "signed out"}</output>;
|
||||
}
|
||||
|
||||
function QueryProbe() {
|
||||
const { user } = useAuth();
|
||||
client = useQueryClient();
|
||||
clients.add(client);
|
||||
const projects = useProjects("active");
|
||||
const agents = useAgents();
|
||||
const models = useModels();
|
||||
const data = JSON.stringify({
|
||||
projects: projects.data,
|
||||
agents: agents.agents,
|
||||
models: models.models,
|
||||
});
|
||||
snapshots.push({ user: user?.id, data });
|
||||
return <output data-testid="data">{data}</output>;
|
||||
}
|
||||
|
||||
function App({ children = <QueryProbe /> }: { children?: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider initialUser={userA}>
|
||||
<AuthProbe />
|
||||
<QueryClientProvider>
|
||||
<UserPreferencesBoundary>{children}</UserPreferencesBoundary>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function expectAccount(account: "A" | "B") {
|
||||
await waitFor(() => {
|
||||
const text = screen.getByTestId("data").textContent;
|
||||
expect(text).toContain(`${account} private project`);
|
||||
expect(text).toContain(`${account}-private-agent`);
|
||||
expect(text).toContain(`${account}-private-model`);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rs.mocked(isStaticWebsiteOnly).mockReturnValue(false);
|
||||
owner = "A";
|
||||
failReads = false;
|
||||
holdAReads = false;
|
||||
pendingReads = [];
|
||||
snapshots = [];
|
||||
requests.mockReset();
|
||||
rs.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
requests(url, owner);
|
||||
if (url.endsWith("/api/v1/auth/me")) {
|
||||
return Response.json(owner === "A" ? userA : userB);
|
||||
}
|
||||
if (failReads) return new Response("unavailable", { status: 503 });
|
||||
if (holdAReads && owner === "A") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
pendingReads.push(() => resolve(response(url, "A")));
|
||||
});
|
||||
}
|
||||
return response(url, owner);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
for (const queryClient of clients) queryClient.clear();
|
||||
clients.clear();
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("a confirmed account switch never renders the previous account, even when new reads fail", async () => {
|
||||
render(<App />);
|
||||
await expectAccount("A");
|
||||
const oldClient = client;
|
||||
owner = "B";
|
||||
failReads = true;
|
||||
await act(async () => auth.refreshUser());
|
||||
|
||||
expect(screen.getByTestId("user").textContent).toBe(userB.id);
|
||||
expect(screen.getByTestId("data").textContent).not.toContain("A");
|
||||
expect(
|
||||
snapshots.filter((snapshot) => snapshot.user === userB.id).length,
|
||||
).toBeGreaterThan(0);
|
||||
for (const snapshot of snapshots.filter((item) => item.user === userB.id)) {
|
||||
expect(snapshot.data).not.toContain("A");
|
||||
}
|
||||
expect(client).not.toBe(oldClient);
|
||||
expect(oldClient.getQueryCache().getAll()).toEqual([]);
|
||||
expect(requests).toHaveBeenCalledWith("/api/models", "B");
|
||||
});
|
||||
|
||||
test("late queries from the previous account cannot replace the new account's data", async () => {
|
||||
holdAReads = true;
|
||||
render(<App />);
|
||||
await waitFor(() => expect(pendingReads).toHaveLength(3));
|
||||
owner = "B";
|
||||
act(() => auth.applyUser(userB));
|
||||
await expectAccount("B");
|
||||
|
||||
await act(async () => {
|
||||
for (const resolve of pendingReads) resolve();
|
||||
});
|
||||
await expectAccount("B");
|
||||
for (const snapshot of snapshots.filter((item) => item.user === userB.id)) {
|
||||
expect(snapshot.data).not.toContain("A");
|
||||
}
|
||||
});
|
||||
|
||||
test("late mutation callbacks retain only the previous account's client", async () => {
|
||||
const pending = deferred<void>();
|
||||
const callback = rs.fn();
|
||||
let mutate!: () => Promise<void>;
|
||||
function MutationProbe() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => pending.promise,
|
||||
onSuccess: () => {
|
||||
callback();
|
||||
queryClient.setQueryData(
|
||||
["projects", { status: "active" }],
|
||||
[{ id: "A-late-project", name: "A late mutation" }],
|
||||
);
|
||||
},
|
||||
});
|
||||
mutate = () => mutation.mutateAsync();
|
||||
return <QueryProbe />;
|
||||
}
|
||||
render(
|
||||
<App>
|
||||
<MutationProbe />
|
||||
</App>,
|
||||
);
|
||||
await expectAccount("A");
|
||||
let completion!: Promise<void>;
|
||||
act(() => {
|
||||
completion = mutate();
|
||||
});
|
||||
owner = "B";
|
||||
act(() => auth.applyUser(userB));
|
||||
await expectAccount("B");
|
||||
await act(async () => {
|
||||
pending.resolve();
|
||||
await completion;
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
await expectAccount("B");
|
||||
expect(screen.getByTestId("data").textContent).not.toContain("A");
|
||||
});
|
||||
|
||||
test("logout clears query consumers immediately, before the server confirms logout", async () => {
|
||||
render(<App />);
|
||||
await expectAccount("A");
|
||||
const oldClient = client;
|
||||
const pending = deferred<Response>();
|
||||
rs.mocked(globalThis.fetch).mockReturnValue(pending.promise);
|
||||
let completion!: Promise<void>;
|
||||
act(() => {
|
||||
completion = auth.logout();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("user").textContent).toBe("signed out");
|
||||
expect(screen.queryByTestId("data")).toBeNull();
|
||||
expect(oldClient.getQueryCache().getAll()).toEqual([]);
|
||||
expect(globalThis.fetch).toHaveBeenLastCalledWith("/api/v1/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
await act(async () => {
|
||||
pending.resolve(new Response(null, { status: 204 }));
|
||||
await completion;
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["token_expired", "account_disabled"])(
|
||||
"a confirmed %s session clears the old query cache",
|
||||
async (code) => {
|
||||
render(<App />);
|
||||
await expectAccount("A");
|
||||
const oldClient = client;
|
||||
rs.mocked(globalThis.fetch).mockResolvedValue(
|
||||
Response.json({ detail: { code, message: code } }, { status: 401 }),
|
||||
);
|
||||
await act(async () => auth.refreshUser());
|
||||
|
||||
expect(screen.getByTestId("user").textContent).toBe("signed out");
|
||||
expect(screen.queryByTestId("data")).toBeNull();
|
||||
expect(oldClient.getQueryCache().getAll()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
test("static demo auth still mounts query consumers and logout clears them", async () => {
|
||||
rs.mocked(isStaticWebsiteOnly).mockReturnValue(true);
|
||||
render(<App />);
|
||||
await waitFor(() => expect(screen.getByTestId("data")).toBeTruthy());
|
||||
expect(
|
||||
requests.mock.calls.some(([url]) => url === "/api/projects?status=active"),
|
||||
).toBe(false);
|
||||
await act(async () => auth.logout());
|
||||
|
||||
expect(screen.queryByTestId("data")).toBeNull();
|
||||
expect(
|
||||
requests.mock.calls.some(([url]) => url === "/api/v1/auth/logout"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("refreshing the same account preserves the client and session-fresh model data", async () => {
|
||||
render(<App />);
|
||||
await expectAccount("A");
|
||||
const oldClient = client;
|
||||
await act(async () => auth.refreshUser());
|
||||
|
||||
expect(client).toBe(oldClient);
|
||||
expect(
|
||||
requests.mock.calls.filter(([url]) => url === "/api/models"),
|
||||
).toHaveLength(1);
|
||||
await expectAccount("A");
|
||||
});
|
||||
|
||||
test("StrictMode effect replay preserves active observers and cache invalidation", async () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
await expectAccount("A");
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(3);
|
||||
owner = "B";
|
||||
await act(async () => {
|
||||
await client.invalidateQueries();
|
||||
});
|
||||
await expectAccount("B");
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("providers without auth and with unknown auth have independent mount-local caches", () => {
|
||||
function ClientProbe() {
|
||||
client = useQueryClient();
|
||||
clients.add(client);
|
||||
return <output>public content</output>;
|
||||
}
|
||||
const first = render(
|
||||
<QueryClientProvider>
|
||||
<ClientProbe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const firstClient = client;
|
||||
firstClient.setQueryData(["models"], "private data");
|
||||
first.unmount();
|
||||
render(
|
||||
<AuthProvider initialUser={null} initialAuthUnknown>
|
||||
<QueryClientProvider>
|
||||
<ClientProbe />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("public content")).toBeTruthy();
|
||||
expect(client).not.toBe(firstClient);
|
||||
expect(client.getQueryData(["models"])).toBeUndefined();
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@ -8,7 +9,8 @@ import {
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner";
|
||||
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import { AuthProvider, useAuth } from "@/core/auth/AuthProvider";
|
||||
import type { User } from "@/core/auth/types";
|
||||
|
||||
// AuthProvider pulls useRouter/usePathname from next/navigation. Hand it a
|
||||
// no-op router so logout()'s `router.push("/")` stays inert under happy-dom
|
||||
@ -129,3 +131,74 @@ describe("GatewayOfflineBanner logout recovery (#3001)", () => {
|
||||
expect(logoutCall.method).not.toBe("GET");
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["switch account", "logout", "unmount"] as const)(
|
||||
"ignores a probe body that finishes after %s",
|
||||
async (action) => {
|
||||
const userA: User = {
|
||||
id: "account-a",
|
||||
email: "a@example.com",
|
||||
system_role: "user",
|
||||
needs_setup: false,
|
||||
};
|
||||
const userB = { ...userA, id: "account-b" };
|
||||
let auth!: ReturnType<typeof useAuth>;
|
||||
let resolve!: (user: User) => void;
|
||||
const body = new Promise<User>((done) => (resolve = done));
|
||||
const response = Response.json(userA);
|
||||
const json = rs.fn(() => body);
|
||||
response.json = json;
|
||||
rs.spyOn(globalThis, "fetch").mockImplementation(async (input) =>
|
||||
input === "/api/v1/auth/logout"
|
||||
? new Response(null, { status: 204 })
|
||||
: response,
|
||||
);
|
||||
function AuthProbe() {
|
||||
auth = useAuth();
|
||||
return <output>{auth.user?.id ?? "no user"}</output>;
|
||||
}
|
||||
function Recovery({ showBanner = true }: { showBanner?: boolean }) {
|
||||
return (
|
||||
<AuthProvider initialUser={null} initialAuthUnknown>
|
||||
<AuthProbe />
|
||||
{showBanner && <GatewayOfflineBanner gatewayUnavailable />}
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
const view = render(<Recovery />);
|
||||
await waitFor(() => expect(json).toHaveBeenCalledOnce());
|
||||
await act(async () => {
|
||||
if (action === "switch account") auth.applyUser(userB);
|
||||
else if (action === "logout") await auth.logout();
|
||||
else view.rerender(<Recovery showBanner={false} />);
|
||||
});
|
||||
await act(async () => resolve(userA));
|
||||
|
||||
expect(auth.user).toEqual(action === "switch account" ? userB : null);
|
||||
expect(screen.queryByText(userA.id)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("applies the active recovery probe without a second auth request", async () => {
|
||||
const user: User = {
|
||||
id: "recovered-account",
|
||||
email: "recovered@example.com",
|
||||
system_role: "user",
|
||||
needs_setup: false,
|
||||
};
|
||||
const fetch = rs
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(Response.json(user));
|
||||
function AuthProbe() {
|
||||
return <output>{useAuth().user?.id}</output>;
|
||||
}
|
||||
render(
|
||||
<AuthProvider initialUser={null} initialAuthUnknown>
|
||||
<AuthProbe />
|
||||
<GatewayOfflineBanner gatewayUnavailable />
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(user.id)).toBeTruthy();
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@ -124,3 +124,75 @@ test("useRequireAuth returns to the page it guards, query string included", () =
|
||||
"/login?next=%2Fworkspace%2Fscheduled-tasks%3Ftask_id%3Dt-1",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["applyUser", "logout"] as const)(
|
||||
"%s supersedes an older successful auth refresh",
|
||||
async (action) => {
|
||||
mount();
|
||||
let resolve!: (response: Response) => void;
|
||||
const pending = new Promise<Response>((done) => (resolve = done));
|
||||
globalThis.fetch = rs.fn((input) =>
|
||||
input === "/api/v1/auth/me"
|
||||
? pending
|
||||
: Promise.resolve(new Response(null, { status: 204 })),
|
||||
) as typeof globalThis.fetch;
|
||||
let completion!: Promise<void>;
|
||||
act(() => {
|
||||
completion = ctx!.refreshUser();
|
||||
});
|
||||
const nextUser = { ...initialUser, id: "u-2" };
|
||||
await act(async () => {
|
||||
if (action === "logout") await ctx!.logout();
|
||||
else ctx!.applyUser(nextUser);
|
||||
});
|
||||
await act(async () => {
|
||||
resolve(jsonResponse(initialUser));
|
||||
await completion;
|
||||
});
|
||||
|
||||
expect(ctx!.user).toEqual(action === "logout" ? null : nextUser);
|
||||
expect(ctx!.isLoading).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test("a stale disabled-session response cannot clear a newer account or redirect it", async () => {
|
||||
mount();
|
||||
let resolve!: (response: Response) => void;
|
||||
globalThis.fetch = rs.fn(
|
||||
() => new Promise<Response>((done) => (resolve = done)),
|
||||
);
|
||||
let completion!: Promise<void>;
|
||||
act(() => {
|
||||
completion = ctx!.refreshUser();
|
||||
});
|
||||
const nextUser = { ...initialUser, id: "u-2" };
|
||||
act(() => ctx!.applyUser(nextUser));
|
||||
await act(async () => {
|
||||
resolve(jsonResponse({ detail: { code: "account_disabled" } }, 401));
|
||||
await completion;
|
||||
});
|
||||
|
||||
expect(ctx!.user).toEqual(nextUser);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a response whose body completes after an account switch cannot restore the old account", async () => {
|
||||
mount();
|
||||
let resolve!: (user: User) => void;
|
||||
const body = new Promise<User>((done) => (resolve = done));
|
||||
const response = jsonResponse(initialUser);
|
||||
response.json = () => body;
|
||||
globalThis.fetch = rs.fn(async () => response);
|
||||
let completion!: Promise<void>;
|
||||
await act(async () => {
|
||||
completion = ctx!.refreshUser();
|
||||
});
|
||||
const nextUser = { ...initialUser, id: "u-2" };
|
||||
act(() => ctx!.applyUser(nextUser));
|
||||
await act(async () => {
|
||||
resolve(initialUser);
|
||||
await completion;
|
||||
});
|
||||
|
||||
expect(ctx!.user).toEqual(nextUser);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user