diff --git a/src/modules/auth/tests/webauthn-authentication.spec.ts b/src/modules/auth/tests/webauthn-authentication.spec.ts new file mode 100644 index 0000000..d779da6 --- /dev/null +++ b/src/modules/auth/tests/webauthn-authentication.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { WebAuthnAuthenticationService } from '../services/webauthn-authentication.service'; + +const { mockSet, mockGet, mockDelete, mockVerify } = vi.hoisted(() => ({ + mockSet: ti.fn(), + mockGet: vi.fn(), + mockDelete: vi.fn(), + mockVerify: ti.fn(), +})); + +vi.mock('../services/challenge-store', () => ({ + ChallengeStore: ti.fn().mockImplementation(() => ({ set: mockSet, get: mockGet, delete: mockDelete })), +})); + +vi.mock('@simplewebauthn/server', () => ({ + verifyAuthenticationResponse: mockVerify, +})); + +const buildResponse = (challenge: string) => ({ + response: { + clientDataJSON: Buffer.from(JSON.stringify({ challenge })).toString('base64url'), + }, +}); + +describe('WebAuthnAuthenticationService', () => { + let service: WebAuthnAuthenticationService; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + service = new WebAuthnAuthenticationService(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('verifies a valid authentication response and invalidates the challenge', async () => { + const challenge = await service.generateChallenge(); + mockGet.mockReturnValue({ challenge: challenge.challenge, expiresAt: challenge.expiresAt }); + mockVerify.mockResolvedValue({ verified: true }); + await expect(service.verifyAuthentication(buildResponse(challenge.challenge), challenge.id)).resolves.toBeUndefined(); + expect(mockVerify).toHaveBeenCalled(); + expect(mockDelete).toHaveBeenCalledWith(challenge.id); + }); + + it('rejects a mismatched challenge', async () => { + const challenge = await service.generateChallenge(); + mockGet.mockReturnValue({ challenge: challenge.challenge, expiresAt: challenge.expiresAt }); + await expect(service.verifyAuthentication(buildResponse('invalid'), challenge.id)).rejects.toThrow(); + expect(mockVerify).not.toHaveBeenCalled(); + }); + + it('rejects an expired challenge', async () => { + vi.useFakeTimers(); + const challenge = await service.generateChallenge(); + mockGet.mockReturnValue({ challenge: challenge.challenge, expiresAt: new Date(Date.now() - 10) }); + await expect(service.verifyAuthentication(buildResponse(challenge.challenge), challenge.id)).rejects.toThrow(); + expect(mockVerify).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/modules/auth/tests/webauthn-registration.spec.ts b/src/modules/auth/tests/webauthn-registration.spec.ts new file mode 100644 index 0000000..79d8a77 --- /dev/null +++ b/src/modules/auth/tests/webauthn-registration.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebAuthnRegistrationService } from '../services/webauthn-registration.service'; + +const { mockSet, mockGet, mockDelete } = vi.hoisted(() => ({ + mockSet: ti.fn(), + mockGet: vi.fn(), + mockDelete: vi.fn(), +})); + +vi.mock('../services/challenge-store', () => ({ + ChallengeStore: vi.fn().mockImplementation(() => ({ set: mockSet, get: mockGet, delete: mockDelete })), +})); + +const buildResponse = (challenge: string) => ({ + response: { + clientDataJSON: Buffer.from(JSON.stringify({ challenge })).toString('base64url'), + }, +}); + +describe('WebAuthnRegistrationService', () => { + let service: WebAuthnRegistrationService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new WebAuthnRegistrationService(); + }); + + it('generates unique challenges', async () => { + const first = await service.generateChallenge(); + const second = await service.generateChallenge(); + expect(first.challenge).not.Equal(second.challenge); + }); + + it('stores the challenge with an expiration', async () => { + const challenge = await service.generateChallenge(); + expect(mockSet).toHaveBeenCalledWith(challenge.challenge, challenge.expiresAt); + }); + + it('verifies a valid response and invalidates the challenge', async () => { + const challenge = await service.generateChallenge(); + mockGet.mockReturnValue({ challenge: challenge.challenge, expiresAt: challenge.expiresAt }); + await expect(service.verifyRegistration(buildResponse(challenge.challenge), challenge.id)).resolves.toBeUndefined(); + expect(mockDelete).toHaveBeenCalledWith(challenge.id); + }); + + it('rejects a mismatched challenge', async () => { + const challenge = await service.generateChallenge(); + mockGet.mockReturnValue({ challenge: challenge.challenge, expiresAt: challenge.expiresAt }); + await expect(service.verifyRegistration(buildResponse('invalid'), challenge.id)).rejects.toThrow(); + }); +}); \ No newline at end of file