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 }) => 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); }); });