Skip to content
70 changes: 70 additions & 0 deletions desktop/src/__tests__/entry-guards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Use vi.hoisted so the mock reference is stable across vi.resetModules() calls.
// vi.mock factories only run once; vi.resetModules clears the module cache but
// does not re-invoke the factory, so the returned vi.fn() instance persists.
// mockClear() between tests resets the call count so each assertion sees only
// the calls from the current test's dynamic import().
const { installAuthGuard: mockGuard } = vi.hoisted(() => ({
installAuthGuard: vi.fn(),
}));

vi.mock("../lib/auth-guard", () => ({
installAuthGuard: mockGuard,
SESSION_EXPIRED_EVENT: "taos-session-expired",
}));

// Entry modules call createRoot(document.getElementById("root")!).render(...).
// Stub createRoot so the render tree doesn't actually mount (components pull in
// real stores, contexts, and side-effects we don't need for this assertion).
vi.mock("react-dom/client", () => ({
createRoot: vi.fn(() => ({ render: vi.fn() })),
}));

// Some entry modules import AppShell / AppStandalone / ChatStandalone which
// transitively pull in heavy dependency trees. Stub them as no-op components.
vi.mock("../components/AppShell", () => ({
AppShell: ({ children }: { children?: React.ReactNode }) => children ?? null,
}));

vi.mock("../App", () => ({ App: () => null }));
vi.mock("../ChatStandalone", () => ({ ChatStandalone: () => null }));
vi.mock("../AppStandalone", () => ({ AppStandalone: () => null }));

vi.mock("../stores/theme-store", () => ({
restoreActiveTheme: vi.fn(),
installWebkitRepaintGuards: vi.fn(),
}));

vi.mock("../registry/app-registry", () => ({
getApp: vi.fn(() => undefined),
}));

vi.mock("../lib/client-log", () => ({
installGlobalErrorReporting: vi.fn(),
}));

describe("entry module auth guards", () => {
beforeEach(() => {
vi.resetModules();
mockGuard.mockClear();
// Every entry module calls createRoot(document.getElementById("root")!).
// Provide the element so the non-null assertion (!) doesn't throw.
document.body.innerHTML = '<div id="root"></div>';
});

it("desktop shell (main.tsx) installs the auth guard", async () => {
await import("../main");
expect(mockGuard).toHaveBeenCalledTimes(1);
});

it("chat PWA (chat-main.tsx) installs the auth guard", async () => {
await import("../chat-main");
expect(mockGuard).toHaveBeenCalledTimes(1);
});

it("standalone app PWA (app-standalone-main.tsx) installs the auth guard", async () => {
await import("../app-standalone-main");
expect(mockGuard).toHaveBeenCalledTimes(1);
});
});
3 changes: 3 additions & 0 deletions desktop/src/app-standalone-main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { restoreActiveTheme, installWebkitRepaintGuards } from "./stores/theme-s
import { getApp } from "./registry/app-registry";
import "./theme/tokens.css";

// Wrap window.fetch so any 401 from /api/* triggers a session-expired
// event that LoginGate picks up and shows the login screen (same guard
// installed by main.tsx for the desktop shell PWA).
installAuthGuard();

// Apply the user's persisted theme on boot, same as chat-main.tsx.
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/chat-main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { installAuthGuard } from "./lib/auth-guard";
import { restoreActiveTheme, installWebkitRepaintGuards } from "./stores/theme-store";
import "./theme/tokens.css";

// Wrap window.fetch so any 401 from /api/* triggers a session-expired
// event that LoginGate picks up and shows the login screen (same guard
// installed by main.tsx for the desktop shell PWA).
installAuthGuard();

// Apply the user's persisted theme (light/dark/etc.) on boot, the same as the
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/lib/agent-browsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export interface CookieEntry {

async function fetchJson<T>(url: string, fallback: T, init?: RequestInit): Promise<T> {
try {
const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
const headers = new Headers(init?.headers);
headers.set("Accept", "application/json");
const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
Expand Down
16 changes: 8 additions & 8 deletions desktop/src/lib/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("fetchStarred", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/github/starred");
expect(url).toContain("page=1");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});

it("returns empty result on non-ok response", async () => {
Expand Down Expand Up @@ -96,7 +96,7 @@ describe("fetchNotifications", () => {

const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/notifications");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});

it("returns empty result on non-ok response", async () => {
Expand Down Expand Up @@ -136,7 +136,7 @@ describe("fetchRepo", () => {

const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/repo/octocat/hello-world");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});

it("returns null on non-ok response", async () => {
Expand Down Expand Up @@ -192,7 +192,7 @@ describe("fetchIssues", () => {
expect(url).toContain("/api/github/repo/octocat/hello-world/issues");
expect(url).toContain("state=open");
expect(url).toContain("page=2");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});

it("returns empty result on non-ok response", async () => {
Expand Down Expand Up @@ -350,7 +350,7 @@ describe("saveToLibrary", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/ingest");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.url).toBe("https://github.com/octocat/hello-world");
expect(body.source).toBe("github-browser");
Expand Down Expand Up @@ -399,7 +399,7 @@ describe("startDeviceFlow", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/oauth/device/start");
expect(opts.method).toBe("POST");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});

it("throws on non-ok response", async () => {
Expand Down Expand Up @@ -434,7 +434,7 @@ describe("pollDeviceFlow", () => {
expect(url).toBe("/api/github/oauth/device/poll");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.device_code).toBe("dev-code");
});
Expand Down Expand Up @@ -501,7 +501,7 @@ describe("deleteIdentity", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/github/identities/gid-1");
expect(opts.method).toBe("DELETE");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
});

it("returns false on non-ok response", async () => {
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export type DevicePoll =

async function fetchJson<T>(url: string, fallback: T, init?: RequestInit): Promise<T> {
try {
const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
const headers = new Headers(init?.headers);
headers.set("Accept", "application/json");
const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/lib/knowledge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe("listItems", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/items");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result.items).toHaveLength(2);
expect(result.items[0].id).toBe("ki-1");
expect(result.count).toBe(2);
Expand Down Expand Up @@ -104,7 +104,7 @@ describe("getItem", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/items/ki-1");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual({ id: "ki-1", title: "Hello", source_type: "web" });
});

Expand Down Expand Up @@ -369,7 +369,7 @@ describe("createRule", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/rules");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.pattern).toBe("github.com");
expect(body.match_on).toBe("url");
Expand Down Expand Up @@ -483,7 +483,7 @@ describe("setSubscription", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/knowledge/subscriptions");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.agent_name).toBe("agent-1");
expect(body.category).toBe("dev");
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/lib/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export interface AgentSubscription {

async function fetchJson<T>(url: string, fallback: T, init?: RequestInit): Promise<T> {
try {
const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
const headers = new Headers(init?.headers);
headers.set("Accept", "application/json");
const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/lib/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export interface LibraryItemDetail {

async function fetchJson<T>(url: string, fallback: T, init?: RequestInit): Promise<T> {
try {
const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });
const headers = new Headers(init?.headers);
headers.set("Accept", "application/json");
const res = await fetch(url, { ...init, headers });
if (!res.ok) return fallback;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return fallback;
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/lib/mail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe("fetchAccounts", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/mail/accounts");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("acc-1");
expect(result[0].email_address).toBe("test@example.com");
Expand Down Expand Up @@ -198,7 +198,7 @@ describe("fetchFolders", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/mail/accounts/acc-1/folders");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual(["INBOX", "Sent", "Drafts"]);
});

Expand Down Expand Up @@ -255,7 +255,7 @@ describe("fetchMessages", () => {
expect(url).toContain("/api/mail/accounts/acc-1/messages?");
expect(url).toContain("folder=INBOX");
expect(url).toContain("limit=25");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toHaveLength(1);
expect(result[0].uid).toBe("100");
expect(result[0].subject).toBe("Hello");
Expand Down Expand Up @@ -323,7 +323,7 @@ describe("fetchMessage", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain("/api/mail/accounts/acc-1/messages/100?");
expect(url).toContain("folder=INBOX");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result.uid).toBe("100");
expect(result.body_text).toBe("Hi there");
expect(result.attachments).toEqual([]);
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/lib/memory-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe("fetchMemoryModel", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/model");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual({ model: "gpt-4o-mini", supported: true });
});

Expand Down Expand Up @@ -85,7 +85,7 @@ describe("setMemoryModel", () => {
expect(url).toBe("/api/memory/model");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.model).toBe("gpt-4o");
expect(result).toEqual({ model: "gpt-4o" });
Expand Down
16 changes: 8 additions & 8 deletions desktop/src/lib/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("fetchMemoryStats", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/stats");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual({ total: 42 });
});

Expand All @@ -62,7 +62,7 @@ describe("fetchBackendCapabilities", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/backend/capabilities");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
expect(result).toEqual({ name: "openai", version: "1", capabilities: ["embed"] });
});

Expand Down Expand Up @@ -137,8 +137,8 @@ describe("updateMemorySettings", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/settings");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.model).toBe("gpt-4o");
expect(result).toEqual({ model: "gpt-4o" });
Expand Down Expand Up @@ -239,8 +239,8 @@ describe("triggerCatalogIndex", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/memory/catalog/index");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const sentBody = JSON.parse(opts.body);
expect(sentBody.date).toBe("2025-01-01");
expect(sentBody.force).toBe(true);
Expand Down Expand Up @@ -341,8 +341,8 @@ describe("updateAgentMemoryConfig", () => {
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/agents/agent-1/memory-config");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/json");
expect(opts.headers.Accept).toBe("application/json");
expect((opts.headers?.get?.("Content-Type") ?? opts.headers?.["Content-Type"])).toBe("application/json");
expect((opts.headers?.get?.("Accept") ?? opts.headers?.Accept)).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.auto_recall).toBe(false);
expect(result).toEqual({ auto_recall: false });
Expand Down
Loading
Loading