Skip to content

Commit

Permalink
Add tests for SNTNotificationQueue
Browse files Browse the repository at this point in the history
  • Loading branch information
mlw committed Dec 23, 2024
1 parent 0b6e035 commit a8b2f10
Show file tree
Hide file tree
Showing 6 changed files with 312 additions and 12 deletions.
6 changes: 1 addition & 5 deletions Source/common/RingBufferTest.mm
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,7 @@ - (void)testErase {
rb.Enqueue({700, 7});
rb.Enqueue({8, 800});

rb.Erase(std::remove_if(rb.begin(), rb.end(),
[](const Foo &f) {
return f.x < f.y;
}),
rb.end());
rb.Erase(std::remove_if(rb.begin(), rb.end(), [](const Foo &f) { return f.x < f.y; }), rb.end());

res = rb.Dequeue().value_or(Foo{0, 0});
XCTAssertEqual(res.x, 500);
Expand Down
15 changes: 15 additions & 0 deletions Source/santad/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ objc_library(
],
)

santa_unit_test(
name = "SNTNotificationQueueTest",
srcs = ["SNTNotificationQueueTest.mm"],
deps = [
":SNTNotificationQueue",
"//Source/common:SNTStoredEvent",
"//Source/common:SNTXPCNotifierInterface",
"//Source/common:TestUtils",
"@MOLXPCConnection",
"@OCMock",
],
)

objc_library(
name = "SNTSyncdQueue",
srcs = ["SNTSyncdQueue.m"],
Expand Down Expand Up @@ -822,6 +835,7 @@ objc_library(
":TTYWriter",
":WatchItems",
"//Source/common:PrefixTree",
"//Source/common:RingBuffer",
"//Source/common:SNTConfigurator",
"//Source/common:SNTLogging",
"//Source/common:SNTMetricSet",
Expand Down Expand Up @@ -1494,6 +1508,7 @@ test_suite(
":SNTExecutionControllerTest",
":SNTPolicyProcessorTest",
":SNTRuleTableTest",
":SNTNotificationQueueTest",
":SantadTest",
":WatchItemsTest",
"//Source/santad/Logs/EndpointSecurity/Writers/FSSpool:fsspool_test",
Expand Down
9 changes: 9 additions & 0 deletions Source/santad/SNTNotificationQueue.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,22 @@

#import <Foundation/Foundation.h>

#include <memory>

#include "Source/common/RingBuffer.h"

@class SNTStoredEvent;
@class MOLXPCConnection;

@interface SNTNotificationQueue : NSObject

@property(nonatomic) MOLXPCConnection *notifierConnection;

- (instancetype)initWithRingBuffer:
(std::unique_ptr<santa::RingBuffer<NSMutableDictionary *>>)pendingNotifications NS_DESIGNATED_INITIALIZER;

- (instancetype)init NS_UNAVAILABLE;

- (void)addEvent:(SNTStoredEvent *)event
withCustomMessage:(NSString *)message
customURL:(NSString *)url
Expand Down
12 changes: 6 additions & 6 deletions Source/santad/SNTNotificationQueue.mm
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
#import "Source/common/SNTStrengthify.h"
#import "Source/common/SNTXPCNotifierInterface.h"

static const int kMaximumNotifications = 10;

@interface SNTNotificationQueue ()
@property dispatch_queue_t pendingQueue;
@end
Expand All @@ -33,12 +31,14 @@ @implementation SNTNotificationQueue {
std::unique_ptr<santa::RingBuffer<NSMutableDictionary *>> _pendingNotifications;
}

- (instancetype)init {
- (instancetype)initWithRingBuffer:
(std::unique_ptr<santa::RingBuffer<NSMutableDictionary *>>)pendingNotifications {
self = [super init];
if (self) {
_pendingNotifications = std::move(pendingNotifications);

_pendingQueue = dispatch_queue_create("com.northpolesec.santa.daemon.SNTNotificationQueue",
DISPATCH_QUEUE_SERIAL);
_pendingNotifications = std::make_unique<santa::RingBuffer<NSMutableDictionary *>>(kMaximumNotifications);
}
return self;
}
Expand All @@ -65,8 +65,8 @@ - (void)addEvent:(SNTStoredEvent *)event
NSDictionary *msg = _pendingNotifications->Enqueue(d).value_or(nil);

if (msg != nil) {
LOGI(@"Pending GUI notification count is over %d, dropping oldest notification.",
kMaximumNotifications);
LOGI(@"Pending GUI notification count is over %zu, dropping oldest notification.",
_pendingNotifications->Capacity());
// Check if the dropped notification had a reply block and if so, call it
// so any resources can be cleaned up.
void (^replyBlock)(BOOL) = msg[@"reply"];
Expand Down
278 changes: 278 additions & 0 deletions Source/santad/SNTNotificationQueueTest.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/// Copyright 2024 North Pole Security, Inc.
///
/// 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
///
/// https://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/santad/SNTNotificationQueue.h"

#import <Foundation/Foundation.h>
#import <MOLXPCConnection/MOLXPCConnection.h>
#import <OCMock/OCMock.h>
#import <XCTest/XCTest.h>

#include <memory>

#import "Source/common/SNTStoredEvent.h"
#import "Source/common/SNTXPCNotifierInterface.h"
#include "Source/common/TestUtils.h"

@interface SNTNotificationQueue (Testing)
- (void)clearAllPendingRepliesLocked;
@end

@interface SNTNotificationQueueTest : XCTestCase
@property santa::RingBuffer<NSMutableDictionary *> *ringbuf;
@property SNTNotificationQueue *sut;
@property id mockConnection;
@property id mockProxy;
@end

@implementation SNTNotificationQueueTest

- (void)setUp {
auto rbUnique = std::make_unique<santa::RingBuffer<NSMutableDictionary *>>(3);
self.ringbuf = rbUnique.get();
self.sut = [[SNTNotificationQueue alloc] initWithRingBuffer:std::move(rbUnique)];

self.mockConnection = OCMClassMock([MOLXPCConnection class]);
self.mockProxy = OCMProtocolMock(@protocol(SNTNotifierXPC));

// Setup mock connection to return mock proxy
OCMStub([self.mockConnection remoteObjectProxy]).andReturn(self.mockProxy);
self.sut.notifierConnection = self.mockConnection;
}

- (void)testAddEventBasic {
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
SNTStoredEvent *se = [[SNTStoredEvent alloc] init];
NSString *customMessage = @"custom msg";
NSString *customURL = @"https://northpolesec.com";

OCMExpect([self.mockProxy postBlockNotification:se
withCustomMessage:customMessage
customURL:customURL
andReply:OCMOCK_ANY])
.andDo(^(NSInvocation *inv) {
// Extract the reply block from the invocation and call it
void (^replyBlock)(BOOL);
[inv getArgument:&replyBlock atIndex:5];
replyBlock(YES);
});

[self.sut addEvent:se
withCustomMessage:customMessage
customURL:customURL
andReply:^(BOOL) {
dispatch_semaphore_signal(sema);
}];

XCTAssertSemaTrue(sema, 3, "Reply block not called within expected window");
OCMVerifyAll(self.mockProxy);
}

- (void)testAddEventNil {
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
NSString *customMessage = @"custom msg";
NSString *customURL = @"https://northpolesec.com";

[self.sut addEvent:nil
withCustomMessage:customMessage
customURL:customURL
andReply:^(BOOL val) {
XCTAssertFalse(val);
dispatch_semaphore_signal(sema);
}];

XCTAssertSemaTrue(sema, 3, "Reply block not called within expected window");

OCMVerify(never(), [self.mockProxy postBlockNotification:OCMOCK_ANY
withCustomMessage:OCMOCK_ANY
customURL:OCMOCK_ANY
andReply:OCMOCK_ANY]);
}

// This test pre-populates the ring buffer to be full to ensure that when a newly added
// message forcefully dequeues the first item, the reply block is called with FALSE, as
// well as posting messages for everything in the queue.
- (void)testAddEventMulti {
NSString *customMessage = @"custom msg";
NSString *customURL = @"https://northpolesec.com";

SNTStoredEvent *se1 = [[SNTStoredEvent alloc] init];
SNTStoredEvent *se2 = [[SNTStoredEvent alloc] init];
SNTStoredEvent *se3 = [[SNTStoredEvent alloc] init];
SNTStoredEvent *se4 = [[SNTStoredEvent alloc] init];

XCTestExpectation *reply1Expectation = [self expectationWithDescription:@"Reply 1 called"];
XCTestExpectation *reply2Expectation = [self expectationWithDescription:@"Reply 2 called"];
XCTestExpectation *reply3Expectation = [self expectationWithDescription:@"Reply 3 called"];
XCTestExpectation *reply4Expectation = [self expectationWithDescription:@"Reply 4 called"];

void (^replyBlock1)(BOOL) = ^(BOOL val) {
XCTAssertFalse(val);
[reply1Expectation fulfill];
};

void (^replyBlock2)(BOOL) = ^(BOOL val) {
XCTAssertTrue(val);
[reply2Expectation fulfill];
};

void (^replyBlock3)(BOOL) = ^(BOOL val) {
XCTAssertTrue(val);
[reply3Expectation fulfill];
};

void (^replyBlock4)(BOOL) = ^(BOOL val) {
XCTAssertTrue(val);
[reply4Expectation fulfill];
};

// Create dictionaries to enqueue
NSMutableDictionary *d1 = [NSMutableDictionary dictionary];
[d1 setValue:se1 forKey:@"event"];
[d1 setValue:@"Message 1" forKey:@"message"];
[d1 setValue:@"https://northpolesec.com/1" forKey:@"url"];
[d1 setValue:replyBlock1 forKey:@"reply"];

NSMutableDictionary *d2 = [NSMutableDictionary dictionary];
[d2 setValue:se2 forKey:@"event"];
[d2 setValue:@"Message 2" forKey:@"message"];
[d2 setValue:@"https://northpolesec.com/2" forKey:@"url"];
[d2 setValue:replyBlock2 forKey:@"reply"];

NSMutableDictionary *d3 = [NSMutableDictionary dictionary];
[d3 setValue:se3 forKey:@"event"];
[d3 setValue:@"Message 3" forKey:@"message"];
[d3 setValue:@"https://northpolesec.com/3" forKey:@"url"];
[d3 setValue:replyBlock3 forKey:@"reply"];

self.ringbuf->Enqueue(d1);
self.ringbuf->Enqueue(d2);
self.ringbuf->Enqueue(d3);

XCTAssertTrue(self.ringbuf->Full());
XCTAssertFalse(self.ringbuf->Empty());

// postBlockNotification should never be called for `se1` since it will fall out of the ring
OCMVerify(never(), [self.mockProxy postBlockNotification:se1
withCustomMessage:@"Message 1"
customURL:@"https://northpolesec.com/1"
andReply:OCMOCK_ANY]);

OCMExpect([self.mockProxy postBlockNotification:se2
withCustomMessage:@"Message 2"
customURL:@"https://northpolesec.com/2"
andReply:OCMOCK_ANY])
.andDo(^(NSInvocation *invocation) {
void (^replyBlock)(BOOL);
[invocation getArgument:&replyBlock atIndex:5];
replyBlock(YES);
});

OCMExpect([self.mockProxy postBlockNotification:se3
withCustomMessage:@"Message 3"
customURL:@"https://northpolesec.com/3"
andReply:OCMOCK_ANY])
.andDo(^(NSInvocation *invocation) {
void (^replyBlock)(BOOL);
[invocation getArgument:&replyBlock atIndex:5];
replyBlock(YES);
});

OCMExpect([self.mockProxy postBlockNotification:se4
withCustomMessage:customMessage
customURL:customURL
andReply:OCMOCK_ANY])
.andDo(^(NSInvocation *inv) {
void (^replyBlock)(BOOL);
[inv getArgument:&replyBlock atIndex:5];
replyBlock(YES);
});

[self.sut addEvent:se4 withCustomMessage:customMessage customURL:customURL andReply:replyBlock4];

[self waitForExpectationsWithTimeout:4.0 handler:nil];

XCTAssertFalse(self.ringbuf->Full());
XCTAssertTrue(self.ringbuf->Empty());

OCMVerifyAll(self.mockProxy);
}

- (void)testClearAllPendingRepliesLocked {
SNTStoredEvent *se1 = [[SNTStoredEvent alloc] init];
SNTStoredEvent *se2 = [[SNTStoredEvent alloc] init];
SNTStoredEvent *se3 = [[SNTStoredEvent alloc] init];

// Setup expectations for reply blocks
XCTestExpectation *reply1Expectation =
[self expectationWithDescription:@"Reply 1 called with NO"];
XCTestExpectation *reply2Expectation =
[self expectationWithDescription:@"Reply 2 called with NO"];

void (^replyBlock1)(BOOL) = ^(BOOL val) {
XCTAssertFalse(val);
[reply1Expectation fulfill];
};
void (^replyBlock2)(BOOL) = ^(BOOL val) {
XCTAssertFalse(val);
[reply2Expectation fulfill];
};

// Create dictionaries to enqueue
NSMutableDictionary *d1 = [NSMutableDictionary dictionary];
[d1 setValue:se1 forKey:@"event"];
[d1 setValue:@"Message 1" forKey:@"message"];
[d1 setValue:@"https://northpolesec.com/1" forKey:@"url"];
[d1 setValue:[replyBlock1 copy] forKey:@"reply"];

NSMutableDictionary *d2 = [NSMutableDictionary dictionary];
[d2 setValue:se2 forKey:@"event"];
[d2 setValue:@"Message 2" forKey:@"message"];
[d2 setValue:@"https://northpolesec.com/2" forKey:@"url"];
[d2 setValue:[replyBlock2 copy] forKey:@"reply"];

// Create dictionary with no reply block
NSMutableDictionary *d3 = [NSMutableDictionary dictionary];
[d3 setValue:se3 forKey:@"event"];
[d3 setValue:@"Message 3" forKey:@"message"];
[d3 setValue:@"https://northpolesec.com/3" forKey:@"url"];
// Intentionally not setting a reply block for d3

self.ringbuf->Enqueue(d1);
self.ringbuf->Enqueue(d2);
self.ringbuf->Enqueue(d3);

XCTAssertTrue(self.ringbuf->Full());
XCTAssertFalse(self.ringbuf->Empty());

[self.sut clearAllPendingRepliesLocked];

// Wait for the reply blocks to be called
[self waitForExpectationsWithTimeout:1.0 handler:nil];

// Check the ring is still full (replies are cleared, but entries are not removed)
XCTAssertTrue(self.ringbuf->Full());
XCTAssertFalse(self.ringbuf->Empty());

// Verify that the reply keys were removed from the dictionaries
XCTAssertNil(d1[@"reply"]);
XCTAssertNil(d2[@"reply"]);

// Verify d3 remains unchanged (it never had a reply block)
XCTAssertNil(d3[@"reply"]);
XCTAssertEqual(d3[@"message"], @"Message 3");
XCTAssertEqual(d3[@"url"], @"https://northpolesec.com/3");
}

@end
Loading

0 comments on commit a8b2f10

Please sign in to comment.