4 #import <CloudKit/CloudKit.h>
5 #import <XCTest/XCTest.h>
6 #import <OCMock/OCMock.h>
9 #import "keychain/ckks/tests/CloudKitMockXCTest.h"
10 #import "keychain/ckks/tests/CloudKitKeychainSyncingMockXCTest.h"
11 #import "keychain/ckks/tests/CloudKitKeychainSyncingTestsBase.h"
12 #import "keychain/ckks/CKKS.h"
13 #import "keychain/ckks/CKKSKeychainView.h"
14 #import "keychain/ckks/CKKSZoneStateEntry.h"
16 #import "keychain/ckks/tests/MockCloudKit.h"
17 #import "keychain/ot/ObjCImprovements.h"
19 @interface CloudKitKeychainFetchTests : CloudKitKeychainSyncingTestsBase
22 @implementation CloudKitKeychainFetchTests
24 - (void)testMoreComing {
25 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
26 [self saveTLKMaterialToKeychain:self.keychainZoneID];
28 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
29 [self startCKKSSubsystem];
30 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
31 OCMVerifyAllWithDelay(self.mockDatabase, 20);
32 [self waitForCKModifications];
34 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
36 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
37 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
38 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
39 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
40 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
42 ckzone.limitFetchTo = ck1;
44 self.silentFetchesAllowed = false;
46 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
47 // Assert that the fetch is happening with the change token we paused at before
48 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
49 if(changeToken && [changeToken isEqual:ck1]) {
54 } runBeforeFinished:^{}];
56 // Trigger a notification (with hilariously fake data)
57 [self.keychainView notifyZoneChange:nil];
59 [self.keychainView waitForFetchAndIncomingQueueProcessing];
61 OCMVerifyAllWithDelay(self.mockDatabase, 20);
63 NSTimeInterval delta = [ckzone.fetchRecordZoneChangesTimestamps[2] timeIntervalSinceDate:ckzone.fetchRecordZoneChangesTimestamps[1]];
64 XCTAssertLessThan(delta, 2, "operation 1 and 2 should be back-to-back");
66 [self findGenericPassword: @"account0" expecting:errSecSuccess];
67 [self findGenericPassword: @"account1" expecting:errSecSuccess];
68 [self findGenericPassword: @"account2" expecting:errSecSuccess];
69 [self findGenericPassword: @"account3" expecting:errSecSuccess];
72 - (void)testMoreComingRepeated {
73 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
74 [self saveTLKMaterialToKeychain:self.keychainZoneID];
76 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
77 [self startCKKSSubsystem];
78 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
79 OCMVerifyAllWithDelay(self.mockDatabase, 20);
80 [self waitForCKModifications];
82 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
84 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
85 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
86 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
87 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
88 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
89 CKServerChangeToken* ck2 = ckzone.currentChangeToken;
90 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D04" withAccount:@"account4"]];
91 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D05" withAccount:@"account5"]];
93 ckzone.limitFetchTo = ck1;
95 self.silentFetchesAllowed = false;
96 // This fetch will return up to ck1.
97 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
99 } runBeforeFinished:^{
100 ckzone.limitFetchTo = ck2;
103 // This fetch will return up to ck2.
104 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
105 // Assert that the fetch is happening with the change token we paused at before
106 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
107 if(changeToken && [changeToken isEqual:ck1]) {
112 } runBeforeFinished:^{}];
114 // This fetch will return the final two items.
115 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
116 // Assert that the fetch is happening with the change token we paused at before
117 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
118 if(changeToken && [changeToken isEqual:ck2]) {
123 } runBeforeFinished:^{}];
125 // Trigger a notification (with hilariously fake data)
126 [self.keychainView notifyZoneChange:nil];
128 [self.keychainView waitForFetchAndIncomingQueueProcessing];
130 OCMVerifyAllWithDelay(self.mockDatabase, 20);
132 NSTimeInterval delta = [ckzone.fetchRecordZoneChangesTimestamps[2] timeIntervalSinceDate:ckzone.fetchRecordZoneChangesTimestamps[1]];
133 XCTAssertLessThan(delta, 2, "operation 1 and 2 should be back-to-back");
135 delta = [ckzone.fetchRecordZoneChangesTimestamps[3] timeIntervalSinceDate:ckzone.fetchRecordZoneChangesTimestamps[2]];
136 XCTAssertLessThan(delta, 2, "operation 2 and 3 should be back-to-back");
138 [self findGenericPassword: @"account0" expecting:errSecSuccess];
139 [self findGenericPassword: @"account1" expecting:errSecSuccess];
140 [self findGenericPassword: @"account2" expecting:errSecSuccess];
141 [self findGenericPassword: @"account3" expecting:errSecSuccess];
142 [self findGenericPassword: @"account4" expecting:errSecSuccess];
143 [self findGenericPassword: @"account5" expecting:errSecSuccess];
146 - (void)testMoreComingDespitePartialTimeout {
147 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
148 [self saveTLKMaterialToKeychain:self.keychainZoneID];
150 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
151 [self startCKKSSubsystem];
152 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
153 OCMVerifyAllWithDelay(self.mockDatabase, 20);
154 [self waitForCKModifications];
156 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
158 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
159 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
160 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
161 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
162 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
164 // The fetch fails with partial results
165 ckzone.limitFetchTo = ck1;
166 ckzone.limitFetchError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorNetworkFailure userInfo:@{CKErrorRetryAfterKey : [NSNumber numberWithInt:4]}];
168 self.silentFetchesAllowed = false;
169 [self expectCKFetch];
170 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
171 // Assert that the fetch is happening with the change token we paused at before
172 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
173 if(changeToken && [changeToken isEqual:ck1]) {
178 } runBeforeFinished:^{}];
180 // Trigger a notification (with hilariously fake data)
181 [self.keychainView notifyZoneChange:nil];
183 [self.keychainView waitForFetchAndIncomingQueueProcessing];
185 OCMVerifyAllWithDelay(self.mockDatabase, 20);
187 [self findGenericPassword: @"account0" expecting:errSecSuccess];
188 [self findGenericPassword: @"account1" expecting:errSecSuccess];
189 [self findGenericPassword: @"account2" expecting:errSecSuccess];
190 [self findGenericPassword: @"account3" expecting:errSecSuccess];
193 - (void)testMoreComingWithFullFailure {
195 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
196 [self saveTLKMaterialToKeychain:self.keychainZoneID];
198 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
199 [self startCKKSSubsystem];
200 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
201 OCMVerifyAllWithDelay(self.mockDatabase, 20);
202 [self waitForCKModifications];
204 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
206 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
207 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
208 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
209 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
210 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
212 // The fetch fails with partial results
213 ckzone.limitFetchTo = ck1;
214 ckzone.limitFetchError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorNetworkFailure userInfo:@{CKErrorRetryAfterKey : [NSNumber numberWithInt:4]}];
216 self.silentFetchesAllowed = false;
217 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
219 } runBeforeFinished:^{
221 // We want to fail with a full network failure error, but we explicitly don't want to set network reachability:
222 // CKKS won't send the fetch in that case. So...
223 [self.keychainZone failNextFetchWith:[NSError errorWithDomain:CKErrorDomain code:CKErrorNetworkFailure userInfo:NULL]];
224 [self expectCKFetch];
227 // Trigger a notification (with hilariously fake data)
228 [self.keychainView notifyZoneChange:nil];
230 // Wait for both fetches....
231 OCMVerifyAllWithDelay(self.mockDatabase, 20);
232 OCMVerifyAllWithDelay(self.mockDatabase, 20);
234 // Potential race here: we need to start this expectation before CKKS issues the fetch. With a 4s delay, this should be safe.
236 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
237 // Assert that the fetch is happening with the change token we paused at before
238 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
239 if(changeToken && [changeToken isEqual:ck1]) {
244 } runBeforeFinished:^{}];
246 [self.reachabilityTracker setNetworkReachability:true];
248 OCMVerifyAllWithDelay(self.mockDatabase, 20);
250 [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
252 [self findGenericPassword: @"account0" expecting:errSecSuccess];
253 [self findGenericPassword: @"account1" expecting:errSecSuccess];
254 [self findGenericPassword: @"account2" expecting:errSecSuccess];
255 [self findGenericPassword: @"account3" expecting:errSecSuccess];
258 - (void)testFetchOnRestartWithMoreComing {
259 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
260 [self saveTLKMaterialToKeychain:self.keychainZoneID];
262 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
263 [self startCKKSSubsystem];
264 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
265 OCMVerifyAllWithDelay(self.mockDatabase, 20);
266 [self waitForCKModifications];
268 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
270 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
271 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
272 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
274 // Allow CKKS to fetch fully, then fake on-disk that it received MoreComing.
275 // (It's very hard to tear down the retry logic in-process)
276 self.silentFetchesAllowed = false;
277 [self expectCKFetch];
279 // Trigger a notification (with hilariously fake data)
280 [self.keychainView notifyZoneChange:nil];
282 OCMVerifyAllWithDelay(self.mockDatabase, 20);
283 [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
284 [self findGenericPassword: @"account0" expecting:errSecSuccess];
285 [self findGenericPassword: @"account1" expecting:errSecSuccess];
287 // Now, edit the on-disk CKSE
288 [self.keychainView halt];
290 [self.keychainView dispatchSync: ^bool {
291 NSError* error = nil;
292 CKKSZoneStateEntry* ckse = [CKKSZoneStateEntry fromDatabase:self.keychainZoneID.zoneName error:&error];
294 XCTAssertNil(error, "no error pulling ckse from database");
295 XCTAssertNotNil(ckse, "received a ckse");
297 ckse.moreRecordsInCloudKit = YES;
298 [ckse saveToDatabase: &error];
299 XCTAssertNil(error, "no error saving to database");
303 // CKKS should, upon restart, kick off a fetch starting from the previous checkpoint
304 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
305 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
307 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
308 // Assert that the fetch is happening with the change token we paused at before
309 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
310 if(changeToken && [changeToken isEqual:ck1]) {
315 } runBeforeFinished:^{}];
317 self.keychainView = [self.injectedManager restartZone:self.keychainZoneID.zoneName];
318 [self.keychainView beginCloudKitOperation];
319 [self beginSOSTrustedViewOperation:self.keychainView];
321 OCMVerifyAllWithDelay(self.mockDatabase, 20);
323 [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
325 [self findGenericPassword: @"account0" expecting:errSecSuccess];
326 [self findGenericPassword: @"account1" expecting:errSecSuccess];
327 [self findGenericPassword: @"account2" expecting:errSecSuccess];
328 [self findGenericPassword: @"account3" expecting:errSecSuccess];
331 - (void)testMoreComingAsFirstFetch {
332 [self putFakeKeyHierarchyInCloudKit:self.keychainZoneID];
333 [self saveTLKMaterialToKeychain:self.keychainZoneID];
335 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
337 [self.keychainZone addToZone: [self createFakeRecord:self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
338 [self.keychainZone addToZone: [self createFakeRecord:self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
339 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
340 [self.keychainZone addToZone: [self createFakeRecord:self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
341 [self.keychainZone addToZone: [self createFakeRecord:self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
343 ckzone.limitFetchTo = ck1;
345 self.silentFetchesAllowed = false;
346 [self expectCKFetch];
347 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
348 // Assert that the fetch is happening with the change token we paused at before
349 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
350 if(changeToken && [changeToken isEqual:ck1]) {
355 } runBeforeFinished:^{}];
357 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
358 [self startCKKSSubsystem];
359 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
360 OCMVerifyAllWithDelay(self.mockDatabase, 20);
361 [self waitForCKModifications];
363 [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
365 OCMVerifyAllWithDelay(self.mockDatabase, 20);
367 [self findGenericPassword:@"account0" expecting:errSecSuccess];
368 [self findGenericPassword:@"account1" expecting:errSecSuccess];
369 [self findGenericPassword:@"account2" expecting:errSecSuccess];
370 [self findGenericPassword:@"account3" expecting:errSecSuccess];