diff --git a/FirebaseAppCheck.podspec b/FirebaseAppCheck.podspec index fb8f438b0b..c29ef0194f 100644 --- a/FirebaseAppCheck.podspec +++ b/FirebaseAppCheck.podspec @@ -44,7 +44,7 @@ Pod::Spec.new do |s| s.osx.weak_framework = 'DeviceCheck' s.tvos.weak_framework = 'DeviceCheck' - s.dependency 'AppCheckCore', '~> 12.0' + s.dependency 'AppCheckCore', '~> 12.1' s.dependency 'FirebaseAppCheckInterop', '~> 13.2.0' s.dependency 'FirebaseCore', '~> 13.2.0' s.dependency 'GoogleUtilities/Environment', '>= 8.1.3', '< 9.0' diff --git a/FirebaseAppCheck/CHANGELOG.md b/FirebaseAppCheck/CHANGELOG.md index bcd31c39c6..dfb1580a07 100644 --- a/FirebaseAppCheck/CHANGELOG.md +++ b/FirebaseAppCheck/CHANGELOG.md @@ -1,3 +1,14 @@ +# 13.2.0 +- [added] `AppCheckDebugProvider` now automatically uses a debug token + provisioned by the Firebase CLI (`firebase appcheck:debugtokens:create`) when + running in a Simulator. The token is read for the app's Google App ID from + `~/.config/configstore/firebase-appcheck.json` on the host machine, so no + manual token registration is needed. The `AppCheckDebugToken` and + `FIRAAppCheckDebugToken` environment variables still take priority. (#17017) +- [fixed] When the `AppCheckDebugToken` or `FIRAAppCheckDebugToken` environment + variable is set, `AppCheckDebugProvider` no longer generates an unused local + debug token and logs registration instructions for it. (#17017) + # 12.19.0 - [fixed] Fixed an issue where the Recaptcha provider could cause build errors in binary environments, due to unsupported platforms. (#16564) diff --git a/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h b/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h index 373f43b22e..03d3d2fab5 100644 --- a/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h +++ b/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h @@ -35,10 +35,13 @@ FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeProviderIsMissing // FIRAppCheckDebugProvider.m FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageDebugProviderIncompleteFIROptions; FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeDebugTokenExchangeFailed; - -// FIRAppCheckDebugProviderFactory.m +FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeDebugTokenFromConfigStore; +FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeConfigStoreDebugTokenExchangeFailed; FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeDebugToken; +// FIRAppCheckDebugTokenConfigStore.m +FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeDebugTokenConfigStoreUnreadable; + // FIRDeviceCheckProvider.m FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageDeviceCheckProviderIncompleteFIROptions; diff --git a/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.m b/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.m index 0693d4bc41..d77349ae18 100644 --- a/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.m +++ b/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.m @@ -29,10 +29,13 @@ NSString *const kFIRLoggerAppCheckMessageCodeProviderIsMissing = @"I-FAA002002"; // FIRAppCheckDebugProvider.m NSString *const kFIRLoggerAppCheckMessageDebugProviderIncompleteFIROptions = @"I-FAA004001"; NSString *const kFIRLoggerAppCheckMessageCodeDebugTokenExchangeFailed = @"I-FAA004002"; - -// FIRAppCheckDebugProviderFactory.m +NSString *const kFIRLoggerAppCheckMessageCodeDebugTokenFromConfigStore = @"I-FAA004003"; +NSString *const kFIRLoggerAppCheckMessageCodeConfigStoreDebugTokenExchangeFailed = @"I-FAA004004"; NSString *const kFIRLoggerAppCheckMessageCodeDebugToken = @"I-FAA005001"; +// FIRAppCheckDebugTokenConfigStore.m +NSString *const kFIRLoggerAppCheckMessageCodeDebugTokenConfigStoreUnreadable = @"I-FAA004005"; + // FIRDeviceCheckProvider.m NSString *const kFIRLoggerAppCheckMessageDeviceCheckProviderIncompleteFIROptions = @"I-FAA006001"; diff --git a/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugProvider.m b/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugProvider.m index d08e314b3e..9a161d8623 100644 --- a/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugProvider.m +++ b/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugProvider.m @@ -29,6 +29,7 @@ #import "FirebaseAppCheck/Sources/Core/FIRAppCheckToken+Internal.h" #import "FirebaseAppCheck/Sources/Core/FIRAppCheckValidator.h" #import "FirebaseAppCheck/Sources/Core/FIRHeartbeatLogger+AppCheck.h" +#import "FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.h" #import "FirebaseCore/Extension/FirebaseCoreInternal.h" @@ -39,6 +40,12 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, readonly) GACAppCheckDebugProvider *debugProvider; @property(nonatomic, readonly, copy, nullable) NSString *projectID; @property(nonatomic, readonly, copy) NSString *googleAppID; +/// Where `debugProvider` got the debug token it exchanges. +/// +/// The only token `-initWithApp:` passes to `debugProvider` is one read from the Firebase CLI +/// config store on the host machine, so `GACAppCheckDebugTokenSourceProvided` means that token is +/// in use. +@property(nonatomic, readonly) GACAppCheckDebugTokenSource debugTokenSource; @end @@ -52,8 +59,22 @@ NS_ASSUME_NONNULL_BEGIN _debugProvider = debugProvider; _projectID = [projectID copy]; _googleAppID = [googleAppID copy]; + _debugTokenSource = debugProvider.debugTokenSource; - [self logDebugTokenMessageWithMessageCode:kFIRLoggerAppCheckMessageCodeDebugToken prefix:@""]; + switch (_debugTokenSource) { // No `default:` so `-Wswitch` flags new AppCheckCore sources. + case GACAppCheckDebugTokenSourceLocal: + [self logDebugTokenMessageWithMessageCode:kFIRLoggerAppCheckMessageCodeDebugToken + prefix:@""]; + break; + case GACAppCheckDebugTokenSourceProvided: + FIRLogNotice(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeDebugTokenFromConfigStore, + @"Debug token loaded from ~/%@.", + FIRAppCheckDebugTokenConfigStore.relativePath); + break; + case GACAppCheckDebugTokenSourceEnvironmentVariable: + // App Check Core logs that the environment variable is used; the local token is unused. + break; + } } return self; } @@ -69,12 +90,18 @@ NS_ASSUME_NONNULL_BEGIN return nil; } + // In a Simulator, use the debug token provisioned by the Firebase CLI on the host machine (if + // any) instead of generating a new one that would need to be registered manually. + NSString *configStoreDebugToken = + [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:app.options.googleAppID]; + GACAppCheckDebugProvider *debugProvider = [[GACAppCheckDebugProvider alloc] initWithServiceName:app.name resourceName:app.resourceName baseURL:nil APIKey:app.options.APIKey - requestHooks:@[ [app.heartbeatLogger requestHook] ]]; + requestHooks:@[ [app.heartbeatLogger requestHook] ] + debugToken:configStoreDebugToken]; return [self initWithDebugProvider:debugProvider projectID:app.options.projectID @@ -119,9 +146,57 @@ NS_ASSUME_NONNULL_BEGIN } } -- (void)logDebugTokenExchangeError { - [self logDebugTokenMessageWithMessageCode:kFIRLoggerAppCheckMessageCodeDebugTokenExchangeFailed - prefix:@"Failed to exchange debug token. "]; +- (void)logDebugTokenExchangeError:(NSError *)error { + if (self.debugTokenSource == GACAppCheckDebugTokenSourceLocal) { + [self logDebugTokenMessageWithMessageCode:kFIRLoggerAppCheckMessageCodeDebugTokenExchangeFailed + prefix:@"Failed to exchange debug token. "]; + return; + } + + NSString *message = [self debugTokenExchangeFailureMessageForError:error]; + if (!message) { + return; + } + NSString *messageCode = self.debugTokenSource == GACAppCheckDebugTokenSourceProvided + ? kFIRLoggerAppCheckMessageCodeConfigStoreDebugTokenExchangeFailed + : kFIRLoggerAppCheckMessageCodeDebugTokenExchangeFailed; + FIRLogWarning(kFIRLoggerAppCheck, messageCode, @"%@", message); +} + +/// Returns the message to log when exchanging an environment variable or config store debug token +/// fails, or `nil` when there is nothing actionable to report (e.g., the server was unreachable). +/// +/// Unlike the local token's registration instructions, these messages never include the token. +- (nullable NSString *)debugTokenExchangeFailureMessageForError:(NSError *)error { + if ([error.domain isEqualToString:GACAppCheckErrors.errorDomain] && + error.code == GACAppCheckErrorCodeServerUnreachable) { + return nil; + } + + NSString *projectArgument = self.projectID.length > 0 + ? [NSString stringWithFormat:@" --project %@", self.projectID] + : @""; + // App Check Core errors describe the failure in `NSLocalizedFailureReasonErrorKey`; using it + // directly avoids Foundation's generic "The operation couldn’t be completed." prefix. + NSString *errorDescription = error.localizedFailureReason ?: error.localizedDescription; + if (self.debugTokenSource == GACAppCheckDebugTokenSourceProvided) { + // The config store is only read at launch, so a new token requires relaunching the app. + return [NSString stringWithFormat:@"Failed to exchange the debug token loaded from ~/%@: %@\n\n" + @"If the token was rejected by the server, run " + @"`firebase appcheck:debugtokens:create --force%@ --app %@` " + @"to provision a new token, then relaunch the app.", + FIRAppCheckDebugTokenConfigStore.relativePath, + errorDescription, projectArgument, self.googleAppID]; + } + if (self.debugTokenSource == GACAppCheckDebugTokenSourceEnvironmentVariable) { + return [NSString + stringWithFormat:@"Failed to exchange the debug token from the `AppCheckDebugToken` or " + @"`FIRAAppCheckDebugToken` environment variable: %@\n\n" + @"Make sure the token is registered for app %@, for example with " + @"`firebase appcheck:debugtokens:create %@ --app %@`.", + errorDescription, self.googleAppID, projectArgument, self.googleAppID]; + } + return nil; } #pragma mark - FIRAppCheckProvider @@ -131,7 +206,7 @@ NS_ASSUME_NONNULL_BEGIN [self.debugProvider getTokenWithCompletion:^(GACAppCheckToken *_Nullable internalToken, NSError *_Nullable error) { if (error) { - [self logDebugTokenExchangeError]; + [self logDebugTokenExchangeError:error]; handler(nil, error); return; } @@ -145,7 +220,7 @@ NS_ASSUME_NONNULL_BEGIN [self.debugProvider getLimitedUseTokenWithCompletion:^(GACAppCheckToken *_Nullable internalToken, NSError *_Nullable error) { if (error) { - [self logDebugTokenExchangeError]; + [self logDebugTokenExchangeError:error]; handler(nil, error); return; } diff --git a/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.h b/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.h new file mode 100644 index 0000000000..7bb0062a65 --- /dev/null +++ b/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.h @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Reads App Check debug tokens provisioned by the Firebase CLI +/// (`firebase appcheck:debugtokens:create`) into the config store file on the developer's host +/// machine (`~/.config/configstore/firebase-appcheck.json`). +/// +/// Only apps running in a Simulator (iOS, tvOS, watchOS, or visionOS) can reach the host file +/// system, using the `SIMULATOR_HOST_HOME` environment variable that the Simulator sets to the +/// host user's home directory. On devices, macOS, and Mac Catalyst the lookup is compiled out and +/// always returns `nil`. +@interface FIRAppCheckDebugTokenConfigStore : NSObject + +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; + +/// The path of the config store file, relative to the host user's home directory. +@property(class, nonatomic, readonly) NSString *relativePath; + +/// Returns the debug token provisioned for the given app, or `nil` when not running in a +/// Simulator, when `SIMULATOR_HOST_HOME` is unset, or when the config store file is missing, +/// unreadable, or contains no token for the app. +/// +/// @param googleAppID The Google App ID of the `FirebaseApp` to look up. ++ (nullable NSString *)debugTokenForGoogleAppID:(NSString *)googleAppID; + +/// Variant of `+debugTokenForGoogleAppID:` that reads `SIMULATOR_HOST_HOME` from the given +/// environment rather than from the current process. Exposed for testing. ++ (nullable NSString *)debugTokenForGoogleAppID:(NSString *)googleAppID + environment:(NSDictionary *)environment; + +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.m b/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.m new file mode 100644 index 0000000000..f172fe4c9b --- /dev/null +++ b/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.m @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.h" + +#import +#import + +#import "FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h" + +#import "FirebaseCore/Extension/FirebaseCoreInternal.h" + +NS_ASSUME_NONNULL_BEGIN + +#if TARGET_OS_SIMULATOR +/// Environment variable set by the Simulator to the host user's home directory. +static NSString *const kSimulatorHostHomeEnvKey = @"SIMULATOR_HOST_HOME"; + +/// Top-level key of the config store file mapping Google App IDs to debug tokens. +static NSString *const kTokensKey = @"tokens"; +#endif // TARGET_OS_SIMULATOR + +@implementation FIRAppCheckDebugTokenConfigStore + ++ (NSString *)relativePath { + return @".config/configstore/firebase-appcheck.json"; +} + +#if TARGET_OS_SIMULATOR +/// Returns whether `error`, from reading the config store file, means that the file does not exist. +/// +/// Besides a missing file, this includes a path component that is a regular file rather than a +/// directory (e.g., `~/.config`), since the config store cannot exist at that path either. ++ (BOOL)isMissingConfigStoreError:(nullable NSError *)error { + if ([error.domain isEqualToString:NSCocoaErrorDomain] && + error.code == NSFileReadNoSuchFileError) { + return YES; + } + NSError *underlyingError = error.userInfo[NSUnderlyingErrorKey]; + if (![underlyingError isKindOfClass:[NSError class]]) { + return NO; + } + return [underlyingError.domain isEqualToString:NSPOSIXErrorDomain] && + underlyingError.code == ENOTDIR; +} +#endif // TARGET_OS_SIMULATOR + ++ (nullable NSString *)debugTokenForGoogleAppID:(NSString *)googleAppID { + return [self debugTokenForGoogleAppID:googleAppID + environment:[NSProcessInfo processInfo].environment]; +} + ++ (nullable NSString *)debugTokenForGoogleAppID:(NSString *)googleAppID + environment: + (NSDictionary *)environment { +#if TARGET_OS_SIMULATOR + NSString *hostHome = environment[kSimulatorHostHomeEnvKey]; + if (hostHome.length == 0 || googleAppID.length == 0) { + return nil; + } + + NSURL *fileURL = [[NSURL fileURLWithPath:hostHome + isDirectory:YES] URLByAppendingPathComponent:self.relativePath + isDirectory:NO]; + NSError *readError; + NSData *data = [NSData dataWithContentsOfURL:fileURL options:0 error:&readError]; + if (!data) { + // A missing file (or a path where it cannot exist) means the Firebase CLI flow is not in use. + if (![self isMissingConfigStoreError:readError]) { + FIRLogWarning(kFIRLoggerAppCheck, + kFIRLoggerAppCheckMessageCodeDebugTokenConfigStoreUnreadable, + @"Failed to read App Check debug tokens from ~/%@: %@", self.relativePath, + readError.localizedDescription); + } + return nil; + } + + NSError *parseError; + id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseError]; + if (![json isKindOfClass:[NSDictionary class]]) { + FIRLogWarning(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeDebugTokenConfigStoreUnreadable, + @"Failed to parse App Check debug tokens from ~/%@: %@", self.relativePath, + parseError.localizedDescription ?: @"expected a JSON object"); + return nil; + } + + NSDictionary *tokens = json[kTokensKey]; + if (!tokens) { + // A config store without `tokens` (e.g., `{}` after the last token was removed) is valid. + return nil; + } + if (![tokens isKindOfClass:[NSDictionary class]]) { + FIRLogWarning(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeDebugTokenConfigStoreUnreadable, + @"Failed to parse App Check debug tokens from ~/%@: `%@` is not a JSON object", + self.relativePath, kTokensKey); + return nil; + } + + NSString *token = tokens[googleAppID]; + if (![token isKindOfClass:[NSString class]] || token.length == 0) { + return nil; + } + return token; +#else + return nil; +#endif // TARGET_OS_SIMULATOR +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckDebugProvider.h b/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckDebugProvider.h index f7d2ff1ca5..84289afa97 100644 --- a/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckDebugProvider.h +++ b/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckDebugProvider.h @@ -47,6 +47,11 @@ NS_ASSUME_NONNULL_BEGIN /// Once the debug token is registered the debug provider will be able to provide a valid Firebase /// App Check token. /// +/// Alternatively, provision a debug token with the Firebase CLI +/// (`firebase appcheck:debugtokens:create --app `), which registers it and saves it to +/// `~/.config/configstore/firebase-appcheck.json`. Apps launched from Xcode in a Simulator +/// pick up the saved token automatically, so no manual registration is needed. +/// /// To use `AppCheckDebugProvider` on a simulator on a build server: /// 1. Create a new Firebase App Check debug token in the Firebase console /// 2. Add the debug token to the secure storage of your build environment. E.g. see [Encrypted @@ -73,6 +78,9 @@ NS_SWIFT_NAME(AppCheckDebugProvider) /** Returns the currently used App Check debug token. The priority: * - `AppCheckDebugToken` env variable value * - `FIRAAppCheckDebugToken` env variable value (deprecated) + * - Simulator only: the token for the app's Google App ID in + * `~/.config/configstore/firebase-appcheck.json` on the host machine, as + * provisioned by `firebase appcheck:debugtokens:create` * - A previously generated token, stored locally on the device * - A newly generated random token. The generated token will be stored * locally for future use diff --git a/FirebaseAppCheck/Tests/Unit/DebugProvider/FIRAppCheckDebugProviderTests.m b/FirebaseAppCheck/Tests/Unit/DebugProvider/FIRAppCheckDebugProviderTests.m index 3a67e81f46..a738db1055 100644 --- a/FirebaseAppCheck/Tests/Unit/DebugProvider/FIRAppCheckDebugProviderTests.m +++ b/FirebaseAppCheck/Tests/Unit/DebugProvider/FIRAppCheckDebugProviderTests.m @@ -14,6 +14,7 @@ * limitations under the License. */ +#import #import #import @@ -21,6 +22,7 @@ #import "FirebaseAppCheck/Sources/Core/FIRApp+AppCheck.h" #import "FirebaseAppCheck/Sources/Core/FIRAppCheckToken+Internal.h" #import "FirebaseAppCheck/Sources/Core/FIRHeartbeatLogger+AppCheck.h" +#import "FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.h" #import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckDebugProvider.h" #import "FirebaseCore/Extension/FirebaseCoreInternal.h" @@ -36,8 +38,11 @@ static NSString *const kProjectNumber = @"123456789"; - (instancetype)initWithDebugProvider:(GACAppCheckDebugProvider *)debugProvider projectID:(nullable NSString *)projectID googleAppID:(NSString *)googleAppID; +- (nullable NSString *)debugTokenExchangeFailureMessageForError:(NSError *)error; + @property(nonatomic, readonly, copy, nullable) NSString *projectID; @property(nonatomic, readonly, copy) NSString *googleAppID; +@property(nonatomic, readonly) GACAppCheckDebugTokenSource debugTokenSource; @end @@ -45,6 +50,8 @@ static NSString *const kProjectNumber = @"123456789"; @property(nonatomic, copy) NSString *resourceName; @property(nonatomic) id debugProviderMock; +/// Returned by `debugProviderMock` whenever `debugTokenSource` is called. +@property(nonatomic) GACAppCheckDebugTokenSource stubbedDebugTokenSource; @property(nonatomic) FIRAppCheckDebugProvider *provider; @end @@ -54,6 +61,15 @@ static NSString *const kProjectNumber = @"123456789"; - (void)setUp { self.resourceName = [NSString stringWithFormat:@"projects/%@/apps/%@", kProjectID, kAppID]; self.debugProviderMock = OCMStrictClassMock([GACAppCheckDebugProvider class]); + // A single stub that reads `stubbedDebugTokenSource`, since OCMock uses the first matching stub: + // a later `OCMStub` or `OCMExpect` for `debugTokenSource` would be shadowed by this one. Set + // `stubbedDebugTokenSource` instead. + __weak __typeof__(self) weakSelf = self; + OCMStub([self.debugProviderMock debugTokenSource]).andDo(^(NSInvocation *invocation) { + GACAppCheckDebugTokenSource source = weakSelf.stubbedDebugTokenSource; + [invocation setReturnValue:&source]; + }); + self.stubbedDebugTokenSource = GACAppCheckDebugTokenSourceLocal; self.provider = [[FIRAppCheckDebugProvider alloc] initWithDebugProvider:self.debugProviderMock projectID:nil googleAppID:kAppID]; @@ -84,6 +100,7 @@ static NSString *const kProjectNumber = @"123456789"; - (void)testInitWithDebugProvider { XCTAssertNil(self.provider.projectID); XCTAssertEqualObjects(self.provider.googleAppID, kAppID); + XCTAssertEqual(self.provider.debugTokenSource, GACAppCheckDebugTokenSourceLocal); } - (void)testInitWithIncompleteApp { @@ -103,6 +120,241 @@ static NSString *const kProjectNumber = @"123456789"; XCTAssertNil([[FIRAppCheckDebugProvider alloc] initWithApp:missingProjectIDApp]); } +#pragma mark - Debug token source + +- (void)testInitWithConfigStoreDebugTokenLogsAttribution { + self.stubbedDebugTokenSource = GACAppCheckDebugTokenSourceProvided; + + FIRAppCheckDebugProvider *provider = + [[FIRAppCheckDebugProvider alloc] initWithDebugProvider:self.debugProviderMock + projectID:kProjectID + googleAppID:kAppID]; + + XCTAssertEqual(provider.debugTokenSource, GACAppCheckDebugTokenSourceProvided); + // `localDebugToken` is never stubbed, so the strict mock fails if the local token is logged. + OCMVerifyAll(self.debugProviderMock); +} + +- (void)testInitWithEnvironmentVariableDebugTokenDoesNotLogLocalToken { + self.stubbedDebugTokenSource = GACAppCheckDebugTokenSourceEnvironmentVariable; + + FIRAppCheckDebugProvider *provider = + [[FIRAppCheckDebugProvider alloc] initWithDebugProvider:self.debugProviderMock + projectID:kProjectID + googleAppID:kAppID]; + + XCTAssertEqual(provider.debugTokenSource, GACAppCheckDebugTokenSourceEnvironmentVariable); + // `localDebugToken` is never stubbed, so the strict mock fails if an unused local token is + // generated or logged. + OCMVerifyAll(self.debugProviderMock); +} + +- (void)testInitWithLocalDebugTokenLogsLocalToken { + self.stubbedDebugTokenSource = GACAppCheckDebugTokenSourceLocal; + OCMExpect([self.debugProviderMock localDebugToken]).andReturn(@"local_token"); + + FIRAppCheckDebugProvider *provider = + [[FIRAppCheckDebugProvider alloc] initWithDebugProvider:self.debugProviderMock + projectID:kProjectID + googleAppID:kAppID]; + + XCTAssertEqual(provider.debugTokenSource, GACAppCheckDebugTokenSourceLocal); + OCMVerifyAll(self.debugProviderMock); +} + +- (void)testGetTokenAPIErrorWithConfigStoreDebugTokenDoesNotLogLocalToken { + FIRAppCheckDebugProvider *provider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceProvided projectID:kProjectID]; + NSError *expectedError = [NSError errorWithDomain:@"testGetTokenAPIError" code:-1 userInfo:nil]; + OCMExpect([self.debugProviderMock + getTokenWithCompletion:([OCMArg invokeBlockWithArgs:[NSNull null], expectedError, nil])]); + __block BOOL handlerCalled = NO; + + [provider getTokenWithCompletion:^(FIRAppCheckToken *_Nullable token, NSError *_Nullable error) { + handlerCalled = YES; + XCTAssertNil(token); + XCTAssertEqualObjects(error, expectedError); + }]; + + XCTAssertTrue(handlerCalled); + // `localDebugToken` is never stubbed, so the strict mock fails if the fallback message is logged. + OCMVerifyAll(self.debugProviderMock); +} + +- (void)testGetLimitedUseTokenAPIErrorWithConfigStoreDebugTokenDoesNotLogLocalToken { + FIRAppCheckDebugProvider *provider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceProvided projectID:kProjectID]; + NSError *expectedError = [NSError errorWithDomain:@"testGetLimitedUseTokenAPIError" + code:-1 + userInfo:nil]; + OCMExpect([self.debugProviderMock + getLimitedUseTokenWithCompletion:([OCMArg invokeBlockWithArgs:[NSNull null], expectedError, + nil])]); + __block BOOL handlerCalled = NO; + + [provider getLimitedUseTokenWithCompletion:^(FIRAppCheckToken *_Nullable token, + NSError *_Nullable error) { + handlerCalled = YES; + XCTAssertNil(token); + XCTAssertEqualObjects(error, expectedError); + }]; + + XCTAssertTrue(handlerCalled); + // `localDebugToken` is never stubbed, so the strict mock fails if the fallback message is logged. + OCMVerifyAll(self.debugProviderMock); +} + +- (void)testGetTokenAPIErrorWithEnvironmentVariableDebugTokenDoesNotLogLocalToken { + FIRAppCheckDebugProvider *provider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceEnvironmentVariable + projectID:kProjectID]; + NSError *expectedError = [NSError errorWithDomain:@"testGetTokenAPIError" code:-1 userInfo:nil]; + OCMExpect([self.debugProviderMock + getTokenWithCompletion:([OCMArg invokeBlockWithArgs:[NSNull null], expectedError, nil])]); + __block BOOL handlerCalled = NO; + + [provider getTokenWithCompletion:^(FIRAppCheckToken *_Nullable token, NSError *_Nullable error) { + handlerCalled = YES; + XCTAssertNil(token); + XCTAssertEqualObjects(error, expectedError); + }]; + + XCTAssertTrue(handlerCalled); + // `localDebugToken` is never stubbed, so the strict mock fails if the fallback message is logged. + OCMVerifyAll(self.debugProviderMock); +} + +#pragma mark - Exchange failure messages + +- (void)testConfigStoreExchangeFailureMessageSuggestsForceCreateAndRelaunch { + FIRAppCheckDebugProvider *provider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceProvided projectID:kProjectID]; + NSError *error = [NSError errorWithDomain:@"testDomain" + code:-1 + userInfo:@{NSLocalizedDescriptionKey : @"Permission denied"}]; + + NSString *message = [provider debugTokenExchangeFailureMessageForError:error]; + + XCTAssertTrue([message containsString:@"~/.config/configstore/firebase-appcheck.json"]); + XCTAssertTrue([message containsString:@"Permission denied"]); + NSString *expectedCommand = + [NSString stringWithFormat:@"`firebase appcheck:debugtokens:create --force --project %@ " + @"--app %@`", + kProjectID, kAppID]; + XCTAssertTrue([message containsString:expectedCommand], @"%@", message); + XCTAssertTrue([message containsString:@"relaunch the app"], @"%@", message); +} + +- (void)testConfigStoreExchangeFailureMessageOmitsMissingProjectID { + FIRAppCheckDebugProvider *provider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceProvided projectID:nil]; + NSError *error = [NSError errorWithDomain:@"testDomain" code:-1 userInfo:nil]; + + NSString *message = [provider debugTokenExchangeFailureMessageForError:error]; + + XCTAssertFalse([message containsString:@"--project"], @"%@", message); + XCTAssertFalse([message containsString:@"(null)"], @"%@", message); + NSString *expectedCommand = [NSString + stringWithFormat:@"`firebase appcheck:debugtokens:create --force --app %@`", kAppID]; + XCTAssertTrue([message containsString:expectedCommand], @"%@", message); +} + +- (void)testConfigStoreExchangeFailureMessagePrefersFailureReason { + FIRAppCheckDebugProvider *provider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceProvided projectID:kProjectID]; + NSError *error = [NSError + errorWithDomain:@"testDomain" + code:-1 + userInfo:@{NSLocalizedFailureReasonErrorKey : @"The server responded with an error"}]; + + NSString *message = [provider debugTokenExchangeFailureMessageForError:error]; + + XCTAssertTrue([message containsString:@": The server responded with an error"], @"%@", message); + XCTAssertFalse([message containsString:@"couldn’t be completed"], @"%@", message); +} + +- (void)testEnvironmentVariableExchangeFailureMessageNamesVariablesWithoutForce { + FIRAppCheckDebugProvider *provider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceEnvironmentVariable + projectID:kProjectID]; + NSError *error = [NSError errorWithDomain:@"testDomain" + code:-1 + userInfo:@{NSLocalizedDescriptionKey : @"Permission denied"}]; + + NSString *message = [provider debugTokenExchangeFailureMessageForError:error]; + + XCTAssertTrue([message containsString:@"`AppCheckDebugToken`"], @"%@", message); + XCTAssertTrue([message containsString:@"`FIRAAppCheckDebugToken`"], @"%@", message); + XCTAssertTrue([message containsString:@"Permission denied"], @"%@", message); + NSString *expectedCommand = + [NSString stringWithFormat:@"`firebase appcheck:debugtokens:create --project %@ " + @"--app %@`", + kProjectID, kAppID]; + XCTAssertTrue([message containsString:expectedCommand], @"%@", message); + // Provisioning a new config store token would not help while the environment variable wins. + XCTAssertFalse([message containsString:@"--force"], @"%@", message); + XCTAssertFalse([message containsString:@"firebase-appcheck.json"], @"%@", message); +} + +- (void)testExchangeFailureMessageIsNilWhenServerUnreachable { + NSError *error = [NSError errorWithDomain:GACAppCheckErrors.errorDomain + code:GACAppCheckErrorCodeServerUnreachable + userInfo:nil]; + FIRAppCheckDebugProvider *configStoreProvider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceProvided projectID:kProjectID]; + FIRAppCheckDebugProvider *environmentVariableProvider = + [self providerWithDebugTokenSource:GACAppCheckDebugTokenSourceEnvironmentVariable + projectID:kProjectID]; + + NSString *configStoreMessage = + [configStoreProvider debugTokenExchangeFailureMessageForError:error]; + NSString *environmentVariableMessage = + [environmentVariableProvider debugTokenExchangeFailureMessageForError:error]; + + XCTAssertNil(configStoreMessage); + XCTAssertNil(environmentVariableMessage); +} + +#if TARGET_OS_SIMULATOR +/// Exercises `-initWithApp:` end to end, without mocks: config store lookup by each app's Google +/// App ID, through to the token used by the App Check Core debug provider. +- (void)testInitWithAppUsesConfigStoreDebugTokenForEachApp { + NSString *otherAppID = @"other_test_app_id"; + NSURL *hostHomeURL = [self temporaryHostHomeWithTokens:@{ + kAppID : @"config_store_token", + otherAppID : @"other_config_store_token", + }]; + [self setEnvironmentVariable:@"SIMULATOR_HOST_HOME" value:hostHomeURL.path]; + [self setEnvironmentVariable:@"AppCheckDebugToken" value:nil]; + [self setEnvironmentVariable:@"FIRAAppCheckDebugToken" value:nil]; + FIRApp *app = [self appWithName:kAppName googleAppID:kAppID]; + FIRApp *otherApp = [self appWithName:@"other_test_app_name" googleAppID:otherAppID]; + + FIRAppCheckDebugProvider *provider = [[FIRAppCheckDebugProvider alloc] initWithApp:app]; + FIRAppCheckDebugProvider *otherProvider = [[FIRAppCheckDebugProvider alloc] initWithApp:otherApp]; + + XCTAssertEqualObjects([provider currentDebugToken], @"config_store_token"); + XCTAssertEqual(provider.debugTokenSource, GACAppCheckDebugTokenSourceProvided); + XCTAssertEqualObjects([otherProvider currentDebugToken], @"other_config_store_token"); + XCTAssertEqual(otherProvider.debugTokenSource, GACAppCheckDebugTokenSourceProvided); +} + +/// An environment variable wins over the config store even when both hold the same token, so the +/// token must not be attributed to the config store. +- (void)testInitWithAppAttributesMatchingEnvironmentVariableToEnvironment { + NSURL *hostHomeURL = [self temporaryHostHomeWithTokens:@{kAppID : @"same_token"}]; + [self setEnvironmentVariable:@"SIMULATOR_HOST_HOME" value:hostHomeURL.path]; + [self setEnvironmentVariable:@"AppCheckDebugToken" value:@"same_token"]; + [self setEnvironmentVariable:@"FIRAAppCheckDebugToken" value:nil]; + FIRApp *app = [self appWithName:kAppName googleAppID:kAppID]; + + FIRAppCheckDebugProvider *provider = [[FIRAppCheckDebugProvider alloc] initWithApp:app]; + + XCTAssertEqualObjects([provider currentDebugToken], @"same_token"); + XCTAssertEqual(provider.debugTokenSource, GACAppCheckDebugTokenSourceEnvironmentVariable); +} +#endif // TARGET_OS_SIMULATOR + #pragma mark - Current Debug token - (void)testCurrentTokenShim { @@ -204,4 +456,73 @@ static NSString *const kProjectNumber = @"123456789"; OCMVerifyAll(self.debugProviderMock); } +#pragma mark - Helpers + +/// Returns an app with complete options for token exchange and automatic token refresh disabled. +- (FIRApp *)appWithName:(NSString *)name googleAppID:(NSString *)googleAppID { + FIROptions *options = [[FIROptions alloc] initWithGoogleAppID:googleAppID + GCMSenderID:kProjectNumber]; + options.APIKey = kAPIKey; + options.projectID = kProjectID; + FIRApp *app = [[FIRApp alloc] initInstanceWithName:name options:options]; + // The following disables automatic token refresh, which could interfere with tests. + app.dataCollectionDefaultEnabled = NO; + return app; +} + +/// Creates a temporary host home directory containing a config store with the given tokens, +/// removed when the test finishes. +- (NSURL *)temporaryHostHomeWithTokens:(NSDictionary *)tokens { + NSURL *hostHomeURL = [[NSURL fileURLWithPath:NSTemporaryDirectory() isDirectory:YES] + URLByAppendingPathComponent:[NSUUID UUID].UUIDString + isDirectory:YES]; + NSURL *fileURL = + [hostHomeURL URLByAppendingPathComponent:FIRAppCheckDebugTokenConfigStore.relativePath + isDirectory:NO]; + NSError *error; + XCTAssertTrue( + [[NSFileManager defaultManager] createDirectoryAtURL:fileURL.URLByDeletingLastPathComponent + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + NSData *data = [NSJSONSerialization dataWithJSONObject:@{@"tokens" : tokens} + options:0 + error:&error]; + XCTAssertTrue([data writeToURL:fileURL options:NSDataWritingAtomic error:&error], @"%@", error); + [self addTeardownBlock:^{ + [[NSFileManager defaultManager] removeItemAtURL:hostHomeURL error:nil]; + }]; + return hostHomeURL; +} + +/// Sets (or unsets, when `value` is `nil`) a process environment variable for the duration of the +/// test, restoring its previous value afterwards. +- (void)setEnvironmentVariable:(NSString *)name value:(nullable NSString *)value { + const char *previousValue = getenv(name.UTF8String); + NSString *previous = previousValue ? @(previousValue) : nil; + if (value) { + setenv(name.UTF8String, value.UTF8String, 1); + } else { + unsetenv(name.UTF8String); + } + [self addTeardownBlock:^{ + if (previous) { + setenv(name.UTF8String, previous.UTF8String, 1); + } else { + unsetenv(name.UTF8String); + } + }]; +} + +/// Returns a provider whose core debug provider resolved its debug token from `source`, which must +/// not be `GACAppCheckDebugTokenSourceLocal` (that would log the local token on the strict mock). +- (FIRAppCheckDebugProvider *)providerWithDebugTokenSource:(GACAppCheckDebugTokenSource)source + projectID:(nullable NSString *)projectID { + self.stubbedDebugTokenSource = source; + return [[FIRAppCheckDebugProvider alloc] initWithDebugProvider:self.debugProviderMock + projectID:projectID + googleAppID:kAppID]; +} + @end diff --git a/FirebaseAppCheck/Tests/Unit/DebugProvider/FIRAppCheckDebugTokenConfigStoreTests.m b/FirebaseAppCheck/Tests/Unit/DebugProvider/FIRAppCheckDebugTokenConfigStoreTests.m new file mode 100644 index 0000000000..b49b4ea5b8 --- /dev/null +++ b/FirebaseAppCheck/Tests/Unit/DebugProvider/FIRAppCheckDebugTokenConfigStoreTests.m @@ -0,0 +1,411 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import +#import + +#import "FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.h" + +static NSString *const kAppID = @"1:248211933065:ios:91feeddd848ff1a48f0358"; + +// The host config store can only be read from a Simulator; elsewhere the lookup is compiled out. +#if TARGET_OS_SIMULATOR + +static NSString *const kOtherAppID = @"1:248211933065:android:0123456789abcdef012345"; +static NSString *const kToken = @"29d93e77-7bea-4f2a-b6ed-c1d1db892666"; + +@interface FIRAppCheckDebugTokenConfigStore (Tests) + ++ (BOOL)isMissingConfigStoreError:(nullable NSError *)error; + +@end + +@interface FIRAppCheckDebugTokenConfigStoreTests : XCTestCase + +/// A temporary directory standing in for the host user's home directory. +@property(nonatomic) NSURL *hostHomeURL; +@property(nonatomic, copy) NSDictionary *environment; + +@end + +@implementation FIRAppCheckDebugTokenConfigStoreTests + +- (void)setUp { + [super setUp]; + NSString *directoryName = [NSString + stringWithFormat:@"%@-%@", NSStringFromClass([self class]), [NSUUID UUID].UUIDString]; + self.hostHomeURL = [[NSURL fileURLWithPath:NSTemporaryDirectory() + isDirectory:YES] URLByAppendingPathComponent:directoryName + isDirectory:YES]; + NSError *error; + XCTAssertTrue([[NSFileManager defaultManager] createDirectoryAtURL:self.hostHomeURL + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + self.environment = @{@"SIMULATOR_HOST_HOME" : self.hostHomeURL.path}; +} + +- (void)tearDown { + [[NSFileManager defaultManager] removeItemAtURL:self.hostHomeURL error:nil]; + [super tearDown]; +} + +#pragma mark - Helpers + +- (void)writeConfigStoreContents:(NSString *)contents { + NSURL *fileURL = + [self.hostHomeURL URLByAppendingPathComponent:FIRAppCheckDebugTokenConfigStore.relativePath]; + NSError *error; + XCTAssertTrue( + [[NSFileManager defaultManager] createDirectoryAtURL:fileURL.URLByDeletingLastPathComponent + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + XCTAssertTrue([contents writeToURL:fileURL + atomically:YES + encoding:NSUTF8StringEncoding + error:&error], + @"%@", error); +} + +- (void)writeConfigStoreTokens:(NSDictionary *)tokens { + NSDictionary *json = @{ + @"_comment" : @"This file contains secrets and should not be committed to source repositories", + @"tokens" : tokens, + }; + NSError *error; + NSData *data = [NSJSONSerialization dataWithJSONObject:json options:0 error:&error]; + XCTAssertNotNil(data, @"%@", error); + [self writeConfigStoreContents:[[NSString alloc] initWithData:data + encoding:NSUTF8StringEncoding]]; +} + +#pragma mark - Tests + +- (void)testRelativePath { + XCTAssertEqualObjects(FIRAppCheckDebugTokenConfigStore.relativePath, + @".config/configstore/firebase-appcheck.json"); +} + +- (void)testDebugTokenForAppInConfigStore { + [self writeConfigStoreTokens:@{kAppID : kToken, kOtherAppID : @"other-token"}]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertEqualObjects(token, kToken); +} + +- (void)testDebugTokenForAppMissingFromConfigStore { + [self writeConfigStoreTokens:@{kOtherAppID : @"other-token"}]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenConfigStoreFileMissing { + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenSimulatorHostHomeUnset { + [self writeConfigStoreTokens:@{kAppID : kToken}]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:@{}]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenSimulatorHostHomeEmpty { + [self writeConfigStoreTokens:@{kAppID : kToken}]; + + NSString *token = + [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:@{@"SIMULATOR_HOST_HOME" : @""}]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenConfigStoreMalformed { + [self writeConfigStoreContents:@"{ not json"]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenTokensNotADictionary { + [self writeConfigStoreContents:@"{\"tokens\": [\"not\", \"a\", \"dictionary\"]}"]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenTokensKeyMissing { + [self writeConfigStoreContents:@"{\"tokenNames\": {}}"]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenConfigStoreIsEmptyObject { + [self writeConfigStoreContents:@"{}"]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenTokenNotAString { + [self writeConfigStoreTokens:@{kAppID : @42}]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenTokenEmpty { + [self writeConfigStoreTokens:@{kAppID : @""}]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenGoogleAppIDEmpty { + [self writeConfigStoreTokens:@{kAppID : kToken}]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:@"" + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenTopLevelJSONIsAnArray { + [self writeConfigStoreContents:@"[{\"tokens\": {}}]"]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenTokenIsNull { + [self writeConfigStoreTokens:@{kAppID : [NSNull null]}]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenSimulatorHostHomeHasTrailingSlash { + [self writeConfigStoreTokens:@{kAppID : kToken}]; + NSDictionary *environment = + @{@"SIMULATOR_HOST_HOME" : [self.hostHomeURL.path stringByAppendingString:@"/"]}; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:environment]; + + XCTAssertEqualObjects(token, kToken); +} + +- (void)testDebugTokenWhenConfigStorePathIsADirectory { + NSURL *directoryURL = + [self.hostHomeURL URLByAppendingPathComponent:FIRAppCheckDebugTokenConfigStore.relativePath + isDirectory:YES]; + NSError *error; + XCTAssertTrue([[NSFileManager defaultManager] createDirectoryAtURL:directoryURL + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +- (void)testDebugTokenWhenConfigStoreParentIsAFile { + // `~/.config` is a regular file, so the config store cannot exist; treated like a missing file. + [self writeEmptyFileAtRelativePath:@".config"]; + + NSString *token = [FIRAppCheckDebugTokenConfigStore debugTokenForGoogleAppID:kAppID + environment:self.environment]; + + XCTAssertNil(token); +} + +#pragma mark - Missing config store errors + +- (void)testMissingFileIsMissingConfigStoreError { + NSError *error = [self configStoreReadError]; + + XCTAssertTrue([FIRAppCheckDebugTokenConfigStore isMissingConfigStoreError:error], @"%@", error); +} + +- (void)testConfigParentFileIsMissingConfigStoreError { + [self writeEmptyFileAtRelativePath:@".config"]; + + NSError *error = [self configStoreReadError]; + + XCTAssertTrue([FIRAppCheckDebugTokenConfigStore isMissingConfigStoreError:error], @"%@", error); +} + +- (void)testConfigstoreParentFileIsMissingConfigStoreError { + [self writeEmptyFileAtRelativePath:@".config/configstore"]; + + NSError *error = [self configStoreReadError]; + + XCTAssertTrue([FIRAppCheckDebugTokenConfigStore isMissingConfigStoreError:error], @"%@", error); +} + +- (void)testDirectoryAtConfigStorePathIsNotMissingConfigStoreError { + NSURL *directoryURL = + [self.hostHomeURL URLByAppendingPathComponent:FIRAppCheckDebugTokenConfigStore.relativePath + isDirectory:YES]; + NSError *createError; + XCTAssertTrue([[NSFileManager defaultManager] createDirectoryAtURL:directoryURL + withIntermediateDirectories:YES + attributes:nil + error:&createError], + @"%@", createError); + + NSError *error = [self configStoreReadError]; + + XCTAssertFalse([FIRAppCheckDebugTokenConfigStore isMissingConfigStoreError:error], @"%@", error); +} + +- (void)testUnreadableConfigStoreIsNotMissingConfigStoreError { + [self writeConfigStoreTokens:@{kAppID : kToken}]; + NSString *path = + [self.hostHomeURL URLByAppendingPathComponent:FIRAppCheckDebugTokenConfigStore.relativePath] + .path; + NSError *attributesError; + XCTAssertTrue([[NSFileManager defaultManager] setAttributes:@{NSFilePosixPermissions : @0} + ofItemAtPath:path + error:&attributesError], + @"%@", attributesError); + + NSError *error = [self configStoreReadError]; + + XCTAssertFalse([FIRAppCheckDebugTokenConfigStore isMissingConfigStoreError:error], @"%@", error); +} + +- (void)testNilIsNotMissingConfigStoreError { + XCTAssertFalse([FIRAppCheckDebugTokenConfigStore isMissingConfigStoreError:nil]); +} + +- (void)testNonErrorUnderlyingValueIsNotMissingConfigStoreError { + NSError *error = [NSError errorWithDomain:NSCocoaErrorDomain + code:NSFileReadUnknownError + userInfo:@{NSUnderlyingErrorKey : @"not an error"}]; + + BOOL isMissing = [FIRAppCheckDebugTokenConfigStore isMissingConfigStoreError:error]; + + XCTAssertFalse(isMissing); +} + +#pragma mark - Error helpers + +/// Writes an empty regular file at `relativePath` under the temporary host home. +- (void)writeEmptyFileAtRelativePath:(NSString *)relativePath { + NSURL *fileURL = [self.hostHomeURL URLByAppendingPathComponent:relativePath isDirectory:NO]; + NSError *error; + XCTAssertTrue( + [[NSFileManager defaultManager] createDirectoryAtURL:fileURL.URLByDeletingLastPathComponent + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + XCTAssertTrue([@"" writeToURL:fileURL atomically:YES encoding:NSUTF8StringEncoding error:&error], + @"%@", error); +} + +/// Reads the config store file under the temporary host home the same way the config store does, +/// and returns the resulting error. +- (nullable NSError *)configStoreReadError { + NSURL *fileURL = + [self.hostHomeURL URLByAppendingPathComponent:FIRAppCheckDebugTokenConfigStore.relativePath + isDirectory:NO]; + NSError *error; + NSData *data = [NSData dataWithContentsOfURL:fileURL options:0 error:&error]; + XCTAssertNil(data); + XCTAssertNotNil(error); + return error; +} + +@end + +#else // TARGET_OS_SIMULATOR + +@interface FIRAppCheckDebugTokenConfigStoreTests : XCTestCase +@end + +@implementation FIRAppCheckDebugTokenConfigStoreTests + +- (void)testDebugTokenIsNilOutsideSimulator { + // Seed a valid config store so a `nil` result proves the lookup is compiled out rather than + // failing to find the file. + NSURL *hostHomeURL = [[NSURL fileURLWithPath:NSTemporaryDirectory() isDirectory:YES] + URLByAppendingPathComponent:[NSUUID UUID].UUIDString + isDirectory:YES]; + NSURL *fileURL = + [hostHomeURL URLByAppendingPathComponent:FIRAppCheckDebugTokenConfigStore.relativePath + isDirectory:NO]; + NSError *error; + XCTAssertTrue( + [[NSFileManager defaultManager] createDirectoryAtURL:fileURL.URLByDeletingLastPathComponent + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + [self addTeardownBlock:^{ + [[NSFileManager defaultManager] removeItemAtURL:hostHomeURL error:nil]; + }]; + NSString *contents = [NSString stringWithFormat:@"{\"tokens\": {\"%@\": \"token\"}}", kAppID]; + XCTAssertTrue([contents writeToURL:fileURL + atomically:YES + encoding:NSUTF8StringEncoding + error:&error], + @"%@", error); + + NSString *token = [FIRAppCheckDebugTokenConfigStore + debugTokenForGoogleAppID:kAppID + environment:@{@"SIMULATOR_HOST_HOME" : hostHomeURL.path}]; + + XCTAssertNil(token); +} + +@end + +#endif // TARGET_OS_SIMULATOR diff --git a/Package.swift b/Package.swift index 8bf30a3268..5d7d8543ad 100644 --- a/Package.swift +++ b/Package.swift @@ -1848,12 +1848,18 @@ func appCheckDependency() -> Package.Dependency { let appCheckURL = "https://github.com/google/app-check.git" if let localPath = Context.environment["FIREBASE_APP_CHECK_LOCAL_PATH"] { - return .package(path: localPath) + // Pin the package name so a checkout in a directory not named `app-check` still resolves. + return .package(name: "app-check", path: localPath) } if let branch = Context.environment["FIREBASE_APP_CHECK_BRANCH"] { return .package(url: appCheckURL, branch: branch) } - return .package(url: appCheckURL, "12.0.0" ..< "13.0.0") + // Point SPM CI to the tip of main of https://github.com/google/app-check until 12.1.0 is tagged. + if Context.environment["FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT"] != nil { + return .package(url: appCheckURL, branch: "main") + } + + return .package(url: appCheckURL, "12.1.0" ..< "13.0.0") }