From e955edf924911ed940e04cb40e721ae3552e5063 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 20 Jul 2026 17:05:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=84=20Firebase=20=EC=84=A4=EC=A0=95=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../actions/install-private-config/action.yml | 24 ++++++++++++++++--- .github/workflows/appstore.yml | 1 + .github/workflows/ci.yml | 2 ++ .github/workflows/testflight.yml | 1 + 4 files changed, 25 insertions(+), 3 deletions(-) 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 From 8a74104c9f1d6379e0257168dc442b5e29155cad Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 20 Jul 2026 17:27:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=95=B1=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=ED=8C=90=EB=B3=84=20=EA=B8=B0=EC=A4=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/App/Project.swift | 3 ++ Application/App/Sources/Resource/Info.plist | 2 ++ .../Sources/Settings/SettingsFeature.swift | 6 ++-- .../Tests/Settings/SettingsFeatureTests.swift | 33 ++++++++++++++++++- 4 files changed, 40 insertions(+), 4 deletions(-) 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/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() From 6e1f0f7f2e6d57d0a4df3e699bb4c8df7c33d52a Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 20 Jul 2026 17:27:44 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20Firebase=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=88=84=EB=9D=BD=20=EC=8B=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/FirebaseConfiguration.swift | 80 ++++++++++------- .../Common/FirebaseConfigurationTests.swift | 86 +++++++++++++++++++ 2 files changed, 137 insertions(+), 29 deletions(-) create mode 100644 Application/Infra/Tests/Common/FirebaseConfigurationTests.swift 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" + ) + } + } +}