소셜 로그인 인증 경로 · 세션 전이 · Apple 로그인 콜백 · 인증 테스트
This commit is contained in:
+16
-17
@@ -23,7 +23,7 @@ cd app
|
||||
flutter pub get
|
||||
flutter run # 실기기·시뮬레이터
|
||||
flutter analyze # 정적 분석
|
||||
flutter test # 스모크·회귀 테스트 9건
|
||||
flutter test # 스모크·인증 상태 전이·회귀 테스트 19건
|
||||
flutter build apk --debug # Android 디버그 APK
|
||||
```
|
||||
|
||||
@@ -141,12 +141,17 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|
||||
|---|---|---|---|
|
||||
| 카카오 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) |
|
||||
| 네이버 | 스텁 | `KeylessSocialProvider` | 앱 키(Q3) |
|
||||
| Apple | 실 코드 경로 | `AppleSignInProvider` | Firebase 설정(S2) + Xcode Capability |
|
||||
| Apple | 실 코드 경로 | `AppleSignInProvider` | Firebase 설정(S2) + Xcode Capability + Android Service ID/redirect URI |
|
||||
| Google | 실 코드 경로 | `GoogleSignInProvider` | Firebase 설정(S2) |
|
||||
|
||||
설계 원칙: **「키 미투입」은 예외가 아니라 상태다.** 예외를 던지지 않고
|
||||
`AuthNotConfigured(provider, missing)` 를 반환해 "무엇이 없어서 못 쓰는지"를 화면에 그대로 보여 준다.
|
||||
|
||||
Apple은 로그인 시도마다 암호학적 nonce를 만들고 SHA-256 해시를 Apple에, 원문 nonce를
|
||||
Firebase에 전달한다. Android에서는 Service ID와 redirect URI가 없으면 SDK를 호출하지 않는다.
|
||||
Firebase가 live가 되면 보존된 `currentUser`를 앱 시작 시 복원하고 `authStateChanges`를 계속 들어
|
||||
만료·원격 로그아웃도 라우터에 반영한다. 이 내부 계약은 fake 세션·provider로 자동 검증한다.
|
||||
|
||||
**이메일 가입은 구현하지 않았다.** CD-18 미확정(Q2)이라 `SocialAuthProvider` 인터페이스에
|
||||
구현체 하나가 더 붙을 자리만 비워 뒀다. **기능 플래그 상수는 두지 않았다** — 읽는 분기가 없는
|
||||
플래그는 "false 라서 닫혀 있다" 는 착각만 만들고, 누가 true 로 바꿔도 아무 일이 일어나지 않는다
|
||||
@@ -188,7 +193,8 @@ app/
|
||||
├── analysis_options.yaml flutter_lints + strict-casts/raw-types/inference
|
||||
├── tool/gen_tokens.py design-tokens.md → tokens.dart 생성기
|
||||
├── firebase/ T1(Firestore 설계) 영역 — 규칙·인덱스
|
||||
├── test/app_smoke_test.dart 스모크 3종
|
||||
├── test/app_smoke_test.dart 앱 기동·5탭·목 세션 회귀 10건
|
||||
├── test/auth_repository_test.dart provider 결과·세션 복원·로그아웃 9건
|
||||
└── lib/
|
||||
├── main.dart FirebaseBoot.init() → runApp
|
||||
├── app.dart MaterialApp.router + 테마 주입
|
||||
@@ -214,21 +220,14 @@ Analyzing app...
|
||||
No issues found! (ran in 3.2s)
|
||||
|
||||
$ flutter test
|
||||
00:00 +0: loading /Users/Kinos/Projects/p-fancake/app/test/app_smoke_test.dart
|
||||
00:00 +0: 1. 앱이 예외 없이 뜨고 로그인 화면이 나온다
|
||||
00:00 +1: 2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다
|
||||
00:00 +2: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 목 모드 + 릴리스면 isMockSessionAvailable 이 false 다
|
||||
00:00 +3: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스에서 signInWithMockSession 을 불러도 로그인되지 않는다
|
||||
00:00 +4: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 릴리스 빌드 로그인 화면에 목 세션 버튼이 없다
|
||||
00:00 +5: 릴리스 빌드에서는 목 우회 입구가 열리지 않는다 디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)
|
||||
00:00 +6: 제공자 4종의 미투입 안내에 내부 파일 경로가 들어가지 않는다
|
||||
00:00 +7: 화면에 띄우는 사유에 내부 파일 경로가 들어가지 않는다
|
||||
00:00 +8: 3. 탭을 전환하면 각 탭 루트 화면이 바뀐다
|
||||
00:00 +9: All tests passed!
|
||||
app_smoke_test.dart: 앱 기동·세션 복원·5탭·릴리스 목 우회 차단 10건 통과
|
||||
auth_repository_test.dart: 제공자 4종 성공/취소/실패/미설정·예외 안전성·
|
||||
Android Apple 설정 게이트·세션 복원/만료·로그아웃/재로그인 9건 통과
|
||||
00:01 +19: All tests passed!
|
||||
|
||||
$ flutter build apk --debug
|
||||
Running Gradle task 'assembleDebug'... 219.5s (첫 빌드 — NDK 28.2 자동 설치 포함)
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk (164,432,730 bytes · 약 156.8 MiB · debug)
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk (164,473,984 bytes · 약 156.9 MiB · debug)
|
||||
10.6s (증분 재빌드)
|
||||
|
||||
$ flutter build ios --debug --no-codesign
|
||||
@@ -242,7 +241,7 @@ $ python3 tool/gen_tokens.py
|
||||
전 쌍 AA 통과. (최저 color-on-neon-violet / color-neon-violet = 5.09:1)
|
||||
```
|
||||
|
||||
**테스트 9종은 Firebase 설정 파일이 없는 상태(목 모드)에서 통과한 결과다.**
|
||||
**테스트 19종은 Firebase 설정 파일이 없는 상태(목 모드)에서 통과한 결과다.**
|
||||
|
||||
음성 검증 2회 — 테스트가 실제로 게이트 역할을 하는지 직접 깨뜨려 확인했다.
|
||||
|
||||
@@ -296,7 +295,7 @@ I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Fail
|
||||
| L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 |
|
||||
| L7 | **iOS 최소 지원 버전을 13.0 → 15.0 으로 올렸다** | `firebase_auth` 6.x 가 15.0 이상을 요구해 13.0 으로는 `pod install` 이 실패했다. 기준 단말 iPhone 11(S4)은 iOS 18까지 지원하므로 범위 영향 없음. `ios/Podfile` · `Runner.xcodeproj` 3곳 수정 | 반영 완료 |
|
||||
| L8 | **릴리스 서명 키 없음 → `flutter build apk --release` 는 의도적으로 실패한다** | 서명 키는 스토어 등록 명의(Q5·S3)가 정해져야 만들 수 있다. Flutter 기본 템플릿은 이 상태에서 **debug 키로 조용히 서명**해 Play 가 거부하는 산출물을 "성공"으로 내놓는다 — 그 경로를 막고 사유를 띄우도록 `android/app/build.gradle.kts` 를 고쳤다. 키가 생기면 `android/key.properties`(storeFile·storePassword·keyAlias·keyPassword)만 놓으면 된다. 해당 파일과 `*.jks`/`*.keystore` 는 `app/android/.gitignore` 가 이미 막고 있다(확인함) | Q5·S3 |
|
||||
| L8-1 | debug APK 164,432,730 bytes(약 156.8 MiB) | debug 빌드 특성(미난독화·전 ABI 포함). release + `--split-per-abi` 로 크게 줄어들지만 **L8 때문에 아직 실측하지 못했다** | Q5·S3 |
|
||||
| L8-1 | debug APK 164,473,984 bytes(약 156.9 MiB) | debug 빌드 특성(미난독화·전 ABI 포함). release + `--split-per-abi` 로 크게 줄어들지만 **L8 때문에 아직 실측하지 못했다** | Q5·S3 |
|
||||
| L9 | 첫 Gradle 빌드에서 Kotlin 데몬 경고 1회 | `e: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0` — 자동 재시도로 성공했고 산출물에 영향 없음. 증분 빌드에서는 재현되지 않음 | — |
|
||||
| L9-1 | iOS 런치 화면 색 변경은 **컴파일 검증만** 했다 | `LaunchScreen.storyboard` 배경을 흰색 → `color-cosmic-void`(#000)로 바꿨다. Android 는 에뮬레이터에서 눈으로 확인했지만 iOS 는 시뮬레이터 실행을 하지 않아 빌드 성공까지만 확인했다 | 3주차 실기기 빌드 |
|
||||
| L10 | 온보딩 6화면 라우트 없음 | S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS | 3주차 |
|
||||
|
||||
@@ -25,6 +25,19 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Apple Android 웹 인증 callback. Service ID/redirect URI가 투입되기 전에는
|
||||
Dart 설정 게이트가 SDK 호출을 막는다. -->
|
||||
<activity
|
||||
android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="signinwithapple" />
|
||||
<data android:path="callback" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -277,6 +277,13 @@ service cloud.firestore {
|
||||
allow write: if false; // 수령은 fn-herodrop-claim 경유
|
||||
}
|
||||
|
||||
// 단결형 아이템 발동 상태.
|
||||
// 화면: S-M-SHOP · 근거: 별첨 1 ⑥ 「지급 대상·배수·시간창 정확」
|
||||
match /fandoms/{fandomId}/solidarityBoosts/{boostId} {
|
||||
allow read: if signedIn();
|
||||
allow write: if false; // 발동·배수·시간창은 Functions 전용
|
||||
}
|
||||
|
||||
// ───────────────────────── 4. 퀘스트 · 장소 · 브리핑 ─────────────────────────
|
||||
|
||||
// 퀘스트 정의·보상 파라미터. 화면: S-H-HOME · S-AD-QUEST
|
||||
|
||||
@@ -111,6 +111,58 @@ describe('원칙 1 — POP·FPI·원장은 클라이언트 쓰기 불가', () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe('단결형 아이템 접근 경계', () => {
|
||||
beforeEach(async () => {
|
||||
await seedDocuments(testEnvironment, {
|
||||
'fandoms/fandom-a/solidarityBoosts/boost-1': {
|
||||
triggerUid: 'alice',
|
||||
multiplier: 2,
|
||||
startsAt: '2026-08-14T00:00:00Z',
|
||||
endsAt: '2026-08-14T01:00:00Z',
|
||||
scope: 'fandom',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('로그인 사용자는 단결형 활성 상태를 읽을 수 있다', async () => {
|
||||
await assertSucceeds(
|
||||
getDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/boost-1')),
|
||||
);
|
||||
});
|
||||
|
||||
test('미인증 사용자는 단결형 활성 상태를 읽을 수 없다', async () => {
|
||||
await assertFails(
|
||||
getDoc(doc(anonymousDb(), 'fandoms/fandom-a/solidarityBoosts/boost-1')),
|
||||
);
|
||||
});
|
||||
|
||||
test('일반 사용자는 단결형 상태를 생성할 수 없다', async () => {
|
||||
await assertFails(
|
||||
setDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/forged'), {
|
||||
triggerUid: 'alice',
|
||||
multiplier: 999,
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('master도 단결형 상태를 수정할 수 없다', async () => {
|
||||
await assertFails(
|
||||
updateDoc(
|
||||
doc(dbFor('master-user', { role: 'master' }), 'fandoms/fandom-a/solidarityBoosts/boost-1'),
|
||||
{ multiplier: 999 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('일반 사용자는 단결형 상태를 삭제할 수 없다', async () => {
|
||||
await assertFails(
|
||||
deleteDoc(doc(dbFor('alice'), 'fandoms/fandom-a/solidarityBoosts/boost-1')),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('원칙 2 — 개인 리소스와 쓰기 필드 제한', () => {
|
||||
beforeEach(async () => {
|
||||
await seedDocuments(testEnvironment, {
|
||||
|
||||
@@ -6,21 +6,19 @@ import '../theme/tokens.dart';
|
||||
///
|
||||
/// 2주차 범위는 「앱 골격 · 5탭 네비게이션 · 소셜 로그인 · 디자인시스템」이다(별첨2).
|
||||
/// 각 탭의 실제 화면은 3주차 이후이므로, 여기서는 어느 화면이 들어올 자리인지와
|
||||
/// 근거 명세 경로만 보여 준다. 빈 화면을 두면 검수 때 무엇이 빠진 건지 알 수 없다.
|
||||
/// 다음 구현 시점을 보여 준다. 빈 화면을 두면 검수 때 무엇이 빠진 건지 알 수 없다.
|
||||
class TabPlaceholder extends StatelessWidget {
|
||||
const TabPlaceholder({
|
||||
super.key,
|
||||
required this.tab,
|
||||
required this.screenId,
|
||||
required this.screenName,
|
||||
required this.specPath,
|
||||
});
|
||||
|
||||
/// 계약 고정 탭 라벨(견적서 v2.1 5탭).
|
||||
final String tab;
|
||||
final String screenId;
|
||||
final String screenName;
|
||||
final String specPath;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -53,7 +51,7 @@ class TabPlaceholder extends StatelessWidget {
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text('화면 구현은 3주차 이후', style: text.bodySmall),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Text(specPath, style: text.bodySmall),
|
||||
Text('승인된 화면 정의를 기준으로 구현합니다.', style: text.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -100,3 +100,15 @@ abstract interface class SocialAuthProvider {
|
||||
|
||||
Future<void> signOut();
|
||||
}
|
||||
|
||||
/// 앱 재실행·토큰 만료·다른 기기 로그아웃을 인증 상태에 반영하는 세션 경계.
|
||||
///
|
||||
/// 저장소는 이 인터페이스만 알며 Firebase SDK 자체는 어댑터 뒤에 둔다. 덕분에
|
||||
/// 외부 설정 파일 없이도 세션 복원과 만료 전이를 단위 테스트할 수 있다.
|
||||
abstract interface class AuthSession {
|
||||
AuthAccount? get currentAccount;
|
||||
|
||||
Stream<AuthAccount?> get accountChanges;
|
||||
|
||||
Future<void> signOut();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/firebase/firebase_boot.dart';
|
||||
@@ -6,13 +8,22 @@ import 'social_providers.dart';
|
||||
|
||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
|
||||
class AuthRepository extends ChangeNotifier {
|
||||
AuthRepository({List<SocialAuthProvider>? providers, bool? releaseMode})
|
||||
: providers = providers ?? _defaultProviders(),
|
||||
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
|
||||
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
|
||||
_releaseMode = releaseMode ?? kReleaseMode;
|
||||
AuthRepository({
|
||||
List<SocialAuthProvider>? providers,
|
||||
AuthSession? session,
|
||||
bool? releaseMode,
|
||||
}) : providers = providers ?? _defaultProviders(),
|
||||
_session = session,
|
||||
_account = session?.currentAccount,
|
||||
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
|
||||
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
|
||||
_releaseMode = releaseMode ?? kReleaseMode {
|
||||
_sessionSubscription = session?.accountChanges.listen(_replaceAccount);
|
||||
}
|
||||
|
||||
final bool _releaseMode;
|
||||
final AuthSession? _session;
|
||||
StreamSubscription<AuthAccount?>? _sessionSubscription;
|
||||
|
||||
/// 로그인 화면에 그릴 제공자 목록. 순서는 시안 S-A-LOGIN 버튼 순서다.
|
||||
final List<SocialAuthProvider> providers;
|
||||
@@ -21,27 +32,38 @@ class AuthRepository extends ChangeNotifier {
|
||||
AuthAccount? get account => _account;
|
||||
bool get isSignedIn => _account != null;
|
||||
|
||||
static List<SocialAuthProvider> _defaultProviders() => const [
|
||||
KeylessSocialProvider(SocialProvider.kakao, [
|
||||
static List<SocialAuthProvider> _defaultProviders() => [
|
||||
const KeylessSocialProvider(SocialProvider.kakao, [
|
||||
'카카오 네이티브 앱 키',
|
||||
'카카오 개발자 콘솔 앱 등록(사업자 정보 필요)',
|
||||
]),
|
||||
KeylessSocialProvider(SocialProvider.naver, [
|
||||
const KeylessSocialProvider(SocialProvider.naver, [
|
||||
'네이버 client ID / client secret',
|
||||
'네이버 개발자센터 앱 등록(사업자 정보 필요)',
|
||||
]),
|
||||
AppleSignInProvider(),
|
||||
GoogleSignInProvider(),
|
||||
AppleSignInProvider(
|
||||
webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(),
|
||||
),
|
||||
const GoogleSignInProvider(),
|
||||
// 이메일 가입 구현체는 여기 없다. CD-18 미확정(Q2) — 회신이 오면 이 목록에 추가한다.
|
||||
// 자세한 사유는 auth.dart 상단 「이메일 가입에 대하여」 참고.
|
||||
];
|
||||
|
||||
Future<AuthOutcome> signIn(SocialProvider provider) async {
|
||||
final impl = providers.firstWhere((p) => p.provider == provider);
|
||||
final outcome = await impl.signIn();
|
||||
late final AuthOutcome outcome;
|
||||
try {
|
||||
outcome = await impl.signIn();
|
||||
} on Object catch (error) {
|
||||
// SDK가 문서화하지 않은 플랫폼 예외를 던져도 로그인 화면이 busy 상태에
|
||||
// 고착되지 않게 사용자용 결과로 닫는다. 원문 예외·토큰은 화면에 노출하지 않는다.
|
||||
debugPrint(
|
||||
'[AuthRepository] ${provider.name} sign-in failed: ${error.runtimeType}',
|
||||
);
|
||||
outcome = const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||
}
|
||||
if (outcome is AuthSucceeded) {
|
||||
_account = outcome.account;
|
||||
notifyListeners();
|
||||
_replaceAccount(outcome.account);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -71,10 +93,49 @@ class AuthRepository extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
for (final p in providers) {
|
||||
await p.signOut();
|
||||
final currentProvider = _account?.provider;
|
||||
SocialAuthProvider? provider;
|
||||
if (currentProvider != null) {
|
||||
for (final candidate in providers) {
|
||||
if (candidate.provider == currentProvider) {
|
||||
provider = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_account = null;
|
||||
|
||||
try {
|
||||
await Future.wait([
|
||||
if (provider != null) provider.signOut(),
|
||||
if (_session != null) _session.signOut(),
|
||||
], eagerError: false);
|
||||
} finally {
|
||||
// 원격 SDK 정리가 실패해도 로컬 라우터는 로그인 상태에 고착되지 않는다.
|
||||
_replaceAccount(null);
|
||||
}
|
||||
}
|
||||
|
||||
void _replaceAccount(AuthAccount? next) {
|
||||
if (_sameAccount(_account, next)) return;
|
||||
_account = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _sameAccount(AuthAccount? a, AuthAccount? b) =>
|
||||
identical(a, b) ||
|
||||
(a != null &&
|
||||
b != null &&
|
||||
a.uid == b.uid &&
|
||||
a.provider == b.provider &&
|
||||
a.displayName == b.displayName &&
|
||||
a.email == b.email &&
|
||||
a.isMock == b.isMock);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final subscription = _sessionSubscription;
|
||||
_sessionSubscription = null;
|
||||
if (subscription != null) unawaited(subscription.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||
|
||||
import 'auth.dart';
|
||||
|
||||
/// Firebase Auth가 보존한 세션을 앱의 인증 계약으로 변환한다.
|
||||
///
|
||||
/// [FirebaseBoot]가 live인 경우에만 생성해야 한다. 설정이 없는 목 모드에서는
|
||||
/// Firebase 인스턴스에 접근하지 않고 [AuthRepository]에 null 세션을 전달한다.
|
||||
final class FirebaseAuthSession implements AuthSession {
|
||||
FirebaseAuthSession({fb.FirebaseAuth? firebaseAuth})
|
||||
: _firebaseAuth = firebaseAuth ?? fb.FirebaseAuth.instance;
|
||||
|
||||
final fb.FirebaseAuth _firebaseAuth;
|
||||
|
||||
@override
|
||||
AuthAccount? get currentAccount => _toAccount(_firebaseAuth.currentUser);
|
||||
|
||||
@override
|
||||
Stream<AuthAccount?> get accountChanges =>
|
||||
_firebaseAuth.authStateChanges().map(_toAccount);
|
||||
|
||||
@override
|
||||
Future<void> signOut() => _firebaseAuth.signOut();
|
||||
}
|
||||
|
||||
AuthAccount? _toAccount(fb.User? user) {
|
||||
if (user == null) return null;
|
||||
return AuthAccount(
|
||||
uid: user.uid,
|
||||
provider: _providerOf(user),
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
);
|
||||
}
|
||||
|
||||
SocialProvider? _providerOf(fb.User user) {
|
||||
final ids = user.providerData.map((data) => data.providerId).toSet();
|
||||
if (ids.contains('google.com')) return SocialProvider.google;
|
||||
if (ids.contains('apple.com')) return SocialProvider.apple;
|
||||
if (ids.any((id) => id.toLowerCase().contains('kakao'))) {
|
||||
return SocialProvider.kakao;
|
||||
}
|
||||
if (ids.any((id) => id.toLowerCase().contains('naver'))) {
|
||||
return SocialProvider.naver;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart' as gsi;
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
|
||||
|
||||
@@ -12,6 +16,8 @@ import 'auth.dart';
|
||||
final class GoogleSignInProvider implements SocialAuthProvider {
|
||||
const GoogleSignInProvider();
|
||||
|
||||
static Future<void>? _initialization;
|
||||
|
||||
@override
|
||||
SocialProvider get provider => SocialProvider.google;
|
||||
|
||||
@@ -32,14 +38,16 @@ final class GoogleSignInProvider implements SocialAuthProvider {
|
||||
try {
|
||||
final signIn = gsi.GoogleSignIn.instance;
|
||||
// clientId/serverClientId 는 설정 파일에서 읽힌다 — 여기서 하드코딩하지 않는다.
|
||||
await signIn.initialize();
|
||||
await (_initialization ??= signIn.initialize());
|
||||
final account = await signIn.authenticate();
|
||||
final idToken = account.authentication.idToken;
|
||||
if (idToken == null) {
|
||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||
}
|
||||
final credential = fb.GoogleAuthProvider.credential(idToken: idToken);
|
||||
final result = await fb.FirebaseAuth.instance.signInWithCredential(credential);
|
||||
final result = await fb.FirebaseAuth.instance.signInWithCredential(
|
||||
credential,
|
||||
);
|
||||
return AuthSucceeded(_toAccount(result.user, provider));
|
||||
} on gsi.GoogleSignInException catch (error) {
|
||||
if (error.code == gsi.GoogleSignInExceptionCode.canceled) {
|
||||
@@ -54,8 +62,9 @@ final class GoogleSignInProvider implements SocialAuthProvider {
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
if (!FirebaseBoot.isLive) return;
|
||||
await (_initialization ??= gsi.GoogleSignIn.instance.initialize());
|
||||
await gsi.GoogleSignIn.instance.signOut();
|
||||
await fb.FirebaseAuth.instance.signOut();
|
||||
// Firebase 세션 종료는 AuthSession이 한 번만 담당한다.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +74,11 @@ final class GoogleSignInProvider implements SocialAuthProvider {
|
||||
/// Apple Developer 콘솔의 Service ID + redirect URI 를 각각 요구한다.
|
||||
/// 둘 다 스토어 계정 명의가 정해져야(Q5) 발급할 수 있어 지금은 설정 파일 게이트에서 끊긴다.
|
||||
final class AppleSignInProvider implements SocialAuthProvider {
|
||||
const AppleSignInProvider();
|
||||
const AppleSignInProvider({this.webAuthenticationOptions});
|
||||
|
||||
/// Android·Web에서 필요한 Service ID와 검증된 callback URL.
|
||||
/// 값이 없으면 SDK를 호출하지 않고 [AuthNotConfigured]로 닫는다.
|
||||
final apple.WebAuthenticationOptions? webAuthenticationOptions;
|
||||
|
||||
@override
|
||||
SocialProvider get provider => SocialProvider.apple;
|
||||
@@ -74,9 +87,12 @@ final class AppleSignInProvider implements SocialAuthProvider {
|
||||
bool get isLive => true;
|
||||
|
||||
@override
|
||||
List<String> get missingConfig => FirebaseBoot.isLive
|
||||
? const []
|
||||
: [...FirebaseBoot.missingLabels, 'Apple 개발자 설정'];
|
||||
List<String> get missingConfig => [
|
||||
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
|
||||
if (_requiresWebAuthentication && webAuthenticationOptions == null)
|
||||
'Apple Android 웹 인증 설정',
|
||||
if (!FirebaseBoot.isLive) 'Apple 개발자 설정',
|
||||
];
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() async {
|
||||
@@ -87,11 +103,17 @@ final class AppleSignInProvider implements SocialAuthProvider {
|
||||
return const AuthFailed('E-AUTH-03', '이 기기에서는 Apple 로그인을 쓸 수 없습니다.');
|
||||
}
|
||||
try {
|
||||
final rawNonce = apple.generateNonce();
|
||||
final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
|
||||
final credential = await apple.SignInWithApple.getAppleIDCredential(
|
||||
scopes: const [
|
||||
apple.AppleIDAuthorizationScopes.email,
|
||||
apple.AppleIDAuthorizationScopes.fullName,
|
||||
],
|
||||
webAuthenticationOptions: _requiresWebAuthentication
|
||||
? webAuthenticationOptions
|
||||
: null,
|
||||
nonce: hashedNonce,
|
||||
);
|
||||
final idToken = credential.identityToken;
|
||||
if (idToken == null) {
|
||||
@@ -100,6 +122,7 @@ final class AppleSignInProvider implements SocialAuthProvider {
|
||||
final oauth = fb.OAuthProvider('apple.com').credential(
|
||||
idToken: idToken,
|
||||
accessToken: credential.authorizationCode,
|
||||
rawNonce: rawNonce,
|
||||
);
|
||||
final result = await fb.FirebaseAuth.instance.signInWithCredential(oauth);
|
||||
return AuthSucceeded(_toAccount(result.user, provider));
|
||||
@@ -108,6 +131,9 @@ final class AppleSignInProvider implements SocialAuthProvider {
|
||||
return const AuthCancelled();
|
||||
}
|
||||
return AuthFailed('E-AUTH-03', error.message);
|
||||
} on apple.SignInWithAppleException catch (error) {
|
||||
debugPrint('[AppleSignInProvider] failed: ${error.runtimeType}');
|
||||
return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.');
|
||||
} on fb.FirebaseAuthException catch (error) {
|
||||
return AuthFailed('E-AUTH-03', error.message ?? error.code);
|
||||
}
|
||||
@@ -115,9 +141,11 @@ final class AppleSignInProvider implements SocialAuthProvider {
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
if (!FirebaseBoot.isLive) return;
|
||||
await fb.FirebaseAuth.instance.signOut();
|
||||
// Apple SDK에는 앱 로컬 로그아웃 API가 없다. Firebase 세션은 AuthSession이 닫는다.
|
||||
}
|
||||
|
||||
bool get _requiresWebAuthentication =>
|
||||
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
|
||||
}
|
||||
|
||||
/// 앱 키가 없어 아직 붙이지 못한 제공자 — 카카오·네이버.
|
||||
@@ -137,12 +165,32 @@ final class KeylessSocialProvider implements SocialAuthProvider {
|
||||
bool get isLive => false;
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() async => AuthNotConfigured(provider, missingConfig);
|
||||
Future<AuthOutcome> signIn() async =>
|
||||
AuthNotConfigured(provider, missingConfig);
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
}
|
||||
|
||||
/// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다.
|
||||
/// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다.
|
||||
apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() {
|
||||
const clientId = String.fromEnvironment('FANUZU_APPLE_SERVICE_ID');
|
||||
const redirect = String.fromEnvironment('FANUZU_APPLE_REDIRECT_URI');
|
||||
if (clientId.isEmpty || redirect.isEmpty) return null;
|
||||
|
||||
final redirectUri = Uri.tryParse(redirect);
|
||||
if (redirectUri == null ||
|
||||
!redirectUri.hasScheme ||
|
||||
!redirectUri.hasAuthority) {
|
||||
return null;
|
||||
}
|
||||
return apple.WebAuthenticationOptions(
|
||||
clientId: clientId,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
}
|
||||
|
||||
AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount(
|
||||
uid: user?.uid ?? '',
|
||||
provider: provider,
|
||||
|
||||
@@ -11,6 +11,5 @@ class GalaxyScreen extends StatelessWidget {
|
||||
tab: 'GALAXY',
|
||||
screenId: 'S-G-PLANET',
|
||||
screenName: '내 행성',
|
||||
specPath: 'wireframe/planning/screen-spec/galaxy/S-G-PLANET.md',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,5 @@ class HomeScreen extends StatelessWidget {
|
||||
tab: 'HOME',
|
||||
screenId: 'S-H-HOME',
|
||||
screenName: '오늘의 퀘스트',
|
||||
specPath: 'wireframe/planning/screen-spec/home/S-H-HOME.md',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,5 @@ class InsideScreen extends StatelessWidget {
|
||||
tab: 'INSIDE',
|
||||
screenId: 'S-I-LOUNGE',
|
||||
screenName: '팬덤 라운지',
|
||||
specPath: 'wireframe/planning/screen-spec/inside/S-I-LOUNGE.md',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,5 @@ class MyScreen extends StatelessWidget {
|
||||
tab: 'MY',
|
||||
screenId: 'S-M-HOME',
|
||||
screenName: '마이 페이지',
|
||||
specPath: 'wireframe/planning/screen-spec/my/S-M-HOME.md',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,5 @@ class StationScreen extends StatelessWidget {
|
||||
tab: 'STATION',
|
||||
screenId: 'S-S-HUB',
|
||||
screenName: 'STATION 허브',
|
||||
specPath: 'wireframe/planning/screen-spec/station/S-S-HUB.md',
|
||||
);
|
||||
}
|
||||
|
||||
+3
-1
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import 'app.dart';
|
||||
import 'core/firebase/firebase_boot.dart';
|
||||
import 'features/auth/auth_repository.dart';
|
||||
import 'features/auth/firebase_auth_session.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고.
|
||||
await FirebaseBoot.init();
|
||||
runApp(FancakeApp(auth: AuthRepository()));
|
||||
final session = FirebaseBoot.isLive ? FirebaseAuthSession() : null;
|
||||
runApp(FancakeApp(auth: AuthRepository(session: session)));
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -21,6 +21,9 @@ dependencies:
|
||||
firebase_core: ^4.13.0
|
||||
firebase_auth: ^6.5.7
|
||||
|
||||
# Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다.
|
||||
crypto: ^3.0.7
|
||||
|
||||
# 소셜 로그인 — Google·Apple 만 실 코드 경로. 카카오·네이버는 앱 키가 없어 스텁이다(Q3).
|
||||
google_sign_in: ^7.2.0
|
||||
sign_in_with_apple: ^8.1.0
|
||||
|
||||
@@ -11,7 +11,10 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// 2주차 스모크 — Firebase 설정 파일이 **없는 상태**(목 모드)에서 전부 통과해야 한다.
|
||||
void main() {
|
||||
setUp(() {
|
||||
FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '스모크 테스트 — 설정 파일 미투입 상황 재현');
|
||||
FirebaseBoot.debugSetMode(
|
||||
FirebaseMode.mock,
|
||||
reason: '스모크 테스트 — 설정 파일 미투입 상황 재현',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('1. 앱이 예외 없이 뜨고 로그인 화면이 나온다', (tester) async {
|
||||
@@ -26,6 +29,20 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('저장된 세션이 있으면 로그인 화면 대신 5탭 셸로 복원된다', (tester) async {
|
||||
final session = _SmokeSession(
|
||||
const AuthAccount(uid: 'restored-user', provider: SocialProvider.google),
|
||||
);
|
||||
final auth = AuthRepository(session: session, releaseMode: true);
|
||||
|
||||
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.text('목 세션으로 탭 둘러보기'), findsNothing);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async {
|
||||
await _enterShell(tester);
|
||||
|
||||
@@ -61,17 +78,25 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('릴리스 빌드 로그인 화면에 목 세션 버튼이 없다', (tester) async {
|
||||
await tester.pumpWidget(FancakeApp(auth: AuthRepository(releaseMode: true)));
|
||||
await tester.pumpWidget(
|
||||
FancakeApp(auth: AuthRepository(releaseMode: true)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('목 세션으로 탭 둘러보기'), findsNothing);
|
||||
expect(find.byType(NavigationBar), findsNothing, reason: '셸에 진입할 수 없어야 한다');
|
||||
expect(
|
||||
find.byType(NavigationBar),
|
||||
findsNothing,
|
||||
reason: '셸에 진입할 수 없어야 한다',
|
||||
);
|
||||
// 소셜 버튼은 그대로 있어야 한다 — 우회 입구만 막는 것이지 로그인을 막는 게 아니다.
|
||||
expect(find.text(SocialProvider.google.label), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)', (tester) async {
|
||||
await tester.pumpWidget(FancakeApp(auth: AuthRepository(releaseMode: false)));
|
||||
await tester.pumpWidget(
|
||||
FancakeApp(auth: AuthRepository(releaseMode: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('목 세션으로 탭 둘러보기'), findsOneWidget);
|
||||
@@ -81,7 +106,13 @@ void main() {
|
||||
// 화면에 나가는 문자열 전체를 훑는다. reason 만 고쳤다가 제공자 missingConfig 로
|
||||
// 리포 경로가 그대로 새어 나간 적이 있다(에뮬레이터 실기동 중 발견).
|
||||
test('제공자 4종의 미투입 안내에 내부 파일 경로가 들어가지 않는다', () {
|
||||
const forbidden = ['google-services.json', 'GoogleService-Info.plist', 'app/', '.plist', '.json'];
|
||||
const forbidden = [
|
||||
'google-services.json',
|
||||
'GoogleService-Info.plist',
|
||||
'app/',
|
||||
'.plist',
|
||||
'.json',
|
||||
];
|
||||
for (final p in AuthRepository().providers) {
|
||||
for (final item in p.missingConfig) {
|
||||
for (final bad in forbidden) {
|
||||
@@ -126,9 +157,27 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final (screenId, screenName) = entry.value;
|
||||
expect(find.text(screenId), findsOneWidget, reason: '${entry.key} → $screenId');
|
||||
expect(
|
||||
find.text(screenId),
|
||||
findsOneWidget,
|
||||
reason: '${entry.key} → $screenId',
|
||||
);
|
||||
// AppBar 제목과 본문 제목 두 군데에 나온다.
|
||||
expect(find.text(screenName), findsWidgets, reason: '${entry.key} → $screenName');
|
||||
expect(
|
||||
find.text(screenName),
|
||||
findsWidgets,
|
||||
reason: '${entry.key} → $screenName',
|
||||
);
|
||||
expect(
|
||||
find.textContaining('wireframe/'),
|
||||
findsNothing,
|
||||
reason: '${entry.key} 화면에 내부 저장소 경로가 노출되면 안 된다',
|
||||
);
|
||||
expect(
|
||||
find.textContaining('screen-spec'),
|
||||
findsNothing,
|
||||
reason: '${entry.key} 화면에 내부 명세 경로가 노출되면 안 된다',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -156,8 +205,22 @@ List<String> _contractTabs() {
|
||||
final file = File('../design/craft-allowlist.yaml');
|
||||
final line = file.readAsLinesSync().firstWhere(
|
||||
(l) => l.trimLeft().startsWith('APP_TABS:'),
|
||||
orElse: () => throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
|
||||
orElse: () =>
|
||||
throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
|
||||
);
|
||||
final inner = RegExp(r'\[(.*)\]').firstMatch(line)!.group(1)!;
|
||||
return inner.split(',').map((s) => s.trim()).toList();
|
||||
}
|
||||
|
||||
final class _SmokeSession implements AuthSession {
|
||||
const _SmokeSession(this.currentAccount);
|
||||
|
||||
@override
|
||||
final AuthAccount? currentAccount;
|
||||
|
||||
@override
|
||||
Stream<AuthAccount?> get accountChanges => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||
import 'package:fancake/features/auth/auth.dart';
|
||||
import 'package:fancake/features/auth/auth_repository.dart';
|
||||
import 'package:fancake/features/auth/social_providers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '인증 저장소 단위 테스트');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
group('provider 결과 계약', () {
|
||||
test('4개 provider의 성공 결과가 계정 상태로 반영된다', () async {
|
||||
for (final provider in SocialProvider.values) {
|
||||
final account = AuthAccount(
|
||||
uid: 'uid-${provider.name}',
|
||||
provider: provider,
|
||||
);
|
||||
final auth = AuthRepository(
|
||||
providers: [_FakeProvider(provider, result: AuthSucceeded(account))],
|
||||
releaseMode: true,
|
||||
);
|
||||
|
||||
final outcome = await auth.signIn(provider);
|
||||
|
||||
expect(outcome, isA<AuthSucceeded>(), reason: provider.name);
|
||||
expect(auth.account?.uid, account.uid, reason: provider.name);
|
||||
expect(auth.isSignedIn, isTrue, reason: provider.name);
|
||||
auth.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('4개 provider의 취소는 실패 안내나 로그인 상태를 만들지 않는다', () async {
|
||||
for (final provider in SocialProvider.values) {
|
||||
final auth = AuthRepository(
|
||||
providers: [_FakeProvider(provider, result: const AuthCancelled())],
|
||||
releaseMode: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
await auth.signIn(provider),
|
||||
isA<AuthCancelled>(),
|
||||
reason: provider.name,
|
||||
);
|
||||
expect(auth.isSignedIn, isFalse, reason: provider.name);
|
||||
auth.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('4개 provider의 SDK 실패가 E-AUTH-03으로 유지된다', () async {
|
||||
for (final provider in SocialProvider.values) {
|
||||
final auth = AuthRepository(
|
||||
providers: [
|
||||
_FakeProvider(
|
||||
provider,
|
||||
result: const AuthFailed('E-AUTH-03', 'SDK 실패'),
|
||||
),
|
||||
],
|
||||
releaseMode: true,
|
||||
);
|
||||
|
||||
final outcome = await auth.signIn(provider);
|
||||
|
||||
expect(outcome, isA<AuthFailed>(), reason: provider.name);
|
||||
expect(
|
||||
(outcome as AuthFailed).code,
|
||||
'E-AUTH-03',
|
||||
reason: provider.name,
|
||||
);
|
||||
expect(auth.isSignedIn, isFalse, reason: provider.name);
|
||||
auth.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('4개 provider의 미설정 상태가 provider와 누락 항목을 보존한다', () async {
|
||||
for (final provider in SocialProvider.values) {
|
||||
final auth = AuthRepository(
|
||||
providers: [
|
||||
_FakeProvider(
|
||||
provider,
|
||||
result: AuthNotConfigured(provider, const ['테스트 설정']),
|
||||
),
|
||||
],
|
||||
releaseMode: true,
|
||||
);
|
||||
|
||||
final outcome = await auth.signIn(provider);
|
||||
|
||||
expect(outcome, isA<AuthNotConfigured>(), reason: provider.name);
|
||||
expect((outcome as AuthNotConfigured).provider, provider);
|
||||
expect(outcome.missing, ['테스트 설정']);
|
||||
expect(auth.isSignedIn, isFalse, reason: provider.name);
|
||||
auth.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('문서화되지 않은 SDK 예외도 사용자용 실패로 닫힌다', () async {
|
||||
final auth = AuthRepository(
|
||||
providers: [
|
||||
_FakeProvider(
|
||||
SocialProvider.google,
|
||||
signInError: StateError('raw SDK error'),
|
||||
),
|
||||
],
|
||||
releaseMode: true,
|
||||
);
|
||||
|
||||
final outcome = await auth.signIn(SocialProvider.google);
|
||||
|
||||
expect(outcome, isA<AuthFailed>());
|
||||
expect((outcome as AuthFailed).code, 'E-AUTH-03');
|
||||
expect(outcome.message, '소셜 로그인에 실패했습니다.');
|
||||
expect(auth.isSignedIn, isFalse);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
test('Android Apple 로그인은 웹 인증 설정 전 SDK 호출을 차단한다', () {
|
||||
FirebaseBoot.debugSetMode(
|
||||
FirebaseMode.live,
|
||||
reason: 'Android 설정 게이트 테스트',
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
|
||||
const provider = AppleSignInProvider();
|
||||
|
||||
expect(provider.missingConfig, contains('Apple Android 웹 인증 설정'));
|
||||
});
|
||||
});
|
||||
|
||||
group('세션 복원과 로그아웃', () {
|
||||
test('현재 세션을 생성 즉시 복원하고 변경·만료를 계속 반영한다', () async {
|
||||
final initial = const AuthAccount(
|
||||
uid: 'restored-user',
|
||||
provider: SocialProvider.google,
|
||||
);
|
||||
final session = _FakeSession(initial);
|
||||
final auth = AuthRepository(
|
||||
providers: const [],
|
||||
session: session,
|
||||
releaseMode: true,
|
||||
);
|
||||
|
||||
expect(auth.account?.uid, 'restored-user');
|
||||
expect(auth.isSignedIn, isTrue);
|
||||
|
||||
session.emit(
|
||||
const AuthAccount(uid: 'apple-user', provider: SocialProvider.apple),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(auth.account?.uid, 'apple-user');
|
||||
|
||||
session.emit(null);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(auth.isSignedIn, isFalse);
|
||||
|
||||
auth.dispose();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('로그아웃 뒤 같은 provider로 다시 로그인할 수 있다', () async {
|
||||
final provider = _FakeProvider(
|
||||
SocialProvider.google,
|
||||
result: const AuthSucceeded(
|
||||
AuthAccount(uid: 'relogin-user', provider: SocialProvider.google),
|
||||
),
|
||||
);
|
||||
final session = _FakeSession(
|
||||
const AuthAccount(uid: 'old-user', provider: SocialProvider.google),
|
||||
);
|
||||
final auth = AuthRepository(
|
||||
providers: [provider],
|
||||
session: session,
|
||||
releaseMode: true,
|
||||
);
|
||||
|
||||
await auth.signOut();
|
||||
expect(auth.isSignedIn, isFalse);
|
||||
expect(provider.signOutCalls, 1);
|
||||
expect(session.signOutCalls, 1);
|
||||
|
||||
expect(await auth.signIn(SocialProvider.google), isA<AuthSucceeded>());
|
||||
expect(auth.account?.uid, 'relogin-user');
|
||||
|
||||
auth.dispose();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('원격 로그아웃 실패가 나도 로컬 세션은 반드시 정리된다', () async {
|
||||
final provider = _FakeProvider(
|
||||
SocialProvider.apple,
|
||||
result: const AuthCancelled(),
|
||||
signOutError: StateError('provider sign-out failed'),
|
||||
);
|
||||
final session = _FakeSession(
|
||||
const AuthAccount(uid: 'apple-user', provider: SocialProvider.apple),
|
||||
);
|
||||
final auth = AuthRepository(
|
||||
providers: [provider],
|
||||
session: session,
|
||||
releaseMode: true,
|
||||
);
|
||||
|
||||
await expectLater(auth.signOut(), throwsA(isA<StateError>()));
|
||||
|
||||
expect(auth.isSignedIn, isFalse);
|
||||
expect(provider.signOutCalls, 1);
|
||||
expect(session.signOutCalls, 1);
|
||||
auth.dispose();
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
final class _FakeProvider implements SocialAuthProvider {
|
||||
_FakeProvider(
|
||||
this.provider, {
|
||||
this.result,
|
||||
this.signInError,
|
||||
this.signOutError,
|
||||
});
|
||||
|
||||
@override
|
||||
final SocialProvider provider;
|
||||
final AuthOutcome? result;
|
||||
final Object? signInError;
|
||||
final Object? signOutError;
|
||||
int signOutCalls = 0;
|
||||
|
||||
@override
|
||||
bool get isLive => true;
|
||||
|
||||
@override
|
||||
List<String> get missingConfig => const [];
|
||||
|
||||
@override
|
||||
Future<AuthOutcome> signIn() async {
|
||||
if (signInError case final error?) throw error;
|
||||
return result!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
signOutCalls += 1;
|
||||
if (signOutError case final error?) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeSession implements AuthSession {
|
||||
_FakeSession(this._current);
|
||||
|
||||
AuthAccount? _current;
|
||||
final StreamController<AuthAccount?> _changes =
|
||||
StreamController<AuthAccount?>.broadcast();
|
||||
int signOutCalls = 0;
|
||||
|
||||
@override
|
||||
AuthAccount? get currentAccount => _current;
|
||||
|
||||
@override
|
||||
Stream<AuthAccount?> get accountChanges => _changes.stream;
|
||||
|
||||
void emit(AuthAccount? account) {
|
||||
_current = account;
|
||||
_changes.add(account);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
signOutCalls += 1;
|
||||
_current = null;
|
||||
}
|
||||
|
||||
Future<void> close() => _changes.close();
|
||||
}
|
||||
Reference in New Issue
Block a user