2 * Copyright (c) 2017 Apple Inc. All Rights Reserved.
4 * @APPLE_LICENSE_HEADER_START@
6 * This file contains Original Code and/or Modifications of Original Code
7 * as defined in and that are subject to the Apple Public Source License
8 * Version 2.0 (the 'License'). You may not use this file except in
9 * compliance with the License. Please obtain a copy of the License at
10 * http://www.opensource.apple.com/apsl/ and read it before using this
13 * The Original Code and all software distributed under the License are
14 * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
15 * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
16 * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
17 * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
18 * Please see the License for the specific language governing rights and
19 * limitations under the License.
21 * @APPLE_LICENSE_HEADER_END@
24 #import <Foundation/Foundation.h>
25 #import <Foundation/NSKeyedArchiver_Private.h>
26 #import <XCTest/XCTest.h>
27 #import "keychain/ckks/RateLimiter.h"
29 @interface TestObject : NSObject
30 @property NSString *uuid;
31 - (NSString *)invalid;
34 @implementation TestObject
35 - (instancetype)init {
38 _uuid = [[NSUUID UUID] UUIDString];
43 - (instancetype)initWithNilUuid {
51 // It's super illegal for this to get called because of property allowlisting
52 - (NSString *)invalid {
53 NSAssert(NO, @"'invalid' is not an approved property");
58 @interface RateLimiterTests : XCTestCase
59 @property NSDictionary *config;
60 @property NSString *filepath;
61 @property NSDate *time;
62 @property RateLimiter *RL;
63 @property TestObject *obj;
66 @implementation RateLimiterTests
70 // instantiate config, write to disk
71 NSData *configData = [@"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
72 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\
73 <plist version=\"1.0\">\
77 <key>maxStateSize</key>\
78 <integer>250</integer>\
79 <key>maxItemAge</key>\
80 <integer>3600</integer>\
81 <key>overloadDuration</key>\
82 <integer>1800</integer>\
84 <string>CKKS</string>\
92 <string>global</string>\
94 <integer>20</integer>\
96 <integer>30</integer>\
102 <string>uuid</string>\
104 <integer>3</integer>\
106 <integer>600</integer>\
108 <integer>3</integer>\
112 <string>invalid</string>\
114 <integer>0</integer>\
116 <integer>60000</integer>\
118 <integer>4</integer>\
123 " dataUsingEncoding:NSUTF8StringEncoding];
125 _config = [NSPropertyListSerialization propertyListWithData:configData options:NSPropertyListImmutable format:nil error:&err];
127 XCTFail(@"Could not deserialize property list: %@", err);
129 _filepath = [NSString stringWithFormat:@"/tmp/ratelimitertests_%@.plist", [[NSUUID UUID] UUIDString]];
130 if (![configData writeToFile:_filepath atomically:NO]) {
131 XCTFail(@"Could not write plist to %@", _filepath);
133 _RL = [[RateLimiter alloc] initWithConfig:_config];
134 _obj = [TestObject new];
135 _time = [NSDate date];
140 if (![[NSFileManager defaultManager] removeItemAtPath:_filepath error:&err]) {
141 XCTFail(@"Couldn't delete file %@: %@", _filepath, err);
146 - (void)testInitWithConfig {
147 self.RL = [[RateLimiter alloc] initWithConfig:self.config];
148 XCTAssertNotNil(self.RL, @"RateLimiter with config succeeds");
149 XCTAssertNil(self.RL.assetType, @"initWithConfig means no assetType");
150 XCTAssertEqualObjects(self.config, self.RL.config, @"config was copied properly");
153 - (void)testInitWithPlist {
154 RateLimiter *RL = [[RateLimiter alloc] initWithPlistFromURL:[NSURL URLWithString:[NSString stringWithFormat:@"file://%@", self.filepath]]];
155 XCTAssertNotNil(RL, @"RateLimiter with plist succeeds");
156 XCTAssertNil(RL.assetType, @"initWithPlist means no assetType");
157 XCTAssertEqualObjects(self.config, RL.config, @"config was loaded properly");
158 RL = [[RateLimiter alloc] initWithPlistFromURL:[NSURL URLWithString:[NSString stringWithFormat:@"file://%@.nonexisting", self.filepath]]];
159 XCTAssertNil(RL, "Cannot instantiate RateLimiter with invalid plist URL");
162 - (void)testEncodingAndDecoding {
163 NSDate* date = [NSDate date];
165 [self.RL judge:self.obj at:date limitTime:&limit];
167 NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initRequiringSecureCoding:YES];
168 [self.RL encodeWithCoder:encoder];
169 NSData* data = encoder.encodedData;
171 XCTAssertEqualObjects(self.config, self.RL.config, @"config unmodified after encoding");
172 XCTAssertNil(self.RL.assetType, @"assetType still nil after encoding");
174 NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingFromData:data error:nil];
175 RateLimiter *RL2 = [[RateLimiter alloc] initWithCoder:decoder];
176 XCTAssertNotNil(RL2, @"Received an object from initWithCoder");
177 XCTAssertEqualObjects(self.RL.config, RL2.config, @"config is the same after encoding and decoding");
178 XCTAssertTrue([self.RL isEqual:RL2], @"RateLimiters believe they are the same");
179 XCTAssertNil(RL2.assetType, @"assetType remains nil");
182 - (void)testInitWithAssetType {
183 // Not implemented yet, expect nil
184 XCTAssertNil([[RateLimiter alloc] initWithAssetType:@"test"]);
188 NSDate *limitTime = nil;
189 [self.RL judge:[TestObject new] at:self.time limitTime:&limitTime];
190 XCTAssertEqual([self.RL stateSize], 2ul, @"Single property judged once, state is 1 global plus 1 property");
192 XCTAssertEqual([self.RL stateSize], 0ul, @"No buckets after reset");
193 XCTAssertEqualObjects(self.config, self.RL.config);
196 // Cause it to complain based on one item being hit repeatedly
197 - (void)testJudgeSingleItem {
198 NSDate *limitTime = nil;
199 for (int idx = 0; idx < [self.config[@"groups"][1][@"capacity"] intValue]; ++idx) {
200 XCTAssertEqual([self.RL judge:self.obj at:self.time limitTime:&limitTime], RateLimiterBadnessClear, @"Received RateLimiterBadnessClear");
201 XCTAssertNil(limitTime, @"single object, clear to process right now");
204 XCTAssertEqual([self.RL judge:self.obj at:self.time limitTime:&limitTime], RateLimiterBadnessGridlocked, @"Received RateLimiterBadnessGridlocked");
205 XCTAssertNotNil(limitTime, @"After hammering same object need to wait now");
206 XCTAssertEqualObjects(limitTime, [self.time dateByAddingTimeInterval:[self.config[@"groups"][1][@"rate"] intValue]], @"time: %@, process-OK time is time + rate (%d)", self.time, [self.config[@"groups"][1][@"rate"] intValue]);
209 // Cause it to complain based on too many items in total
210 - (void)testJudgeRandomItems {
211 NSDate *limitTime = nil;
213 for (int idx = 0; idx < [self.config[@"groups"][0][@"capacity"] intValue]; ++idx) {
214 obj = [TestObject new];
215 XCTAssertEqual([self.RL judge:obj at:self.time limitTime:&limitTime], RateLimiterBadnessClear, @"Received RateLimiterBadnessClear");
216 XCTAssertNil(limitTime, @"single object, clear to process right now");
219 XCTAssertEqual([self.RL judge:obj at:self.time limitTime:&limitTime], RateLimiterBadnessCongested, @"Received RateLimiterBadnessCongested");
220 XCTAssertNotNil(limitTime, @"After hammering same object need to wait now");
221 XCTAssertEqualObjects(limitTime, [self.time dateByAddingTimeInterval:[self.config[@"groups"][0][@"rate"] intValue]], @"time: %@, process-OK time is time + rate (%d)", self.time, [self.config[@"groups"][0][@"rate"] intValue]);
224 - (void)testOverload {
225 NSDate *limitTime = nil;
226 while ([self.RL stateSize] <= [self.config[@"general"][@"maxStateSize"] unsignedIntegerValue]) {
227 TestObject *obj = [TestObject new];
228 RateLimiterBadness rlb = [self.RL judge:obj at:self.time limitTime:&limitTime];
229 XCTAssertTrue(rlb != RateLimiterBadnessOverloaded, @"No issues judging random objects under max state size");
232 // While check is performed at the start of the loop, so now stateSize > maxStateSize. Judge should realize this right away, try to cope, fail and throw a fit
233 XCTAssertEqual([self.RL judge:self.obj at:self.time limitTime:&limitTime], RateLimiterBadnessOverloaded, @"RateLimiter overloaded");
234 XCTAssertEqualObjects(limitTime, [self.time dateByAddingTimeInterval:[self.config[@"general"][@"overloadDuration"] intValue]], @"Overload duration matches expectations");
237 - (void)testTrimmingDueToTime {
238 NSDate *limitTime = nil;
239 for (int idx = 0; idx < [self.config[@"general"][@"maxStateSize"] intValue]/2; ++idx) {
240 TestObject *obj = [TestObject new];
241 [self.RL judge:obj at:self.time limitTime:&limitTime];
243 NSUInteger stateSize = [self.RL stateSize];
244 XCTAssertEqual(stateSize, [self.config[@"general"][@"maxStateSize"] unsignedIntegerValue] / 2 + 1, @"Number of objects added matches expectations");
245 // Advance time enough to age out the existing objects
246 NSDate *time = [self.time dateByAddingTimeInterval:[self.config[@"general"][@"maxItemAge"] intValue] + 1];
248 // It's been so long, judge should first trim and decide to throw away everything it has
249 XCTAssertEqual([self.RL judge:self.obj at:time limitTime:&limitTime], RateLimiterBadnessClear, @"New judgment after long time goes fine");
250 XCTAssertEqual([self.RL stateSize], 2ul, @"Old items gone, just global and one new item left");
253 // RateLimiter is set to ignore properties that return nil
254 - (void)testNilUuid {
255 NSDate *limitTime = nil;
256 TestObject *obj = [[TestObject alloc] initWithNilUuid];
257 for (int idx = 0; idx < [self.config[@"groups"][0][@"capacity"] intValue]; ++idx) {
258 XCTAssertEqual([self.RL judge:obj at:self.time limitTime:&limitTime], RateLimiterBadnessClear, @"Same object with nil property only judged on global rate");
259 XCTAssertEqual([self.RL stateSize], 1ul, @"Nil property objects can't be added to state");
263 - (void)testTrimmingDueToSize {
264 NSDate *limitTime = nil;
265 // Put first half of items in
266 for (int idx = 0; idx < [self.config[@"general"][@"maxStateSize"] intValue] / 2; ++idx) {
267 TestObject *obj = [TestObject new];
268 [self.RL judge:obj at:self.time limitTime:&limitTime];
271 NSDate *time = [self.time dateByAddingTimeInterval:[self.config[@"general"][@"maxItemAge"] intValue] / 2];
273 // Put second half in later so trim has something to do afterwards
274 while ([self.RL stateSize] <= [self.config[@"general"][@"maxStateSize"] unsignedIntegerValue]) {
275 TestObject *obj = [TestObject new];
276 RateLimiterBadness rlb = [self.RL judge:obj at:time limitTime:&limitTime];
277 XCTAssertTrue(rlb != RateLimiterBadnessOverloaded, @"No issues judging random objects under max state size");
280 NSUInteger expectedStateSize = [self.RL stateSize] - [self.config[@"general"][@"maxStateSize"] intValue] / 2 + 1;
282 // Advance time past first batch but before second batch
283 time = [self.time dateByAddingTimeInterval:[self.config[@"general"][@"maxItemAge"] intValue] + 1];
284 // ...which requires adjusting for the fact that the token buckes will be almost full (i.e. further in the past)
285 time = [time dateByAddingTimeInterval:-(([self.config[@"groups"][1][@"capacity"] integerValue] - 1) * [self.config[@"groups"][1][@"rate"] integerValue])];
287 XCTAssertNotEqual([self.RL judge:self.obj at:time limitTime:&limitTime], RateLimiterBadnessOverloaded, @"Judgment caused RL to trim out old items");
288 XCTAssertEqual([self.RL stateSize], expectedStateSize);