Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions apps/cyberstorm-remix/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -578,12 +579,19 @@ const TooltipProvider = memo(function TooltipProvider({

function App() {
const data = useLoaderData<RootLoadersType>();
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 (
<Outlet
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import { assert, describe, it, beforeEach } from "vitest";
import {
assert,
describe,
it,
beforeEach,
afterEach,
vi,
expect,
} from "vitest";

vi.mock("@thunderstore/dapper-ts", () => ({
DapperTs: vi.fn(),
}));

let getCurrentUserMock: ReturnType<typeof vi.fn>;

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";
Expand All @@ -20,12 +40,102 @@
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

Check failure

Code scanning / ESLint

Disallow the `any` type Error test

Unexpected any. Specify a different type.
);
});

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", () => {
Expand Down Expand Up @@ -303,5 +413,47 @@
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");
});
});
});
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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");
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
13 changes: 8 additions & 5 deletions apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ export function makeTeamSettingsTabLoader<T>(

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()
);
};
5 changes: 4 additions & 1 deletion apps/cyberstorm-remix/cyberstorm/utils/dapperSingleton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}
}

Expand Down
Loading
Loading