Hyeonsang Cho 3f71b2e13f
fix(frontend): return to the opened workspace page after signing in (#6681)
* fix(frontend): return to the opened workspace page after signing in

Opening a workspace page without a valid session sent the user to a bare
/login: the workspace layout called redirect("/login"), and a layout
cannot read the request URL, so the path and query were both lost. A
scheduled-task notification link (/workspace/scheduled-tasks?task_id=X)
opened after the session expired therefore landed on the default
workspace after sign-in.

For an unauthenticated verdict the layout now renders
WorkspaceLoginRedirect, which replaces the page with
/login?next=<path+query> from the browser. It renders nothing else, so
the workspace pages under it never mount.

AuthProvider.refreshUser and useRequireAuth kept the path but dropped
the query. They now share currentReturnPath() with the fetcher, which
already kept both.

* docs(changelog): reference #6681

* fix(frontend): keep the fragment in the sign-in return path

currentReturnPath() kept the path and query but not location.hash, so
an anchored deep link (/workspace/chats/x#section) lost its anchor across
the login round trip. It is now the single source for all four bounce
sites, so include the fragment. The server never receives the fragment,
so only the browser-side bounce can keep it. validateAuthNextPath, the
backend's validate_next_param and its quote() into /auth/callback all
carry a '#' through unchanged.

The models API test stubbed window.location without hash; a real
Location always has one, so the stub now carries it.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-11 19:05:03 +08:00

95 lines
2.5 KiB
TypeScript

import { afterEach, expect, test, rs } from "@rstest/core";
import { UnauthorizedError } from "@/core/api/errors";
afterEach(() => {
rs.unstubAllGlobals();
});
test("loadModels includes credentials for authenticated gateway requests", async () => {
let requestedInit: RequestInit | undefined;
const fetchMock = rs.fn(
async (_input: RequestInfo | URL, init?: RequestInit) => {
requestedInit = init;
return new Response(
JSON.stringify({
models: [
{
id: "model-1",
name: "model-1",
model: "model-1",
display_name: "Model 1",
},
],
token_usage: { enabled: true },
}),
{ status: 200 },
);
},
);
rs.stubGlobal("fetch", fetchMock);
const { loadModels } = await import("@/core/models/api");
await expect(loadModels()).resolves.toMatchObject({
models: [{ id: "model-1" }],
token_usage: { enabled: true },
});
expect(requestedInit?.credentials).toBe("include");
});
test("loadModels rejects unsuccessful gateway responses", async () => {
rs.stubGlobal(
"fetch",
rs.fn(
async () =>
new Response(JSON.stringify({ detail: "Model registry unavailable" }), {
status: 503,
statusText: "Service Unavailable",
}),
),
);
const { loadModels } = await import("@/core/models/api");
await expect(loadModels()).rejects.toThrow("Model registry unavailable");
});
test("loadModels exposes the typed 401 redirect error", async () => {
// `search` and `hash` are always strings on a real Location; the redirect
// target is built from pathname + search + hash so the stub carries all three.
const location = {
href: "",
pathname: "/workspace/chats",
search: "",
hash: "",
};
rs.stubGlobal("window", { location });
rs.stubGlobal(
"fetch",
rs.fn(async () => new Response(null, { status: 401 })),
);
const { loadModels } = await import("@/core/models/api");
await expect(loadModels()).rejects.toBeInstanceOf(UnauthorizedError);
expect(location.href).toBe("/login?next=%2Fworkspace%2Fchats");
});
test("loadModels includes the status code when statusText is empty", async () => {
rs.stubGlobal(
"fetch",
rs.fn(
async () =>
new Response("upstream unavailable", {
status: 503,
statusText: "",
}),
),
);
const { loadModels } = await import("@/core/models/api");
await expect(loadModels()).rejects.toThrow("Failed to load models: 503");
});