Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/db/users.mfa.test.ts
T

281 lines
12 KiB
TypeScript

// @vitest-environment node
import { describe, expect, test, vi, beforeEach } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const mockUpdate = vi.fn();
const mockUpdateMany = vi.fn();
const mockFindUnique = vi.fn();
vi.mock("../db.server", () => ({
db: {
user: {
update: mockUpdate,
updateMany: mockUpdateMany,
findUnique: mockFindUnique,
},
},
}));
vi.mock("../cache.server", () => ({ cachified: vi.fn(), clearCacheKey: vi.fn() }));
vi.mock("~/modules/crm/services/CrmService", () => ({ default: { createContact: vi.fn() } }));
const testDir = path.dirname(fileURLToPath(import.meta.url));
const usersSource = readFileSync(path.join(testDir, "users.db.server.ts"), "utf8");
const schemaSource = readFileSync(path.join(testDir, "../../../../../prisma/schema/accounts.prisma"), "utf8");
const migrationSource = readFileSync(path.join(testDir, "../../../../../prisma/migrations/20260508130000_user_totp_mfa/migration.sql"), "utf8");
const recoveryCodesMigrationSource = readFileSync(
path.join(testDir, "../../../../../prisma/migrations/20260508133000_user_mfa_recovery_codes/migration.sql"),
"utf8"
);
describe("user TOTP MFA storage contract", () => {
beforeEach(() => {
process.env.CRYPTO_SECRET = "test-recovery-secret";
mockUpdate.mockReset();
mockUpdateMany.mockReset();
mockFindUnique.mockReset();
});
test("schema and migration define nullable encrypted TOTP state without defaults", () => {
expect(schemaSource).toContain("totpSecretEncrypted String?");
expect(schemaSource).toContain("totpPendingSecretEncrypted String?");
expect(schemaSource).toContain("totpEnabledAt DateTime?");
expect(schemaSource).toContain("totpPendingCreatedAt DateTime?");
for (const column of ["totpSecretEncrypted", "totpPendingSecretEncrypted", "totpEnabledAt", "totpPendingCreatedAt"]) {
expect(migrationSource).toContain(`ADD COLUMN "${column}"`);
}
expect(migrationSource).not.toMatch(/NOT NULL|DEFAULT|otpauth|secret_fixture|totp-secret/i);
});
test("schema and migration define nullable hashed recovery-code state without plaintext fixtures", () => {
expect(schemaSource).toContain("mfaRecoveryCodeHashes Json?");
expect(schemaSource).toContain("mfaRecoveryCodesGeneratedAt DateTime?");
expect(schemaSource).toContain("mfaRecoveryCodeLastUsedAt DateTime?");
expect(schemaSource).not.toMatch(/recoveryCodesPlaintext|recoveryCodePlaintext|mfaRecoveryCodePlain|mfaRecoveryCodesPlain/i);
for (const column of ["mfaRecoveryCodeHashes", "mfaRecoveryCodesGeneratedAt", "mfaRecoveryCodeLastUsedAt"]) {
expect(recoveryCodesMigrationSource).toContain(`ADD COLUMN "${column}"`);
}
expect(recoveryCodesMigrationSource).not.toMatch(/NOT NULL|DEFAULT|\b[A-Z0-9]{4}-[A-Z0-9]{4}\b|plaintext|plain text|secret_fixture|recovery-code-secret/i);
});
test("generic getUser select omits encrypted TOTP fields and recovery-code hashes", () => {
const getUserBlock = usersSource.match(/export async function getUser\(userId\?: string\)[\s\S]*?export async function setUserTotpPendingSecret/);
expect(getUserBlock?.[0]).toContain("select: {");
expect(getUserBlock?.[0]).not.toContain("totpSecretEncrypted");
expect(getUserBlock?.[0]).not.toContain("totpPendingSecretEncrypted");
expect(getUserBlock?.[0]).not.toContain("mfaRecoveryCodeHashes");
});
test("setUserTotpPendingSecret stores pending secret metadata and clears cache", async () => {
const { setUserTotpPendingSecret } = await import("./users.db.server");
const { clearCacheKey } = await import("../cache.server");
const pendingCreatedAt = new Date("2026-05-08T13:00:00.000Z");
mockUpdate.mockResolvedValueOnce({ id: "user_1" });
await setUserTotpPendingSecret("user_1", "encrypted-pending-secret", pendingCreatedAt);
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: "user_1" },
data: {
totpPendingSecretEncrypted: "encrypted-pending-secret",
totpPendingCreatedAt: pendingCreatedAt,
},
});
expect(clearCacheKey).toHaveBeenCalledWith("user:user_1");
});
test("activateUserTotpPendingSecret promotes pending secret, clears pending fields, and clears cache", async () => {
const { activateUserTotpPendingSecret } = await import("./users.db.server");
const { clearCacheKey } = await import("../cache.server");
const enabledAt = new Date("2026-05-08T13:05:00.000Z");
mockUpdate.mockResolvedValueOnce({ id: "user_1" });
await activateUserTotpPendingSecret("user_1", "encrypted-active-secret", enabledAt);
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: "user_1" },
data: {
totpSecretEncrypted: "encrypted-active-secret",
totpEnabledAt: enabledAt,
totpPendingSecretEncrypted: null,
totpPendingCreatedAt: null,
},
});
expect(clearCacheKey).toHaveBeenCalledWith("user:user_1");
});
test("disableUserTotp clears active and pending TOTP state and clears cache", async () => {
const { disableUserTotp } = await import("./users.db.server");
const { clearCacheKey } = await import("../cache.server");
mockUpdate.mockResolvedValueOnce({ id: "user_1" });
await disableUserTotp("user_1");
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: "user_1" },
data: {
totpSecretEncrypted: null,
totpPendingSecretEncrypted: null,
totpEnabledAt: null,
totpPendingCreatedAt: null,
},
});
expect(clearCacheKey).toHaveBeenCalledWith("user:user_1");
});
test("getUserTotpStatus exposes booleans and timestamps without encrypted secrets", async () => {
const { getUserTotpStatus } = await import("./users.db.server");
const enabledAt = new Date("2026-05-08T13:05:00.000Z");
const pendingCreatedAt = new Date("2026-05-08T13:00:00.000Z");
mockFindUnique.mockResolvedValueOnce({
totpSecretEncrypted: "encrypted-active-secret",
totpPendingSecretEncrypted: "encrypted-pending-secret",
totpEnabledAt: enabledAt,
totpPendingCreatedAt: pendingCreatedAt,
});
const status = await getUserTotpStatus("user_1");
expect(mockFindUnique).toHaveBeenCalledWith({
where: { id: "user_1" },
select: {
totpSecretEncrypted: true,
totpPendingSecretEncrypted: true,
totpEnabledAt: true,
totpPendingCreatedAt: true,
},
});
expect(status).toEqual({
enabled: true,
pending: true,
enabledAt,
pendingCreatedAt,
});
expect(status).not.toHaveProperty("totpSecretEncrypted");
expect(status).not.toHaveProperty("totpPendingSecretEncrypted");
});
test("setUserMfaRecoveryCodes replaces hashes, resets last-used timestamp, and clears cache", async () => {
const { setUserMfaRecoveryCodes } = await import("./users.db.server");
const { clearCacheKey } = await import("../cache.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const entries = [{ hash: "hash-1", usedAt: null, createdAt: generatedAt }];
mockUpdate.mockResolvedValueOnce({ id: "user_1" });
await setUserMfaRecoveryCodes("user_1", entries, generatedAt);
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: "user_1" },
data: {
mfaRecoveryCodeHashes: entries,
mfaRecoveryCodesGeneratedAt: generatedAt,
mfaRecoveryCodeLastUsedAt: null,
},
});
expect(clearCacheKey).toHaveBeenCalledWith("user:user_1");
expect(await setUserMfaRecoveryCodes(undefined, entries, generatedAt)).toBeNull();
});
test("getUserMfaRecoveryCodeStatus returns redacted status only", async () => {
const { getUserMfaRecoveryCodeStatus } = await import("./users.db.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const lastUsedAt = new Date("2026-05-08T14:05:00.000Z");
mockFindUnique.mockResolvedValueOnce({
mfaRecoveryCodeHashes: [
{ hash: "hash-1", usedAt: null, createdAt: generatedAt },
{ hash: "hash-2", usedAt: lastUsedAt, createdAt: generatedAt },
],
mfaRecoveryCodesGeneratedAt: generatedAt,
mfaRecoveryCodeLastUsedAt: lastUsedAt,
});
const status = await getUserMfaRecoveryCodeStatus("user_1");
expect(mockFindUnique).toHaveBeenCalledWith({
where: { id: "user_1" },
select: {
mfaRecoveryCodeHashes: true,
mfaRecoveryCodesGeneratedAt: true,
mfaRecoveryCodeLastUsedAt: true,
},
});
expect(status).toEqual({ configured: true, remainingCount: 1, generatedAt, lastUsedAt });
expect(JSON.stringify(status)).not.toMatch(/hash-1|hash-2|recovery-code/i);
expect(await getUserMfaRecoveryCodeStatus(undefined)).toBeNull();
});
test("consumeUserMfaRecoveryCode marks one matching unused code and clears cache", async () => {
const { consumeUserMfaRecoveryCode } = await import("./users.db.server");
const { hashRecoveryCode } = await import("~/utils/auth/mfa/recovery-codes.server");
const { clearCacheKey } = await import("../cache.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const usedAt = new Date("2026-05-08T14:10:00.000Z");
const entries = [
{ hash: hashRecoveryCode("ABCD-EFGH-JKLM")!, usedAt: null, createdAt: generatedAt },
{ hash: hashRecoveryCode("NPQR-STUV-WXYZ")!, usedAt: null, createdAt: generatedAt },
];
mockFindUnique.mockResolvedValueOnce({
mfaRecoveryCodeHashes: entries,
mfaRecoveryCodesGeneratedAt: generatedAt,
mfaRecoveryCodeLastUsedAt: null,
});
mockUpdateMany.mockResolvedValueOnce({ count: 1 });
const result = await consumeUserMfaRecoveryCode("user_1", "ABCD-EFGH-JKLM", { now: usedAt });
expect(mockUpdateMany).toHaveBeenCalledWith({
where: { id: "user_1", mfaRecoveryCodeHashes: { equals: entries } },
data: {
mfaRecoveryCodeHashes: [{ ...entries[0], usedAt }, entries[1]],
mfaRecoveryCodeLastUsedAt: usedAt,
},
});
expect(result).toEqual({ consumed: true, remainingCount: 1, usedAt });
expect(clearCacheKey).toHaveBeenCalledWith("user:user_1");
});
test("consumeUserMfaRecoveryCode rejects missing state, wrong code, and used-code replay without update", async () => {
const { consumeUserMfaRecoveryCode } = await import("./users.db.server");
const { hashRecoveryCode } = await import("~/utils/auth/mfa/recovery-codes.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const usedAt = new Date("2026-05-08T14:10:00.000Z");
const usedEntries = [{ hash: hashRecoveryCode("ABCD-EFGH-JKLM")!, usedAt, createdAt: generatedAt }];
mockFindUnique.mockResolvedValueOnce(null);
await expect(consumeUserMfaRecoveryCode("user_1", "ABCD-EFGH-JKLM")).resolves.toEqual({
consumed: false,
remainingCount: 0,
usedAt: null,
});
mockFindUnique.mockResolvedValueOnce({
mfaRecoveryCodeHashes: usedEntries,
mfaRecoveryCodesGeneratedAt: generatedAt,
mfaRecoveryCodeLastUsedAt: usedAt,
});
await expect(consumeUserMfaRecoveryCode("user_1", "WRONG-CODE-HERE")).resolves.toEqual({
consumed: false,
remainingCount: 0,
usedAt: null,
});
mockFindUnique.mockResolvedValueOnce({
mfaRecoveryCodeHashes: usedEntries,
mfaRecoveryCodesGeneratedAt: generatedAt,
mfaRecoveryCodeLastUsedAt: usedAt,
});
await expect(consumeUserMfaRecoveryCode("user_1", "ABCD-EFGH-JKLM")).resolves.toEqual({
consumed: false,
remainingCount: 0,
usedAt: null,
});
await expect(consumeUserMfaRecoveryCode(undefined, "ABCD-EFGH-JKLM")).resolves.toBeNull();
expect(mockUpdateMany).not.toHaveBeenCalled();
});
});