173 lines
6.8 KiB
Dart
173 lines
6.8 KiB
Dart
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')),
|
|
);
|
|
});
|
|
|
|
test('MainActivity 가 패키지 기본 task 에 있어 LINE 로그인 후 앱으로 돌아온다', () {
|
|
// LINE SDK 는 lineauth:// 복귀 시 결과 화면을 NEW_TASK|CLEAR_TOP 으로 띄워 패키지
|
|
// affinity 의 task 를 찾는다. MainActivity 의 taskAffinity 가 비어 있으면 새 task 가
|
|
// 생겨 Chrome 맞춤 탭이 앱 위에 남는다(사용자가 X 를 눌러야 로그인이 끝난다).
|
|
final manifest = _read('android/app/src/main/AndroidManifest.xml');
|
|
final mainActivity = RegExp(
|
|
r'<activity\s[^>]*android:name="\.MainActivity"[^>]*>',
|
|
).firstMatch(manifest)?.group(0);
|
|
expect(mainActivity, isNotNull);
|
|
expect(mainActivity, isNot(contains('taskAffinity')));
|
|
});
|
|
});
|
|
|
|
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',
|
|
);
|
|
});
|
|
});
|
|
}
|