diff --git a/.github/actions/install-private-config/action.yml b/.github/actions/install-private-config/action.yml index 314dbe47..61f46b48 100644 --- a/.github/actions/install-private-config/action.yml +++ b/.github/actions/install-private-config/action.yml @@ -1,5 +1,5 @@ name: Install Private Config -description: Fetch Config.xcconfig and GoogleService-Info.plist from a private repository. +description: Fetch Config.xcconfig and an environment-specific GoogleService-Info.plist from a private repository. inputs: git_url: @@ -8,6 +8,9 @@ inputs: git_basic_authorization: description: Base64-encoded basic authorization header value required: true + environment: + description: Firebase environment to install (staging or prod) + required: true ref: description: Git ref to fetch from the private repository required: false @@ -21,14 +24,29 @@ runs: env: PRIVATE_CONFIG_GIT_URL: ${{ inputs.git_url }} PRIVATE_CONFIG_GIT_BASIC_AUTHORIZATION: ${{ inputs.git_basic_authorization }} + PRIVATE_CONFIG_ENVIRONMENT: ${{ inputs.environment }} PRIVATE_CONFIG_REF: ${{ inputs.ref }} run: | set -euo pipefail privateConfigCheckoutPath="$RUNNER_TEMP/private-config" trap 'rm -rf "$privateConfigCheckoutPath"' EXIT + + case "$PRIVATE_CONFIG_ENVIRONMENT" in + staging) + googleServiceInfoFileName="GoogleService-Info-Staging.plist" + ;; + prod) + googleServiceInfoFileName="GoogleService-Info-Prod.plist" + ;; + *) + echo "Invalid Firebase environment: $PRIVATE_CONFIG_ENVIRONMENT (expected staging or prod)" >&2 + exit 1 + ;; + esac + configSourcePath="$privateConfigCheckoutPath/resources/DevLog/Config.xcconfig" - googleServiceInfoSourcePath="$privateConfigCheckoutPath/resources/DevLog/GoogleService-Info.plist" + googleServiceInfoSourcePath="$privateConfigCheckoutPath/resources/DevLog/$googleServiceInfoFileName" configDestinationPath="$GITHUB_WORKSPACE/Application/App/Sources/Resource/Config.xcconfig" googleServiceInfoDestinationPath="$GITHUB_WORKSPACE/Application/App/Sources/Resource/GoogleService-Info.plist" @@ -40,7 +58,7 @@ runs: git -C "$privateConfigCheckoutPath" config core.sparseCheckout true printf '%s\n' \ 'resources/DevLog/Config.xcconfig' \ - 'resources/DevLog/GoogleService-Info.plist' \ + "resources/DevLog/$googleServiceInfoFileName" \ > "$privateConfigCheckoutPath/.git/info/sparse-checkout" git -C "$privateConfigCheckoutPath" \ diff --git a/.github/workflows/appstore.yml b/.github/workflows/appstore.yml index 9a156c53..9cecd566 100644 --- a/.github/workflows/appstore.yml +++ b/.github/workflows/appstore.yml @@ -41,6 +41,7 @@ jobs: with: git_url: ${{ env.MATCH_GIT_URL }} git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }} + environment: prod - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce81e7b5..c71152d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: with: git_url: ${{ env.MATCH_GIT_URL }} git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }} + environment: staging - name: Select Xcode shell: bash @@ -240,6 +241,7 @@ jobs: with: git_url: ${{ env.MATCH_GIT_URL }} git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }} + environment: staging - name: Select Xcode shell: bash diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index af624833..011414f7 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -42,6 +42,7 @@ jobs: with: git_url: ${{ env.MATCH_GIT_URL }} git_basic_authorization: ${{ env.MATCH_GIT_BASIC_AUTHORIZATION }} + environment: staging - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/Application/App/Project.swift b/Application/App/Project.swift index 9c305624..d4aca877 100644 --- a/Application/App/Project.swift +++ b/Application/App/Project.swift @@ -53,14 +53,17 @@ let project = Project( "PRODUCT_MODULE_NAME": "App", ], debug: [ + "APP_ENVIRONMENT": "staging", "APS_ENVIRONMENT": "development", "FIRESTORE_DATABASE_ID": "staging", ], staging: [ + "APP_ENVIRONMENT": "staging", "APS_ENVIRONMENT": "production", "FIRESTORE_DATABASE_ID": "staging", ], release: [ + "APP_ENVIRONMENT": "prod", "APS_ENVIRONMENT": "production", "FIRESTORE_DATABASE_ID": "prod", ] diff --git a/Application/App/Sources/Resource/Info.plist b/Application/App/Sources/Resource/Info.plist index 4ae74740..3b8f5b05 100644 --- a/Application/App/Sources/Resource/Info.plist +++ b/Application/App/Sources/Resource/Info.plist @@ -43,6 +43,8 @@ #else #endif + APP_ENVIRONMENT + $(APP_ENVIRONMENT) FIRESTORE_DATABASE_ID $(FIRESTORE_DATABASE_ID) FUNCTION_API_BASE_URL diff --git a/Application/Infra/Sources/Common/FirebaseConfiguration.swift b/Application/Infra/Sources/Common/FirebaseConfiguration.swift index bb72978c..6667e83b 100644 --- a/Application/Infra/Sources/Common/FirebaseConfiguration.swift +++ b/Application/Infra/Sources/Common/FirebaseConfiguration.swift @@ -8,31 +8,25 @@ import FirebaseFirestore import Foundation +enum FirebaseConfigurationError: Error, Equatable { + case unresolvedValue(String) +} + public enum FirebaseConfiguration { private enum InfoKey { static let databaseID = "FIRESTORE_DATABASE_ID" static let functionAPIBaseURL = "FUNCTION_API_BASE_URL" } - static let defaultDatabaseID = "staging" - public static var databaseID: String { - let environmentValue = ProcessInfo.processInfo.environment[InfoKey.databaseID]? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let environmentValue, !environmentValue.isEmpty { - return environmentValue - } - - guard let rawValue = Bundle.main.object(forInfoDictionaryKey: InfoKey.databaseID) as? String else { - return defaultDatabaseID - } - - let databaseID = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - if databaseID.isEmpty || databaseID.hasPrefix("$(") { - return defaultDatabaseID + do { + return try resolveDatabaseID( + environmentValue: ProcessInfo.processInfo.environment[InfoKey.databaseID], + bundleValue: Bundle.main.object(forInfoDictionaryKey: InfoKey.databaseID) as? String + ) + } catch { + preconditionFailure("\(InfoKey.databaseID) is missing or unresolved.") } - - return databaseID } static var firestore: Firestore { @@ -40,25 +34,53 @@ public enum FirebaseConfiguration { } static func functionAPIBaseURL() throws -> URL { - if let value = resolvedValue(for: InfoKey.functionAPIBaseURL), - let url = URL(string: value) { - return url + try resolveFunctionAPIBaseURL( + environmentValue: ProcessInfo.processInfo.environment[InfoKey.functionAPIBaseURL], + bundleValue: Bundle.main.object(forInfoDictionaryKey: InfoKey.functionAPIBaseURL) as? String + ) + } + + static func resolveDatabaseID( + environmentValue: String?, + bundleValue: String? + ) throws -> String { + guard let value = resolvedValue( + environmentValue: environmentValue, + bundleValue: bundleValue + ) else { + throw FirebaseConfigurationError.unresolvedValue(InfoKey.databaseID) } - throw URLError(.badURL) + return value } - private static func resolvedValue(for key: String) -> String? { - let environmentValue = ProcessInfo.processInfo.environment[key]? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let environmentValue, !environmentValue.isEmpty { - return environmentValue + static func resolveFunctionAPIBaseURL( + environmentValue: String?, + bundleValue: String? + ) throws -> URL { + guard let value = resolvedValue( + environmentValue: environmentValue, + bundleValue: bundleValue + ), + let url = URL(string: value), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host != nil else { + throw URLError(.badURL) } - guard let rawValue = Bundle.main.object(forInfoDictionaryKey: key) as? String else { - return nil - } + return url + } + + private static func resolvedValue( + environmentValue: String?, + bundleValue: String? + ) -> String? { + normalizedValue(environmentValue) ?? normalizedValue(bundleValue) + } + private static func normalizedValue(_ rawValue: String?) -> String? { + guard let rawValue else { return nil } let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty, !value.hasPrefix("$(") else { return nil diff --git a/Application/Infra/Tests/Common/FirebaseConfigurationTests.swift b/Application/Infra/Tests/Common/FirebaseConfigurationTests.swift new file mode 100644 index 00000000..ec799b6c --- /dev/null +++ b/Application/Infra/Tests/Common/FirebaseConfigurationTests.swift @@ -0,0 +1,86 @@ +// +// FirebaseConfigurationTests.swift +// InfraTests +// +// Created by opfic on 7/20/26. +// + +import Foundation +import Testing +@testable import Infra + +struct FirebaseConfigurationTests { + @Test("환경 변수의 database ID를 우선 사용한다") + func 환경_변수의_database_ID를_우선_사용한다() throws { + let databaseID = try FirebaseConfiguration.resolveDatabaseID( + environmentValue: "prod", + bundleValue: "staging" + ) + + #expect(databaseID == "prod") + } + + @Test("환경 변수의 database ID가 비어 있으면 bundle 값을 사용한다") + func 환경_변수의_database_ID가_비어_있으면_bundle_값을_사용한다() throws { + let databaseID = try FirebaseConfiguration.resolveDatabaseID( + environmentValue: " ", + bundleValue: "prod" + ) + + #expect(databaseID == "prod") + } + + @Test("database ID가 누락되면 오류를 반환한다") + func database_ID가_누락되면_오류를_반환한다() { + #expect( + throws: FirebaseConfigurationError.unresolvedValue("FIRESTORE_DATABASE_ID") + ) { + try FirebaseConfiguration.resolveDatabaseID( + environmentValue: nil, + bundleValue: nil + ) + } + } + + @Test("database ID가 미치환 값이면 오류를 반환한다") + func database_ID가_미치환_값이면_오류를_반환한다() { + #expect( + throws: FirebaseConfigurationError.unresolvedValue("FIRESTORE_DATABASE_ID") + ) { + try FirebaseConfiguration.resolveDatabaseID( + environmentValue: nil, + bundleValue: "$(FIRESTORE_DATABASE_ID)" + ) + } + } + + @Test("절대 HTTPS Functions URL을 반환한다") + func 절대_HTTPS_Functions_URL을_반환한다() throws { + let url = try FirebaseConfiguration.resolveFunctionAPIBaseURL( + environmentValue: nil, + bundleValue: "https://example.com/stagingApi/api" + ) + + #expect(url == URL(string: "https://example.com/stagingApi/api")) + } + + @Test("Functions URL이 누락되면 bad URL 오류를 반환한다") + func Functions_URL이_누락되면_bad_URL_오류를_반환한다() { + #expect(throws: URLError.self) { + try FirebaseConfiguration.resolveFunctionAPIBaseURL( + environmentValue: nil, + bundleValue: nil + ) + } + } + + @Test("Functions URL이 상대 경로이면 bad URL 오류를 반환한다") + func Functions_URL이_상대_경로이면_bad_URL_오류를_반환한다() { + #expect(throws: URLError.self) { + try FirebaseConfiguration.resolveFunctionAPIBaseURL( + environmentValue: nil, + bundleValue: "stagingApi/api" + ) + } + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Settings/SettingsFeature.swift b/Application/Presentation/ProfileTab/Sources/Settings/SettingsFeature.swift index f2201985..03d3a026 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/SettingsFeature.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/SettingsFeature.swift @@ -35,7 +35,7 @@ struct SettingsFeature { var alertType: Action.AlertType? var appVersion = Self.appVersion() var betaTestURL = Self.betaTestURL( - databaseID: Bundle.main.object(forInfoDictionaryKey: "FIRESTORE_DATABASE_ID") as? String, + appEnvironment: Bundle.main.object(forInfoDictionaryKey: "APP_ENVIRONMENT") as? String, testFlightURL: Bundle.main.object(forInfoDictionaryKey: "TESTFLIGHT_URL") as? String ) var policyURL = Bundle.main.object(forInfoDictionaryKey: "PRIVACY_POLICY_URL") as? String @@ -44,8 +44,8 @@ struct SettingsFeature { loading.isLoading } - static func betaTestURL(databaseID: String?, testFlightURL: String?) -> URL? { - guard databaseID?.trimmingCharacters(in: .whitespacesAndNewlines) == "prod", + static func betaTestURL(appEnvironment: String?, testFlightURL: String?) -> URL? { + guard appEnvironment?.trimmingCharacters(in: .whitespacesAndNewlines) == "prod", let rawValue = testFlightURL else { return nil } diff --git a/Application/Presentation/ProfileTab/Tests/Settings/SettingsFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Settings/SettingsFeatureTests.swift index 2c855e62..96236428 100644 --- a/Application/Presentation/ProfileTab/Tests/Settings/SettingsFeatureTests.swift +++ b/Application/Presentation/ProfileTab/Tests/Settings/SettingsFeatureTests.swift @@ -5,14 +5,45 @@ // Created by opfic on 6/12/26. // -import Testing import Core import Domain +import Foundation import PresentationShared +import Testing @testable import ProfileTab @MainActor struct SettingsFeatureTests { + @Test("prod 환경이면 베타 테스트 URL을 제공한다") + func prod_환경이면_베타_테스트_URL을_제공한다() { + let url = SettingsFeature.State.betaTestURL( + appEnvironment: "prod", + testFlightURL: "https://testflight.apple.com/join/b8mpr4UN" + ) + + #expect(url == URL(string: "https://testflight.apple.com/join/b8mpr4UN")) + } + + @Test("staging 환경이면 베타 테스트 URL을 제공하지 않는다") + func staging_환경이면_베타_테스트_URL을_제공하지_않는다() { + let url = SettingsFeature.State.betaTestURL( + appEnvironment: "staging", + testFlightURL: "https://testflight.apple.com/join/b8mpr4UN" + ) + + #expect(url == nil) + } + + @Test("미치환 환경값이면 베타 테스트 URL을 제공하지 않는다") + func 미치환_환경값이면_베타_테스트_URL을_제공하지_않는다() { + let url = SettingsFeature.State.betaTestURL( + appEnvironment: "$(APP_ENVIRONMENT)", + testFlightURL: "https://testflight.apple.com/join/b8mpr4UN" + ) + + #expect(url == nil) + } + @Test("네트워크 상태 관찰 결과를 상태에 반영한다") func 네트워크_상태_관찰_결과를_상태에_반영한다() async { let networkSpy = ObserveNetworkConnectivityUseCaseSpy()