271 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|