186 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|