diff --git a/canvas/src/components/CommunicationOverlay.tsx b/canvas/src/components/CommunicationOverlay.tsx index ee69531a..7b0c49c7 100644 --- a/canvas/src/components/CommunicationOverlay.tsx +++ b/canvas/src/components/CommunicationOverlay.tsx @@ -150,7 +150,7 @@ export function CommunicationOverlay() { {c.targetName}
- {statusIcon} + {age}
diff --git a/canvas/src/components/__tests__/AuthGate.test.tsx b/canvas/src/components/__tests__/AuthGate.test.tsx new file mode 100644 index 00000000..7f581769 --- /dev/null +++ b/canvas/src/components/__tests__/AuthGate.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, cleanup, act } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +// ── Mocks (defined before dynamic import of component) ─────────────────────── +let mockFetchSession: ReturnType; +let mockRedirectToLogin: ReturnType; +let mockGetTenantSlug: ReturnType; + +beforeEach(() => { + mockFetchSession = vi.fn(); + mockRedirectToLogin = vi.fn(); + mockGetTenantSlug = vi.fn(() => null); // default: non-SaaS (pass-through) +}); + +vi.mock("@/lib/auth", () => ({ + fetchSession: (...args: unknown[]) => mockFetchSession(...args), + redirectToLogin: (...args: unknown[]) => mockRedirectToLogin(...args), +})); + +vi.mock("@/lib/tenant", () => ({ + getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args), +})); + +// Import after mocks are set up +import { AuthGate } from "../AuthGate"; + +// ───────────────────────────────────────────────────────────────────────────── + +describe("AuthGate — loading state", () => { + it("renders a blank overlay while session fetch is in-flight (prevents flash of unauth'd content)", () => { + // getTenantSlug returns a slug so the session fetch is triggered + mockGetTenantSlug.mockReturnValue("acme"); + // fetchSession never resolves — keeps the component in loading state + mockFetchSession.mockReturnValue(new Promise(() => {})); + + const { container } = render( + +
Protected content
+
+ ); + + const overlay = container.querySelector(".bg-zinc-950.fixed.inset-0"); + expect(overlay).not.toBeNull(); + expect(overlay?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("does not render children while in loading state", () => { + mockGetTenantSlug.mockReturnValue("acme"); + mockFetchSession.mockReturnValue(new Promise(() => {})); + + const { queryByTestId } = render( + +
Protected content
+
+ ); + + expect(queryByTestId("child")).toBeNull(); + }); +}); + +describe("AuthGate — non-SaaS / pass-through mode", () => { + it("renders children immediately when there is no tenant slug", async () => { + mockGetTenantSlug.mockReturnValue(null); + + let result: ReturnType; + await act(async () => { + result = render( + +
Protected content
+
+ ); + }); + + expect(result!.getByTestId("child")).toBeTruthy(); + }); +}); + +describe("AuthGate — authenticated state", () => { + it("renders children after a successful session fetch", async () => { + mockGetTenantSlug.mockReturnValue("acme"); + mockFetchSession.mockResolvedValue({ userId: "u1", email: "a@b.com" }); + + let result: ReturnType; + await act(async () => { + result = render( + +
Protected content
+
+ ); + }); + + expect(result!.getByTestId("child")).toBeTruthy(); + }); +}); + +describe("AuthGate — anonymous / redirect state", () => { + it("calls redirectToLogin when session fetch returns null", async () => { + mockGetTenantSlug.mockReturnValue("acme"); + mockFetchSession.mockResolvedValue(null); + + await act(async () => { + render( + +
Protected content
+
+ ); + }); + + expect(mockRedirectToLogin).toHaveBeenCalledWith("sign-in"); + }); +}); diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 2c402833..f1b8bbb0 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -109,6 +109,7 @@ export function ChatTab({ workspaceId, data }: Props) { }} >