281 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|