소셜 로그인 인증 경로 · 세션 전이 · Apple 로그인 콜백 · 인증 테스트
This commit is contained in:
@@ -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