diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index 51b1194..885b3dc 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -83,7 +83,7 @@ final class FirebaseAccountLinker implements AccountLinker { final result = await user.linkWithCredential(obtained.credential); return AuthSucceeded(_accountOf(result.user, provider)); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error)); + return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.link)); } on Object catch (error) { // SDK 가 문서화하지 않은 플랫폼 예외를 던져도 화면이 busy 에 고착되지 않게 닫는다. debugPrint('[FirebaseAccountLinker] link failed: ${error.runtimeType}'); @@ -110,7 +110,7 @@ final class FirebaseAccountLinker implements AccountLinker { await user.unlink(id); return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider)); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error)); + return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.link)); } } diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index dd120f2..e77b5ae 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -83,7 +83,7 @@ final class GoogleSignInProvider } return AuthFailed('E-AUTH-03', error.description ?? error.code.name); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error)); + return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn)); } } @@ -179,7 +179,7 @@ final class AppleSignInProvider debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}'); return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error)); + return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn)); } } @@ -299,7 +299,7 @@ final class EmailPasswordProvider implements EmailAuthProvider { await fb.FirebaseAuth.instance.sendPasswordResetEmail(email: email.trim()); return const AuthCancelled(); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error)); + return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn)); } } @@ -310,7 +310,7 @@ final class EmailPasswordProvider implements EmailAuthProvider { final result = await action(); return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error)); + return AuthFailed(authCatalogCode(error), authMessage(error, flow: AuthFlow.signIn)); } } @@ -348,16 +348,60 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { _ => 'E-AUTH-01', }; -String authMessage(fb.FirebaseAuthException error) => switch (error.code) { +/// 오류 문구가 갈리는 경로. +/// +/// 같은 SDK 코드가 경로에 따라 **다른 사실**을 뜻하므로 호출부가 어느 경로인지 +/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지 +/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다. +enum AuthFlow { + /// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`). + signIn, + + /// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로 + /// (`linkWithCredential` · `unlink`) — 별첨 B v1.4. + link, +} + +/// 사용자에게 띄우는 문구. +/// +/// `account-exists-with-different-credential` 이 경로에 따라 뒤집히는 대표 코드다. +/// **로그인** 경로에서는 「같은 이메일의 계정이 다른 제공자로 가입돼 있다」이므로 +/// 그 제공자로 들어가 달라고 안내해야 하고, **계정 연결** 경로에서는 「이 소셜 +/// 계정이 다른 FANUZU 계정에 이미 붙어 있다」다. 한 벌로 적으면 로그인에 실패한 +/// 사용자가 자기가 하지도 않은 계정 연결을 설명받고, 무엇을 해야 하는지 모른다. +String authMessage( + fb.FirebaseAuthException error, { + required AuthFlow flow, +}) => switch (error.code) { 'email-already-in-use' => '이미 가입된 이메일입니다.', 'weak-password' => '비밀번호가 너무 짧습니다. 8자 이상으로 정해 주세요.', 'invalid-email' => '이메일 형식이 올바르지 않습니다.', 'too-many-requests' => '잠시 후 다시 시도해 주세요.', - 'credential-already-in-use' || 'account-exists-with-different-credential' => - '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + 'account-exists-with-different-credential' => switch (flow) { + AuthFlow.signIn => _otherProviderSignInGuide(error.email), + AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + }, + // 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다. + 'credential-already-in-use' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', 'provider-already-linked' => '이미 연결된 계정입니다.', 'no-such-provider' => '연결되어 있지 않은 계정입니다.', 'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.', 'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', _ => '이메일 또는 비밀번호를 확인해 주세요.', }; + +/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내. +/// +/// [email] 은 오류 응답의 힌트다(`FirebaseAuthException.email`). 사용자가 방금 +/// 소셜 인증을 통과해서 받은 **자기 이메일**이라 되돌려 주는 것이 계정 열거가 +/// 아니다. 이걸 빼면 계정이 여러 개인 사용자가 어느 계정으로 들어가야 하는지 +/// 알 수 없다. +/// +/// ponytail: **기존 제공자 이름은 오류에 없다.** `error.credential` 은 이번에 +/// 시도한 자격증명이고, 기존 제공자를 알려면 `fetchSignInMethodsForEmail` 이 +/// 필요한데 이메일 열거 방지가 켜지면 빈 목록을 돌려준다. 이름까지 띄워야 하면 +/// 서버에서 Admin SDK 로 조회해 내려주는 쪽으로 올린다. +String _otherProviderSignInGuide(String? email) => + '${email == null ? '이 계정' : email}은 다른 로그인 수단으로 가입돼 있습니다. ' + '처음 가입에 쓰신 수단으로 로그인한 뒤 MY > 설정 > SNS 연동에서 ' + '이 수단을 연결해 주세요.'; diff --git a/app/test/auth_error_message_test.dart b/app/test/auth_error_message_test.dart new file mode 100644 index 0000000..6862d64 --- /dev/null +++ b/app/test/auth_error_message_test.dart @@ -0,0 +1,103 @@ +import 'package:fancake/features/auth/social_providers.dart'; +import 'package:firebase_auth/firebase_auth.dart' as fb; +import 'package:flutter_test/flutter_test.dart'; + +/// 인증 오류 문구가 **경로별로** 맞는 사실을 말하는지 고정한다. +/// +/// `account-exists-with-different-credential` 은 로그인 경로와 계정 연결 경로에서 +/// 서로 다른 사실을 뜻한다(별첨 B v1.4 「복수 소셜 Provider 연결 허용」). +/// 한 벌로 합쳐 두면 로그인에 실패한 사용자가 자기가 하지도 않은 계정 연결을 +/// 설명받고 무엇을 해야 하는지 알 수 없으므로, 회귀 가드를 둔다. +void main() { + group('account-exists-with-different-credential — 경로별 문구', () { + test('로그인 경로는 「다른 수단으로 로그인한 뒤 연결」을 안내한다', () { + final message = authMessage( + _exception('account-exists-with-different-credential', + email: 'fan@gmail.com'), + flow: AuthFlow.signIn, + ); + + // 오류 응답의 이메일 힌트를 그대로 넣어야 어느 계정인지 알 수 있다. + expect(message, contains('fan@gmail.com')); + expect(message, contains('처음 가입에 쓰신 수단으로 로그인')); + expect(message, contains('SNS 연동')); + // 로그인 경로에서는 「연결된 소셜 계정」이 사실이 아니다. + expect(message, isNot(contains('이미 연결된 소셜 계정'))); + }); + + test('계정 연결 경로는 「다른 FANUZU 계정에 붙어 있다」로 남는다', () { + final message = authMessage( + _exception('account-exists-with-different-credential', + email: 'fan@gmail.com'), + flow: AuthFlow.link, + ); + + expect(message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.'); + }); + + test('두 경로의 문구가 서로 다르다', () { + final error = _exception('account-exists-with-different-credential', + email: 'fan@gmail.com'); + + expect( + authMessage(error, flow: AuthFlow.signIn), + isNot(authMessage(error, flow: AuthFlow.link)), + ); + }); + + test('이메일 힌트가 없으면 이메일 없이 같은 안내를 한다', () { + // Apple 은 최초 인가 때만 이메일을 준다 — 두 번째부터 힌트가 없다. + final message = authMessage( + _exception('account-exists-with-different-credential'), + flow: AuthFlow.signIn, + ); + + expect(message, startsWith('이 계정은')); + expect(message, contains('처음 가입에 쓰신 수단으로 로그인')); + expect(message, isNot(contains('null'))); + }); + + test('에러 카탈로그 ID 는 경로와 무관하게 E-AUTH-08 이다', () { + // 문구만 갈랐다. 카탈로그를 손대면 Admin·QA 의 코드 대조가 어긋난다. + expect( + authCatalogCode(_exception('account-exists-with-different-credential')), + 'E-AUTH-08', + ); + }); + }); + + group('경로와 무관한 코드는 한 문구를 유지한다', () { + test('credential-already-in-use 는 양쪽이 같다', () { + final error = _exception('credential-already-in-use'); + + expect( + authMessage(error, flow: AuthFlow.signIn), + '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + ); + expect( + authMessage(error, flow: AuthFlow.link), + authMessage(error, flow: AuthFlow.signIn), + ); + }); + + test('계정 존재 여부를 갈라 주지 않는다 — 열거 방지', () { + // 「등록되지 않은 이메일」과 「비밀번호 오류」가 같은 문구로 나가야 한다. + for (final code in ['user-not-found', 'wrong-password']) { + expect( + authMessage(_exception(code), flow: AuthFlow.signIn), + '이메일 또는 비밀번호를 확인해 주세요.', + reason: code, + ); + } + }); + }); +} + +/// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면 +/// 분석 경고가 난다. 하위 클래스에서 부르는 것은 허용되므로 테스트용으로 뚫는다. +final class _FakeAuthException extends fb.FirebaseAuthException { + _FakeAuthException({required super.code, super.email}); +} + +fb.FirebaseAuthException _exception(String code, {String? email}) => + _FakeAuthException(code: code, email: email);