Files
community-rule/tests/unit/sessionLifecycle.test.ts
2026-04-22 22:24:59 -06:00

166 lines
5.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sessionCreateMock = vi.fn();
const sessionDeleteManyMock = vi.fn();
const getSessionPepperMock = vi.fn();
const newSessionTokenMock = vi.fn();
const hashSessionTokenMock = vi.fn();
const loggerWarnMock = vi.fn();
vi.mock("../../lib/server/db", () => ({
prisma: {
session: {
create: (...args: unknown[]) => sessionCreateMock(...args),
deleteMany: (...args: unknown[]) => sessionDeleteManyMock(...args),
},
},
}));
vi.mock("../../lib/server/env", () => ({
getSessionPepper: () => getSessionPepperMock(),
}));
vi.mock("../../lib/server/hash", () => ({
hashSessionToken: (...args: unknown[]) => hashSessionTokenMock(...args),
newSessionToken: () => newSessionTokenMock(),
}));
vi.mock("../../lib/logger", () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: (...args: unknown[]) => loggerWarnMock(...args),
error: vi.fn(),
},
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
import {
createSessionForUser,
pruneExpiredSessions,
} from "../../lib/server/session";
beforeEach(() => {
sessionCreateMock.mockReset();
sessionDeleteManyMock.mockReset();
getSessionPepperMock.mockReset();
newSessionTokenMock.mockReset();
hashSessionTokenMock.mockReset();
loggerWarnMock.mockReset();
getSessionPepperMock.mockReturnValue("test-pepper");
newSessionTokenMock.mockReturnValue("token-raw");
hashSessionTokenMock.mockReturnValue("token-hash");
sessionCreateMock.mockResolvedValue({});
sessionDeleteManyMock.mockResolvedValue({ count: 0 });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("pruneExpiredSessions", () => {
it("deletes globally expired rows when no userId is supplied", async () => {
sessionDeleteManyMock.mockResolvedValueOnce({ count: 7 });
const count = await pruneExpiredSessions();
expect(count).toBe(7);
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { expiresAt: { lt: Date }; userId?: string };
};
expect(arg.where.userId).toBeUndefined();
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
});
it("scopes the prune to a single user when userId is supplied", async () => {
sessionDeleteManyMock.mockResolvedValueOnce({ count: 2 });
const count = await pruneExpiredSessions({ userId: "user-1" });
expect(count).toBe(2);
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { expiresAt: { lt: Date }; userId?: string };
};
expect(arg.where.userId).toBe("user-1");
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
});
it("never matches non-expired rows (multi-device safety)", async () => {
await pruneExpiredSessions({ userId: "user-1" });
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { expiresAt: { lt: Date } };
};
expect(Object.keys(arg.where.expiresAt)).toEqual(["lt"]);
});
});
describe("createSessionForUser cleanup behaviour", () => {
it("creates the new session and prunes the same user's expired rows", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.99);
const result = await createSessionForUser("user-1");
expect(result.token).toBe("token-raw");
expect(result.expiresAt).toBeInstanceOf(Date);
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { userId?: string; expiresAt: { lt: Date } };
};
expect(arg.where.userId).toBe("user-1");
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
});
it("runs an additional global sweep when the probability roll succeeds", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.01);
await createSessionForUser("user-1");
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(2);
const userScoped = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { userId?: string };
};
const globalSweep = sessionDeleteManyMock.mock.calls[1]?.[0] as {
where: { userId?: string };
};
expect(userScoped.where.userId).toBe("user-1");
expect(globalSweep.where.userId).toBeUndefined();
});
it("skips the global sweep when the probability roll fails", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.06);
await createSessionForUser("user-1");
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
});
it("does not throw out of sign-in when cleanup fails", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.01);
sessionDeleteManyMock.mockRejectedValue(new Error("db down"));
const result = await createSessionForUser("user-1");
expect(result.token).toBe("token-raw");
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
});
it("never deletes the user's other still-valid sessions (multi-device policy)", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.99);
await createSessionForUser("user-1");
for (const call of sessionDeleteManyMock.mock.calls) {
const arg = call[0] as { where: { expiresAt?: { lt: Date } } };
expect(arg.where.expiresAt?.lt).toBeInstanceOf(Date);
}
});
});