@@ -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
0 commit comments