From 994721ffe52f1a04bdd9658b1f9ac24d29b6f790 Mon Sep 17 00:00:00 2001 From: Pete Markowsky Date: Thu, 23 Sep 2021 09:23:14 -0400 Subject: [PATCH] Initial commit of an HTTP writer for SNTMetricSets. This PR adds support for shipping serialized SNTMetricSets to an HTTP server via POSTs. --- Source/santametricservice/BUILD | 3 +- .../Formats/SNTMetricRawJSONFormat.m | 2 +- Source/santametricservice/SNTMetricService.m | 5 +- .../santametricservice/SNTMetricServiceTest.m | 61 ++++++++ Source/santametricservice/Writers/BUILD | 33 +++- .../Writers/SNTMetricHTTPWriter.h | 18 +++ .../Writers/SNTMetricHTTPWriter.m | 97 ++++++++++++ .../Writers/SNTMetricHTTPWriterTest.m | 144 ++++++++++++++++++ 8 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 Source/santametricservice/Writers/SNTMetricHTTPWriter.h create mode 100644 Source/santametricservice/Writers/SNTMetricHTTPWriter.m create mode 100644 Source/santametricservice/Writers/SNTMetricHTTPWriterTest.m diff --git a/Source/santametricservice/BUILD b/Source/santametricservice/BUILD index 9296d6afc..6068755bb 100644 --- a/Source/santametricservice/BUILD +++ b/Source/santametricservice/BUILD @@ -19,6 +19,7 @@ objc_library( "//Source/common:SNTXPCMetricServiceInterface", "//Source/santametricservice/Formats:SNTMetricRawJSONFormat", "//Source/santametricservice/Writers:SNTMetricFileWriter", + "//Source/santametricservice/Writers:SNTMetricHTTPWriter", "@MOLCodesignChecker", "@MOLXPCConnection", ], @@ -39,7 +40,7 @@ test_suite( tests = [ ":SNTMetricServiceTest", "//Source/santametricservice/Formats:SNTMetricRawJSONFormatTest", - "//Source/santametricservice/Writers:SNTMetricFileWriterTest", + "//Source/santametricservice/Writers:writer_tests", ], ) diff --git a/Source/santametricservice/Formats/SNTMetricRawJSONFormat.m b/Source/santametricservice/Formats/SNTMetricRawJSONFormat.m index 7f6bdba07..ee47bd98f 100644 --- a/Source/santametricservice/Formats/SNTMetricRawJSONFormat.m +++ b/Source/santametricservice/Formats/SNTMetricRawJSONFormat.m @@ -81,7 +81,7 @@ - (NSDictionary *)normalize:(NSDictionary *)metrics { if (![NSJSONSerialization isValidJSONObject:normalizedMetrics]) { if (err != nil) { *err = [[NSError alloc] - initWithDomain:@"SNTMetricRawJSONFileWriter" + initWithDomain:@"com.google.santa.metricservice.formatters.rawjson" code:EINVAL userInfo:@{ NSLocalizedDescriptionKey : @"unable to convert metrics to JSON: invalid metrics" diff --git a/Source/santametricservice/SNTMetricService.m b/Source/santametricservice/SNTMetricService.m index 59d353827..1309e2d66 100644 --- a/Source/santametricservice/SNTMetricService.m +++ b/Source/santametricservice/SNTMetricService.m @@ -21,6 +21,7 @@ #import "SNTMetricService.h" #import "Source/santametricservice/Formats/SNTMetricRawJSONFormat.h" #import "Source/santametricservice/Writers/SNTMetricFileWriter.h" +#import "Source/santametricservice/Writers/SNTMetricHTTPWriter.h" @interface SNTMetricService () @property MOLXPCConnection *notifierConnection; @@ -38,7 +39,9 @@ - (instancetype)init { self = [super init]; if (self) { rawJSONFormatter = [[SNTMetricRawJSONFormat alloc] init]; - metricWriters = @{@"file" : [[SNTMetricFileWriter alloc] init]}; + metricWriters = @{@"file" : [[SNTMetricFileWriter alloc] init], + @"http": [[SNTMetricHTTPWriter alloc] init], + }; _queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0); } diff --git a/Source/santametricservice/SNTMetricServiceTest.m b/Source/santametricservice/SNTMetricServiceTest.m index ecafe8f8d..f792f7b66 100644 --- a/Source/santametricservice/SNTMetricServiceTest.m +++ b/Source/santametricservice/SNTMetricServiceTest.m @@ -6,6 +6,7 @@ #import "Source/common/SNTMetricSet.h" #import +#import #import "Source/santametricservice/Formats/SNTMetricFormatTestHelper.h" #import "Source/santametricservice/SNTMetricService.h" @@ -16,6 +17,9 @@ @interface SNTMetricServiceTest : XCTestCase @property id mockConfigurator; @property NSString *tempDir; @property NSURL *jsonURL; +@property id mockSession; +@property id mockSessionDataTask; +@property id mockMOLAuthenticatingURLSession; @end @implementation SNTMetricServiceTest @@ -39,6 +43,15 @@ - (void)setUp { - (void)tearDown { [self.mockConfigurator stopMocking]; + if (self.mockSessionDataTask != nil) { + [self.mockSessionDataTask stopMocking]; + } + if (self.mockSession != nil) { + [self.mockSession stopMocking]; + } + if (self.mockMOLAuthenticatingURLSession != nil) { + [self.mockMOLAuthenticatingURLSession stopMocking]; + } // delete the temp dir [[NSFileManager defaultManager] removeItemAtPath:self.tempDir error:NULL]; @@ -112,4 +125,52 @@ - (void)testWritingRawJSONFile { XCTAssertEqualObjects(validMetricsDict, parsedJSONData, @"invalid JSON created"); } + +- (void)testWritingJSON { + NSURL *url = [NSURL URLWithString:@"http://localhost:9444"]; + OCMStub([self.mockConfigurator exportMetrics]).andReturn(YES); + OCMStub([self.mockConfigurator metricFormat]).andReturn(SNTMetricFormatTypeRawJSON); + OCMStub([self.mockConfigurator metricURL]).andReturn(url); + + self.mockSession = [OCMockObject niceMockForClass:[NSURLSession class]]; + self.mockSessionDataTask = [OCMockObject niceMockForClass:[NSURLSessionDataTask class]]; + self.mockMOLAuthenticatingURLSession = + [OCMockObject niceMockForClass:[MOLAuthenticatingURLSession class]]; + + [[[self.mockMOLAuthenticatingURLSession stub] andReturn:self.mockMOLAuthenticatingURLSession] alloc]; + [[[self.mockMOLAuthenticatingURLSession stub] andReturn:self.mockSession] session]; + + NSHTTPURLResponse *response = + [[NSHTTPURLResponse alloc] initWithURL:url + statusCode:200 + HTTPVersion:@"HTTP/1.1" + headerFields:@{@"content-type" : @"application/json"}]; + + __block void (^passedBlock)(NSData *, NSURLResponse *, NSError *); + + XCTestExpectation *responseCallback = [[XCTestExpectation alloc] initWithDescription:@"ensure writer passed JSON"]; + + void (^getCompletionHandler)(NSInvocation *) = ^(NSInvocation *invocation) { + [invocation getArgument:&passedBlock atIndex:3]; + }; + + void (^callCompletionHandler)(NSInvocation *) = ^(NSInvocation *invocation) { + passedBlock(nil, response, nil); + [responseCallback fulfill]; + }; + + + // stub out session to call completion handler immediately. + [(NSURLSessionDataTask *)[[self.mockSessionDataTask stub] andDo:callCompletionHandler] resume]; + + // stub out NSURLSession to assign our completion handler and return our mock + [[[[self.mockSession stub] andDo:getCompletionHandler] andReturn:self.mockSessionDataTask] + dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg any]]; + + SNTMetricService *service = [[SNTMetricService alloc] init]; + [service exportForMonitoring:[SNTMetricFormatTestHelper createValidMetricsDictionary]]; + [self waitForExpectations:@[responseCallback] timeout:10.0]; +} @end + diff --git a/Source/santametricservice/Writers/BUILD b/Source/santametricservice/Writers/BUILD index fd4a710f1..1cc3e3435 100644 --- a/Source/santametricservice/Writers/BUILD +++ b/Source/santametricservice/Writers/BUILD @@ -14,7 +14,6 @@ objc_library( srcs = [ "SNTMetricFileWriter.h", "SNTMetricFileWriter.m", - "SNTMetricWriter.h", ], deps = [ ":SNTMetricWriter", @@ -31,3 +30,35 @@ santa_unit_test( ":SNTMetricFileWriter", ], ) + +objc_library( + name = "SNTMetricHTTPWriter", + srcs = [ + "SNTMetricHTTPWriter.h", + "SNTMetricHTTPWriter.m", + ], + deps = [ + ":SNTMetricWriter", + "//Source/common:SNTLogging", + "@MOLAuthenticatingURLSession", + ], +) + +santa_unit_test( + name = "SNTMetricHTTPWriterTest", + srcs = [ + "SNTMetricHTTPWriterTest.m", + ], + deps = [ + ":SNTMetricHTTPWriter", + "@OCMock", + ], +) + +test_suite( + name = "writer_tests", + tests = [ + ":SNTMetricFileWriterTest", + ":SNTMetricHTTPWriterTest", + ], +) diff --git a/Source/santametricservice/Writers/SNTMetricHTTPWriter.h b/Source/santametricservice/Writers/SNTMetricHTTPWriter.h new file mode 100644 index 000000000..117878657 --- /dev/null +++ b/Source/santametricservice/Writers/SNTMetricHTTPWriter.h @@ -0,0 +1,18 @@ +/// Copyright 2021 Google Inc. All rights reserved. +/// +/// 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 "Source/santametricservice/Writers/SNTMetricWriter.h" + +@interface SNTMetricHTTPWriter : NSObject +@end \ No newline at end of file diff --git a/Source/santametricservice/Writers/SNTMetricHTTPWriter.m b/Source/santametricservice/Writers/SNTMetricHTTPWriter.m new file mode 100644 index 000000000..85f6e29dd --- /dev/null +++ b/Source/santametricservice/Writers/SNTMetricHTTPWriter.m @@ -0,0 +1,97 @@ +/// Copyright 2021 Google Inc. All rights reserved. +/// +/// 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. +#include +#import + +#import "Source/santametricservice/Writers/SNTMetricHTTPWriter.h" + +@implementation SNTMetricHTTPWriter { + @private + NSMutableURLRequest *_request; + MOLAuthenticatingURLSession *_authSession; + NSURLSession *_session; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _request = [[NSMutableURLRequest alloc] init]; + _request.HTTPMethod = @"POST"; + _authSession = [[MOLAuthenticatingURLSession alloc] init]; + } + return self; +} + +/** + * Post serialzied metrics to the specified URL one object at a time. + **/ +- (BOOL)write:(NSArray *)metrics toURL:(NSURL *)url error:(NSError **)error { + // open the file and write it. + __block NSError *_blockError = nil; + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = @"POST"; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + + _authSession.serverHostname = url.host; + _session = _authSession.session; + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [metrics enumerateObjectsUsingBlock:^(id value, NSUInteger index, BOOL *stop) { + request.HTTPBody = (NSData *)value; + [[_session dataTaskWithRequest:request + completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, + NSError *_Nullable err) { + if (err != nil) { + _blockError = err; + *stop = YES; + } + + if (response == nil) { + *stop = YES; + } else if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + + // Check HTTP error codes. + if (httpResponse && httpResponse.statusCode != 200) { + _blockError = [[NSError alloc] + initWithDomain:@"com.google.santa.metricservice.writers.http" + code:httpResponse.statusCode + userInfo:@{ + NSLocalizedDescriptionKey : + [NSString stringWithFormat:@"received http status code %ld from %@", + httpResponse.statusCode, url] + }]; + + *stop = YES; + } + } + }] resume]; + }]; + dispatch_semaphore_signal(semaphore); + }); + + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + if (_blockError != nil) { + if (error != nil) { + *error = [_blockError copy]; + } + return NO; + } + + return YES; +} +@end diff --git a/Source/santametricservice/Writers/SNTMetricHTTPWriterTest.m b/Source/santametricservice/Writers/SNTMetricHTTPWriterTest.m new file mode 100644 index 000000000..011dd7fa6 --- /dev/null +++ b/Source/santametricservice/Writers/SNTMetricHTTPWriterTest.m @@ -0,0 +1,144 @@ +#import +#import + +#import +#import + +#import "Source/santametricservice/Writers/SNTMetricHTTPWriter.h" + +@interface SNTMetricHTTPWriterTest : XCTestCase +@property id mockSession; +@property id mockSessionDataTask; +@property id mockMOLAuthenticatingURLSession; +@property SNTMetricHTTPWriter *httpWriter; +@end + +@implementation SNTMetricHTTPWriterTest + +- (void)setUp { + self.mockSession = [OCMockObject niceMockForClass:[NSURLSession class]]; + self.mockSessionDataTask = [OCMockObject niceMockForClass:[NSURLSessionDataTask class]]; + self.mockMOLAuthenticatingURLSession = + [OCMockObject niceMockForClass:[MOLAuthenticatingURLSession class]]; + [[[self.mockMOLAuthenticatingURLSession stub] andReturn:self.mockMOLAuthenticatingURLSession] + alloc]; + [[[self.mockMOLAuthenticatingURLSession stub] andReturn:self.mockSession] session]; + + self.httpWriter = [[SNTMetricHTTPWriter alloc] init]; +} + +- (void)tearDown { + [self.mockSessionDataTask stopMocking]; + [self.mockSession stopMocking]; + [self.mockMOLAuthenticatingURLSession stopMocking]; +} + +- (void)createMockResponseWithURL:(NSURL *)url + withCode:(NSInteger)code + withData:(NSData *)data + withError:(NSError *)err { + NSHTTPURLResponse *response = + [[NSHTTPURLResponse alloc] initWithURL:url + statusCode:code + HTTPVersion:@"HTTP/1.1" + headerFields:@{@"content-type" : @"application/json"}]; + + __block void (^passedBlock)(NSData *, NSURLResponse *, NSError *); + + void (^getCompletionHandler)(NSInvocation *) = ^(NSInvocation *invocation) { + [invocation getArgument:&passedBlock atIndex:3]; + }; + + void (^callCompletionHandler)(NSInvocation *) = ^(NSInvocation *invocation) { + passedBlock(data, response, err); + }; + + // stub out session to call completion handler immediately. + [(NSURLSessionDataTask *)[[self.mockSessionDataTask stub] andDo:callCompletionHandler] resume]; + + // stub out NSURLSession to assign our completion handler and return our mock + [[[[self.mockSession stub] andDo:getCompletionHandler] andReturn:self.mockSessionDataTask] + dataTaskWithRequest:[OCMArg any] + completionHandler:[OCMArg any]]; +} + +- (void)testValidPostOfData { + NSURL *url = [[NSURL alloc] initWithString:@"http://localhost:8444/submit"]; + + [self createMockResponseWithURL:url withCode:200 withData:nil withError:nil]; + + SNTMetricHTTPWriter *httpWriter = [[SNTMetricHTTPWriter alloc] init]; + + NSData *JSONdata = [@"{\"foo\": \"bar\"}\r\n" dataUsingEncoding:NSUTF8StringEncoding]; + + NSError *err; + BOOL result = [httpWriter write:@[ JSONdata ] toURL:url error:&err]; + XCTAssertEqual(YES, result); + XCTAssertNil(err); +} + +- (void)testEnsureHTTPErrorCodesResultInErrors { + NSURL *url = [NSURL URLWithString:@"http://localhost:10444"]; + + NSData *JSONdata = [@"{\"foo\": \"bar\"}\r\n" dataUsingEncoding:NSUTF8StringEncoding]; + NSError *err; + + for (long code = 400; code < 600; code += 100) { + [self createMockResponseWithURL:url withCode:code withData:nil withError:nil]; + + BOOL result = [self.httpWriter write:@[ JSONdata ] toURL:url error:&err]; + + XCTAssertEqual(NO, result, @"result of call to write did not fail as expected"); + XCTAssertNotNil(err); + } +} + +- (void)testEnsureErrorsFromTransportAreHandled { + NSURL *url = [NSURL URLWithString:@"http://localhost:9444"]; + NSError *mockErr = [[NSError alloc] initWithDomain:@"com.google.santa.metricservice.writers.http" + code:505 + userInfo:@{NSLocalizedDescriptionKey : @"test error"}]; + NSError *err; + + [self createMockResponseWithURL:url withCode:505 withData:nil withError:mockErr]; + + NSData *JSONdata = [@"{\"foo\": \"bar\"}\r\n" dataUsingEncoding:NSUTF8StringEncoding]; + + BOOL result = [self.httpWriter write:@[ JSONdata ] toURL:url error:&err]; + + XCTAssertEqual(NO, result, @"result of call to write did not fail as expected"); + XCTAssertEqual(mockErr.code, err.code); + XCTAssertEqualObjects(mockErr.domain, err.domain); + XCTAssertEqualObjects(@"received http status code 505 from http://localhost:9444", + err.userInfo[NSLocalizedDescriptionKey]); +} + +- (void)testEnsurePassingNilOrNullErrorDoesNotCrash { + NSURL *url = [NSURL URLWithString:@"http://localhost:9444"]; + + // Ensure that non-200 status codes codes do not crash + [self createMockResponseWithURL:url withCode:400 withData:nil withError:nil]; + + NSData *JSONdata = [@"{\"foo\": \"bar\"}\r\n" dataUsingEncoding:NSUTF8StringEncoding]; + + BOOL result = [self.httpWriter write:@[ JSONdata ] toURL:url error:nil]; + XCTAssertEqual(NO, result); + + result = [self.httpWriter write:@[ JSONdata ] toURL:url error:NULL]; + XCTAssertEqual(NO, result); + + NSError *mockErr = + [[NSError alloc] initWithDomain:@"com.google.santa.metricservice.writers.http.test" + code:505 + userInfo:@{NSLocalizedDescriptionKey : @"test error"}]; + + [self createMockResponseWithURL:url withCode:500 withData:nil withError:mockErr]; + + result = [self.httpWriter write:@[ JSONdata ] toURL:url error:nil]; + XCTAssertFalse(result); + + result = [self.httpWriter write:@[ JSONdata ] toURL:url error:NULL]; + + XCTAssertFalse(result); +} +@end