diff --git a/apps/cyberstorm-remix/app/root.tsx b/apps/cyberstorm-remix/app/root.tsx index bbe5c637e..4915dea18 100644 --- a/apps/cyberstorm-remix/app/root.tsx +++ b/apps/cyberstorm-remix/app/root.tsx @@ -44,6 +44,7 @@ import { } from "@thunderstore/ts-api-react/src/SessionContext"; import { getPublicEnvVariables, + getSessionTools, type publicEnvVariablesType, } from "cyberstorm/security/publicEnvVariables"; import { StorageManager } from "@thunderstore/ts-api-react/src/storage"; @@ -578,12 +579,19 @@ const TooltipProvider = memo(function TooltipProvider({ function App() { const data = useLoaderData(); - const dapper = new DapperTs(() => { - return { - apiHost: data?.publicEnvVariables.VITE_API_URL, - sessionId: data?.config.sessionId, - }; - }); + const sessionTools = getSessionTools(); + const dapper = new DapperTs( + () => { + return { + apiHost: data?.publicEnvVariables.VITE_API_URL, + sessionId: data?.config.sessionId, + }; + }, + () => + sessionTools.clearInvalidSession( + data?.publicEnvVariables.VITE_COOKIE_DOMAIN + ) + ); return ( ({ + DapperTs: vi.fn(), +})); + +let getCurrentUserMock: ReturnType; + import { SESSION_STORAGE_KEY, CURRENT_USER_KEY, STALE_KEY, API_HOST_KEY, COOKIE_DOMAIN_KEY, + getCookie, + clearCookies, + clearInvalidSession, + getConfig, getSessionContext, getSessionStale, setSessionStale, runSessionValidationCheck, storeCurrentUser, clearSession, + updateCurrentUser, getSessionCurrentUser, } from "@thunderstore/ts-api-react/src/SessionContext"; import { StorageManager } from "@thunderstore/ts-api-react/src/storage"; @@ -20,12 +40,102 @@ describe("SessionContext", () => { const testApiHost = "https://api.example.invalid"; const testCookieDomain = ".example.invalid"; - beforeEach(() => { + beforeEach(async () => { // Clear localStorage before each test window.localStorage.clear(); // Clear cookies document.cookie = "sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + + getCurrentUserMock = vi.fn().mockResolvedValue(null); + const { DapperTs } = await import("@thunderstore/dapper-ts"); + vi.mocked(DapperTs).mockImplementation( + () => ({ getCurrentUser: getCurrentUserMock }) as any + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getCookie", () => { + it("should return cookie value when present", () => { + document.cookie = "sessionid=test-session-id"; + assert.strictEqual(getCookie("sessionid"), "test-session-id"); + }); + + it("should return null when cookie is missing", () => { + document.cookie = "other=123"; + assert.isNull(getCookie("sessionid")); + }); + }); + + describe("getConfig", () => { + it("should use domain fallback when apiHost is not stored", () => { + document.cookie = "sessionid=abc"; + const storage = new StorageManager(SESSION_STORAGE_KEY); + + const config = getConfig(storage, "http://fallback.invalid"); + assert.strictEqual(config.apiHost, "http://fallback.invalid"); + assert.strictEqual(config.sessionId, "abc"); + }); + + it("should use stored apiHost over domain", () => { + const storage = new StorageManager(SESSION_STORAGE_KEY); + storage.setValue(API_HOST_KEY, "http://stored.invalid"); + + const config = getConfig(storage, "http://fallback.invalid"); + assert.strictEqual(config.apiHost, "http://stored.invalid"); + }); + }); + + describe("clearCookies", () => { + it("should clear sessionid cookie", () => { + document.cookie = "sessionid=abc; path=/"; + clearCookies("localhost"); + assert.isNull(getCookie("sessionid")); + }); + }); + + describe("clearInvalidSession", () => { + it("should clear current user and mark session stale", () => { + const storage = new StorageManager(SESSION_STORAGE_KEY); + storage.setValue(COOKIE_DOMAIN_KEY, ".example.invalid"); + + const testUser: User = { + username: "testUser", + capabilities: [], + connections: [], + subscription: { expires: null }, + teams: [], + teams_full: [], + }; + storeCurrentUser(storage, testUser); + + clearInvalidSession(storage); + + assert.isNull(storage.safeGetJsonValue(CURRENT_USER_KEY)); + assert.strictEqual(storage.safeGetValue(STALE_KEY), "yes"); + }); + + it("should not throw if storage operations fail", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const brokenStorage = { + safeGetValue: () => { + throw new Error("boom"); + }, + removeValue: () => { + throw new Error("boom"); + }, + setValue: () => { + throw new Error("boom"); + }, + } as unknown as StorageManager; + + clearInvalidSession(brokenStorage); + assert.isTrue(errorSpy.mock.calls.length >= 1); + }); }); describe("StorageManager", () => { @@ -303,5 +413,47 @@ describe("SessionContext", () => { assert.deepEqual(result.capabilities, ["cap1"]); assert.deepEqual(result.teams, ["team1"]); }); + + it("should throw when stored user is invalid", async () => { + const storage = new StorageManager(SESSION_STORAGE_KEY); + storage.setJsonValue(CURRENT_USER_KEY, { username: 123 } as unknown); + setSessionStale(storage, false); + + await expect(getSessionCurrentUser(storage, false)).rejects.toThrow( + /Failed to parse current user/ + ); + }); + }); + + describe("updateCurrentUser", () => { + it("should store currentUser and clear stale when user is returned", async () => { + const storage = new StorageManager(SESSION_STORAGE_KEY); + + getCurrentUserMock.mockResolvedValue({ + username: "testUser", + capabilities: [], + connections: [], + subscription: { expires: null }, + teams: [], + teams_full: [], + }); + + await updateCurrentUser(storage); + + const storedUser = storage.safeGetJsonValue(CURRENT_USER_KEY) as User; + assert.strictEqual(storedUser.username, "testUser"); + assert.strictEqual(storage.safeGetValue(STALE_KEY), "no"); + }); + + it("should clear currentUser when API returns null/empty and clear stale", async () => { + const storage = new StorageManager(SESSION_STORAGE_KEY); + storage.setJsonValue(CURRENT_USER_KEY, { username: "old" } as unknown); + + getCurrentUserMock.mockResolvedValue(null); + await updateCurrentUser(storage); + + assert.isNull(storage.safeGetJsonValue(CURRENT_USER_KEY)); + assert.strictEqual(storage.safeGetValue(STALE_KEY), "no"); + }); }); }); diff --git a/apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperClientLoaders.test.ts b/apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperClientLoaders.test.ts new file mode 100644 index 000000000..d2f8e9bcf --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperClientLoaders.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("cyberstorm/security/publicEnvVariables", () => ({ + getSessionTools: vi.fn().mockReturnValue({ + getConfig: vi.fn().mockReturnValue({ + apiHost: "http://api.example.invalid", + sessionId: "sid", + }), + clearInvalidSession: vi.fn(), + }), +})); + +vi.mock("@thunderstore/dapper-ts", () => ({ + DapperTs: vi.fn().mockImplementation((configFactory, removeSessionHook) => { + return { + __configFactory: configFactory, + __removeSessionHook: removeSessionHook, + getCurrentUser: vi.fn(), + }; + }), +})); + +import { ApiError } from "@thunderstore/thunderstore-api"; +import { DapperTs } from "@thunderstore/dapper-ts"; +import { getSessionTools } from "cyberstorm/security/publicEnvVariables"; + +import { makeTeamSettingsTabLoader } from "../dapperClientLoaders"; + +describe("dapperClientLoaders", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls dataFetcher with dapper and teamName and merges return value", async () => { + const dataFetcher = vi.fn().mockResolvedValue({ foo: 123 }); + const loader = makeTeamSettingsTabLoader(dataFetcher); + + const result = await loader({ + params: { namespaceId: "MyTeam" }, + } as unknown as { params: { namespaceId: string } }); + + expect(result).toEqual({ teamName: "MyTeam", foo: 123 }); + expect(dataFetcher).toHaveBeenCalledTimes(1); + expect(dataFetcher.mock.calls[0][1]).toBe("MyTeam"); + expect(DapperTs).toHaveBeenCalledTimes(1); + + const dapperArg = dataFetcher.mock.calls[0][0] as unknown as { + __configFactory: () => unknown; + __removeSessionHook: () => void; + }; + expect(dapperArg.__configFactory()).toEqual({ + apiHost: "http://api.example.invalid", + sessionId: "sid", + }); + + const tools = (getSessionTools as unknown as ReturnType).mock + .results[0].value; + dapperArg.__removeSessionHook(); + expect(tools.clearInvalidSession).toHaveBeenCalledTimes(1); + }); + + it("translates ApiError with detail into a Response", async () => { + const dataFetcher = vi.fn().mockImplementation(() => { + throw new ApiError({ + message: "403: Forbidden", + response: new Response(null, { status: 403, statusText: "Forbidden" }), + responseJson: { detail: "Nope" }, + }); + }); + + const loader = makeTeamSettingsTabLoader(dataFetcher); + + let thrown: unknown; + try { + await loader({ + params: { namespaceId: "MyTeam" }, + } as unknown as { params: { namespaceId: string } }); + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(Response); + const res = thrown as Response; + expect(res.status).toBe(403); + expect(await res.text()).toBe("Nope"); + }); + + it("uses response statusText when ApiError has no detail", async () => { + const dataFetcher = vi.fn().mockImplementation(() => { + throw new ApiError({ + message: "404: Not Found", + response: new Response(null, { status: 404, statusText: "Not Found" }), + }); + }); + + const loader = makeTeamSettingsTabLoader(dataFetcher); + + try { + await loader({ + params: { namespaceId: "MyTeam" }, + } as unknown as { params: { namespaceId: string } }); + } catch (e) { + const res = e as Response; + expect(res).toBeInstanceOf(Response); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Not Found"); + } + }); +}); diff --git a/apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperSingleton.test.ts b/apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperSingleton.test.ts index 15b138c32..a5fb5a832 100644 --- a/apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperSingleton.test.ts +++ b/apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperSingleton.test.ts @@ -195,4 +195,25 @@ describe("dapperSingleton", () => { expect(mockGetCommunity).toHaveBeenCalledTimes(1); }); }); + + describe("resetDapperSingletonForTest", () => { + it("clears request-scoped proxy cache and config factory", () => { + initializeClientDapper(); + const request = new Request("http://localhost"); + + const proxy1 = getDapperForRequest(request); + // Ensure factory was resolved once + expect(publicEnvVariables.getSessionTools).toHaveBeenCalled(); + + resetDapperSingletonForTest(); + + // After reset, same request should produce a new proxy + const proxy2 = getDapperForRequest(request); + expect(proxy2).not.toBe(proxy1); + + // And config factory should be re-resolved + initializeClientDapper(); + expect(publicEnvVariables.getSessionTools).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts b/apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts index 807439792..e157ac19f 100644 --- a/apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts +++ b/apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts @@ -45,9 +45,12 @@ export function makeTeamSettingsTabLoader( const setupDapper = () => { const tools = getSessionTools(); - const config = tools?.getConfig(); - return new DapperTs(() => ({ - apiHost: config?.apiHost, - sessionId: config?.sessionId, - })); + const config = tools.getConfig(); + return new DapperTs( + () => ({ + apiHost: config.apiHost, + sessionId: config.sessionId, + }), + () => tools.clearInvalidSession() + ); }; diff --git a/apps/cyberstorm-remix/cyberstorm/utils/dapperSingleton.ts b/apps/cyberstorm-remix/cyberstorm/utils/dapperSingleton.ts index 0d1c0f735..d64a48c82 100644 --- a/apps/cyberstorm-remix/cyberstorm/utils/dapperSingleton.ts +++ b/apps/cyberstorm-remix/cyberstorm/utils/dapperSingleton.ts @@ -36,7 +36,10 @@ export function initializeClientDapper(factory?: ConfigFactory) { if (!window.Dapper) { const resolvedFactory = resolveConfigFactory(); - window.Dapper = new DapperTs(resolvedFactory); + const tools = getSessionTools(); + window.Dapper = new DapperTs(resolvedFactory, () => + tools.clearInvalidSession() + ); } } diff --git a/apps/cyberstorm-remix/vitest.config.ts b/apps/cyberstorm-remix/vitest.config.ts index 46fdf7e85..19440af0a 100644 --- a/apps/cyberstorm-remix/vitest.config.ts +++ b/apps/cyberstorm-remix/vitest.config.ts @@ -1,6 +1,13 @@ import { defineProject } from "vitest/config"; +const cyberstormRoot = new URL("./cyberstorm", import.meta.url).pathname; + export default defineProject({ + resolve: { + alias: { + cyberstorm: cyberstormRoot, + }, + }, test: { include: ["**/__tests__/**/*.test.ts"], exclude: ["dist/**/*"], diff --git a/packages/dapper-ts/src/methods/__tests__/currentUser.test.ts b/packages/dapper-ts/src/methods/__tests__/currentUser.test.ts new file mode 100644 index 000000000..9e188bc8b --- /dev/null +++ b/packages/dapper-ts/src/methods/__tests__/currentUser.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DapperTsInterface } from "../../index"; + +vi.mock("@thunderstore/thunderstore-api", () => { + class ApiError extends Error { + response: Response; + responseJson?: unknown; + + constructor(args: { + message: string; + response: Response; + responseJson?: unknown; + }) { + super(args.message); + this.name = "ApiError"; + this.response = args.response; + this.responseJson = args.responseJson; + } + } + + return { + ApiError, + fetchCurrentUser: vi.fn(), + fetchCurrentUserTeamPermissions: vi.fn(), + }; +}); + +import type { Mock } from "vitest"; +import { + ApiError, + fetchCurrentUser, + fetchCurrentUserTeamPermissions, +} from "@thunderstore/thunderstore-api"; + +import { getCurrentUser, getCurrentUserTeamPermissions } from "../currentUser"; + +describe("dapper-ts currentUser methods", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("getCurrentUser returns fetched data", async () => { + const ctx = { + config: () => ({ apiHost: "http://api", sessionId: "sid" }), + removeSessionHook: vi.fn(), + } satisfies Pick; + + const fetchCurrentUserMock = fetchCurrentUser as unknown as Mock; + + fetchCurrentUserMock.mockResolvedValue({ username: "abc" }); + + await expect( + getCurrentUser.call(ctx as unknown as DapperTsInterface) + ).resolves.toEqual({ + username: "abc", + }); + + expect(fetchCurrentUser).toHaveBeenCalledWith({ + config: ctx.config, + params: {}, + data: {}, + queryParams: {}, + }); + }); + + it("getCurrentUser clears session hook and returns null on 401 ApiError", async () => { + const removeSessionHook = vi.fn(); + const ctx = { + config: () => ({ apiHost: "http://api", sessionId: "sid" }), + removeSessionHook, + } satisfies Pick; + + const fetchCurrentUserMock = fetchCurrentUser as unknown as Mock; + + const response = new Response(null, { + status: 401, + statusText: "Unauthorized", + }); + const err = new ApiError({ message: "401: Unauthorized", response }); + + fetchCurrentUserMock.mockRejectedValue(err); + + await expect( + getCurrentUser.call(ctx as unknown as DapperTsInterface) + ).resolves.toBeNull(); + expect(removeSessionHook).toHaveBeenCalledTimes(1); + }); + + it("getCurrentUser rethrows non-401 ApiError", async () => { + const ctx = { + config: () => ({ apiHost: "http://api", sessionId: "sid" }), + removeSessionHook: vi.fn(), + } satisfies Pick; + + const fetchCurrentUserMock = fetchCurrentUser as unknown as Mock; + + const response = new Response(null, { + status: 500, + statusText: "Server Error", + }); + const err = new ApiError({ message: "500: Server Error", response }); + + fetchCurrentUserMock.mockRejectedValue(err); + + await expect( + getCurrentUser.call(ctx as unknown as DapperTsInterface) + ).rejects.toBe(err); + }); + + it("getCurrentUserTeamPermissions forwards team_name param", async () => { + const ctx = { + config: () => ({ apiHost: "http://api", sessionId: "sid" }), + removeSessionHook: vi.fn(), + } satisfies Pick; + + const fetchPermissionsMock = + fetchCurrentUserTeamPermissions as unknown as Mock; + + fetchPermissionsMock.mockResolvedValue({ ok: true }); + + await expect( + getCurrentUserTeamPermissions.call( + ctx as unknown as DapperTsInterface, + "MyTeam" + ) + ).resolves.toEqual({ ok: true }); + + expect(fetchCurrentUserTeamPermissions).toHaveBeenCalledWith({ + config: ctx.config, + params: { team_name: "MyTeam" }, + data: {}, + queryParams: {}, + }); + }); +}); diff --git a/packages/dapper-ts/src/methods/currentUser.ts b/packages/dapper-ts/src/methods/currentUser.ts index ee80db2f5..1aa4cba2e 100644 --- a/packages/dapper-ts/src/methods/currentUser.ts +++ b/packages/dapper-ts/src/methods/currentUser.ts @@ -17,13 +17,12 @@ export async function getCurrentUser(this: DapperTsInterface) { return data; } catch (error) { if (error instanceof ApiError && error.response.status === 401) { - // If the user is not authenticated, we remove the session hook + // Any 401 means the session/auth context is invalid or stale. + // Clear persisted session data to allow consumers to recover (e.g. re-auth). this.removeSessionHook?.(); return null; - } else { - // If it's another error, we throw it - throw error; } + throw error; } } diff --git a/packages/dapper-ts/vitest.config.ts b/packages/dapper-ts/vitest.config.ts index 846f5db08..f2cbfd3fc 100644 --- a/packages/dapper-ts/vitest.config.ts +++ b/packages/dapper-ts/vitest.config.ts @@ -4,10 +4,9 @@ export default defineProject({ test: { include: ["src/**/__tests__/**/*.test.ts"], exclude: ["dist/**/*"], + environment: "node", browser: { - provider: "playwright", - enabled: true, - instances: [{ browser: "chromium", headless: true }], + enabled: false, }, }, }); diff --git a/packages/ts-api-react/src/SessionContext.tsx b/packages/ts-api-react/src/SessionContext.tsx index b943cc8be..029c69a2a 100644 --- a/packages/ts-api-react/src/SessionContext.tsx +++ b/packages/ts-api-react/src/SessionContext.tsx @@ -17,6 +17,10 @@ export interface ContextInterface { clearSession: (clearApiHost?: boolean) => void; /** Remove session cookies. */ clearCookies: (domain: string) => void; + /** + * Clear persisted session data (current user, cookies) and mark session as stale. + */ + clearInvalidSession: (cookieDomainOverride?: string) => void; /** Set SessionData in storage */ setSession: (sessionData: SessionData) => void; /** Set session stale state */ @@ -99,6 +103,29 @@ export const clearCookies = (domain: string) => { deleteCookie("sessionid", domain); }; +export const clearInvalidSession = ( + _storage: StorageManager, + cookieDomainOverride?: string +) => { + if (typeof window === "undefined") { + return; + } + try { + // Preserve API host to allow session recovery / re-authentication flows. + clearSession(_storage, false); + const cookieDomain = + cookieDomainOverride || + _storage.safeGetValue(COOKIE_DOMAIN_KEY) || + undefined; + if (cookieDomain) { + clearCookies(cookieDomain); + } + setSessionStale(_storage, true); + } catch (error) { + console.error("Failed to clear invalid session", error); + } +}; + export const getConfig = ( _storage: StorageManager, domain?: string @@ -178,10 +205,7 @@ export const updateCurrentUser = async ( customClearSession ? customClearSession : () => { - // This function gets called when the dapper getCurrentUser gets 401 as a response - clearSession(_storage, false); - // We want to clear the sessionid cookie if it's invalid. - clearCookies(_storage.safeGetValue(COOKIE_DOMAIN_KEY) || ""); + clearInvalidSession(_storage); } ); const currentUser = await dapper.getCurrentUser(); @@ -247,6 +271,13 @@ export const getSessionContext = ( clearCookies(domain); }; + const _clearInvalidSession = (cookieDomainOverride?: string) => { + clearInvalidSession( + _storage, + cookieDomainOverride || cookieDomain || undefined + ); + }; + const _getConfig = (domain?: string): RequestConfig => { return getConfig(_storage, domain); }; @@ -289,6 +320,7 @@ export const getSessionContext = ( return { clearSession: _clearSession, clearCookies: _clearCookies, + clearInvalidSession: _clearInvalidSession, getConfig: _getConfig, runSessionValidationCheck: _runSessionValidationCheck, updateCurrentUser: _updateCurrentUser, diff --git a/packages/ts-api-react/src/index.ts b/packages/ts-api-react/src/index.ts index 4c85be554..020f4c6e8 100644 --- a/packages/ts-api-react/src/index.ts +++ b/packages/ts-api-react/src/index.ts @@ -3,6 +3,7 @@ export { CURRENT_USER_KEY, setSession, clearSession, + clearInvalidSession, getConfig, runSessionValidationCheck, storeCurrentUser,