import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/server/modules/auth/repository", () => ({
findUserWithTotpById: vi.fn(),
setPendingTotpSecret: vi.fn(),
completeTotpEnrollment: vi.fn(),
clearTotpEnrollment: vi.fn(),
updateTotpBackupCodes: vi.fn(),
createSession: vi.fn(),
deleteSessionsForUser: vi.fn(),
}));
vi.mock("@/lib/server/modules/auth/partial-auth-token", () => ({
verifyPartialAuthToken: vi.fn(),
}));
vi.mock("@/lib/server/modules/auth/session-token", () => ({
createSessionToken: vi.fn(
(sessionId: string, exp: number) => `sess.${sessionId}.${exp}.sig`,
),
}));
vi.mock("@/lib/server/env", () => ({
serverEnv: { AUTH_SESSION_HOURS: 168 },
}));
vi.mock("@/lib/server/modules/auth/totp-crypto", () => ({
encryptSecret: vi.fn((plaintext: string) => `enc:${plaintext}`),
decryptSecret: vi.fn((packed: string) => packed.replace(/^enc:/, "")),
}));
vi.mock("@/lib/server/modules/auth/rate-limit", () => ({
isTotpCodeReplayed: vi.fn(() => false),
markTotpCodeUsed: vi.fn(),
isPartialAuthTokenBlocked: vi.fn(() => false),
isPartialAuthTokenConsumed: vi.fn(() => false),
markPartialAuthTokenConsumed: vi.fn(),
recordPartialAuthFailure: vi.fn(),
}));
vi.mock("@/lib/server/modules/auth/totp", () => ({
generateTotpSecret: vi.fn((username: string) => ({
secret: "JBSWY3DPEHPK3PXP",
otpAuthUrl: `otpauth://totp/Homeio%3A${encodeURIComponent(username)}?secret=JBSWY3DPEHPK3PXP&issuer=Homeio&algorithm=SHA1&digits=6&period=30`,
})),
verifyTotp: vi.fn(),
verifyBackupCode: vi.fn(async () => ({ matchedHash: null })),
generateBackupCodes: vi.fn(async () => ({
plaintext: ["AAAAAAAAAA", "BBBBBBBBBB"],
hashes: ["salt-a:hash-a", "salt-b:hash-b"],
})),
}));
vi.mock("qrcode", () => ({
toString: vi.fn(async () => ''),
}));
import {
TotpServiceError,
beginTotpEnrollment,
completeTotpEnrollment,
completeTotpLogin,
disableTotp,
} from "@/lib/server/modules/auth/totp-service";
import {
clearTotpEnrollment as persistClearedTotp,
completeTotpEnrollment as persistEnrolledTotp,
createSession,
deleteSessionsForUser,
findUserWithTotpById,
setPendingTotpSecret,
updateTotpBackupCodes,
} from "@/lib/server/modules/auth/repository";
import { verifyPartialAuthToken } from "@/lib/server/modules/auth/partial-auth-token";
import { createSessionToken } from "@/lib/server/modules/auth/session-token";
import {
isPartialAuthTokenBlocked,
isPartialAuthTokenConsumed,
isTotpCodeReplayed,
markPartialAuthTokenConsumed,
markTotpCodeUsed,
recordPartialAuthFailure,
} from "@/lib/server/modules/auth/rate-limit";
import {
decryptSecret,
encryptSecret,
} from "@/lib/server/modules/auth/totp-crypto";
import {
verifyBackupCode,
verifyTotp,
} from "@/lib/server/modules/auth/totp";
const PENDING_USER = {
id: "user-1",
username: "admin",
passwordHash: "hash",
totpSecret: "enc:JBSWY3DPEHPK3PXP",
totpEnabled: false,
totpBackupCodes: null,
totpEnrolledAt: null,
};
const FRESH_USER = {
...PENDING_USER,
totpSecret: null,
};
const ENROLLED_USER = {
...PENDING_USER,
totpEnabled: true,
totpBackupCodes: "enc:" + JSON.stringify(["salt-a:hash-a", "salt-b:hash-b"]),
totpEnrolledAt: new Date("2026-05-20T00:00:00.000Z"),
};
beforeEach(() => {
vi.mocked(findUserWithTotpById).mockReset();
// CAS-style helpers return true by default so the happy path keeps working;
// tests covering CAS-loss override with mockResolvedValueOnce(false).
vi.mocked(setPendingTotpSecret).mockReset().mockResolvedValue(true);
vi.mocked(persistEnrolledTotp).mockReset().mockResolvedValue(true);
vi.mocked(persistClearedTotp).mockReset();
vi.mocked(updateTotpBackupCodes).mockReset().mockResolvedValue(true);
vi.mocked(createSession).mockReset();
vi.mocked(deleteSessionsForUser).mockReset();
vi.mocked(encryptSecret).mockClear();
vi.mocked(decryptSecret).mockClear();
vi.mocked(verifyTotp).mockReset();
vi.mocked(verifyBackupCode)
.mockReset()
.mockResolvedValue({ matchedHash: null });
vi.mocked(isTotpCodeReplayed).mockReset().mockReturnValue(false);
vi.mocked(markTotpCodeUsed).mockReset();
vi.mocked(verifyPartialAuthToken).mockReset();
vi.mocked(createSessionToken).mockClear();
vi.mocked(isPartialAuthTokenBlocked).mockReset().mockReturnValue(false);
vi.mocked(isPartialAuthTokenConsumed).mockReset().mockReturnValue(false);
vi.mocked(markPartialAuthTokenConsumed).mockReset();
vi.mocked(recordPartialAuthFailure).mockReset();
});
describe("beginTotpEnrollment", () => {
it("generates a secret, stores its ciphertext, and returns SVG + URL", async () => {
vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...FRESH_USER });
const result = await beginTotpEnrollment("user-1");
expect(result.secret).toMatch(/^[A-Z2-7]+$/);
expect(result.otpAuthUrl).toMatch(/^otpauth:\/\/totp\/Homeio%3Aadmin\?/);
expect(result.qrCodeSvg).toContain("