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 [self.injectedManager.zoneChangeFetcher notifyZoneChange:nil];
57 [self.keychainView waitForFetchAndIncomingQueueProcessing];
59 OCMVerifyAllWithDelay(self.mockDatabase, 20);
61 NSTimeInterval delta = [ckzone.fetchRecordZoneChangesTimestamps[2] timeIntervalSinceDate:ckzone.fetchRecordZoneChangesTimestamps[1]];
62 XCTAssertLessThan(delta, 2, "operation 1 and 2 should be back-to-back");
64 [self findGenericPassword: @"account0" expecting:errSecSuccess];
65 [self findGenericPassword: @"account1" expecting:errSecSuccess];
66 [self findGenericPassword: @"account2" expecting:errSecSuccess];
67 [self findGenericPassword: @"account3" expecting:errSecSuccess];
70 - (void)testMoreComingRepeated {
71 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
72 [self saveTLKMaterialToKeychain:self.keychainZoneID];
74 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
75 [self startCKKSSubsystem];
76 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
77 OCMVerifyAllWithDelay(self.mockDatabase, 20);
78 [self waitForCKModifications];
80 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
82 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
83 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
84 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
85 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
86 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
87 CKServerChangeToken* ck2 = ckzone.currentChangeToken;
88 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D04" withAccount:@"account4"]];
89 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D05" withAccount:@"account5"]];
91 ckzone.limitFetchTo = ck1;
93 self.silentFetchesAllowed = false;
94 // This fetch will return up to ck1.
95 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
97 } runBeforeFinished:^{
98 ckzone.limitFetchTo = ck2;
101 // This fetch will return up to ck2.
102 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
103 // Assert that the fetch is happening with the change token we paused at before
104 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
105 if(changeToken && [changeToken isEqual:ck1]) {
110 } runBeforeFinished:^{}];
112 // This fetch will return the final two items.
113 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
114 // Assert that the fetch is happening with the change token we paused at before
115 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
116 if(changeToken && [changeToken isEqual:ck2]) {
121 } runBeforeFinished:^{}];
123 [self.injectedManager.zoneChangeFetcher notifyZoneChange:nil];
124 [self.keychainView waitForFetchAndIncomingQueueProcessing];
126 OCMVerifyAllWithDelay(self.mockDatabase, 20);
128 NSTimeInterval delta = [ckzone.fetchRecordZoneChangesTimestamps[2] timeIntervalSinceDate:ckzone.fetchRecordZoneChangesTimestamps[1]];
129 XCTAssertLessThan(delta, 2, "operation 1 and 2 should be back-to-back");
131 delta = [ckzone.fetchRecordZoneChangesTimestamps[3] timeIntervalSinceDate:ckzone.fetchRecordZoneChangesTimestamps[2]];
132 XCTAssertLessThan(delta, 2, "operation 2 and 3 should be back-to-back");
134 [self findGenericPassword: @"account0" expecting:errSecSuccess];
135 [self findGenericPassword: @"account1" expecting:errSecSuccess];
136 [self findGenericPassword: @"account2" expecting:errSecSuccess];
137 [self findGenericPassword: @"account3" expecting:errSecSuccess];
138 [self findGenericPassword: @"account4" expecting:errSecSuccess];
139 [self findGenericPassword: @"account5" expecting:errSecSuccess];
142 - (void)testMoreComingDespitePartialTimeout {
143 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
144 [self saveTLKMaterialToKeychain:self.keychainZoneID];
146 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
147 [self startCKKSSubsystem];
148 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
149 OCMVerifyAllWithDelay(self.mockDatabase, 20);
150 [self waitForCKModifications];
152 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
154 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
155 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
156 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
157 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
158 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
160 // The fetch fails with partial results
161 ckzone.limitFetchTo = ck1;
162 ckzone.limitFetchError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorNetworkFailure userInfo:@{CKErrorRetryAfterKey : [NSNumber numberWithInt:4]}];
164 self.silentFetchesAllowed = false;
165 [self expectCKFetch];
166 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
167 // Assert that the fetch is happening with the change token we paused at before
168 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
169 if(changeToken && [changeToken isEqual:ck1]) {
174 } runBeforeFinished:^{}];
176 [self.injectedManager.zoneChangeFetcher notifyZoneChange:nil];
177 [self.keychainView waitForFetchAndIncomingQueueProcessing];
179 OCMVerifyAllWithDelay(self.mockDatabase, 20);
181 [self findGenericPassword: @"account0" expecting:errSecSuccess];
182 [self findGenericPassword: @"account1" expecting:errSecSuccess];
183 [self findGenericPassword: @"account2" expecting:errSecSuccess];
184 [self findGenericPassword: @"account3" expecting:errSecSuccess];
187 - (void)testMoreComingWithFullFailure {
189 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
190 [self saveTLKMaterialToKeychain:self.keychainZoneID];
192 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
193 [self startCKKSSubsystem];
194 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
195 OCMVerifyAllWithDelay(self.mockDatabase, 20);
196 [self waitForCKModifications];
198 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
200 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
201 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
202 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
203 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
204 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
206 // The fetch fails with partial results
207 ckzone.limitFetchTo = ck1;
208 ckzone.limitFetchError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorNetworkFailure userInfo:@{CKErrorRetryAfterKey : [NSNumber numberWithInt:4]}];
210 self.silentFetchesAllowed = false;
211 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
213 } runBeforeFinished:^{
215 // We want to fail with a full network failure error, but we explicitly don't want to set network reachability:
216 // CKKS won't send the fetch in that case. So...
217 [self.keychainZone failNextFetchWith:[NSError errorWithDomain:CKErrorDomain code:CKErrorNetworkFailure userInfo:NULL]];
218 [self expectCKFetch];
221 // Trigger a notification (with hilariously fake data)
222 [self.injectedManager.zoneChangeFetcher notifyZoneChange:nil];
224 // Wait for both fetches....
225 OCMVerifyAllWithDelay(self.mockDatabase, 20);
226 OCMVerifyAllWithDelay(self.mockDatabase, 20);
228 // Potential race here: we need to start this expectation before CKKS issues the fetch. With a 4s delay, this should be safe.
230 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
231 // Assert that the fetch is happening with the change token we paused at before
232 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
233 if(changeToken && [changeToken isEqual:ck1]) {
238 } runBeforeFinished:^{}];
240 [self.reachabilityTracker setNetworkReachability:true];
242 OCMVerifyAllWithDelay(self.mockDatabase, 20);
244 [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
246 [self findGenericPassword: @"account0" expecting:errSecSuccess];
247 [self findGenericPassword: @"account1" expecting:errSecSuccess];
248 [self findGenericPassword: @"account2" expecting:errSecSuccess];
249 [self findGenericPassword: @"account3" expecting:errSecSuccess];
252 - (void)testFetchOnRestartWithMoreComing {
253 [self putFakeKeyHierarchyInCloudKit: self.keychainZoneID];
254 [self saveTLKMaterialToKeychain:self.keychainZoneID];
256 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
257 [self startCKKSSubsystem];
258 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
259 OCMVerifyAllWithDelay(self.mockDatabase, 20);
260 [self waitForCKModifications];
262 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
264 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
265 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
266 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
268 // Allow CKKS to fetch fully, then fake on-disk that it received MoreComing.
269 // (It's very hard to tear down the retry logic in-process)
270 self.silentFetchesAllowed = false;
271 [self expectCKFetch];
273 // Trigger a notification (with hilariously fake data)
274 [self.injectedManager.zoneChangeFetcher notifyZoneChange:nil];
276 OCMVerifyAllWithDelay(self.mockDatabase, 20);
277 [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
278 [self findGenericPassword: @"account0" expecting:errSecSuccess];
279 [self findGenericPassword: @"account1" expecting:errSecSuccess];
281 // Now, edit the on-disk CKSE
282 [self.keychainView halt];
284 [self.keychainView dispatchSyncWithSQLTransaction:^CKKSDatabaseTransactionResult{
285 NSError* error = nil;
286 CKKSZoneStateEntry* ckse = [CKKSZoneStateEntry fromDatabase:self.keychainZoneID.zoneName error:&error];
288 XCTAssertNil(error, "no error pulling ckse from database");
289 XCTAssertNotNil(ckse, "received a ckse");
291 ckse.moreRecordsInCloudKit = YES;
292 [ckse saveToDatabase: &error];
293 XCTAssertNil(error, "no error saving to database");
294 return CKKSDatabaseTransactionCommit;
297 // CKKS should, upon restart, kick off a fetch starting from the previous checkpoint
298 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
299 [self.keychainZone addToZone: [self createFakeRecord: self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
301 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
302 // Assert that the fetch is happening with the change token we paused at before
303 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
304 if(changeToken && [changeToken isEqual:ck1]) {
309 } runBeforeFinished:^{}];
311 self.keychainView = [self.injectedManager restartZone:self.keychainZoneID.zoneName];
312 [self.keychainView beginCloudKitOperation];
313 [self beginSOSTrustedViewOperation:self.keychainView];
315 OCMVerifyAllWithDelay(self.mockDatabase, 20);
317 [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
319 [self findGenericPassword: @"account0" expecting:errSecSuccess];
320 [self findGenericPassword: @"account1" expecting:errSecSuccess];
321 [self findGenericPassword: @"account2" expecting:errSecSuccess];
322 [self findGenericPassword: @"account3" expecting:errSecSuccess];
325 - (void)testMoreComingAsFirstFetch {
326 [self putFakeKeyHierarchyInCloudKit:self.keychainZoneID];
327 [self saveTLKMaterialToKeychain:self.keychainZoneID];
329 FakeCKZone* ckzone = self.zones[self.keychainZoneID];
331 [self.keychainZone addToZone: [self createFakeRecord:self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D00" withAccount:@"account0"]];
332 [self.keychainZone addToZone: [self createFakeRecord:self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D01" withAccount:@"account1"]];
333 CKServerChangeToken* ck1 = ckzone.currentChangeToken;
334 [self.keychainZone addToZone: [self createFakeRecord:self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D02" withAccount:@"account2"]];
335 [self.keychainZone addToZone: [self createFakeRecord:self.keychainZoneID recordName:@"7B598D31-0000-0000-0000-5A507ACB2D03" withAccount:@"account3"]];
337 ckzone.limitFetchTo = ck1;
339 self.silentFetchesAllowed = false;
340 [self expectCKFetch];
341 [self expectCKFetchWithFilter:^BOOL(FakeCKFetchRecordZoneChangesOperation * _Nonnull frzco) {
342 // Assert that the fetch is happening with the change token we paused at before
343 CKServerChangeToken* changeToken = frzco.configurationsByRecordZoneID[self.keychainZoneID].previousServerChangeToken;
344 if(changeToken && [changeToken isEqual:ck1]) {
349 } runBeforeFinished:^{}];
351 [self expectCKModifyKeyRecords:0 currentKeyPointerRecords:0 tlkShareRecords:1 zoneID:self.keychainZoneID];
352 [self startCKKSSubsystem];
353 XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");
354 OCMVerifyAllWithDelay(self.mockDatabase, 20);
355 [self waitForCKModifications];
357 [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
359 OCMVerifyAllWithDelay(self.mockDatabase, 20);
361 [self findGenericPassword:@"account0" expecting:errSecSuccess];
362 [self findGenericPassword:@"account1" expecting:errSecSuccess];
363 [self findGenericPassword:@"account2" expecting:errSecSuccess];
364 [self findGenericPassword:@"account3" expecting:errSecSuccess];