소셜 웹 로그인 중단·실패를 이메일 오류 대신 소셜 로그인 문구로 안내 · 네트워크 오류 안내 · Android 중단된 웹 로그인 정리 후 재시도
This commit is contained in:
@@ -104,3 +104,9 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// MainActivity 의 「버려진 Firebase 웹 로그인 정리」(kr.co.fanuzu.app/auth) — firebase-auth 가 이미 쓰는
|
||||||
|
// 라이브러리라 새로 들어오는 것은 없다. 앱 모듈에서 직접 부르려고 컴파일 경로에 올린다.
|
||||||
|
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
package kr.co.fanuzu.app
|
package kr.co.fanuzu.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
// 버려진 Firebase 웹 로그인(LINE · Apple OIDC) 정리 — Dart `clearStaleWebSignIn`.
|
||||||
|
// 인가 중 다른 앱(LINE 본인 확인 등)으로 갔다 돌아오면 Firebase 의 대기 작업이 결과 없이 남아,
|
||||||
|
// 다음 시도가 `web-context-already-presented` 로 막힌다. Firebase 의 인가 화면이 「닫힘」을 알릴 때
|
||||||
|
// 보내는 것과 같은 취소 신호를 보내 대기 작업을 취소로 끝내고 다음 시도를 연다. 대기 작업이 없으면 아무 일도 없다.
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/auth")
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method != "cancelPendingWebSignIn") {
|
||||||
|
result.notImplemented()
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
|
||||||
|
Intent("com.google.firebase.auth.ACTION_RECEIVE_FIREBASE_AUTH_INTENT")
|
||||||
|
.putExtra("com.google.firebase.auth.internal.EXTRA_CANCELED", true)
|
||||||
|
)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
if (isWebFlowCancelled(error)) return const AuthCancelled();
|
if (isWebFlowCancelled(error)) return const AuthCancelled();
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.link),
|
||||||
authMessage(error, flow: AuthFlow.link),
|
authMessage(error, flow: AuthFlow.link),
|
||||||
);
|
);
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
@@ -108,7 +108,7 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
|
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.link),
|
||||||
authMessage(error, flow: AuthFlow.link),
|
authMessage(error, flow: AuthFlow.link),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,8 +116,8 @@ final class FirebaseAccountLinker implements AccountLinker {
|
|||||||
|
|
||||||
/// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
/// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다.
|
||||||
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
|
||||||
final result = await untilAbandoned(
|
final result = await retryStaleWebContext(
|
||||||
user.linkWithProvider(impl.authProvider),
|
() => untilAbandoned(user.linkWithProvider(impl.authProvider)),
|
||||||
);
|
);
|
||||||
return AuthSucceeded(_accountOf(result.user, impl.provider));
|
return AuthSucceeded(_accountOf(result.user, impl.provider));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,9 +369,11 @@ const _names = {
|
|||||||
AuthProviderKind.email: '이메일로',
|
AuthProviderKind.email: '이메일로',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// 로그인 결과 안내. 취소 · 중단도 안내한다 — 로그인 성공으로 처리하지 않고 다시 시도하게 한다.
|
||||||
|
/// 아무 말 없이 화면이 그대로면 사용자는 앱이 멈춘 줄 안다.
|
||||||
String? authNotice(AuthOutcome outcome) => switch (outcome) {
|
String? authNotice(AuthOutcome outcome) => switch (outcome) {
|
||||||
AuthSucceeded() => null,
|
AuthSucceeded() => null,
|
||||||
AuthCancelled() => null,
|
AuthCancelled() => '로그인이 완료되지 않았습니다. 다시 시도해 주세요.',
|
||||||
AuthFailed(:final code, :final message) => '[$code] $message',
|
AuthFailed(:final code, :final message) => '[$code] $message',
|
||||||
AuthNotConfigured(:final provider, :final missing) =>
|
AuthNotConfigured(:final provider, :final missing) =>
|
||||||
'${provider.shortName} 로그인은 아직 준비 중입니다.\n'
|
'${provider.shortName} 로그인은 아직 준비 중입니다.\n'
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ final class GoogleSignInProvider
|
|||||||
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
|
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.signIn),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ final class AppleSignInProvider
|
|||||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.signIn),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -271,16 +271,18 @@ final class OidcSignInProvider implements SocialAuthProvider {
|
|||||||
final missing = missingConfig;
|
final missing = missingConfig;
|
||||||
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
|
||||||
try {
|
try {
|
||||||
final result = await untilAbandoned(
|
final result = await retryStaleWebContext(
|
||||||
|
() => untilAbandoned(
|
||||||
(firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider(
|
(firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider(
|
||||||
authProvider,
|
authProvider,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return AuthSucceeded(_toAccount(result.user, provider));
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
if (isWebFlowCancelled(error)) return const AuthCancelled();
|
if (isWebFlowCancelled(error)) return const AuthCancelled();
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.signIn),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.signIn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -417,7 +419,10 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
|||||||
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
_ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.',
|
||||||
});
|
});
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow));
|
return AuthFailed(
|
||||||
|
authCatalogCode(error, flow: flow),
|
||||||
|
authMessage(error, flow: flow),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,9 +440,39 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
|
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
|
||||||
|
/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름).
|
||||||
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
|
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
|
||||||
error.code == 'web-context-canceled' ||
|
error.code == 'web-context-canceled' ||
|
||||||
error.code == 'web-context-cancelled';
|
error.code == 'web-context-cancelled' ||
|
||||||
|
error.code == 'user-cancelled';
|
||||||
|
|
||||||
|
const _authChannel = MethodChannel('kr.co.fanuzu.app/auth');
|
||||||
|
|
||||||
|
/// Android — 결과 없이 남은 Firebase 웹 인가 대기 작업을 취소로 끝낸다(MainActivity `cancelPendingWebSignIn`).
|
||||||
|
///
|
||||||
|
/// LINE 로그인 도중 본인 확인을 하러 LINE 앱으로 갔다 돌아오는 식으로 흐름이 끊기면, Firebase 는 대기 작업을
|
||||||
|
/// 들고 있어 다음 시도를 `web-context-already-presented` 로 막는다(다시 눌러도 로그인이 안 됨).
|
||||||
|
/// 대기 작업이 없으면 아무 일도 하지 않는다. iOS · 웹은 SDK 가 스스로 정리한다.
|
||||||
|
Future<void> clearStaleWebSignIn() async {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
|
||||||
|
try {
|
||||||
|
await _authChannel.invokeMethod<void>('cancelPendingWebSignIn');
|
||||||
|
} on Object catch (error) {
|
||||||
|
debugPrint('[Auth] clear stale web sign-in skipped: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 웹 인가가 앞선 흐름에 막혔으면(`web-context-already-presented`) 남은 대기를 정리하고 **한 번만** 다시 연다.
|
||||||
|
/// 취소 · 실패 뒤에도 정상적으로 다시 시도할 수 있어야 한다.
|
||||||
|
Future<T> retryStaleWebContext<T>(Future<T> Function() attempt) async {
|
||||||
|
try {
|
||||||
|
return await attempt();
|
||||||
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
|
if (error.code != 'web-context-already-presented') rethrow;
|
||||||
|
await clearStaleWebSignIn();
|
||||||
|
return attempt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다.
|
/// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다.
|
||||||
///
|
///
|
||||||
@@ -462,6 +497,8 @@ Future<T> untilAbandoned<T>(
|
|||||||
if (state != AppLifecycleState.resumed) return;
|
if (state != AppLifecycleState.resumed) return;
|
||||||
timer = Timer(grace, () {
|
timer = Timer(grace, () {
|
||||||
if (done.isCompleted) return;
|
if (done.isCompleted) return;
|
||||||
|
// Firebase 쪽 대기 작업도 끝낸다 — 남기면 다음 시도가 web-context-already-presented 로 막힌다.
|
||||||
|
unawaited(clearStaleWebSignIn());
|
||||||
done.completeError(
|
done.completeError(
|
||||||
fb.FirebaseAuthException(code: 'web-context-canceled'),
|
fb.FirebaseAuthException(code: 'web-context-canceled'),
|
||||||
);
|
);
|
||||||
@@ -550,8 +587,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
|
|||||||
return const AuthCancelled();
|
return const AuthCancelled();
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.email),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.email),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,8 +601,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
|
|||||||
return AuthSucceeded(_toAccount(result.user, provider));
|
return AuthSucceeded(_toAccount(result.user, provider));
|
||||||
} on fb.FirebaseAuthException catch (error) {
|
} on fb.FirebaseAuthException catch (error) {
|
||||||
return AuthFailed(
|
return AuthFailed(
|
||||||
authCatalogCode(error),
|
authCatalogCode(error, flow: AuthFlow.email),
|
||||||
authMessage(error, flow: AuthFlow.signIn),
|
authMessage(error, flow: AuthFlow.email),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,7 +617,14 @@ final class EmailPasswordProvider implements EmailAuthProvider {
|
|||||||
///
|
///
|
||||||
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
|
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
|
||||||
/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다.
|
/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다.
|
||||||
String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
|
///
|
||||||
|
/// 알 수 없는 코드는 경로별로 갈린다 — 이메일 폼([AuthFlow.email])은 E-AUTH-01, 소셜 로그인 · 연결은
|
||||||
|
/// 소셜 실패 E-AUTH-03 이다. 하나로 E-AUTH-01 이면 LINE 웹 로그인이 끊길 때(`web-*`)
|
||||||
|
/// 「이메일 또는 비밀번호를 확인해 주세요」가 뜬다.
|
||||||
|
String authCatalogCode(
|
||||||
|
fb.FirebaseAuthException error, {
|
||||||
|
required AuthFlow flow,
|
||||||
|
}) => switch (error.code) {
|
||||||
'email-already-in-use' => 'E-AUTH-04',
|
'email-already-in-use' => 'E-AUTH-04',
|
||||||
'weak-password' => 'E-AUTH-05',
|
'weak-password' => 'E-AUTH-05',
|
||||||
'invalid-email' => 'E-AUTH-06',
|
'invalid-email' => 'E-AUTH-06',
|
||||||
@@ -600,7 +644,7 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
|
|||||||
'invalid-user-token' ||
|
'invalid-user-token' ||
|
||||||
'user-mismatch' ||
|
'user-mismatch' ||
|
||||||
'user-disabled' => 'E-AUTH-12',
|
'user-disabled' => 'E-AUTH-12',
|
||||||
_ => 'E-AUTH-01',
|
_ => flow == AuthFlow.email ? 'E-AUTH-01' : 'E-AUTH-03',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 오류 문구가 갈리는 경로.
|
/// 오류 문구가 갈리는 경로.
|
||||||
@@ -609,9 +653,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
|
|||||||
/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지
|
/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지
|
||||||
/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다.
|
/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다.
|
||||||
enum AuthFlow {
|
enum AuthFlow {
|
||||||
/// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`).
|
/// 소셜 로그인 경로(`signInWithCredential` · `signInWithProvider`).
|
||||||
signIn,
|
signIn,
|
||||||
|
|
||||||
|
/// 이메일 · 비밀번호 폼(QA 내부 경로). 알 수 없는 실패를 「이메일 또는 비밀번호」로 안내하는 유일한 경로다.
|
||||||
|
email,
|
||||||
|
|
||||||
/// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로
|
/// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로
|
||||||
/// (`linkWithCredential` · `unlink`) — 별첨 B v1.4.
|
/// (`linkWithCredential` · `unlink`) — 별첨 B v1.4.
|
||||||
link,
|
link,
|
||||||
@@ -631,7 +678,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
|||||||
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
'invalid-email' => '이메일 형식이 올바르지 않습니다.',
|
||||||
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
'too-many-requests' => '잠시 후 다시 시도해 주세요.',
|
||||||
'account-exists-with-different-credential' => switch (flow) {
|
'account-exists-with-different-credential' => switch (flow) {
|
||||||
AuthFlow.signIn => _otherProviderSignInGuide(error.email),
|
AuthFlow.signIn || AuthFlow.email =>
|
||||||
|
_otherProviderSignInGuide(error.email),
|
||||||
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.',
|
||||||
},
|
},
|
||||||
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
|
||||||
@@ -644,7 +692,15 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
|
|||||||
'invalid-user-token' ||
|
'invalid-user-token' ||
|
||||||
'user-mismatch' ||
|
'user-mismatch' ||
|
||||||
'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.',
|
||||||
_ => '이메일 또는 비밀번호를 확인해 주세요.',
|
// 웹 인가 흐름이 끊기거나 망이 끊겼다 — 소셜 경로의 사실대로 알리고 다시 시도하게 한다.
|
||||||
|
'web-context-already-presented' => '로그인이 완료되지 않았습니다. 다시 시도해 주세요.',
|
||||||
|
'web-network-request-failed' ||
|
||||||
|
'network-request-failed' => '네트워크 연결을 확인한 뒤 다시 시도해 주세요.',
|
||||||
|
_ => switch (flow) {
|
||||||
|
AuthFlow.email => '이메일 또는 비밀번호를 확인해 주세요.',
|
||||||
|
AuthFlow.signIn => '소셜 로그인에 실패했습니다.',
|
||||||
|
AuthFlow.link => '계정 연결에 실패했습니다.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
|
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
|
import 'package:fancake/features/auth/login_screen.dart';
|
||||||
import 'package:fancake/features/auth/social_providers.dart';
|
import 'package:fancake/features/auth/social_providers.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -61,7 +63,10 @@ void main() {
|
|||||||
test('에러 카탈로그 ID 는 경로와 무관하게 E-AUTH-08 이다', () {
|
test('에러 카탈로그 ID 는 경로와 무관하게 E-AUTH-08 이다', () {
|
||||||
// 문구만 갈랐다. 카탈로그를 손대면 Admin·QA 의 코드 대조가 어긋난다.
|
// 문구만 갈랐다. 카탈로그를 손대면 Admin·QA 의 코드 대조가 어긋난다.
|
||||||
expect(
|
expect(
|
||||||
authCatalogCode(_exception('account-exists-with-different-credential')),
|
authCatalogCode(
|
||||||
|
_exception('account-exists-with-different-credential'),
|
||||||
|
flow: AuthFlow.signIn,
|
||||||
|
),
|
||||||
'E-AUTH-08',
|
'E-AUTH-08',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -85,13 +90,54 @@ void main() {
|
|||||||
// 「등록되지 않은 이메일」과 「비밀번호 오류」가 같은 문구로 나가야 한다.
|
// 「등록되지 않은 이메일」과 「비밀번호 오류」가 같은 문구로 나가야 한다.
|
||||||
for (final code in ['user-not-found', 'wrong-password']) {
|
for (final code in ['user-not-found', 'wrong-password']) {
|
||||||
expect(
|
expect(
|
||||||
authMessage(_exception(code), flow: AuthFlow.signIn),
|
authMessage(_exception(code), flow: AuthFlow.email),
|
||||||
'이메일 또는 비밀번호를 확인해 주세요.',
|
'이메일 또는 비밀번호를 확인해 주세요.',
|
||||||
reason: code,
|
reason: code,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('소셜 웹 로그인 실패 · 중단 — 이메일 문구를 쓰지 않는다', () {
|
||||||
|
const webCodes = [
|
||||||
|
'web-context-already-presented',
|
||||||
|
'web-network-request-failed',
|
||||||
|
'web-internal-error',
|
||||||
|
'internal-error',
|
||||||
|
'invalid-credential',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('소셜 로그인 · 연결의 알 수 없는 실패는 E-AUTH-03 · 소셜 문구', () {
|
||||||
|
for (final code in webCodes) {
|
||||||
|
for (final flow in [AuthFlow.signIn, AuthFlow.link]) {
|
||||||
|
expect(authCatalogCode(_exception(code), flow: flow), 'E-AUTH-03', reason: '$code $flow');
|
||||||
|
expect(
|
||||||
|
authMessage(_exception(code), flow: flow),
|
||||||
|
isNot('이메일 또는 비밀번호를 확인해 주세요.'),
|
||||||
|
reason: '$code $flow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
authMessage(_exception('web-context-already-presented'), flow: AuthFlow.signIn),
|
||||||
|
'로그인이 완료되지 않았습니다. 다시 시도해 주세요.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
authMessage(_exception('web-network-request-failed'), flow: AuthFlow.signIn),
|
||||||
|
'네트워크 연결을 확인한 뒤 다시 시도해 주세요.',
|
||||||
|
);
|
||||||
|
expect(authMessage(_exception('internal-error'), flow: AuthFlow.signIn), '소셜 로그인에 실패했습니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('이메일 폼만 E-AUTH-01 · 「이메일 또는 비밀번호」', () {
|
||||||
|
expect(authCatalogCode(_exception('internal-error'), flow: AuthFlow.email), 'E-AUTH-01');
|
||||||
|
expect(authMessage(_exception('internal-error'), flow: AuthFlow.email), '이메일 또는 비밀번호를 확인해 주세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('취소는 성공이 아니고 다시 시도하라고 안내한다', () {
|
||||||
|
expect(authNotice(const AuthCancelled()), '로그인이 완료되지 않았습니다. 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면
|
/// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:fancake/features/auth/social_providers.dart';
|
|||||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||||
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -112,6 +113,52 @@ void main() {
|
|||||||
expect(outcome, isA<AuthCancelled>());
|
expect(outcome, isA<AuthCancelled>());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('웹 흐름 중단 뒤 재시도', () {
|
||||||
|
late List<String> cleared;
|
||||||
|
setUp(() {
|
||||||
|
cleared = [];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), (call) async {
|
||||||
|
cleared.add(call.method);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), null));
|
||||||
|
|
||||||
|
test('앞선 흐름이 남아 막히면(web-context-already-presented) 정리하고 한 번 다시 열어 로그인된다', () async {
|
||||||
|
final auth = _StaleThenOkAuth();
|
||||||
|
final outcome = await line(auth).signIn();
|
||||||
|
expect(outcome, isA<AuthSucceeded>());
|
||||||
|
expect(auth.calls, 2);
|
||||||
|
expect(cleared, ['cancelPendingWebSignIn']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('정리 뒤에도 막히면 소셜 문구로 실패 — 「이메일 또는 비밀번호」가 아니다', () async {
|
||||||
|
final outcome = await line(_ThrowingAuth('web-context-already-presented')).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(outcome.message, '로그인이 완료되지 않았습니다. 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('망 오류도 소셜 경로 문구', () async {
|
||||||
|
final outcome = await line(_ThrowingAuth('web-network-request-failed')).signIn();
|
||||||
|
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||||
|
expect(outcome.message, '네트워크 연결을 확인한 뒤 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Android: 결과 없이 돌아와 취소로 풀릴 때 Firebase 대기도 정리한다', (tester) async {
|
||||||
|
late AuthOutcome outcome;
|
||||||
|
line(_HangingAuth()).signIn().then((o) => outcome = o);
|
||||||
|
for (final s in [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused,
|
||||||
|
AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) {
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(s);
|
||||||
|
}
|
||||||
|
await tester.pump(const Duration(seconds: 9));
|
||||||
|
expect(outcome, isA<AuthCancelled>());
|
||||||
|
expect(cleared, ['cancelPendingWebSignIn']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('계정 연결', () {
|
group('계정 연결', () {
|
||||||
test('같은 UID 에 oidc.line 을 붙인다', () async {
|
test('같은 UID 에 oidc.line 을 붙인다', () async {
|
||||||
final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr');
|
final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr');
|
||||||
@@ -219,6 +266,19 @@ class _ThrowingAuth extends MockFirebaseAuth {
|
|||||||
throw fb.FirebaseAuthException(code: code);
|
throw fb.FirebaseAuthException(code: code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 앞선 웹 흐름이 남아 첫 시도가 막히는 Firebase(Android `web-context-already-presented`).
|
||||||
|
class _StaleThenOkAuth extends MockFirebaseAuth {
|
||||||
|
_StaleThenOkAuth() : super(mockUser: MockUser(uid: 'line-1'));
|
||||||
|
var calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<fb.UserCredential> signInWithProvider(fb.AuthProvider provider) {
|
||||||
|
calls += 1;
|
||||||
|
if (calls == 1) throw fb.FirebaseAuthException(code: 'web-context-already-presented');
|
||||||
|
return super.signInWithProvider(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task.
|
/// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task.
|
||||||
class _HangingAuth extends MockFirebaseAuth {
|
class _HangingAuth extends MockFirebaseAuth {
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user