Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/totp.server.test.ts
T

268 lines
11 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as OTPAuth from "otpauth";
vi.mock("~/utils/api/.server/CryptoApi", () => ({
default: {
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
},
}));
vi.mock("@toolctrl/config", () => ({
loadRequiredWebConfig: vi.fn(() => ({ appName: "ToolCTRL" })),
}));
vi.mock("~/utils/db/users.db.server", () => ({
activateUserTotpPendingSecret: vi.fn(),
getUserTotpPendingEnrollment: vi.fn(),
setUserMfaRecoveryCodes: vi.fn(),
setUserTotpPendingSecret: vi.fn(),
}));
vi.mock("~/utils/auth/mfa/recovery-codes.server", () => ({
generateRecoveryCodes: vi.fn(() => ({
codes: ["recovery-1", "recovery-2"],
entries: [
{ codeHash: "hash-1", createdAt: new Date("2026-05-25T10:00:00.000Z") },
{ codeHash: "hash-2", createdAt: new Date("2026-05-25T10:00:00.000Z") },
],
generatedAt: new Date("2026-05-25T10:00:00.000Z"),
})),
}));
const vectorSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
function generateExpectedCode(secret: string, timestamp: number): string {
const totp = new OTPAuth.TOTP({
issuer: "ToolCTRL",
label: "test",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret),
});
return totp.generate({ timestamp });
}
describe("services/auth/totp.server", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
test("generateTotpSecret returns a 32-char base32 string", async () => {
const { generateTotpSecret } = await import("./totp.server");
const secret = generateTotpSecret();
expect(secret).toMatch(/^[A-Z2-7]{32}$/);
});
test("encryptTotpSecret / decryptTotpSecret round-trip via CryptoApi", async () => {
const { encryptTotpSecret, decryptTotpSecret } = await import("./totp.server");
const encrypted = encryptTotpSecret(vectorSecret);
expect(encrypted).toBe(`encrypted:${vectorSecret}`);
expect(decryptTotpSecret(encrypted)).toBe(vectorSecret);
});
test("verifyTotpCode accepts the current code with default window", async () => {
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
const encryptedSecret = encryptTotpSecret(vectorSecret);
const now = 1_234_567_890_000;
const code = generateExpectedCode(vectorSecret, now);
expect(verifyTotpCode(encryptedSecret, code, { timestamp: now })).toBe(true);
});
test("verifyTotpCode with window=1 accepts ±30s clock drift", async () => {
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
const encryptedSecret = encryptTotpSecret(vectorSecret);
const now = 1_234_567_890_000;
const pastCode = generateExpectedCode(vectorSecret, now - 30_000);
const futureCode = generateExpectedCode(vectorSecret, now + 30_000);
expect(verifyTotpCode(encryptedSecret, pastCode, { timestamp: now })).toBe(true);
expect(verifyTotpCode(encryptedSecret, futureCode, { timestamp: now })).toBe(true);
});
test("verifyTotpCode rejects ±60s clock drift outside the window", async () => {
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
const encryptedSecret = encryptTotpSecret(vectorSecret);
const now = 1_234_567_890_000;
const tooOld = generateExpectedCode(vectorSecret, now - 60_000);
const tooNew = generateExpectedCode(vectorSecret, now + 60_000);
expect(verifyTotpCode(encryptedSecret, tooOld, { timestamp: now })).toBe(false);
expect(verifyTotpCode(encryptedSecret, tooNew, { timestamp: now })).toBe(false);
});
test("verifyTotpCode rejects malformed codes without consulting otpauth", async () => {
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
const encryptedSecret = encryptTotpSecret(vectorSecret);
const now = 1_234_567_890_000;
expect(verifyTotpCode(encryptedSecret, "12345", { timestamp: now })).toBe(false);
expect(verifyTotpCode(encryptedSecret, "1234567", { timestamp: now })).toBe(false);
expect(verifyTotpCode(encryptedSecret, "12345a", { timestamp: now })).toBe(false);
expect(verifyTotpCode(encryptedSecret, "", { timestamp: now })).toBe(false);
});
test("verifyTotpCode returns false when decryption throws", async () => {
const cryptoApi = (await import("~/utils/api/.server/CryptoApi")).default;
vi.mocked(cryptoApi.decrypt).mockImplementationOnce(() => {
throw new Error("Invalid encrypted payload");
});
const { verifyTotpCode } = await import("./totp.server");
const now = 1_234_567_890_000;
const code = generateExpectedCode(vectorSecret, now);
expect(verifyTotpCode("corrupt-encrypted-secret", code, { timestamp: now })).toBe(false);
});
test("verifyTotpCode rejects a non-integer or negative window", async () => {
const { encryptTotpSecret, verifyTotpCode } = await import("./totp.server");
const encryptedSecret = encryptTotpSecret(vectorSecret);
const now = 1_234_567_890_000;
const code = generateExpectedCode(vectorSecret, now);
expect(() => verifyTotpCode(encryptedSecret, code, { timestamp: now, window: -1 })).toThrow();
expect(() => verifyTotpCode(encryptedSecret, code, { timestamp: now, window: 1.5 })).toThrow();
});
test("buildTotpOtpauthUri produces a parsable otpauth:// URI with issuer and label", async () => {
const { buildTotpOtpauthUri } = await import("./totp.server");
const uri = buildTotpOtpauthUri(vectorSecret, "user+security@example.com", { issuer: "Tool Ctrl Admin" });
expect(uri.startsWith("otpauth://totp/")).toBe(true);
expect(uri).toContain(`secret=${vectorSecret}`);
expect(uri).toContain("algorithm=SHA1");
expect(uri).toContain("digits=6");
expect(uri).toContain("period=30");
// Issuer + email appear URL-encoded, not as raw plaintext
expect(uri).not.toContain("Tool Ctrl Admin:user+security@example.com");
const parsed = OTPAuth.URI.parse(uri);
expect(parsed).toBeInstanceOf(OTPAuth.TOTP);
expect(parsed.issuer).toBe("Tool Ctrl Admin");
expect(parsed.label).toBe("user+security@example.com");
});
test("startTotpEnrollment yields encrypted secret + parsable otpauth URI", async () => {
const { startTotpEnrollment } = await import("./totp.server");
const enrollment = startTotpEnrollment("person@example.com", { issuer: "ToolCTRL" });
expect(enrollment.secret).toMatch(/^[A-Z2-7]{32}$/);
expect(enrollment.encryptedSecret).toBe(`encrypted:${enrollment.secret}`);
expect(enrollment.otpauthUri).toBe(enrollment.qrValue);
expect(enrollment.otpauthUri).toContain(`secret=${enrollment.secret}`);
});
test("getTotpSafeStatus exposes only booleans and timestamps", async () => {
const { getTotpSafeStatus } = await import("./totp.server");
const enabledAt = new Date("2026-05-08T13:10:00.000Z");
const pendingCreatedAt = new Date("2026-05-08T13:00:00.000Z");
const status = getTotpSafeStatus({
hasEncryptedSecret: true,
hasEncryptedPendingSecret: true,
totpEnabledAt: enabledAt,
totpPendingCreatedAt: pendingCreatedAt,
});
expect(status).toEqual({ enabled: true, pending: true, enabledAt, pendingCreatedAt });
expect(JSON.stringify(status)).not.toMatch(/secret|otpauth|encrypted/i);
});
test("getTotpSafeStatus returns disabled when prerequisites are missing", async () => {
const { getTotpSafeStatus } = await import("./totp.server");
expect(
getTotpSafeStatus({
hasEncryptedSecret: false,
hasEncryptedPendingSecret: false,
totpEnabledAt: null,
totpPendingCreatedAt: null,
})
).toEqual({ enabled: false, pending: false, enabledAt: null, pendingCreatedAt: null });
});
test("createTotpEnrollmentForUser stores a pending encrypted secret", async () => {
const { setUserTotpPendingSecret } = await import("~/utils/db/users.db.server");
const { createTotpEnrollmentForUser } = await import("./totp.server");
const now = new Date("2026-05-25T09:00:00.000Z");
const result = await createTotpEnrollmentForUser({
userId: "user_1",
userEmail: "security@example.com",
issuer: "ToolCTRL Admin",
now,
});
expect(result.secret).toMatch(/^[A-Z2-7]{32}$/);
expect(result.encryptedSecret).toBe(`encrypted:${result.secret}`);
expect(result.recoveryCodesPending).toBe(true);
expect(result.otpauthUri).toContain(`secret=${result.secret}`);
expect(setUserTotpPendingSecret).toHaveBeenCalledWith("user_1", result.encryptedSecret, now);
});
test("activatePendingTotpForUser returns false when no pending secret exists", async () => {
const usersDb = await import("~/utils/db/users.db.server");
vi.mocked(usersDb.getUserTotpPendingEnrollment).mockResolvedValueOnce(null);
const { activatePendingTotpForUser } = await import("./totp.server");
await expect(
activatePendingTotpForUser({
userId: "user_1",
token: "123456",
now: new Date("2026-05-25T09:00:00.000Z"),
})
).resolves.toEqual({ activated: false, recoveryCodes: [] });
expect(usersDb.activateUserTotpPendingSecret).not.toHaveBeenCalled();
expect(usersDb.setUserMfaRecoveryCodes).not.toHaveBeenCalled();
});
test("activatePendingTotpForUser returns false when the token is invalid", async () => {
const usersDb = await import("~/utils/db/users.db.server");
vi.mocked(usersDb.getUserTotpPendingEnrollment).mockResolvedValueOnce({
totpPendingSecretEncrypted: `encrypted:${vectorSecret}`,
} as never);
const { activatePendingTotpForUser } = await import("./totp.server");
await expect(
activatePendingTotpForUser({
userId: "user_1",
token: "not-a-code",
now: new Date("2026-05-25T09:00:00.000Z"),
})
).resolves.toEqual({ activated: false, recoveryCodes: [] });
expect(usersDb.activateUserTotpPendingSecret).not.toHaveBeenCalled();
expect(usersDb.setUserMfaRecoveryCodes).not.toHaveBeenCalled();
});
test("activatePendingTotpForUser promotes the pending secret and stores recovery codes", async () => {
const usersDb = await import("~/utils/db/users.db.server");
const { generateRecoveryCodes } = await import("~/utils/auth/mfa/recovery-codes.server");
const now = new Date("2026-05-25T09:00:00.000Z");
const code = generateExpectedCode(vectorSecret, now.getTime());
vi.mocked(usersDb.getUserTotpPendingEnrollment).mockResolvedValueOnce({
totpPendingSecretEncrypted: `encrypted:${vectorSecret}`,
} as never);
const { activatePendingTotpForUser, TOTP_RECOVERY_CODE_COUNT } = await import("./totp.server");
await expect(
activatePendingTotpForUser({
userId: "user_1",
token: code,
now,
})
).resolves.toEqual({ activated: true, recoveryCodes: ["recovery-1", "recovery-2"] });
expect(generateRecoveryCodes).toHaveBeenCalledWith({ count: TOTP_RECOVERY_CODE_COUNT, now });
expect(usersDb.activateUserTotpPendingSecret).toHaveBeenCalledWith("user_1", `encrypted:${vectorSecret}`, now);
expect(usersDb.setUserMfaRecoveryCodes).toHaveBeenCalledWith(
"user_1",
[
{ codeHash: "hash-1", createdAt: new Date("2026-05-25T10:00:00.000Z") },
{ codeHash: "hash-2", createdAt: new Date("2026-05-25T10:00:00.000Z") },
],
new Date("2026-05-25T10:00:00.000Z")
);
});
});