Fix auth and config validation regressions

This commit is contained in:
Abhishek Kumar 2026-07-09 18:18:44 +05:30
parent e4b53f78e9
commit 8ac6f1aa06
9 changed files with 346 additions and 49 deletions

View file

@ -2,10 +2,12 @@
import type { CurrentUser, Team } from "@stackframe/stack";
import React, { useState } from "react";
import { toast } from "sonner";
import SpinLoader from "@/components/SpinLoader";
import { useAuth } from "@/lib/auth";
import { reloadApp } from "@/lib/browserReload";
import logger from "@/lib/logger";
// Lazy load Stack's SelectedTeamSwitcher, but own the selected-team write here.
// The stock component re-applies the selectedTeam prop asynchronously, which
@ -42,8 +44,9 @@ function SidebarTeamSwitcherContent({ user }: { user: CurrentUser }) {
await user.setSelectedTeam(team);
reloadApp();
} catch (error) {
logger.error("Failed to switch Stack team", error);
toast.error("Could not switch teams. Please try again.");
setIsSwitching(false);
throw error;
}
};

View file

@ -33,11 +33,18 @@ import { TranslationContext } from "../../../../node_modules/@stackframe/stack/d
import { SidebarTeamSwitcher } from "../SidebarTeamSwitcher";
const reloadAppMock = vi.hoisted(() => vi.fn());
const toastErrorMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/browserReload", () => ({
reloadApp: reloadAppMock,
}));
vi.mock("sonner", () => ({
toast: {
error: toastErrorMock,
},
}));
// Controls what the faked Stack useUser() returns; tests flip this to null to
// simulate the session dying (token expiry / sign-out from another tab).
const stackState: { user: ReturnType<typeof makeStackUser> | null } = {
@ -157,6 +164,7 @@ describe("SidebarTeamSwitcher", () => {
beforeEach(() => {
stackState.user = makeStackUser();
reloadAppMock.mockClear();
toastErrorMock.mockClear();
// React logs boundary-caught render errors; keep test output readable.
vi.spyOn(console, "error").mockImplementation(() => {});
});
@ -238,6 +246,30 @@ describe("SidebarTeamSwitcher", () => {
});
});
it("shows feedback and does not reload when Stack fails to persist the selected team", async () => {
stackState.user!.setSelectedTeam = vi.fn(async () => {
throw new Error("Stack write failed");
});
render(
withProviders(
<SidebarTeamSwitcher />,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
const switcher = await screen.findByRole("combobox");
fireEvent.click(switcher);
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(
"Could not switch teams. Please try again."
);
});
expect(screen.queryByRole("status")).toBeNull();
expect(reloadAppMock).not.toHaveBeenCalled();
});
it("unmounts the switcher instead of crashing when the session dies (user flips to null)", async () => {
const errors: Error[] = [];

View file

@ -1,6 +1,6 @@
'use client';
import { type CurrentUser, StackClientApp, StackProvider, StackTheme } from '@stackframe/stack';
import { StackClientApp, StackProvider, StackTheme } from '@stackframe/stack';
import React, { useMemo, useRef } from 'react';
import { AuthContext } from './AuthProvider';
@ -41,34 +41,7 @@ function StackAuthContextProvider({
children: React.ReactNode;
app: StackClientApp<true, string>;
}) {
const [stackUser, setStackUser] = React.useState<CurrentUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
let cancelled = false;
setIsLoading(true);
app.getUser()
.then((user) => {
if (!cancelled) {
setStackUser(user);
}
})
.catch(() => {
if (!cancelled) {
setStackUser(null);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [app]);
const stackUser = app.useUser();
// Store user in ref for callbacks to access latest value without creating new callbacks
const userRef = useRef(stackUser);
@ -123,21 +96,17 @@ function StackAuthContextProvider({
}
}, []);
// Use primitive values in deps so user object reference churn does not
// invalidate the context value unless the effective auth state changes.
const userId = stackUser?.id;
const contextValue = useMemo(() => ({
user: userRef.current,
isAuthenticated: !!userId,
loading: isLoading,
user: stackUser,
isAuthenticated: !!stackUser,
loading: false,
getAccessToken,
redirectToLogin,
logout,
provider: 'stack' as const,
getSelectedTeam,
listPermissions,
}), [userId, isLoading, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
}), [stackUser, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
return (
<AuthContext.Provider value={contextValue}>

View file

@ -0,0 +1,81 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAuth } from "../AuthProvider";
import { StackProviderWrapper } from "../StackProviderWrapper";
type MockStackUser = {
id: string;
selectedTeam: null;
getAuthJson: () => Promise<{ accessToken: string }>;
};
const stackState = vi.hoisted(() => ({
user: null as MockStackUser | null,
}));
vi.mock("@stackframe/stack", () => {
class MockStackClientApp {
useUser = vi.fn(() => stackState.user);
constructor(
public options: {
projectId: string;
publishableClientKey: string;
}
) {}
}
return {
StackClientApp: MockStackClientApp,
StackProvider: ({ children }: { children: React.ReactNode }) => children,
StackTheme: ({ children }: { children: React.ReactNode }) => children,
};
});
function AuthProbe() {
const auth = useAuth();
return (
<div data-testid="auth-state">
{auth.loading ? "loading" : auth.user?.id ?? "signed-out"}
</div>
);
}
function makeUser(): MockStackUser {
return {
id: "stack-user-1",
selectedTeam: null,
getAuthJson: async () => ({ accessToken: "token" }),
};
}
describe("StackProviderWrapper", () => {
beforeEach(() => {
stackState.user = makeUser();
});
it("updates AuthContext when Stack's reactive user becomes null", () => {
const props = {
projectId: "00000000-0000-4000-8000-000000000000",
publishableClientKey: "publishable-client-key",
};
const { rerender } = render(
<StackProviderWrapper {...props}>
<AuthProbe />
</StackProviderWrapper>
);
expect(screen.getByTestId("auth-state").textContent).toBe("stack-user-1");
stackState.user = null;
rerender(
<StackProviderWrapper {...props}>
<AuthProbe />
</StackProviderWrapper>
);
expect(screen.getByTestId("auth-state").textContent).toBe("signed-out");
});
});