Skip to content

Commit 0e59843

Browse files
committed
add unit tests
1 parent e28a26f commit 0e59843

File tree

3 files changed

+244
-0
lines changed

3 files changed

+244
-0
lines changed

Tests/AuthenticatorTests/Mocks/MockAuthenticationService.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,26 @@ class MockAuthenticationService: AuthenticationService {
1414
// MARK: - Sign In
1515

1616
var signInCount = 0
17+
var signInUsername: String?
18+
var signInPassword: String?
19+
var signInOptions: AuthSignInRequest.Options?
1720
var mockedSignInResult: AuthSignInResult?
21+
var mockedSignInError: Error?
22+
var signInHandler: ((String?, String?, AuthSignInRequest.Options?) throws -> AuthSignInResult)?
1823
func signIn(username: String?, password: String?, options: AuthSignInRequest.Options?) async throws -> AuthSignInResult {
1924
signInCount += 1
25+
signInUsername = username
26+
signInPassword = password
27+
signInOptions = options
28+
29+
if let mockedSignInError = mockedSignInError {
30+
throw mockedSignInError
31+
}
32+
33+
if let signInHandler = signInHandler {
34+
return try signInHandler(username, password, options)
35+
}
36+
2037
if let mockedSignInResult = mockedSignInResult {
2138
return mockedSignInResult
2239
}

Tests/AuthenticatorTests/States/SignInSelectAuthFactorStateTests.swift

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class SignInSelectAuthFactorStateTests: XCTestCase {
117117
XCTAssertEqual(authenticatorState.setCurrentStepCount, 1)
118118
}
119119

120+
@MainActor
120121
@available(iOS 17.4, macOS 13.5, visionOS 1.0, *)
121122
func testSelectAuthFactor_withWebAuthn_shouldInitiateWebAuthn() async throws {
122123
// Given
@@ -287,6 +288,193 @@ class SignInSelectAuthFactorStateTests: XCTestCase {
287288
state.selectedAuthFactor = .password(srp: true)
288289
XCTAssertEqual(state.selectedAuthFactor, .password(srp: true))
289290
}
291+
292+
// MARK: - Auth Factor Re-selection Tests (Flow Restart)
293+
294+
@MainActor
295+
func testSelectAuthFactor_firstTimeSelection_shouldUseConfirmSignIn() async throws {
296+
// Given - No previous selection (credentials.selectedAuthFactor is nil)
297+
state.selectedAuthFactor = .emailOtp
298+
XCTAssertNil(state.credentials.selectedAuthFactor, "Should start with no previous selection")
299+
300+
// Mock OTP sending
301+
authenticationService.mockedConfirmSignInResult = AuthSignInResult(
302+
nextStep: .confirmSignInWithOTP(.init(destination: .email("[email protected]")))
303+
)
304+
305+
// When
306+
try await state.selectAuthFactor()
307+
308+
// Then - Should use confirmSignIn (not signIn)
309+
XCTAssertEqual(authenticationService.confirmSignInCount, 1, "Should call confirmSignIn for first-time selection")
310+
XCTAssertEqual(authenticationService.signInCount, 0, "Should NOT call signIn for first-time selection")
311+
312+
// And - Should track the selection
313+
XCTAssertEqual(state.credentials.selectedAuthFactor, .emailOtp)
314+
}
315+
316+
@MainActor
317+
func testSelectAuthFactor_reselection_shouldRestartSignInFlow() async throws {
318+
// Given - User has already selected an auth factor previously
319+
state.credentials.username = "testuser"
320+
state.credentials.selectedAuthFactor = .webAuthn // Previous selection
321+
state.selectedAuthFactor = .emailOtp // New selection
322+
323+
// Mock the restart flow - signIn should return factor selection again, then OTP
324+
authenticationService.mockedSignInResult = AuthSignInResult(
325+
nextStep: .confirmSignInWithOTP(.init(destination: .email("[email protected]")))
326+
)
327+
328+
// When
329+
try await state.selectAuthFactor()
330+
331+
// Then - Should call signIn (restart flow) instead of confirmSignIn
332+
XCTAssertEqual(authenticationService.signInCount, 1, "Should call signIn to restart flow")
333+
XCTAssertEqual(authenticationService.confirmSignInCount, 0, "Should NOT call confirmSignIn for re-selection")
334+
335+
// And - Should update the tracked selection
336+
XCTAssertEqual(state.credentials.selectedAuthFactor, .emailOtp)
337+
}
338+
339+
@MainActor
340+
func testSelectAuthFactor_reselectionWithPassword_shouldIncludePassword() async throws {
341+
// Given - User previously selected webAuthn, now selecting password
342+
state.credentials.username = "testuser"
343+
state.credentials.selectedAuthFactor = .webAuthn // Previous selection
344+
state.selectedAuthFactor = .password(srp: true) // New selection
345+
state.password = "mypassword123"
346+
347+
// Mock the restart flow with password - should go through 2-step flow
348+
authenticationService.mockedSignInResult = AuthSignInResult(nextStep: .done)
349+
authenticationService.mockedCurrentUser = MockAuthenticationService.User(
350+
username: "testuser",
351+
userId: "user-123"
352+
)
353+
authenticationService.mockedUnverifiedAttributes = []
354+
355+
// When
356+
try await state.selectAuthFactor()
357+
358+
// Then - Should call signIn with password
359+
XCTAssertEqual(authenticationService.signInCount, 1, "Should call signIn to restart flow")
360+
XCTAssertEqual(authenticationService.signInUsername, "testuser")
361+
XCTAssertEqual(authenticationService.signInPassword, "mypassword123", "Should include password in restart")
362+
363+
// And - Should update credentials
364+
XCTAssertEqual(state.credentials.password, "mypassword123")
365+
XCTAssertEqual(state.credentials.selectedAuthFactor, .password(srp: true))
366+
}
367+
368+
@MainActor
369+
func testSelectAuthFactor_reselectionWithNonPassword_shouldNotIncludePassword() async throws {
370+
// Given - User previously selected password, now selecting SMS OTP
371+
state.credentials.username = "testuser"
372+
state.credentials.selectedAuthFactor = .password(srp: true) // Previous selection
373+
state.selectedAuthFactor = .smsOtp // New selection
374+
state.password = "leftoverpassword" // Should not be sent
375+
376+
// Mock the restart flow
377+
authenticationService.mockedSignInResult = AuthSignInResult(
378+
nextStep: .confirmSignInWithOTP(.init(destination: .phone("+1234567890")))
379+
)
380+
381+
// When
382+
try await state.selectAuthFactor()
383+
384+
// Then - Should call signIn without password
385+
XCTAssertEqual(authenticationService.signInCount, 1)
386+
XCTAssertEqual(authenticationService.signInUsername, "testuser")
387+
XCTAssertNil(authenticationService.signInPassword, "Should NOT include password for non-password factor")
388+
}
389+
390+
@MainActor
391+
func testSelectAuthFactor_cancelPasskeyThenSelectEmail_shouldWork() async throws {
392+
// This is the critical bug scenario from the Android PR
393+
// Given - User selected webAuthn (passkey) and it was tracked
394+
state.credentials.username = "testuser"
395+
state.credentials.selectedAuthFactor = .webAuthn // Simulates previous passkey selection (then cancel)
396+
state.selectedAuthFactor = .emailOtp // User now wants email
397+
398+
// Mock successful restart with email OTP
399+
authenticationService.mockedSignInResult = AuthSignInResult(
400+
nextStep: .confirmSignInWithOTP(.init(destination: .email("[email protected]")))
401+
)
402+
403+
// When
404+
try await state.selectAuthFactor()
405+
406+
// Then - Should successfully restart and transition to OTP confirmation
407+
XCTAssertEqual(authenticationService.signInCount, 1, "Should restart sign-in flow")
408+
XCTAssertEqual(authenticatorState.setCurrentStepCount, 1)
409+
410+
if case .confirmSignInWithOTP = authenticatorState.setCurrentStepValue {
411+
// Success - correct step
412+
} else {
413+
XCTFail("Expected to transition to .confirmSignInWithOTP step")
414+
}
415+
}
416+
417+
@MainActor
418+
func testSelectAuthFactor_multipleReselections_shouldAlwaysRestartFlow() async throws {
419+
// Given - Simulate multiple re-selections
420+
state.credentials.username = "testuser"
421+
422+
// First selection (no previous)
423+
state.selectedAuthFactor = .emailOtp
424+
authenticationService.mockedConfirmSignInResult = AuthSignInResult(
425+
nextStep: .confirmSignInWithOTP(.init(destination: .email("[email protected]")))
426+
)
427+
428+
try await state.selectAuthFactor()
429+
XCTAssertEqual(authenticationService.confirmSignInCount, 1, "First selection uses confirmSignIn")
430+
XCTAssertEqual(authenticationService.signInCount, 0)
431+
432+
// Reset mock counts
433+
authenticationService.confirmSignInCount = 0
434+
435+
// Second selection (re-selection)
436+
state.selectedAuthFactor = .smsOtp
437+
authenticationService.mockedSignInResult = AuthSignInResult(
438+
nextStep: .confirmSignInWithOTP(.init(destination: .phone("+1234567890")))
439+
)
440+
441+
try await state.selectAuthFactor()
442+
XCTAssertEqual(authenticationService.signInCount, 1, "Second selection restarts flow")
443+
XCTAssertEqual(authenticationService.confirmSignInCount, 0, "Should not use confirmSignIn")
444+
445+
// Third selection (another re-selection)
446+
state.selectedAuthFactor = .emailOtp
447+
authenticationService.signInCount = 0
448+
authenticationService.mockedSignInResult = AuthSignInResult(
449+
nextStep: .confirmSignInWithOTP(.init(destination: .email("[email protected]")))
450+
)
451+
452+
try await state.selectAuthFactor()
453+
XCTAssertEqual(authenticationService.signInCount, 1, "Third selection also restarts flow")
454+
}
455+
456+
@MainActor
457+
func testSelectAuthFactor_reselectionWithError_shouldSetErrorMessage() async throws {
458+
// Given - User re-selecting after previous selection
459+
state.credentials.username = "testuser"
460+
state.credentials.selectedAuthFactor = .webAuthn
461+
state.selectedAuthFactor = .emailOtp
462+
463+
// Mock error on restart
464+
authenticationService.mockedSignInError = AuthError.notAuthorized(
465+
"Session expired",
466+
"Please try again"
467+
)
468+
469+
// When/Then
470+
do {
471+
try await state.selectAuthFactor()
472+
XCTFail("Should throw error")
473+
} catch {
474+
XCTAssertEqual(authenticationService.signInCount, 1)
475+
// Error should be thrown and handled
476+
}
477+
}
290478
}
291479

292480
// MARK: - AuthFactor Helper Tests

Tests/AuthenticatorTests/States/SignInStateTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,43 @@ class SignInStateTests: XCTestCase {
226226
XCTAssertEqual(authenticatorState.moveToCount, 1)
227227
XCTAssertEqual(authenticatorState.moveToValue, .signUp)
228228
}
229+
230+
// MARK: - Selected Auth Factor Reset Tests
231+
232+
@MainActor
233+
func testSignIn_shouldResetSelectedAuthFactor() async throws {
234+
// Given - credentials has a previously selected auth factor
235+
state.credentials.selectedAuthFactor = .emailOtp
236+
state.username = "testuser"
237+
238+
authenticationService.mockedSignInResult = .init(nextStep: .done)
239+
authenticationService.mockedCurrentUser = MockAuthenticationService.User(
240+
username: "testuser",
241+
userId: "userId"
242+
)
243+
244+
// When
245+
try await state.signIn()
246+
247+
// Then - selectedAuthFactor should be reset to nil
248+
XCTAssertNil(state.credentials.selectedAuthFactor, "selectedAuthFactor should be reset on new sign-in")
249+
}
250+
251+
@MainActor
252+
func testSignIn_withPreviousWebAuthnSelection_shouldResetForFreshFlow() async throws {
253+
// Given - User previously selected webAuthn (simulating cancel scenario)
254+
state.credentials.selectedAuthFactor = .webAuthn
255+
state.username = "testuser"
256+
257+
// Mock factor selection step (user will need to select again)
258+
authenticationService.mockedSignInResult = .init(
259+
nextStep: .continueSignInWithFirstFactorSelection([.emailOTP, .smsOTP, .passwordSRP])
260+
)
261+
262+
// When
263+
try await state.signIn()
264+
265+
// Then - selectedAuthFactor should be reset so first selection uses confirmSignIn
266+
XCTAssertNil(state.credentials.selectedAuthFactor, "selectedAuthFactor should be reset for fresh flow")
267+
}
229268
}

0 commit comments

Comments
 (0)