로그인과 계정 연결의 오류 안내 문구 분리
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 연동에서 '
|
||||
'이 수단을 연결해 주세요.';
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user