import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Request, Response, Router } from "express"; import type { AuthManager } from "../../../utils/auth-manager.js"; const mocks = vi.hoisted(() => ({ verify: vi.fn(), user: vi.fn(), update: vi.fn(), manager: { authenticateWebAuthnUser: vi.fn(), authenticateOIDCUser: vi.fn(), isTrustedDevice: vi.fn(), generateJWTToken: vi.fn(), getSecureCookieOptions: vi.fn(), getClearCookieOptions: vi.fn(), }, })); vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => mocks.manager }, })); vi.mock("../../../utils/audit-logger.js", () => ({ logAudit: vi.fn(), getRequestMeta: () => ({ ipAddress: "", userAgent: "" }), })); vi.mock("../../../hosts/automation-events.js", () => ({ notifyAutomationInternalEvent: vi.fn(), })); vi.mock("@simplewebauthn/server", () => ({ generateAuthenticationOptions: vi .fn() .mockResolvedValue({ challenge: "challenge" }), verifyAuthenticationResponse: mocks.verify, generateRegistrationOptions: vi.fn(), verifyRegistrationResponse: vi.fn(), })); vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentUserRepository: () => ({ findById: mocks.user }), createCurrentUserAuthRepository: () => ({ listSecondFactors: async () => [], }), createCurrentSettingsRepository: () => ({ get: async () => null }), createCurrentWebauthnCredentialRepository: () => ({ findByCredentialId: vi.fn().mockResolvedValue({ id: 1, credentialId: "key", userId: "user", publicKey: "YQ", counter: 0, transports: "[]", }), updateAuthState: mocks.update, }), getCurrentSettingValue: vi.fn(), })); vi.mock("../../../utils/logger.js", () => { const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn(), debug: vi.fn(), }; return { authLogger: log, sshLogger: log, logger: log, databaseLogger: log }; }); vi.mock("../../../utils/user-agent-parser.js", () => ({ parseUserAgent: () => ({ type: "browser", deviceInfo: "test" }), generateDeviceFingerprint: () => "device", getDeviceId: () => "id", })); const { registerUserWebAuthnRoutes } = await import("../../../database/routes/user-webauthn-routes.js"); type Handler = (req: Request, res: Response) => Promise; const handlers = new Map(); const manager = mocks.manager; registerUserWebAuthnRoutes( { post: (path: string, ...callbacks: Handler[]) => handlers.set(path, callbacks.at(-1)!), get: vi.fn(), delete: vi.fn(), } as unknown as Router, { authenticateJWT: vi.fn(), authManager: manager as unknown as AuthManager, isNativeAppRequest: () => false, }, ); function response() { const res = { json: vi.fn(), status: vi.fn(), cookie: vi.fn() }; res.status.mockReturnValue(res); res.cookie.mockReturnValue(res); return res; } async function login(userVerification: string) { const options = response(); await handlers.get("/webauthn/authenticate/options")!( { body: { userVerification }, get: () => "https://termix.test", } as unknown as Request, options as unknown as Response, ); const challengeId = options.json.mock.calls[0][0].challengeId; const res = response(); await handlers.get("/webauthn/authenticate/verify")!( { body: { challengeId, response: { id: "key" }, rememberMe: true }, get: () => undefined, headers: {}, socket: {}, } as unknown as Request, res as unknown as Response, ); return res; } beforeEach(() => { vi.clearAllMocks(); manager.authenticateWebAuthnUser.mockResolvedValue(true); manager.authenticateOIDCUser.mockResolvedValue(true); manager.generateJWTToken.mockResolvedValue("token"); manager.getSecureCookieOptions.mockReturnValue({ httpOnly: true }); manager.getClearCookieOptions.mockReturnValue({}); mocks.user.mockResolvedValue({ id: "user", username: "alice", totpEnabled: true, }); manager.isTrustedDevice.mockResolvedValue(false); mocks.verify.mockResolvedValue({ verified: true, authenticationInfo: { userVerified: false, newCounter: 1 }, }); }); describe("WebAuthn second factor policy", () => { it.each(["discouraged", "preferred", "required"])( "does not let request preference %s bypass TOTP without verified UV", async (preference) => { const res = await login(preference); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ requires_totp: true, temp_token: "token" }), ); expect(manager.generateJWTToken).toHaveBeenCalledWith("user", { pendingTOTP: true, expiresIn: "10m", }); expect(res.cookie).not.toHaveBeenCalled(); }, ); it("allows verified user verification to satisfy TOTP", async () => { mocks.verify.mockResolvedValue({ verified: true, authenticationInfo: { userVerified: true, newCounter: 1 }, }); const res = await login("preferred"); expect(res.cookie).toHaveBeenCalledWith("jwt", "token", { httpOnly: true }); expect(manager.isTrustedDevice).not.toHaveBeenCalled(); }); it("preserves trusted-device exemption", async () => { manager.isTrustedDevice.mockResolvedValue(true); const res = await login("discouraged"); expect(manager.isTrustedDevice).toHaveBeenCalledWith("user", "device"); expect(res.cookie).toHaveBeenCalled(); }); it("never creates a session for an invalid assertion", async () => { mocks.verify.mockResolvedValue({ verified: false }); const res = await login("preferred"); expect(res.status).toHaveBeenCalledWith(401); expect(manager.generateJWTToken).not.toHaveBeenCalled(); expect(res.cookie).not.toHaveBeenCalled(); }); it("allows users without TOTP to log in with a possession-only key", async () => { mocks.user.mockResolvedValue({ id: "user", username: "alice", totpEnabled: false, }); expect((await login("discouraged")).cookie).toHaveBeenCalled(); }); });