Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/session.impersonation.test.ts
T

324 lines
11 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
const testWebConfig = {
nodeEnv: "test",
sessionSecret: "dGVzdC1zZXNzaW9uLXNlY3JldC13aXRoLWVub3VnaC1lbnRyb3B5LTEyMzQ1Njc4OTA=",
routing: {
appBaseDomain: "localhost",
protocol: "http" as const,
},
stripe: { secretKey: "sk_test_root_data" },
crisp: { chatWebsiteId: undefined },
};
vi.doMock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => testWebConfig,
};
});
vi.mock("./db/tenants.db.server", () => ({
getMyTenants: vi.fn(),
getTenant: vi.fn(),
}));
vi.mock("./db/users.db.server", () => ({
getUser: vi.fn(),
}));
vi.mock("@toolctrl/db/auth-boundary.server", () => ({
getAuthUserWithoutPasswordById: vi.fn(),
getTenantById: vi.fn(),
}));
vi.mock("~/locale/i18next.server", () => ({
i18nCookie: { serialize: vi.fn(async () => "i18n=de") },
remixI18Next: { getLocale: vi.fn(async () => "de") },
}));
vi.mock("~/modules/featureFlags/services/FeatureFlagsService", () => ({
default: { getCurrentFeatureFlags: vi.fn(async () => []) },
}));
vi.mock("~/modules/metrics/services/.server/MetricTracker", () => ({
createMetrics: vi.fn(async () => ({
time: async <T,>(value: T | Promise<T>) => value,
getServerTimingHeader: () => ({ "Server-Timing": "test" }),
})),
}));
vi.mock("~/utils/analyticsCookie.server", () => ({
commitAnalyticsSession: vi.fn(async () => "analytics=session"),
destroyAnalyticsSession: vi.fn(async () => "analytics=; Max-Age=0"),
generateAnalyticsUserId: vi.fn(() => "analytics-user-1"),
getAnalyticsSession: vi.fn(async () => {
const values = new Map<string, string>();
return {
get: (key: string) => values.get(key),
set: (key: string, value: string) => values.set(key, value),
};
}),
}));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({
getAppConfiguration: vi.fn(async () => ({
analytics: { enabled: false },
app: { theme: "light" },
featureFlags: { enabled: false },
metrics: { enabled: false, logToConsole: false, saveToDatabase: false, ignoreUrls: [] },
})),
}));
vi.mock("~/utils/helpers/.server/AnalyticsService", () => ({
default: { getFromRequest: vi.fn() },
}));
const {
IMPERSONATION_SESSION_TTL_MS,
createImpersonationStartedAt,
createUserSession,
getUserInfo,
getUserSession,
logout,
storage,
validateImpersonationSessionState,
} = await import("./session.server");
const { getAuthUserWithoutPasswordById } = await import("@toolctrl/db/auth-boundary.server");
const { loadRootData } = await import("./data/.server/rootData");
const now = new Date("2026-01-01T12:00:00.000Z");
const freshStartedAt = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
const currentFreshStartedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const rootTestUsers = {
"admin-1": { id: "admin-1", email: "admin@example.test", firstName: "Admin", lastName: "User" },
"user-1": { id: "user-1", email: "user@example.test", firstName: "Target", lastName: "User" },
};
async function requestFromSession(sessionValues: Record<string, unknown>) {
const session = await storage.getSession();
for (const [key, value] of Object.entries(sessionValues)) {
session.set(key, value);
}
return new Request("https://example.test/app", {
headers: { Cookie: (await storage.commitSession(session)).split(";")[0] },
});
}
describe("impersonation session TTL", () => {
it("accepts fresh impersonation state", () => {
expect(
validateImpersonationSessionState(
{ impersonatingFromUserId: "admin-1", impersonatingStartedAt: freshStartedAt, userId: "user-1" },
{ now }
)
).toEqual({ valid: true, reason: "valid", actorUserId: "admin-1", startedAt: freshStartedAt });
});
it("rejects missing actor state", () => {
expect(validateImpersonationSessionState({ impersonatingStartedAt: freshStartedAt }, { now })).toMatchObject({
valid: false,
reason: "missing_actor",
});
});
it("rejects missing timestamp state", () => {
expect(validateImpersonationSessionState({ impersonatingFromUserId: "admin-1" }, { now })).toMatchObject({
valid: false,
reason: "missing_started_at",
});
});
it("rejects malformed timestamp state without throwing", () => {
expect(
validateImpersonationSessionState({ impersonatingFromUserId: "admin-1", impersonatingStartedAt: "not-a-date" }, { now })
).toMatchObject({ valid: false, reason: "malformed_started_at" });
});
it("rejects future timestamp state", () => {
expect(
validateImpersonationSessionState(
{ impersonatingFromUserId: "admin-1", impersonatingStartedAt: new Date(now.getTime() + 1).toISOString() },
{ now }
)
).toMatchObject({ valid: false, reason: "future_started_at" });
});
it("rejects stale timestamp state", () => {
expect(
validateImpersonationSessionState(
{ impersonatingFromUserId: "admin-1", impersonatingStartedAt: new Date(now.getTime() - IMPERSONATION_SESSION_TTL_MS - 1).toISOString() },
{ now }
)
).toMatchObject({ valid: false, reason: "stale_started_at" });
});
it("rejects wrong actor state", () => {
expect(
validateImpersonationSessionState(
{ impersonatingFromUserId: "admin-1", impersonatingStartedAt: freshStartedAt },
{ now, expectedActorUserId: "admin-2" }
)
).toMatchObject({ valid: false, reason: "wrong_actor" });
});
it("preserves only fresh impersonation state in getUserInfo", async () => {
const request = await requestFromSession({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: currentFreshStartedAt,
});
await expect(getUserInfo(request)).resolves.toMatchObject({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: currentFreshStartedAt,
});
});
it("omits stale or malformed impersonation state from getUserInfo", async () => {
const request = await requestFromSession({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: "not-a-date",
});
await expect(getUserInfo(request)).resolves.toMatchObject({
userId: "user-1",
impersonatingFromUserId: undefined,
impersonatingStartedAt: undefined,
});
});
it("requires createUserSession impersonation callers to provide an issue timestamp", async () => {
const response = await createUserSession(
{
userId: "user-1",
lightOrDarkMode: "",
lng: "de",
cookies: [],
impersonatingFromUserId: "admin-1",
},
"/app"
);
const cookie = response.headers.get("Set-Cookie") ?? "";
const request = new Request("https://example.test/app", { headers: { Cookie: cookie.split(";")[0] } });
await expect(getUserInfo(request)).resolves.toMatchObject({
impersonatingFromUserId: undefined,
impersonatingStartedAt: undefined,
});
});
it("serializes fresh impersonation timestamps without leaking secrets", async () => {
const response = await createUserSession(
{
userId: "user-1",
lightOrDarkMode: "",
lng: "de",
csrf: "csrf-secret-token",
cookies: [],
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: createImpersonationStartedAt(now),
},
"/app"
);
const cookie = response.headers.get("Set-Cookie") ?? "";
expect(cookie).toContain("RJ_session=");
expect(cookie).not.toContain("csrf-secret-token");
expect(cookie).not.toContain("password");
expect(cookie).not.toContain("totp");
expect(cookie).not.toContain("recovery");
});
it("preserves fresh impersonation metadata and password-policy flags in new sessions", async () => {
const startedAt = createImpersonationStartedAt(new Date());
const response = await createUserSession(
{
userId: "user-1",
lightOrDarkMode: "",
lng: "de",
cookies: [],
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: startedAt,
passwordPolicyOutdated: true,
},
"/app"
);
const request = new Request("https://example.test/app", {
headers: { Cookie: (response.headers.get("Set-Cookie") ?? "").split(";")[0] },
});
await expect(getUserInfo(request)).resolves.toMatchObject({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: startedAt,
passwordPolicyOutdated: true,
});
});
it("clears impersonation state on logout", async () => {
const request = await requestFromSession({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: freshStartedAt,
});
const response = await logout(request);
const cookie = response.headers.get("Set-Cookie") ?? "";
const loggedOutSession = await storage.getSession(cookie.split(";")[0]);
expect(loggedOutSession.get("impersonatingFromUserId")).toBeUndefined();
expect(loggedOutSession.get("impersonatingStartedAt")).toBeUndefined();
expect((await getUserSession(new Request("https://example.test", { headers: { Cookie: cookie.split(";")[0] } }))).get("userId")).toBeUndefined();
});
it("exposes fresh impersonation state in root data", async () => {
vi.mocked(getAuthUserWithoutPasswordById).mockImplementation(async (userId) => rootTestUsers[userId as keyof typeof rootTestUsers] as never);
const request = await requestFromSession({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: currentFreshStartedAt,
});
const response = await loadRootData({ request, params: {} });
const data = await response.json();
expect(data.userSession).toMatchObject({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: currentFreshStartedAt,
});
expect(data.impersonatingSession).toEqual({ fromUser: rootTestUsers["admin-1"], toUser: rootTestUsers["user-1"] });
});
it("omits stale or malformed impersonation metadata from root data", async () => {
vi.mocked(getAuthUserWithoutPasswordById).mockImplementation(async (userId) => rootTestUsers[userId as keyof typeof rootTestUsers] as never);
const staleStartedAt = new Date(Date.now() - IMPERSONATION_SESSION_TTL_MS - 1).toISOString();
const staleRequest = await requestFromSession({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: staleStartedAt,
});
const malformedRequest = await requestFromSession({
userId: "user-1",
impersonatingFromUserId: "admin-1",
impersonatingStartedAt: "not-a-date",
});
const staleData = await (await loadRootData({ request: staleRequest, params: {} })).json();
const malformedData = await (await loadRootData({ request: malformedRequest, params: {} })).json();
expect(staleData.userSession.impersonatingFromUserId).toBeUndefined();
expect(staleData.userSession.impersonatingStartedAt).toBeUndefined();
expect(staleData.impersonatingSession).toBeNull();
expect(malformedData.userSession.impersonatingFromUserId).toBeUndefined();
expect(malformedData.userSession.impersonatingStartedAt).toBeUndefined();
expect(malformedData.impersonatingSession).toBeNull();
});
});