Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/auth/sessionStore.server.test.ts
T

271 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
const dbMock = vi.hoisted(() => ({
userSession: {
create: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
}));
const resolveUserSessionRlsIdentityMock = vi.hoisted(() => vi.fn());
vi.mock("~/utils/db.server", () => ({ db: dbMock, dbRead: dbMock }));
vi.mock("@hivegrid/db/rls-resolvers.server", () => ({
resolveUserSessionRlsIdentity: resolveUserSessionRlsIdentityMock,
}));
vi.mock("~/utils/cache.server", () => ({
clearCacheKey: vi.fn(),
}));
import { clearCacheKey } from "~/utils/cache.server";
import {
createUserSession,
generateSessionToken,
getActiveSessionByToken,
hashSessionToken,
listActiveUserSessions,
markSessionTrusted,
revokeAllUserSessionsExcept,
revokeAllUserSessionsForUser,
revokeSessionByToken,
revokeUserSession,
} from "./sessionStore.server";
const clearCacheKeyMock = vi.mocked(clearCacheKey);
function futureDate(msFromNow = 60_000) {
return new Date(Date.now() + msFromNow);
}
function pastDate(msAgo = 60_000) {
return new Date(Date.now() - msAgo);
}
beforeEach(() => {
vi.clearAllMocks();
resolveUserSessionRlsIdentityMock.mockResolvedValue({ resourceId: "s1", userId: "u1" });
});
describe("session token hashing", () => {
it("hashes deterministically and never stores the raw token", () => {
expect(hashSessionToken("abc")).toBe(hashSessionToken("abc"));
expect(hashSessionToken("abc")).not.toBe("abc");
expect(hashSessionToken("abc")).toMatch(/^[0-9a-f]{64}$/);
});
it("generates unique opaque tokens", () => {
expect(generateSessionToken()).not.toBe(generateSessionToken());
});
});
describe("createUserSession", () => {
it("persists only the token hash and returns the raw token for the cookie", async () => {
dbMock.userSession.create.mockResolvedValue({ id: "s1", userId: "u1" });
const { session, rawToken } = await createUserSession({ userId: "u1", ipHash: "deadbeef", userAgent: "UA" });
expect(session).toEqual({ id: "s1", userId: "u1" });
expect(rawToken).toEqual(expect.any(String));
const data = dbMock.userSession.create.mock.calls[0][0].data;
expect(data.tokenHash).toBe(hashSessionToken(rawToken));
expect(data).not.toHaveProperty("token");
// AD-22: kein Klartext-IP-Feld; nur der Hash.
expect(data).not.toHaveProperty("ipAddress");
expect(data.ipHash).toBe("deadbeef");
expect(data.trustedAt).toBeNull();
});
});
describe("getActiveSessionByToken", () => {
it("returns null for a missing token without hitting the DB", async () => {
expect(await getActiveSessionByToken(undefined)).toBeNull();
expect(dbMock.userSession.findUnique).not.toHaveBeenCalled();
});
it("returns the session when active", async () => {
dbMock.userSession.findUnique.mockResolvedValue({
id: "s1",
revokedAt: null,
expiresAt: futureDate(),
user: { active: true, pendingDeletion: false, pseudonymized: false },
});
expect(await getActiveSessionByToken("raw")).toMatchObject({ id: "s1" });
expect(dbMock.userSession.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ include: { user: { select: expect.objectContaining({ active: true, pendingDeletion: true, pseudonymized: true }) } } })
);
});
it("fails closed when the bootstrap resolver cannot prove the token owner", async () => {
resolveUserSessionRlsIdentityMock.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
await expect(getActiveSessionByToken("unknown")).resolves.toBeNull();
await expect(revokeSessionByToken("unknown")).resolves.toBe(false);
expect(dbMock.userSession.findUnique).not.toHaveBeenCalled();
});
it("checks the source of truth on every cross-request validation", async () => {
dbMock.userSession.findUnique.mockResolvedValue({
id: "s1",
revokedAt: null,
expiresAt: futureDate(),
user: { active: true, pendingDeletion: false, pseudonymized: false },
});
await getActiveSessionByToken("raw");
await getActiveSessionByToken("raw");
expect(dbMock.userSession.findUnique).toHaveBeenCalledTimes(2);
});
it("returns null for a revoked session", async () => {
dbMock.userSession.findUnique.mockResolvedValue({
id: "s1",
revokedAt: pastDate(),
expiresAt: futureDate(),
user: { active: true, pendingDeletion: false, pseudonymized: false },
});
expect(await getActiveSessionByToken("raw")).toBeNull();
});
it("returns null for an expired session", async () => {
dbMock.userSession.findUnique.mockResolvedValue({
id: "s1",
revokedAt: null,
expiresAt: pastDate(),
user: { active: true, pendingDeletion: false, pseudonymized: false },
});
expect(await getActiveSessionByToken("raw")).toBeNull();
});
it.each([
{ active: false, pendingDeletion: false, pseudonymized: false },
{ active: true, pendingDeletion: true, pseudonymized: false },
{ active: true, pendingDeletion: false, pseudonymized: true },
])("returns null when the session owner is unavailable ($active/$pendingDeletion/$pseudonymized)", async (user) => {
dbMock.userSession.findUnique.mockResolvedValue({ id: "s1", revokedAt: null, expiresAt: futureDate(), user });
expect(await getActiveSessionByToken("raw")).toBeNull();
});
});
describe("listActiveUserSessions", () => {
it("filters to non-revoked, non-expired sessions for the device list", async () => {
dbMock.userSession.findMany.mockResolvedValue([{ id: "s1" }]);
await listActiveUserSessions("u1");
const where = dbMock.userSession.findMany.mock.calls[0][0].where;
expect(where.userId).toBe("u1");
expect(where.revokedAt).toBeNull();
expect(where.expiresAt).toHaveProperty("gt");
});
it("reuses an already-bound matching user RLS context", async () => {
dbMock.userSession.findMany.mockImplementationOnce(async () => {
expect(getRlsExecutionContext()).toEqual({ userId: "u1" });
return [];
});
await runWithRlsContext({ userId: "u1" }, () => listActiveUserSessions("u1"));
expect(getRlsExecutionContext()).toBeNull();
});
});
describe("revokeUserSession", () => {
it("rejects revoking a session that is not owned by the user", async () => {
dbMock.userSession.findFirst.mockResolvedValue(null);
expect(await revokeUserSession({ userId: "u1", sessionId: "s1" })).toBe(false);
expect(dbMock.userSession.update).not.toHaveBeenCalled();
});
it("is a no-op for an already revoked session", async () => {
dbMock.userSession.findFirst.mockResolvedValue({ id: "s1", tokenHash: "h", revokedAt: pastDate() });
expect(await revokeUserSession({ userId: "u1", sessionId: "s1" })).toBe(false);
expect(dbMock.userSession.update).not.toHaveBeenCalled();
});
it("revokes an active owned session and clears its cache key", async () => {
dbMock.userSession.findFirst.mockResolvedValue({ id: "s1", tokenHash: "hash1", revokedAt: null });
dbMock.userSession.update.mockResolvedValue({});
expect(await revokeUserSession({ userId: "u1", sessionId: "s1" })).toBe(true);
expect(dbMock.userSession.update).toHaveBeenCalledWith({ where: { id: "s1" }, data: { revokedAt: expect.any(Date) } });
expect(clearCacheKeyMock).toHaveBeenCalledWith("user-session:hash1");
});
});
describe("revokeAllUserSessionsExcept", () => {
it("revokes every other active session and clears each cache key", async () => {
dbMock.userSession.findMany.mockResolvedValue([
{ id: "s2", tokenHash: "h2" },
{ id: "s3", tokenHash: "h3" },
]);
dbMock.userSession.updateMany.mockResolvedValue({ count: 2 });
const revoked = await revokeAllUserSessionsExcept({ userId: "u1", sessionId: "s1" });
expect(revoked).toBe(2);
expect(dbMock.userSession.updateMany.mock.calls[0][0].where.id).toEqual({ not: "s1" });
expect(clearCacheKeyMock).toHaveBeenCalledWith("user-session:h2");
expect(clearCacheKeyMock).toHaveBeenCalledWith("user-session:h3");
});
it("returns zero and does not write when there is nothing else to revoke", async () => {
dbMock.userSession.findMany.mockResolvedValue([]);
expect(await revokeAllUserSessionsExcept({ userId: "u1", sessionId: "s1" })).toBe(0);
expect(dbMock.userSession.updateMany).not.toHaveBeenCalled();
});
});
describe("revokeAllUserSessionsForUser", () => {
it("revokes every active row after an external authorization change", async () => {
dbMock.userSession.findMany.mockResolvedValue([{ tokenHash: "h1" }, { tokenHash: "h2" }]);
dbMock.userSession.updateMany.mockResolvedValue({ count: 2 });
await expect(revokeAllUserSessionsForUser("u1")).resolves.toBe(2);
expect(dbMock.userSession.updateMany).toHaveBeenCalledWith({
where: { userId: "u1", revokedAt: null, expiresAt: { gt: expect.any(Date) } },
data: { revokedAt: expect.any(Date) },
});
expect(clearCacheKeyMock).toHaveBeenCalledWith("user-session:h1");
expect(clearCacheKeyMock).toHaveBeenCalledWith("user-session:h2");
});
});
describe("revokeSessionByToken", () => {
it("returns false for a missing token without touching the DB", async () => {
expect(await revokeSessionByToken(undefined)).toBe(false);
expect(dbMock.userSession.findUnique).not.toHaveBeenCalled();
});
it("revokes the active session for a token and clears its cache key", async () => {
dbMock.userSession.findUnique.mockResolvedValue({ id: "s1", revokedAt: null });
dbMock.userSession.update.mockResolvedValue({});
expect(await revokeSessionByToken("raw")).toBe(true);
expect(dbMock.userSession.update).toHaveBeenCalledWith({ where: { id: "s1" }, data: { revokedAt: expect.any(Date) } });
expect(clearCacheKeyMock).toHaveBeenCalledWith(`user-session:${hashSessionToken("raw")}`);
});
it("is a no-op for an already revoked or unknown token", async () => {
dbMock.userSession.findUnique.mockResolvedValueOnce({ id: "s1", revokedAt: pastDate() });
expect(await revokeSessionByToken("raw")).toBe(false);
dbMock.userSession.findUnique.mockResolvedValueOnce(null);
expect(await revokeSessionByToken("raw")).toBe(false);
expect(dbMock.userSession.update).not.toHaveBeenCalled();
});
});
describe("markSessionTrusted", () => {
it("marks an owned active session trusted", async () => {
dbMock.userSession.updateMany.mockResolvedValue({ count: 1 });
expect(await markSessionTrusted({ userId: "u1", sessionId: "s1" })).toBe(true);
});
it("returns false when no owned active session matched", async () => {
dbMock.userSession.updateMany.mockResolvedValue({ count: 0 });
expect(await markSessionTrusted({ userId: "u1", sessionId: "s1" })).toBe(false);
});
});