diff --git a/packages/firebase_ui_oauth_apple/lib/src/provider.dart b/packages/firebase_ui_oauth_apple/lib/src/provider.dart index fe5d28ee..e4e50a16 100644 --- a/packages/firebase_ui_oauth_apple/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_apple/lib/src/provider.dart @@ -56,9 +56,29 @@ class AppleProvider extends OAuthProvider { void mobileSignIn(AuthAction action) { authListener.onBeforeSignIn(); - if (action == AuthAction.link) { - auth.currentUser - ?.linkWithProvider(firebaseAuthProvider) + // Linking is also used to upgrade an anonymous user, so that the + // anonymous uid survives the sign in. + if (action == AuthAction.link || shouldUpgradeAnonymous) { + final currentUser = auth.currentUser; + + // Only AuthAction.link can reach this with no user, since + // shouldUpgradeAnonymous is false when currentUser is null. Reporting it + // matters because a null-shorting call would leave the flow stuck in its + // loading state with no error and no completion. + if (currentUser == null) { + authListener.onError( + fba.FirebaseAuthException( + code: 'no-current-user', + message: + 'AuthAction.link requires a signed in user to link the ' + 'Apple credential to, but FirebaseAuth.currentUser is null.', + ), + ); + return; + } + + currentUser + .linkWithProvider(firebaseAuthProvider) .then(_onLinked) .catchError(authListener.onError); return; @@ -97,6 +117,22 @@ class AppleProvider extends OAuthProvider { } void _onLinked(fba.UserCredential userCredential) { - authListener.onCredentialLinked(userCredential.credential!); + final credential = userCredential.credential; + + // Nullable on every platform, and a force unwrap here would throw inside + // .then, reaching onError as an Error and hanging the flow. + if (credential == null) { + authListener.onError( + fba.FirebaseAuthException( + code: 'missing-credential', + message: + 'The Apple account was linked, but Firebase returned no ' + 'credential for it.', + ), + ); + return; + } + + authListener.onCredentialLinked(credential); } } diff --git a/tests/integration_test/firebase_ui_oauth_apple/apple_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_apple/apple_sign_in_test.dart index fc232b3e..ca9c08f3 100644 --- a/tests/integration_test/firebase_ui_oauth_apple/apple_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_apple/apple_sign_in_test.dart @@ -17,7 +17,7 @@ import '../utils.dart'; void main() async { final provider = AppleProvider(); - late fba.FirebaseAuth auth; + late MockAuth auth; late MockProvider fbProvider; const labels = DefaultLocalizations(); @@ -95,6 +95,61 @@ void main() async { expect(user.displayName, 'Test User'); expect(user.email, 'test@test.com'); }); + + testWidgets('links the credential when the user is anonymous', ( + tester, + ) async { + final anonymousUser = MockAnonymousUser(); + auth.currentUserOverride = anonymousUser; + + // AuthAction.signIn is explicit: the flow would otherwise resolve to + // AuthAction.link on its own whenever currentUser is non-null, and the + // test would pass without shouldUpgradeAnonymous being consulted. + await render( + tester, + OAuthProviderButton( + provider: provider, + auth: auth, + action: AuthAction.signIn, + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + verify(anonymousUser.linkWithProvider(fbProvider)).called(1); + verifyNever(auth.signInWithProvider(fbProvider)); + }); + + testWidgets('reports an error when linking with no signed in user', ( + tester, + ) async { + final listener = MockListener(); + + await render( + tester, + AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton( + provider: provider, + auth: auth, + action: AuthAction.link, + ), + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isA()); + verifyNever(auth.signInWithProvider(fbProvider)); + }); }, skip: !provider.supportsPlatform(defaultTargetPlatform), ); @@ -124,11 +179,33 @@ class MockUser extends Mock implements fba.User { @override String? get email => 'test@test.com'; + + @override + bool get isAnonymous => false; } +class MockAnonymousUser extends Mock implements fba.User { + @override + bool get isAnonymous => true; + + @override + Future linkWithProvider(Object provider) async { + return super.noSuchMethod( + Invocation.method(#linkWithProvider, [provider]), + returnValue: Future.value(MockCredential()), + returnValueForMissingStub: Future.value(MockCredential()), + ); + } +} + +class MockAuthCredential extends Mock implements fba.AuthCredential {} + class MockCredential extends Mock implements fba.UserCredential { @override fba.User? get user => MockUser(); + + @override + fba.AuthCredential? get credential => MockAuthCredential(); } class MockProvider extends Mock implements fba.AppleAuthProvider {} @@ -136,6 +213,11 @@ class MockProvider extends Mock implements fba.AppleAuthProvider {} class MockApp extends Mock implements FirebaseApp {} class MockAuth extends Mock implements fba.FirebaseAuth { + fba.User? currentUserOverride; + + @override + fba.User? get currentUser => currentUserOverride; + @override Future signInWithProvider(Object provider) async { return super.noSuchMethod(