import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; /// 스토어 제출 준비물 검사. /// /// **여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다.** 문제는 심사에 넣거나 /// 사용자가 권한을 누르는 순간에야 드러나고, 그때는 원인이 코드처럼 보인다. /// 실제로 이 프로젝트에서 최소 지원 OS 가 계약값과 다른 채로 나가고 있었다. /// /// 파일을 문자열로 읽는 이유는 plist·gradle 파서를 붙이지 않기 위해서다. /// 키가 있는지만 보면 되고, 값의 해석은 빌드가 한다. String _read(String path) { final file = File(path); expect(file.existsSync(), isTrue, reason: '$path 가 없습니다'); return file.readAsStringSync(); } void main() { group('iOS — 권한 사용 문구', () { late final String plist = _read('ios/Runner/Info.plist'); test('권한을 요청하는 기능마다 문구가 있다', () { // 문구 없이 요청하면 iOS 가 앱을 즉시 종료한다. 크래시로 보인다. for (final key in const [ 'NSPhotoLibraryUsageDescription', // 프로필 사진 'NSCameraUsageDescription', // 프로필 · 방문 인증 사진 'NSLocationWhenInUseUsageDescription', // 위치 퀘스트 'NSMotionUsageDescription', // 만보기 'NSHealthShareUsageDescription', // 걸음 수 'NSUserTrackingUsageDescription', // 광고 ]) { expect(plist, contains(key), reason: key); } }); test('문구가 용도를 말한다', () { // 「접근이 필요합니다」처럼 용도 없는 문장은 심사 반려 사유다. expect(plist, contains('프로필 사진을 고르기 위해')); expect(plist, contains('퀘스트 보상을 드리기 위해')); expect(plist, contains('다른 건강 정보는 읽지 않습니다')); }); }); group('iOS — 제출 선언', () { late final String plist = _read('ios/Runner/Info.plist'); late final String entitlements = _read('ios/Runner/Runner.entitlements'); test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () { expect(plist, contains('ITSAppUsesNonExemptEncryption')); }); test('Google 로그인 콜백 스킴 자리가 있다', () { expect(plist, contains('CFBundleURLTypes')); // 값은 Firebase 설정 파일에서 뽑아 넣는다. 손으로 적으면 오타로 조용히 실패한다. expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)')); }); test('Sign in with Apple Capability 가 있다', () { // 정책 §P-01 — 소셜 로그인을 제공하면 Apple 심사 필수 조건이다. expect(entitlements, contains('com.apple.developer.applesignin')); }); test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로만 서명한다', () { // 다른 팀으로 서명하면 업로드가 그 팀의 App Store Connect 로 간다(10/2 실제 발생). const teamId = 'V28G543VK5'; // Fancake Inc. final pbx = _read('ios/Runner.xcodeproj/project.pbxproj'); final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);') .allMatches(pbx) .map((m) => m.group(1)) .toSet(); expect(teams, {teamId}); final options = _read('ios/ExportOptions.plist'); expect(options, contains('app-store-connect')); expect(options, contains(teamId)); }); test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () { // 번들에 없으면 Firebase 가 목 모드로 뜨고 Google 로그인 client ID 도 못 읽는다. expect( _read('ios/Runner.xcodeproj/project.pbxproj'), contains('/* GoogleService-Info.plist in Resources */,'), ); }); }); group('Android — 계약값과 배포 경계', () { late final String gradle = _read('android/app/build.gradle.kts'); test('최소 지원 OS 가 계약값 API 29 다', () { // 별첨 1 v1.0 이 Android 10 으로 정했다. Flutter 기본값 24 로 두면 // 검증하지 않은 기기까지 설치가 열린다. expect(gradle, contains('minSdk = 29')); }); test('평문 예외가 개발 소스 세트에만 있다', () { for (final variant in const ['debug', 'profile']) { expect( File('android/app/src/$variant/res/xml/network_security_config.xml') .existsSync(), isTrue, reason: variant, ); } // main 에 있으면 릴리스 빌드로 새어 중간자 공격 표면이 열린다. expect( File('android/app/src/main/res/xml/network_security_config.xml') .existsSync(), isFalse, ); expect( _read('android/app/src/main/AndroidManifest.xml'), isNot(contains('networkSecurityConfig')), ); }); }); group('서명 자산이 저장소에 올라가지 않는다', () { late final String ignore = _read('.gitignore'); test('키스토어·설정 파일 패턴이 걸려 있다', () { for (final pattern in const [ 'key.properties', '*.jks', '*.keystore', '*.p12', '*.p8', '*.mobileprovision', 'google-services.json', 'GoogleService-Info.plist', ]) { expect(ignore, contains(pattern), reason: pattern); } }); test('git 이 실제로 추적하고 있지 않다', () { // .gitignore 에 적혀 있어도 이미 추적 중이면 계속 올라간다. // 패턴 검사만으로는 그 상태를 잡지 못하므로 git 에 직접 묻는다. final result = Process.runSync('git', [ 'ls-files', '--', 'android/app/google-services.json', 'ios/Runner/GoogleService-Info.plist', 'android/key.properties', '*.jks', '*.keystore', '*.p8', ], workingDirectory: '.'); expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다'); final tracked = (result.stdout as String).trim(); expect( tracked, isEmpty, reason: '서명·설정 자산이 저장소에 올라가 있습니다:\n$tracked', ); }); }); }