Forráskód Böngészése

[Heartbeat Logging] Migrate AppCheck from using `FIRHeartbeatInfo` to `FIRHeartbeatLogger` (#9530)

* Integrate AppCheck with FIRHeartbeatLogger

* Fix comment typo

* Fix CI

* [will revert] debug why HeartbeatLoggingTestUtils.podspec is linted?

* Revert "[will revert] debug why HeartbeatLoggingTestUtils.podspec is linted?"

This reverts commit 25a79874d86cdf9ca6eb2eec0484b30edae7bf61.

* Change visibility of shim class

* Fix CI

* [skip ci] Review
Nick Cooke 4 éve
szülő
commit
3d4b0f429e

+ 6 - 0
.github/workflows/app_check.yml

@@ -21,6 +21,9 @@ jobs:
     strategy:
       matrix:
         target: [ios, tvos, macos]
+    # TODO(v9): Remove `env` after publishing `HeartbeatLoggingTestUtils`.
+    env:
+      POD_LIB_LINT_ONLY: 1
     steps:
     - uses: actions/checkout@v2
     - name: Setup Bundler
@@ -34,6 +37,9 @@ jobs:
     # Don't run on private repo unless it is a PR.
     if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request'
     runs-on: macos-11
+    # TODO(v9): Remove `env` after publishing `HeartbeatLoggingTestUtils`.
+    env:
+      POD_LIB_LINT_ONLY: 1
     steps:
     - uses: actions/checkout@v2
     - name: Setup Bundler

+ 2 - 0
.github/workflows/core.yml

@@ -23,6 +23,7 @@ jobs:
     strategy:
       matrix:
         target: [ios, tvos, macos]
+    # TODO(v9): Remove `env` after publishing `HeartbeatLoggingTestUtils`.
     env:
       POD_LIB_LINT_ONLY: 1
     steps:
@@ -54,6 +55,7 @@ jobs:
     if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request'
 
     runs-on: macos-11
+    # TODO(v9): Remove `env` after publishing `HeartbeatLoggingTestUtils`.
     env:
       POD_LIB_LINT_ONLY: 1
     steps:

+ 1 - 0
.github/workflows/core_extension.yml

@@ -21,6 +21,7 @@ jobs:
     strategy:
       matrix:
         target: [ios, tvos, macos]
+    # TODO(v9): Remove `env` after publishing `HeartbeatLoggingTestUtils`.
     env:
       POD_LIB_LINT_ONLY: 1
     steps:

+ 2 - 0
.github/workflows/core_internal.yml

@@ -19,6 +19,7 @@ jobs:
     strategy:
       matrix:
         target: [ios, tvos, macos]
+    # TODO(v9): Remove `env` after publishing `HeartbeatLoggingTestUtils`.
     env:
       POD_LIB_LINT_ONLY: 1
     steps:
@@ -46,6 +47,7 @@ jobs:
     # Don't run on private repo unless it is a PR.
     if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request'
     runs-on: macos-11
+    # TODO(v9): Remove `env` after publishing `HeartbeatLoggingTestUtils`.
     env:
       POD_LIB_LINT_ONLY: 1
     steps:

+ 24 - 18
FirebaseAppCheck.podspec

@@ -54,24 +54,30 @@ Pod::Spec.new do |s|
     'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"'
   }
 
-  s.test_spec 'unit' do |unit_tests|
-    unit_tests.platforms = {
-      :ios => ios_deployment_target,
-      :osx => osx_deployment_target,
-      :tvos => tvos_deployment_target
-    }
-    unit_tests.source_files = [
-      base_dir + 'Tests/Unit/**/*.[mh]',
-      base_dir + 'Tests/Utils/**/*.[mh]',
-      'SharedTestUtilities/AppCheckFake/*',
-      'SharedTestUtilities/AppCheckBackoffWrapperFake/*',
-      'SharedTestUtilities/Date/*',
-      'SharedTestUtilities/URLSession/*',
-    ]
-
-    unit_tests.resources = base_dir + 'Tests/Fixture/**/*'
-    unit_tests.dependency 'OCMock'
-    unit_tests.requires_app_host = true
+  # Using environment variable because of the dependency on the unpublished
+  # HeartbeatLoggingTestUtils.
+  # TODO(v9): Remove above comment and below conditional after publishing.
+  if ENV['POD_LIB_LINT_ONLY'] && ENV['POD_LIB_LINT_ONLY'] == '1' then
+    s.test_spec 'unit' do |unit_tests|
+      unit_tests.platforms = {
+        :ios => ios_deployment_target,
+        :osx => osx_deployment_target,
+        :tvos => tvos_deployment_target
+      }
+      unit_tests.source_files = [
+        base_dir + 'Tests/Unit/**/*.[mh]',
+        base_dir + 'Tests/Utils/**/*.[mh]',
+        'SharedTestUtilities/AppCheckFake/*',
+        'SharedTestUtilities/AppCheckBackoffWrapperFake/*',
+        'SharedTestUtilities/Date/*',
+        'SharedTestUtilities/URLSession/*',
+      ]
+
+      unit_tests.resources = base_dir + 'Tests/Fixture/**/*'
+      unit_tests.dependency 'OCMock'
+      unit_tests.dependency 'HeartbeatLoggingTestUtils'
+      unit_tests.requires_app_host = true
+    end
   end
 
   s.test_spec 'integration' do |integration_tests|

+ 2 - 2
FirebaseAppCheck/Sources/AppAttestProvider/FIRAppAttestProvider.m

@@ -146,8 +146,8 @@ NS_ASSUME_NONNULL_BEGIN
   FIRAppCheckAPIService *APIService =
       [[FIRAppCheckAPIService alloc] initWithURLSession:URLSession
                                                  APIKey:app.options.APIKey
-                                              projectID:app.options.projectID
-                                                  appID:app.options.googleAppID];
+                                                  appID:app.options.googleAppID
+                                        heartbeatLogger:app.heartbeatLogger];
 
   FIRAppAttestAPIService *appAttestAPIService =
       [[FIRAppAttestAPIService alloc] initWithAPIService:APIService

+ 11 - 2
FirebaseAppCheck/Sources/Core/APIService/FIRAppCheckAPIService.h

@@ -20,6 +20,8 @@
 @class GULURLSessionDataResponse;
 @class FIRAppCheckToken;
 
+@protocol FIRHeartbeatLoggerProtocol;
+
 NS_ASSUME_NONNULL_BEGIN
 
 @protocol FIRAppCheckAPIServiceProtocol <NSObject>
@@ -39,10 +41,17 @@ NS_ASSUME_NONNULL_BEGIN
 
 @interface FIRAppCheckAPIService : NSObject <FIRAppCheckAPIServiceProtocol>
 
+/**
+ * The default initializer.
+ * @param session The URL session used to make network requests.
+ * @param APIKey The Firebase project API key (see `FIROptions.APIKey`).
+ * @param appID The Firebase app ID (see `FIROptions.googleAppID`).
+ * @param heartbeatLogger The heartbeat logger used to populate heartbeat data in request headers.
+ */
 - (instancetype)initWithURLSession:(NSURLSession *)session
                             APIKey:(NSString *)APIKey
-                         projectID:(NSString *)projectID
-                             appID:(NSString *)appID;
+                             appID:(NSString *)appID
+                   heartbeatLogger:(id<FIRHeartbeatLoggerProtocol>)heartbeatLogger;
 
 @end
 

+ 9 - 13
FirebaseAppCheck/Sources/Core/APIService/FIRAppCheckAPIService.m

@@ -33,9 +33,7 @@
 NS_ASSUME_NONNULL_BEGIN
 
 static NSString *const kAPIKeyHeaderKey = @"X-Goog-Api-Key";
-static NSString *const kHeartbeatKey = @"X-firebase-client-log-type";
-static NSString *const kHeartbeatStorageTag = @"fire-app-check";
-static NSString *const kUserAgentKey = @"X-firebase-client";
+static NSString *const kHeartbeatKey = @"X-firebase-client";
 static NSString *const kBundleIdKey = @"X-Ios-Bundle-Identifier";
 
 static NSString *const kDefaultBaseURL = @"https://firebaseappcheck.googleapis.com/v1beta";
@@ -44,8 +42,8 @@ static NSString *const kDefaultBaseURL = @"https://firebaseappcheck.googleapis.c
 
 @property(nonatomic, readonly) NSURLSession *URLSession;
 @property(nonatomic, readonly) NSString *APIKey;
-@property(nonatomic, readonly) NSString *projectID;
 @property(nonatomic, readonly) NSString *appID;
+@property(nonatomic, readonly) id<FIRHeartbeatLoggerProtocol> heartbeatLogger;
 
 @end
 
@@ -56,26 +54,26 @@ static NSString *const kDefaultBaseURL = @"https://firebaseappcheck.googleapis.c
 
 - (instancetype)initWithURLSession:(NSURLSession *)session
                             APIKey:(NSString *)APIKey
-                         projectID:(NSString *)projectID
-                             appID:(NSString *)appID {
+                             appID:(NSString *)appID
+                   heartbeatLogger:(id<FIRHeartbeatLoggerProtocol>)heartbeatLogger {
   return [self initWithURLSession:session
                            APIKey:APIKey
-                        projectID:projectID
                             appID:appID
+                  heartbeatLogger:heartbeatLogger
                           baseURL:kDefaultBaseURL];
 }
 
 - (instancetype)initWithURLSession:(NSURLSession *)session
                             APIKey:(NSString *)APIKey
-                         projectID:(NSString *)projectID
                              appID:(NSString *)appID
+                   heartbeatLogger:(id<FIRHeartbeatLoggerProtocol>)heartbeatLogger
                            baseURL:(NSString *)baseURL {
   self = [super init];
   if (self) {
     _URLSession = session;
     _APIKey = APIKey;
-    _projectID = projectID;
     _appID = appID;
+    _heartbeatLogger = heartbeatLogger;
     _baseURL = baseURL;
   }
   return self;
@@ -112,10 +110,8 @@ static NSString *const kDefaultBaseURL = @"https://firebaseappcheck.googleapis.c
 
              [request setValue:self.APIKey forHTTPHeaderField:kAPIKeyHeaderKey];
 
-             [request setValue:[FIRApp firebaseUserAgent] forHTTPHeaderField:kUserAgentKey];
-
-             [request setValue:@([FIRHeartbeatInfo heartbeatCodeForTag:kHeartbeatStorageTag])
-                                   .stringValue
+             [request setValue:FIRHeaderValueFromHeartbeatsPayload(
+                                   [self.heartbeatLogger flushHeartbeatsIntoPayload])
                  forHTTPHeaderField:kHeartbeatKey];
 
              [request setValue:[[NSBundle mainBundle] bundleIdentifier]

+ 2 - 2
FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugProvider.m

@@ -66,8 +66,8 @@ static NSString *const kDebugTokenUserDefaultsKey = @"FIRAAppCheckDebugToken";
   FIRAppCheckAPIService *APIService =
       [[FIRAppCheckAPIService alloc] initWithURLSession:URLSession
                                                  APIKey:app.options.APIKey
-                                              projectID:app.options.projectID
-                                                  appID:app.options.googleAppID];
+                                                  appID:app.options.googleAppID
+                                        heartbeatLogger:app.heartbeatLogger];
 
   FIRAppCheckDebugProviderAPIService *debugAPIService =
       [[FIRAppCheckDebugProviderAPIService alloc] initWithAPIService:APIService

+ 2 - 2
FirebaseAppCheck/Sources/DeviceCheckProvider/FIRDeviceCheckProvider.m

@@ -91,8 +91,8 @@ NS_ASSUME_NONNULL_BEGIN
   FIRAppCheckAPIService *APIService =
       [[FIRAppCheckAPIService alloc] initWithURLSession:URLSession
                                                  APIKey:app.options.APIKey
-                                              projectID:app.options.projectID
-                                                  appID:app.options.googleAppID];
+                                                  appID:app.options.googleAppID
+                                        heartbeatLogger:app.heartbeatLogger];
 
   FIRDeviceCheckAPIService *deviceCheckAPIService =
       [[FIRDeviceCheckAPIService alloc] initWithAPIService:APIService

+ 9 - 2
FirebaseAppCheck/Tests/Integration/FIRDeviceCheckAPIServiceE2ETests.m

@@ -24,22 +24,29 @@
 #import "FirebaseAppCheck/Sources/DeviceCheckProvider/API/FIRDeviceCheckAPIService.h"
 #import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckToken.h"
 
+#import "FirebaseCore/Extension/FirebaseCoreInternal.h"
+
 @interface FIRDeviceCheckAPIServiceE2ETests : XCTestCase
 @property(nonatomic) FIRDeviceCheckAPIService *deviceCheckAPIService;
 @property(nonatomic) FIRAppCheckAPIService *APIService;
 @property(nonatomic) NSURLSession *URLSession;
 @end
 
+// TODO(ncooke3): Fix these tests up and get them running on CI.
+
 @implementation FIRDeviceCheckAPIServiceE2ETests
 
 - (void)setUp {
   self.URLSession = [NSURLSession
       sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
   FIROptions *options = [self firebaseTestOptions];
+  FIRHeartbeatLogger *heartbeatLogger =
+      [[FIRHeartbeatLogger alloc] initWithAppID:options.googleAppID];
+
   self.APIService = [[FIRAppCheckAPIService alloc] initWithURLSession:self.URLSession
                                                                APIKey:options.APIKey
-                                                            projectID:options.projectID
-                                                                appID:options.googleAppID];
+                                                                appID:options.googleAppID
+                                                      heartbeatLogger:heartbeatLogger];
   self.deviceCheckAPIService =
       [[FIRDeviceCheckAPIService alloc] initWithAPIService:self.APIService
                                                  projectID:options.projectID

+ 121 - 63
FirebaseAppCheck/Tests/Unit/Core/FIRAppCheckAPIServiceTests.m

@@ -19,6 +19,8 @@
 #import <OCMock/OCMock.h>
 #import "FBLPromise+Testing.h"
 
+@import HeartbeatLoggingTestUtils;
+
 #import <GoogleUtilities/GULURLSessionDataResponse.h>
 #import <GoogleUtilities/NSURLSession+GULPromises.h>
 
@@ -33,17 +35,54 @@
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
+#pragma mark - Fakes
+
+/// A fake heartbeat logger used for dependency injection during testing.
+@interface FIRHeartbeatLoggerFake : NSObject <FIRHeartbeatLoggerProtocol>
+@property(nonatomic, copy, nullable) FIRHeartbeatsPayload * (^onFlushHeartbeatsIntoPayloadHandler)
+    (void);
+@property(nonatomic, copy, nullable) FIRHeartbeatInfoCode (^onHeartbeatCodeForTodayHandler)(void);
+@end
+
+@implementation FIRHeartbeatLoggerFake
+
+- (nonnull FIRHeartbeatsPayload *)flushHeartbeatsIntoPayload {
+  if (self.onFlushHeartbeatsIntoPayloadHandler) {
+    return self.onFlushHeartbeatsIntoPayloadHandler();
+  } else {
+    return nil;
+  }
+}
+
+- (FIRHeartbeatInfoCode)heartbeatCodeForToday {
+  // This API should not be used by the below tests because the AppCheck SDK
+  // uses only the V2 heartbeat API (`flushHeartbeatsIntoPayload`) for getting
+  // heartbeats.
+  [self doesNotRecognizeSelector:_cmd];
+  return FIRHeartbeatInfoCodeNone;
+}
+
+- (void)log {
+  // This API should not be used by the below tests because the AppCheck SDK
+  // does not log heartbeats in it's networking context.
+  [self doesNotRecognizeSelector:_cmd];
+}
+
+@end
+
+#pragma mark - FIRAppCheckAPIServiceTests
+
 @interface FIRAppCheckAPIServiceTests : XCTestCase
 
 @property(nonatomic) FIRAppCheckAPIService *APIService;
 
 @property(nonatomic) id mockURLSession;
-@property(nonatomic) id mockHeartbeatInfo;
 
 @property(nonatomic) NSString *APIKey;
-@property(nonatomic) NSString *projectID;
 @property(nonatomic) NSString *appID;
 
+@property(nonatomic) FIRHeartbeatLoggerFake *heartbeatLoggerFake;
+
 @end
 
 @implementation FIRAppCheckAPIServiceTests
@@ -52,23 +91,15 @@
   [super setUp];
 
   self.APIKey = @"api_key";
-  self.projectID = @"project_id";
   self.appID = @"app_id";
 
-  // Stub FIRHeartbeatInfo.
-  self.mockHeartbeatInfo = OCMClassMock([FIRHeartbeatInfo class]);
-  OCMStub([self.mockHeartbeatInfo heartbeatCodeForTag:@"fire-app-check"])
-      .andDo(^(NSInvocation *invocation) {
-        XCTAssertFalse([NSThread isMainThread]);
-      })
-      .andReturn(FIRHeartbeatInfoCodeCombined);
-
   self.mockURLSession = OCMStrictClassMock([NSURLSession class]);
 
+  self.heartbeatLoggerFake = [[FIRHeartbeatLoggerFake alloc] init];
   self.APIService = [[FIRAppCheckAPIService alloc] initWithURLSession:self.mockURLSession
                                                                APIKey:self.APIKey
-                                                            projectID:self.projectID
-                                                                appID:self.appID];
+                                                                appID:self.appID
+                                                      heartbeatLogger:self.heartbeatLoggerFake];
 }
 
 - (void)tearDown {
@@ -77,60 +108,30 @@
   self.APIService = nil;
   [self.mockURLSession stopMocking];
   self.mockURLSession = nil;
-  [self.mockHeartbeatInfo stopMocking];
-  self.mockHeartbeatInfo = nil;
 }
 
-- (void)testDataRequestSuccess {
-  NSURL *URL = [NSURL URLWithString:@"https://some.url.com"];
-  NSDictionary *additionalHeaders = @{@"header1" : @"value1"};
-  NSData *requestBody = [@"Request body" dataUsingEncoding:NSUTF8StringEncoding];
-
-  // 1. Stub URL session.
-  FIRRequestValidationBlock requestValidation = ^BOOL(NSURLRequest *request) {
-    XCTAssertEqualObjects(request.URL, URL);
-
-    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"x-firebase-client"],
-                          [FIRApp firebaseUserAgent]);
-    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"X-firebase-client-log-type"], @"3");
-
-    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"X-Goog-Api-Key"], self.APIKey);
-
-    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"X-Ios-Bundle-Identifier"],
-                          [[NSBundle mainBundle] bundleIdentifier]);
-
-    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"header1"], @"value1");
-
-    XCTAssertEqualObjects(request.HTTPMethod, @"POST");
-    XCTAssertEqualObjects(request.HTTPBody, requestBody);
-
-    return YES;
+- (void)testDataRequestSuccessWhenNoHeartbeatsNeedSending {
+  // Given
+  FIRHeartbeatsPayload *emptyHeartbeatsPayload =
+      [FIRHeartbeatLoggingTestUtils emptyHeartbeatsPayload];
+  // When
+  self.heartbeatLoggerFake.onFlushHeartbeatsIntoPayloadHandler = ^FIRHeartbeatsPayload * {
+    return emptyHeartbeatsPayload;
   };
+  // Then
+  [self assertDataRequestSuccessWhenSendingHeartbeatsPayload:emptyHeartbeatsPayload];
+}
 
-  NSData *HTTPResponseBody = [@"A response" dataUsingEncoding:NSUTF8StringEncoding];
-  NSHTTPURLResponse *HTTPResponse = [FIRURLSessionOCMockStub HTTPResponseWithCode:200];
-  [self stubURLSessionDataTaskPromiseWithResponse:HTTPResponse
-                                             body:HTTPResponseBody
-                                            error:nil
-                                   URLSessionMock:self.mockURLSession
-                           requestValidationBlock:requestValidation];
-
-  // 2. Send request.
-  __auto_type requestPromise = [self.APIService sendRequestWithURL:URL
-                                                        HTTPMethod:@"POST"
-                                                              body:requestBody
-                                                 additionalHeaders:additionalHeaders];
-
-  // 3. Verify.
-  XCTAssert(FBLWaitForPromisesWithTimeout(1));
-
-  XCTAssertTrue(requestPromise.isFulfilled);
-  XCTAssertNil(requestPromise.error);
-
-  XCTAssertEqualObjects(requestPromise.value.HTTPResponse, HTTPResponse);
-  XCTAssertEqualObjects(requestPromise.value.HTTPBody, HTTPResponseBody);
-
-  OCMVerifyAll(self.mockURLSession);
+- (void)testDataRequestSuccessWhenHeartbeatsNeedSending {
+  // Given
+  FIRHeartbeatsPayload *nonEmptyHeartbeatsPayload =
+      [FIRHeartbeatLoggingTestUtils nonEmptyHeartbeatsPayload];
+  // When
+  self.heartbeatLoggerFake.onFlushHeartbeatsIntoPayloadHandler = ^FIRHeartbeatsPayload * {
+    return nonEmptyHeartbeatsPayload;
+  };
+  // Then
+  [self assertDataRequestSuccessWhenSendingHeartbeatsPayload:nonEmptyHeartbeatsPayload];
 }
 
 - (void)testDataRequestNetworkError {
@@ -298,6 +299,63 @@
 
 #pragma mark - Helpers
 
+- (void)assertDataRequestSuccessWhenSendingHeartbeatsPayload:
+    (nullable FIRHeartbeatsPayload *)heartbeatsPayload {
+  NSURL *URL = [NSURL URLWithString:@"https://some.url.com"];
+  NSDictionary *additionalHeaders = @{@"header1" : @"value1"};
+  NSData *requestBody = [@"Request body" dataUsingEncoding:NSUTF8StringEncoding];
+
+  // 1. Stub URL session.
+  FIRRequestValidationBlock requestValidation = ^BOOL(NSURLRequest *request) {
+    XCTAssertEqualObjects(request.URL, URL);
+
+    NSMutableDictionary<NSString *, NSString *> *expectedHTTPHeaderFields = @{
+      @"X-Goog-Api-Key" : self.APIKey,
+      @"X-Ios-Bundle-Identifier" : [[NSBundle mainBundle] bundleIdentifier],
+      @"header1" : @"value1",
+    }
+                                                                                .mutableCopy;
+
+    NSString *_Nullable heartbeatHeaderValue =
+        FIRHeaderValueFromHeartbeatsPayload(heartbeatsPayload);
+    if (heartbeatHeaderValue) {
+      expectedHTTPHeaderFields[@"X-firebase-client"] = heartbeatHeaderValue;
+    }
+
+    XCTAssertEqualObjects(request.allHTTPHeaderFields, expectedHTTPHeaderFields);
+
+    XCTAssertEqualObjects(request.HTTPMethod, @"POST");
+    XCTAssertEqualObjects(request.HTTPBody, requestBody);
+
+    return YES;
+  };
+
+  NSData *HTTPResponseBody = [@"A response" dataUsingEncoding:NSUTF8StringEncoding];
+  NSHTTPURLResponse *HTTPResponse = [FIRURLSessionOCMockStub HTTPResponseWithCode:200];
+  [self stubURLSessionDataTaskPromiseWithResponse:HTTPResponse
+                                             body:HTTPResponseBody
+                                            error:nil
+                                   URLSessionMock:self.mockURLSession
+                           requestValidationBlock:requestValidation];
+
+  // 2. Send request.
+  __auto_type requestPromise = [self.APIService sendRequestWithURL:URL
+                                                        HTTPMethod:@"POST"
+                                                              body:requestBody
+                                                 additionalHeaders:additionalHeaders];
+
+  // 3. Verify.
+  XCTAssert(FBLWaitForPromisesWithTimeout(1));
+
+  XCTAssertTrue(requestPromise.isFulfilled);
+  XCTAssertNil(requestPromise.error);
+
+  XCTAssertEqualObjects(requestPromise.value.HTTPResponse, HTTPResponse);
+  XCTAssertEqualObjects(requestPromise.value.HTTPBody, HTTPResponseBody);
+
+  OCMVerifyAll(self.mockURLSession);
+}
+
 - (void)stubURLSessionDataTaskPromiseWithResponse:(NSHTTPURLResponse *)HTTPResponse
                                              body:(NSData *)body
                                             error:(NSError *)error

+ 1 - 0
FirebaseCore.podspec

@@ -65,6 +65,7 @@ Firebase Core includes FIRApp and FIROptions which provide central configuration
 
   # Using environment variable because of the dependency on the unpublished
   # HeartbeatLoggingTestUtils.
+  # TODO(v9): Remove above comment and below conditional after publishing.
   if ENV['POD_LIB_LINT_ONLY'] && ENV['POD_LIB_LINT_ONLY'] == '1' then
     s.test_spec 'unit' do |unit_tests|
       unit_tests.scheme = { :code_coverage => true }

+ 4 - 2
FirebaseCore/Extension/FIRAppInternal.h

@@ -93,13 +93,15 @@ extern NSString *const FIRAuthStateDidChangeInternalNotificationUIDKey;
  */
 @property(nonatomic, readonly) BOOL isDefaultApp;
 
-/*
+/**
  * The container of interop SDKs for this app.
  */
 @property(nonatomic) FIRComponentContainer *container;
 
-/*
+/**
  * The heartbeat logger associated with this app.
+ *
+ * Firebase apps have a 1:1 relationship with heartbeat loggers.
  */
 @property(readonly) FIRHeartbeatLogger *heartbeatLogger;
 

+ 17 - 1
FirebaseCore/Extension/FIRHeartbeatLogger.h

@@ -21,6 +21,19 @@ NS_ASSUME_NONNULL_BEGIN
 
 @class FIRHeartbeatsPayload;
 
+@protocol FIRHeartbeatLoggerProtocol <NSObject>
+
+/// Asynchronously logs a heartbeat.
+- (void)log;
+
+/// Flushes heartbeats from storage into a structured payload of heartbeats.
+- (FIRHeartbeatsPayload *)flushHeartbeatsIntoPayload;
+
+/// Gets the heartbeat code for today.
+- (FIRHeartbeatInfoCode)heartbeatCodeForToday;
+
+@end
+
 /// Returns a nullable string header value from a given heartbeats payload.
 ///
 /// This API returns `nil` when the given heartbeats payload is considered empty.
@@ -29,8 +42,11 @@ NS_ASSUME_NONNULL_BEGIN
 NSString *_Nullable FIRHeaderValueFromHeartbeatsPayload(FIRHeartbeatsPayload *heartbeatsPayload);
 
 /// A thread safe, synchronized object that logs and flushes platform logging info.
-@interface FIRHeartbeatLogger : NSObject
+@interface FIRHeartbeatLogger : NSObject <FIRHeartbeatLoggerProtocol>
 
+/// Designated initializer.
+///
+/// @param appID The app ID that this heartbeat logger corresponds to.
 - (instancetype)initWithAppID:(NSString *)appID;
 
 /// Asynchronously logs a new heartbeat corresponding to the Firebase User Agent, if needed.

+ 3 - 4
FirebaseCore/Internal/Sources/HeartbeatLogging/_ObjC_HeartbeatsPayload.swift

@@ -16,25 +16,24 @@ import Foundation
 
 /// A model object representing a payload of heartbeat data intended for sending in network requests.
 @objc(FIRHeartbeatsPayload)
-@objcMembers
 public class _ObjC_HeartbeatsPayload: NSObject, HTTPHeaderRepresentable {
   /// The underlying Swift structure.
   private let heartbeatsPayload: HeartbeatsPayload
 
   /// Designated initializer.
   /// - Parameter heartbeatsPayload: A native-Swift heartbeats payload.
-  init(_ heartbeatsPayload: HeartbeatsPayload) {
+  public init(_ heartbeatsPayload: HeartbeatsPayload) {
     self.heartbeatsPayload = heartbeatsPayload
   }
 
   /// Returns a processed payload string intended for use in a HTTP header.
   /// - Returns: A string value from the heartbeats payload.
-  public func headerValue() -> String {
+  @objc public func headerValue() -> String {
     heartbeatsPayload.headerValue()
   }
 
   /// A Boolean value indicating whether the payload is empty.
-  public var isEmpty: Bool {
+  @objc public var isEmpty: Bool {
     heartbeatsPayload.isEmpty
   }
 }

+ 2 - 2
FirebaseCore/Tests/Unit/FIRHeartbeatLoggerTest.m → FirebaseCore/Tests/Unit/FIRHeartbeatLoggerTests.m

@@ -23,11 +23,11 @@
             userAgentProvider:(NSString * (^)(void))userAgentProvider;
 @end
 
-@interface FIRHeartbeatLoggerTest : XCTestCase
+@interface FIRHeartbeatLoggerTests : XCTestCase
 @property(nonatomic) FIRHeartbeatLogger *heartbeatLogger;
 @end
 
-@implementation FIRHeartbeatLoggerTest
+@implementation FIRHeartbeatLoggerTests
 
 + (NSString *)dummyAppID {
   return NSStringFromClass([self class]);

+ 1 - 0
FirebaseCoreInternal.podspec

@@ -38,6 +38,7 @@ Pod::Spec.new do |s|
 
   # Using environment variable because of the dependency on the unpublished
   # HeartbeatLoggingTestUtils.
+  # TODO(v9): Remove above comment and below conditional after publishing.
   if ENV['POD_LIB_LINT_ONLY'] && ENV['POD_LIB_LINT_ONLY'] == '1' then
     s.test_spec 'Unit' do |unit_tests|
       unit_tests.scheme = { :code_coverage => true }

+ 41 - 0
HeartbeatLoggingTestUtils/Sources/HeartbeatLoggingTestUtils.swift

@@ -32,6 +32,47 @@ public class HeartbeatLoggingTestUtils: NSObject {
     HeartbeatsPayload.dateFormatter
   }
 
+  public static var emptyHeartbeatsPayload: _ObjC_HeartbeatsPayload {
+    let literalData = """
+       {
+         "version": 2,
+         "heartbeats": []
+       }
+    """
+    .data(using: .utf8)!
+
+    let decoder = JSONDecoder()
+    decoder.dateDecodingStrategy = .formatted(HeartbeatsPayload.dateFormatter)
+
+    let heartbeatsPayload = try! decoder.decode(HeartbeatsPayload.self, from: literalData)
+    return _ObjC_HeartbeatsPayload(heartbeatsPayload)
+  }
+
+  public static var nonEmptyHeartbeatsPayload: _ObjC_HeartbeatsPayload {
+    let literalData = """
+       {
+         "version": 2,
+         "heartbeats": [
+           {
+             "agent": "dummy_agent_1",
+             "dates": ["2021-11-01", "2021-11-02"]
+           },
+           {
+             "agent": "dummy_agent_2",
+             "dates": ["2021-11-03"]
+           }
+         ]
+       }
+    """
+    .data(using: .utf8)!
+
+    let decoder = JSONDecoder()
+    decoder.dateDecodingStrategy = .formatted(HeartbeatsPayload.dateFormatter)
+
+    let heartbeatsPayload = try! decoder.decode(HeartbeatsPayload.self, from: literalData)
+    return _ObjC_HeartbeatsPayload(heartbeatsPayload)
+  }
+
   @objc(assertEncodedPayloadString:isEqualToLiteralString:withError:)
   public static func assertEqualPayloadStrings(_ encoded: String, _ literal: String) throws {
     var encodedData = try XCTUnwrap(Data(base64URLEncoded: encoded))

+ 6 - 1
Package.swift

@@ -1182,7 +1182,12 @@ let package = Package(
     ),
     .testTarget(
       name: "AppCheckUnit",
-      dependencies: ["FirebaseAppCheck", "OCMock", "SharedTestUtilities"],
+      dependencies: [
+        "FirebaseAppCheck",
+        "OCMock",
+        "SharedTestUtilities",
+        "HeartbeatLoggingTestUtils",
+      ],
       path: "FirebaseAppCheck/Tests",
       exclude: [
         // Disable Swift tests as mixed targets are not supported (Xcode 12.3).