Skip to content

Commit ca8fcd5

Browse files
committed
Add centralized invalid session cleanup and wire Dapper to it
- expose a clearInvalidSession helper from ts-api-react that handles storage, cookies, and stale flags with proper error reporting - update Dapper instantiations (root app, singleton, client loaders) to rely on the shared cleanup hook instead of duplicating logic
1 parent a7f22bc commit ca8fcd5

File tree

12 files changed

+495
-25
lines changed

12 files changed

+495
-25
lines changed

apps/cyberstorm-remix/app/root.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from "@thunderstore/ts-api-react/src/SessionContext";
4545
import {
4646
getPublicEnvVariables,
47+
getSessionTools,
4748
type publicEnvVariablesType,
4849
} from "cyberstorm/security/publicEnvVariables";
4950
import { StorageManager } from "@thunderstore/ts-api-react/src/storage";
@@ -578,12 +579,19 @@ const TooltipProvider = memo(function TooltipProvider({
578579

579580
function App() {
580581
const data = useLoaderData<RootLoadersType>();
581-
const dapper = new DapperTs(() => {
582-
return {
583-
apiHost: data?.publicEnvVariables.VITE_API_URL,
584-
sessionId: data?.config.sessionId,
585-
};
586-
});
582+
const sessionTools = getSessionTools();
583+
const dapper = new DapperTs(
584+
() => {
585+
return {
586+
apiHost: data?.publicEnvVariables.VITE_API_URL,
587+
sessionId: data?.config.sessionId,
588+
};
589+
},
590+
() =>
591+
sessionTools.clearInvalidSession(
592+
data?.publicEnvVariables.VITE_COOKIE_DOMAIN
593+
)
594+
);
587595

588596
return (
589597
<Outlet

apps/cyberstorm-remix/cyberstorm/session/__tests__/SessionContext.test.ts

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
1-
import { assert, describe, it, beforeEach } from "vitest";
1+
import {
2+
assert,
3+
describe,
4+
it,
5+
beforeEach,
6+
afterEach,
7+
vi,
8+
expect,
9+
} from "vitest";
10+
11+
vi.mock("@thunderstore/dapper-ts", () => ({
12+
DapperTs: vi.fn(),
13+
}));
14+
15+
let getCurrentUserMock: ReturnType<typeof vi.fn>;
16+
217
import {
318
SESSION_STORAGE_KEY,
419
CURRENT_USER_KEY,
520
STALE_KEY,
621
API_HOST_KEY,
722
COOKIE_DOMAIN_KEY,
23+
getCookie,
24+
clearCookies,
25+
clearInvalidSession,
26+
getConfig,
827
getSessionContext,
928
getSessionStale,
1029
setSessionStale,
1130
runSessionValidationCheck,
1231
storeCurrentUser,
1332
clearSession,
33+
updateCurrentUser,
1434
getSessionCurrentUser,
1535
} from "@thunderstore/ts-api-react/src/SessionContext";
1636
import { StorageManager } from "@thunderstore/ts-api-react/src/storage";
@@ -20,12 +40,102 @@ describe("SessionContext", () => {
2040
const testApiHost = "https://api.example.invalid";
2141
const testCookieDomain = ".example.invalid";
2242

23-
beforeEach(() => {
43+
beforeEach(async () => {
2444
// Clear localStorage before each test
2545
window.localStorage.clear();
2646
// Clear cookies
2747
document.cookie =
2848
"sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
49+
50+
getCurrentUserMock = vi.fn().mockResolvedValue(null);
51+
const { DapperTs } = await import("@thunderstore/dapper-ts");
52+
vi.mocked(DapperTs).mockImplementation(
53+
() => ({ getCurrentUser: getCurrentUserMock }) as any
54+
);
55+
});
56+
57+
afterEach(() => {
58+
vi.restoreAllMocks();
59+
});
60+
61+
describe("getCookie", () => {
62+
it("should return cookie value when present", () => {
63+
document.cookie = "sessionid=test-session-id";
64+
assert.strictEqual(getCookie("sessionid"), "test-session-id");
65+
});
66+
67+
it("should return null when cookie is missing", () => {
68+
document.cookie = "other=123";
69+
assert.isNull(getCookie("sessionid"));
70+
});
71+
});
72+
73+
describe("getConfig", () => {
74+
it("should use domain fallback when apiHost is not stored", () => {
75+
document.cookie = "sessionid=abc";
76+
const storage = new StorageManager(SESSION_STORAGE_KEY);
77+
78+
const config = getConfig(storage, "http://fallback.invalid");
79+
assert.strictEqual(config.apiHost, "http://fallback.invalid");
80+
assert.strictEqual(config.sessionId, "abc");
81+
});
82+
83+
it("should use stored apiHost over domain", () => {
84+
const storage = new StorageManager(SESSION_STORAGE_KEY);
85+
storage.setValue(API_HOST_KEY, "http://stored.invalid");
86+
87+
const config = getConfig(storage, "http://fallback.invalid");
88+
assert.strictEqual(config.apiHost, "http://stored.invalid");
89+
});
90+
});
91+
92+
describe("clearCookies", () => {
93+
it("should clear sessionid cookie", () => {
94+
document.cookie = "sessionid=abc; path=/";
95+
clearCookies("localhost");
96+
assert.isNull(getCookie("sessionid"));
97+
});
98+
});
99+
100+
describe("clearInvalidSession", () => {
101+
it("should clear current user and mark session stale", () => {
102+
const storage = new StorageManager(SESSION_STORAGE_KEY);
103+
storage.setValue(COOKIE_DOMAIN_KEY, ".example.invalid");
104+
105+
const testUser: User = {
106+
username: "testUser",
107+
capabilities: [],
108+
connections: [],
109+
subscription: { expires: null },
110+
teams: [],
111+
teams_full: [],
112+
};
113+
storeCurrentUser(storage, testUser);
114+
115+
clearInvalidSession(storage);
116+
117+
assert.isNull(storage.safeGetJsonValue(CURRENT_USER_KEY));
118+
assert.strictEqual(storage.safeGetValue(STALE_KEY), "yes");
119+
});
120+
121+
it("should not throw if storage operations fail", () => {
122+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
123+
124+
const brokenStorage = {
125+
safeGetValue: () => {
126+
throw new Error("boom");
127+
},
128+
removeValue: () => {
129+
throw new Error("boom");
130+
},
131+
setValue: () => {
132+
throw new Error("boom");
133+
},
134+
} as unknown as StorageManager;
135+
136+
clearInvalidSession(brokenStorage);
137+
assert.isTrue(errorSpy.mock.calls.length >= 1);
138+
});
29139
});
30140

31141
describe("StorageManager", () => {
@@ -303,5 +413,47 @@ describe("SessionContext", () => {
303413
assert.deepEqual(result.capabilities, ["cap1"]);
304414
assert.deepEqual(result.teams, ["team1"]);
305415
});
416+
417+
it("should throw when stored user is invalid", async () => {
418+
const storage = new StorageManager(SESSION_STORAGE_KEY);
419+
storage.setJsonValue(CURRENT_USER_KEY, { username: 123 } as unknown);
420+
setSessionStale(storage, false);
421+
422+
await expect(getSessionCurrentUser(storage, false)).rejects.toThrow(
423+
/Failed to parse current user/
424+
);
425+
});
426+
});
427+
428+
describe("updateCurrentUser", () => {
429+
it("should store currentUser and clear stale when user is returned", async () => {
430+
const storage = new StorageManager(SESSION_STORAGE_KEY);
431+
432+
getCurrentUserMock.mockResolvedValue({
433+
username: "testUser",
434+
capabilities: [],
435+
connections: [],
436+
subscription: { expires: null },
437+
teams: [],
438+
teams_full: [],
439+
});
440+
441+
await updateCurrentUser(storage);
442+
443+
const storedUser = storage.safeGetJsonValue(CURRENT_USER_KEY) as User;
444+
assert.strictEqual(storedUser.username, "testUser");
445+
assert.strictEqual(storage.safeGetValue(STALE_KEY), "no");
446+
});
447+
448+
it("should clear currentUser when API returns null/empty and clear stale", async () => {
449+
const storage = new StorageManager(SESSION_STORAGE_KEY);
450+
storage.setJsonValue(CURRENT_USER_KEY, { username: "old" } as unknown);
451+
452+
getCurrentUserMock.mockResolvedValue(null);
453+
await updateCurrentUser(storage);
454+
455+
assert.isNull(storage.safeGetJsonValue(CURRENT_USER_KEY));
456+
assert.strictEqual(storage.safeGetValue(STALE_KEY), "no");
457+
});
306458
});
307459
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("cyberstorm/security/publicEnvVariables", () => ({
4+
getSessionTools: vi.fn().mockReturnValue({
5+
getConfig: vi.fn().mockReturnValue({
6+
apiHost: "http://api.example.invalid",
7+
sessionId: "sid",
8+
}),
9+
clearInvalidSession: vi.fn(),
10+
}),
11+
}));
12+
13+
vi.mock("@thunderstore/dapper-ts", () => ({
14+
DapperTs: vi.fn().mockImplementation((configFactory, removeSessionHook) => {
15+
return {
16+
__configFactory: configFactory,
17+
__removeSessionHook: removeSessionHook,
18+
getCurrentUser: vi.fn(),
19+
};
20+
}),
21+
}));
22+
23+
import { ApiError } from "@thunderstore/thunderstore-api";
24+
import { DapperTs } from "@thunderstore/dapper-ts";
25+
import { getSessionTools } from "cyberstorm/security/publicEnvVariables";
26+
27+
import { makeTeamSettingsTabLoader } from "../dapperClientLoaders";
28+
29+
describe("dapperClientLoaders", () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
});
33+
34+
it("calls dataFetcher with dapper and teamName and merges return value", async () => {
35+
const dataFetcher = vi.fn().mockResolvedValue({ foo: 123 });
36+
const loader = makeTeamSettingsTabLoader(dataFetcher);
37+
38+
const result = await loader({
39+
params: { namespaceId: "MyTeam" },
40+
} as unknown as { params: { namespaceId: string } });
41+
42+
expect(result).toEqual({ teamName: "MyTeam", foo: 123 });
43+
expect(dataFetcher).toHaveBeenCalledTimes(1);
44+
expect(dataFetcher.mock.calls[0][1]).toBe("MyTeam");
45+
expect(DapperTs).toHaveBeenCalledTimes(1);
46+
47+
const dapperArg = dataFetcher.mock.calls[0][0] as unknown as {
48+
__configFactory: () => unknown;
49+
__removeSessionHook: () => void;
50+
};
51+
expect(dapperArg.__configFactory()).toEqual({
52+
apiHost: "http://api.example.invalid",
53+
sessionId: "sid",
54+
});
55+
56+
const tools = (getSessionTools as unknown as ReturnType<typeof vi.fn>).mock
57+
.results[0].value;
58+
dapperArg.__removeSessionHook();
59+
expect(tools.clearInvalidSession).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it("translates ApiError with detail into a Response", async () => {
63+
const dataFetcher = vi.fn().mockImplementation(() => {
64+
throw new ApiError({
65+
message: "403: Forbidden",
66+
response: new Response(null, { status: 403, statusText: "Forbidden" }),
67+
responseJson: { detail: "Nope" },
68+
});
69+
});
70+
71+
const loader = makeTeamSettingsTabLoader(dataFetcher);
72+
73+
let thrown: unknown;
74+
try {
75+
await loader({
76+
params: { namespaceId: "MyTeam" },
77+
} as unknown as { params: { namespaceId: string } });
78+
} catch (e) {
79+
thrown = e;
80+
}
81+
82+
expect(thrown).toBeInstanceOf(Response);
83+
const res = thrown as Response;
84+
expect(res.status).toBe(403);
85+
expect(await res.text()).toBe("Nope");
86+
});
87+
88+
it("uses response statusText when ApiError has no detail", async () => {
89+
const dataFetcher = vi.fn().mockImplementation(() => {
90+
throw new ApiError({
91+
message: "404: Not Found",
92+
response: new Response(null, { status: 404, statusText: "Not Found" }),
93+
});
94+
});
95+
96+
const loader = makeTeamSettingsTabLoader(dataFetcher);
97+
98+
try {
99+
await loader({
100+
params: { namespaceId: "MyTeam" },
101+
} as unknown as { params: { namespaceId: string } });
102+
} catch (e) {
103+
const res = e as Response;
104+
expect(res).toBeInstanceOf(Response);
105+
expect(res.status).toBe(404);
106+
expect(await res.text()).toBe("Not Found");
107+
}
108+
});
109+
});

apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperSingleton.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,25 @@ describe("dapperSingleton", () => {
195195
expect(mockGetCommunity).toHaveBeenCalledTimes(1);
196196
});
197197
});
198+
199+
describe("resetDapperSingletonForTest", () => {
200+
it("clears request-scoped proxy cache and config factory", () => {
201+
initializeClientDapper();
202+
const request = new Request("http://localhost");
203+
204+
const proxy1 = getDapperForRequest(request);
205+
// Ensure factory was resolved once
206+
expect(publicEnvVariables.getSessionTools).toHaveBeenCalled();
207+
208+
resetDapperSingletonForTest();
209+
210+
// After reset, same request should produce a new proxy
211+
const proxy2 = getDapperForRequest(request);
212+
expect(proxy2).not.toBe(proxy1);
213+
214+
// And config factory should be re-resolved
215+
initializeClientDapper();
216+
expect(publicEnvVariables.getSessionTools).toHaveBeenCalled();
217+
});
218+
});
198219
});

apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@ export function makeTeamSettingsTabLoader<T>(
4545

4646
const setupDapper = () => {
4747
const tools = getSessionTools();
48-
const config = tools?.getConfig();
49-
return new DapperTs(() => ({
50-
apiHost: config?.apiHost,
51-
sessionId: config?.sessionId,
52-
}));
48+
const config = tools.getConfig();
49+
return new DapperTs(
50+
() => ({
51+
apiHost: config.apiHost,
52+
sessionId: config.sessionId,
53+
}),
54+
() => tools.clearInvalidSession()
55+
);
5356
};

apps/cyberstorm-remix/cyberstorm/utils/dapperSingleton.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ export function initializeClientDapper(factory?: ConfigFactory) {
3636

3737
if (!window.Dapper) {
3838
const resolvedFactory = resolveConfigFactory();
39-
window.Dapper = new DapperTs(resolvedFactory);
39+
const tools = getSessionTools();
40+
window.Dapper = new DapperTs(resolvedFactory, () =>
41+
tools.clearInvalidSession()
42+
);
4043
}
4144
}
4245

0 commit comments

Comments
 (0)