Kakao 로그인을 SDK와 서버 커스텀 토큰으로 · sFPI 일 집계 직후 Stage 승급 판정

This commit is contained in:
Sn-Kinos
2026-10-05 19:07:47 +09:00
parent 576dcfbdfd
commit 65669b969e
24 changed files with 1034 additions and 57 deletions
+109
View File
@@ -0,0 +1,109 @@
import 'package:cloud_functions/cloud_functions.dart';
import 'package:fancake/core/firebase/firebase_boot.dart';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/firebase_account_linker.dart';
import 'package:fancake/features/auth/social_providers.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 카카오 — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약.
/// 토큰 검증·기존 oidc.kakao 사용자 매핑은 서버 테스트(functions/test/auth_kakao.test.ts)가 본다.
void main() {
setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: '카카오 테스트'));
tearDown(
() => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '카카오 테스트 끝'),
);
final sent = <Map<String, dynamic>>[];
setUp(sent.clear);
KakaoSignInProvider kakao(
MockFirebaseAuth auth, {
Future<String> Function()? token,
Object? serverError,
String appKey = 'test-key',
}) => KakaoSignInProvider(
accessToken: token ?? () async => 'kakao-access',
exchange: (payload) async {
sent.add(payload);
if (serverError != null) throw serverError;
return {
'token': 'custom-token',
if (payload['link'] == true) 'linked': true,
};
},
firebaseAuth: auth,
nativeAppKey: appKey,
);
test('성공 — 카카오 토큰을 서버에 넘기고 커스텀 토큰으로 로그인한다', () async {
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'kakao:1'));
final outcome = await kakao(auth).signIn();
expect(sent.single, {'accessToken': 'kakao-access'});
expect((outcome as AuthSucceeded).account.uid, 'kakao:1');
expect(outcome.account.provider, AuthProviderKind.kakao);
expect(auth.currentUser?.uid, 'kakao:1');
});
test('앱 키가 없으면 SDK·서버를 부르지 않고 미설정', () async {
final outcome = await kakao(MockFirebaseAuth(), appKey: '').signIn();
expect(outcome, isA<AuthNotConfigured>());
expect(sent, isEmpty);
});
test('카카오 로그인 취소(CANCELED)는 취소 · 서버를 부르지 않는다', () async {
final outcome = await kakao(
MockFirebaseAuth(),
token: () async => throw PlatformException(code: 'CANCELED'),
).signIn();
expect(outcome, isA<AuthCancelled>());
expect(sent, isEmpty);
});
test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async {
final auth = MockFirebaseAuth();
final outcome = await kakao(
auth,
serverError: FirebaseFunctionsException(
code: 'permission-denied',
message: '카카오 인증을 확인하지 못했습니다.',
details: {'code': 'E-AUTH-03'},
),
).signIn();
expect((outcome as AuthFailed).code, 'E-AUTH-03');
expect(auth.currentUser, isNull);
});
group('SNS 연동', () {
test('로그인한 사용자에 link: true 로 붙인다', () async {
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'u-1'),
);
final linker = FirebaseAccountLinker(
providers: [kakao(auth)],
firebaseAuth: auth,
);
final outcome = await linker.link(AuthProviderKind.kakao);
expect(sent.single, {'accessToken': 'kakao-access', 'link': true});
expect((outcome as AuthSucceeded).account.uid, 'u-1');
});
test('다른 이메일이면 E-AUTH-08 · 이메일 안내 문구', () async {
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'u-1'),
);
final outcome = await kakao(
auth,
serverError: FirebaseFunctionsException(
code: 'already-exists',
message: 'x',
details: {'code': 'E-AUTH-08', 'reason': 'different-email'},
),
).link(auth.currentUser!);
expect((outcome as AuthFailed).code, 'E-AUTH-08');
});
});
}
+67 -31
View File
@@ -1,3 +1,5 @@
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';
@@ -5,95 +7,122 @@ import 'package:fancake/features/auth/firebase_account_linker.dart';
import 'package:fancake/features/auth/social_providers.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
/// 카카오·LINE OIDC 제공자 — 콘솔 설정 전이라 실 로그인은 못 하고,
/// LINE OIDC 제공자 — 실 로그인은 못 하고,
/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다.
void main() {
// Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned).
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'OIDC 테스트'));
tearDown(
() => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'OIDC 테스트 끝'),
);
OidcSignInProvider kakao(fb.FirebaseAuth auth) => OidcSignInProvider(
AuthProviderKind.kakao,
providerId: 'oidc.kakao',
OidcSignInProvider line(fb.FirebaseAuth auth) => OidcSignInProvider(
AuthProviderKind.line,
providerId: 'oidc.line',
firebaseAuth: auth,
);
test('기본 목록의 카카오·LINE 은 Firebase OIDC 제공자다 — 「준비 중」이 아니다', () {
test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () {
final providers = {
for (final p in AuthRepository().providers) p.provider: p,
};
for (final kind in [AuthProviderKind.kakao, AuthProviderKind.line]) {
final p = providers[kind];
expect(p, isA<OidcSignInProvider>(), reason: kind.name);
expect(p!.isLive, isTrue);
expect(
(p as OidcSignInProvider).providerId,
FirebaseAccountLinker.providerIds[kind],
);
expect(p.authProvider.providerId, 'oidc.${kind.name}');
}
final p = providers[AuthProviderKind.line];
expect(p, isA<OidcSignInProvider>());
expect(p!.isLive, isTrue);
expect(
(p as OidcSignInProvider).providerId,
FirebaseAccountLinker.providerIds[AuthProviderKind.line],
);
expect(p.authProvider.providerId, 'oidc.line');
expect(providers[AuthProviderKind.kakao], isA<KakaoSignInProvider>());
});
test('Firebase 설정이 없으면 SDK 를 부르지 않고 미설정으로 닫는다', () async {
FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '설정 없음');
final outcome = await kakao(_ThrowingAuth('should-not-call')).signIn();
final outcome = await line(_ThrowingAuth('should-not-call')).signIn();
expect(outcome, isA<AuthNotConfigured>());
});
test('성공하면 카카오 계정으로 로그인된다', () async {
test('성공하면 LINE 계정으로 로그인된다', () async {
final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1'));
final outcome = await kakao(auth).signIn();
final outcome = await line(auth).signIn();
expect(outcome, isA<AuthSucceeded>());
expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.kakao);
expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.line);
expect(outcome.account.uid, 'k-1');
});
test('인가 화면을 닫으면 Android·iOS 철자 모두 취소로 본다', () async {
for (final code in ['web-context-canceled', 'web-context-cancelled']) {
final outcome = await kakao(_ThrowingAuth(code)).signIn();
final outcome = await line(_ThrowingAuth(code)).signIn();
expect(outcome, isA<AuthCancelled>(), reason: code);
}
});
test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async {
final outcome = await kakao(
_ThrowingAuth('operation-not-allowed'),
).signIn();
final outcome = await line(_ThrowingAuth('operation-not-allowed')).signIn();
expect((outcome as AuthFailed).code, 'E-AUTH-11');
});
test('Android 의 LINE 은 앱 자동 로그인을 끈다 · iOS 는 파라미터 없음', () {
final line =
AuthRepository().providers.firstWhere(
(p) => p.provider == AuthProviderKind.line,
)
as OidcSignInProvider;
expect(line.authProvider.parameters, {'disable_auto_login': 'true'});
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
expect(line.authProvider.parameters, isEmpty);
});
testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async {
late AuthOutcome outcome;
line(_HangingAuth()).signIn().then((o) => outcome = o);
void go(List<AppLifecycleState> states) =>
states.forEach(tester.binding.handleAppLifecycleStateChanged);
const inactive = AppLifecycleState.inactive;
const hidden = AppLifecycleState.hidden;
go([inactive, hidden, AppLifecycleState.paused]); // Custom Tab 으로 나감
await tester.pump(const Duration(minutes: 1)); // 브라우저에 오래 있어도 그대로
go([hidden, inactive, AppLifecycleState.resumed]); // 앱으로 돌아옴
await tester.pump(const Duration(seconds: 9));
expect(outcome, isA<AuthCancelled>());
});
group('계정 연결', () {
test('같은 UID 에 oidc.kakao 를 붙인다', () async {
test('같은 UID 에 oidc.line 을 붙인다', () async {
final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr');
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
final linker = FirebaseAccountLinker(
providers: [kakao(auth)],
providers: [line(auth)],
firebaseAuth: auth,
);
final outcome = await linker.link(AuthProviderKind.kakao);
final outcome = await linker.link(AuthProviderKind.line);
expect(outcome, isA<AuthSucceeded>());
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.kakao));
expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line));
});
test('다른 이메일의 카카오 계정은 붙였다가 바로 떼고 E-AUTH-08', () async {
test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async {
final user = _LinkingUser(linkedEmail: 'someone@else.com');
final auth = MockFirebaseAuth(signedIn: true, mockUser: user);
final linker = FirebaseAccountLinker(
providers: [kakao(auth)],
providers: [line(auth)],
firebaseAuth: auth,
);
final outcome = await linker.link(AuthProviderKind.kakao);
final outcome = await linker.link(AuthProviderKind.line);
expect((outcome as AuthFailed).code, 'E-AUTH-08');
expect(
linker.linkedAccounts.keys,
isNot(contains(AuthProviderKind.kakao)),
isNot(contains(AuthProviderKind.line)),
);
});
});
@@ -108,6 +137,13 @@ class _ThrowingAuth extends MockFirebaseAuth {
throw fb.FirebaseAuthException(code: code);
}
/// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task.
class _HangingAuth extends MockFirebaseAuth {
@override
Future<fb.UserCredential> signInWithProvider(fb.AuthProvider provider) =>
Completer<fb.UserCredential>().future;
}
/// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다.
// ignore: must_be_immutable
class _LinkingUser extends MockUser {