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

186 lines
7.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const dbMock = vi.hoisted(() => ({
userSession: {
create: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
}));
vi.mock("~/utils/db.server", () => ({ db: dbMock, dbRead: dbMock }));
vi.mock("~/utils/cache.server", () => ({
// Cache als Pass-through: getFreshValue direkt aufrufen, clearCacheKey als Spy.
cachified: vi.fn(async (opts: { getFreshValue: () => Promise<unknown> }) => opts.getFreshValue()),
clearCacheKey: vi.fn(),
}));
import { clearCacheKey } from "~/utils/cache.server";
import {
createUserSession,
generateSessionToken,
getActiveSessionByToken,
hashSessionToken,
listActiveUserSessions,
markSessionTrusted,
revokeAllUserSessionsExcept,
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();
});
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() });
expect(await getActiveSessionByToken("raw")).toMatchObject({ id: "s1" });
});
it("returns null for a revoked session", async () => {
dbMock.userSession.findUnique.mockResolvedValue({ id: "s1", revokedAt: pastDate(), expiresAt: futureDate() });
expect(await getActiveSessionByToken("raw")).toBeNull();
});
it("returns null for an expired session", async () => {
dbMock.userSession.findUnique.mockResolvedValue({ id: "s1", revokedAt: null, expiresAt: pastDate() });
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");
});
});
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("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);
});
});