2 * Copyright (c) 2016 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@
26 #import "keychain/ckks/tests/MockCloudKit.h"
27 #import "keychain/ckks/CKKS.h"
28 #import "keychain/ckks/CKKSRecordHolder.h"
29 #import "keychain/ckks/CKKSReachabilityTracker.h"
31 #import <CloudKit/CloudKit.h>
32 #import <CloudKit/CloudKit_Private.h>
33 #include <security_utilities/debugging.h>
34 #import <Foundation/Foundation.h>
35 #import <Foundation/NSDistributedNotificationCenter.h>
37 @implementation FakeCKOperation
39 - (BOOL)isFinishingOnCallbackQueue
45 @implementation FakeCKModifyRecordZonesOperation
46 @synthesize database = _database;
47 @synthesize recordZonesToSave = _recordZonesToSave;
48 @synthesize recordZoneIDsToDelete = _recordZoneIDsToDelete;
49 @synthesize modifyRecordZonesCompletionBlock = _modifyRecordZonesCompletionBlock;
50 @synthesize group = _group;
52 - (CKOperationConfiguration*)configuration {
53 return _configuration;
56 - (void)setConfiguration:(CKOperationConfiguration*)configuration {
58 _configuration = configuration;
60 _configuration = [[CKOperationConfiguration alloc] init];
64 - (instancetype)initWithRecordZonesToSave:(nullable NSArray<CKRecordZone *> *)recordZonesToSave recordZoneIDsToDelete:(nullable NSArray<CKRecordZoneID *> *)recordZoneIDsToDelete {
65 if(self = [super init]) {
66 _recordZonesToSave = recordZonesToSave;
67 _recordZoneIDsToDelete = recordZoneIDsToDelete;
68 _modifyRecordZonesCompletionBlock = nil;
70 _recordZonesSaved = nil;
71 _recordZoneIDsDeleted = nil;
74 __weak __typeof(self) weakSelf = self;
75 self.completionBlock = ^{
76 __strong __typeof(weakSelf) strongSelf = weakSelf;
78 ckkserror_global("ckks", "received callback for released object");
82 strongSelf.modifyRecordZonesCompletionBlock(strongSelf.recordZonesSaved, strongSelf.recordZoneIDsDeleted, strongSelf.creationError);
89 // Create the zones we want; delete the ones we don't
90 // No error handling whatsoever
91 FakeCKDatabase* ckdb = [FakeCKModifyRecordZonesOperation ckdb];
93 NSError* possibleError = [FakeCKModifyRecordZonesOperation shouldFailModifyRecordZonesOperation];
95 self.creationError = possibleError;
99 for(CKRecordZone* zone in self.recordZonesToSave) {
100 bool skipCreation = false;
101 FakeCKZone* fakezone = ckdb[zone.zoneID];
102 if(fakezone.failCreationSilently) {
103 // Don't report an error, but do delete the zone
104 ckdb[zone.zoneID] = nil;
107 } else if(fakezone.creationError) {
109 // Not the best way to do this, but it's an error
110 // Needs fixing if you want to support multiple zone failures
111 self.creationError = fakezone.creationError;
114 ckdb[zone.zoneID] = nil;
117 } else if(fakezone) {
118 // Don't remake the zone, but report to the client that it was created
124 ckksnotice_global("ckks", "Creating zone %@", zone);
125 ckdb[zone.zoneID] = [[FakeCKZone alloc] initZone: zone.zoneID];
128 if(!self.recordZonesSaved) {
129 self.recordZonesSaved = [[NSMutableArray alloc] init];
131 [self.recordZonesSaved addObject:zone];
134 for(CKRecordZoneID* zoneID in self.recordZoneIDsToDelete) {
135 FakeCKZone* zone = ckdb[zoneID];
139 [FakeCKModifyRecordZonesOperation ensureZoneDeletionAllowed:zone];
143 if(!self.recordZoneIDsDeleted) {
144 self.recordZoneIDsDeleted = [[NSMutableArray alloc] init];
146 [self.recordZoneIDsDeleted addObject:zoneID];
149 // The zone does not exist! CloudKit will tell us that the deletion failed.
150 if(!self.creationError) {
151 self.creationError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorPartialFailure userInfo:@{
152 CKErrorRetryAfterKey: @(0.2),
156 // There really should be a better way to do this...
157 NSMutableDictionary* newDictionary = [self.creationError.userInfo mutableCopy] ?: [NSMutableDictionary dictionary];
158 NSMutableDictionary* newPartials = newDictionary[CKPartialErrorsByItemIDKey] ?: [NSMutableDictionary dictionary];
159 newPartials[zoneID] = [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorZoneNotFound
160 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Mock CloudKit: zone '%@' not found", zoneID.zoneName]}];
161 newDictionary[CKPartialErrorsByItemIDKey] = newPartials;
163 self.creationError = [[CKPrettyError alloc] initWithDomain:self.creationError.domain code:self.creationError.code userInfo:newDictionary];
168 +(FakeCKDatabase*) ckdb {
169 // Shouldn't ever be called: must be mocked out.
170 @throw [NSException exceptionWithName:NSInternalInconsistencyException
171 reason:[NSString stringWithFormat:@"+ckdb[] must be mocked out for use"]
175 + (NSError* _Nullable)shouldFailModifyRecordZonesOperation
177 // Should be mocked out!
181 +(void)ensureZoneDeletionAllowed:(FakeCKZone*)zone {
182 // Shouldn't ever be called; will be mocked out
187 @implementation FakeCKModifySubscriptionsOperation
188 @synthesize database = _database;
189 @synthesize group = _group;
190 @synthesize subscriptionsToSave = _subscriptionsToSave;
191 @synthesize subscriptionIDsToDelete = _subscriptionIDsToDelete;
192 @synthesize modifySubscriptionsCompletionBlock = _modifySubscriptionsCompletionBlock;
194 - (CKOperationConfiguration*)configuration {
195 return _configuration;
198 - (void)setConfiguration:(CKOperationConfiguration*)configuration {
200 _configuration = configuration;
202 _configuration = [[CKOperationConfiguration alloc] init];
206 - (instancetype)initWithSubscriptionsToSave:(nullable NSArray<CKSubscription *> *)subscriptionsToSave subscriptionIDsToDelete:(nullable NSArray<NSString *> *)subscriptionIDsToDelete {
207 if(self = [super init]) {
208 _subscriptionsToSave = subscriptionsToSave;
209 _subscriptionIDsToDelete = subscriptionIDsToDelete;
210 _modifySubscriptionsCompletionBlock = nil;
212 __weak __typeof(self) weakSelf = self;
213 self.completionBlock = ^{
214 __strong __typeof(weakSelf) strongSelf = weakSelf;
216 ckkserror_global("ckks", "received callback for released object");
220 strongSelf.modifySubscriptionsCompletionBlock(strongSelf.subscriptionsSaved, strongSelf.subscriptionIDsDeleted, strongSelf.subscriptionError);
227 FakeCKDatabase* ckdb = [FakeCKModifySubscriptionsOperation ckdb];
229 // Are these CKRecordZoneSubscription? Who knows!
230 for(CKRecordZoneSubscription* subscription in self.subscriptionsToSave) {
231 FakeCKZone* fakezone = ckdb[subscription.zoneID];
234 // This is an error: the zone doesn't exist
235 ckksnotice("fakeck", subscription.zoneID, "failing subscription for missing zone");
236 self.subscriptionError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain
237 code:CKErrorPartialFailure
239 CKErrorRetryAfterKey: @(0.2),
240 CKPartialErrorsByItemIDKey:
241 @{subscription.zoneID:[[CKPrettyError alloc] initWithDomain:CKErrorDomain
242 code:CKErrorZoneNotFound
246 } else if(fakezone.subscriptionError) {
247 ckksnotice("fakeck", subscription.zoneID, "failing subscription with injected error %@", fakezone.subscriptionError);
248 // Not the best way to do this, but it's an error
249 // Needs fixing if you want to support multiple zone failures
250 self.subscriptionError = fakezone.subscriptionError;
253 fakezone.subscriptionError = nil;
255 ckksnotice("fakeck", subscription.zoneID, "Successfully subscribed to zone");
256 if(!self.subscriptionsSaved) {
257 self.subscriptionsSaved = [[NSMutableArray alloc] init];
259 [self.subscriptionsSaved addObject:subscription];
263 for(NSString* subscriptionID in self.subscriptionIDsToDelete) {
264 secnotice("fakeck", "Successfully deleted subscription: %@", subscriptionID);
265 if(!self.subscriptionIDsDeleted) {
266 self.subscriptionIDsDeleted = [[NSMutableArray alloc] init];
269 [self.subscriptionIDsDeleted addObject:subscriptionID];
273 +(FakeCKDatabase*) ckdb {
274 // Shouldn't ever be called: must be mocked out.
275 @throw [NSException exceptionWithName:NSInternalInconsistencyException
276 reason:[NSString stringWithFormat:@"+ckdb[] must be mocked out for use"]
281 @implementation FakeCKFetchRecordZoneChangesOperation
282 @synthesize database = _database;
283 @synthesize recordZoneIDs = _recordZoneIDs;
284 @synthesize configurationsByRecordZoneID = _configurationsByRecordZoneID;
286 @synthesize fetchAllChanges = _fetchAllChanges;
287 @synthesize recordChangedBlock = _recordChangedBlock;
289 @synthesize recordWithIDWasDeletedBlock = _recordWithIDWasDeletedBlock;
290 @synthesize recordZoneChangeTokensUpdatedBlock = _recordZoneChangeTokensUpdatedBlock;
291 @synthesize recordZoneFetchCompletionBlock = _recordZoneFetchCompletionBlock;
292 @synthesize fetchRecordZoneChangesCompletionBlock = _fetchRecordZoneChangesCompletionBlock;
294 @synthesize deviceIdentifier = _deviceIdentifier;
296 @synthesize operationID = _operationID;
297 @synthesize resolvedConfiguration = _resolvedConfiguration;
298 @synthesize group = _group;
300 - (CKOperationConfiguration*)configuration {
301 return _configuration;
304 - (void)setConfiguration:(CKOperationConfiguration*)configuration {
306 _configuration = configuration;
308 _configuration = [[CKOperationConfiguration alloc] init];
312 - (instancetype)initWithRecordZoneIDs:(NSArray<CKRecordZoneID *> *)recordZoneIDs configurationsByRecordZoneID:(nullable NSDictionary<CKRecordZoneID *, CKFetchRecordZoneChangesConfiguration *> *)configurationsByRecordZoneID {
313 if(self = [super init]) {
314 _recordZoneIDs = recordZoneIDs;
315 _configurationsByRecordZoneID = configurationsByRecordZoneID;
317 _operationID = @"fake-operation-ID";
318 _deviceIdentifier = @"ckkstests";
323 + (bool)isNetworkReachable
325 // For mocking purposes.
330 // iterate through database, and return items that aren't in lastDatabase
331 FakeCKDatabase* ckdb = [FakeCKFetchRecordZoneChangesOperation ckdb];
332 if(self.recordZoneIDs.count == 0) {
333 secerror("fakeck: No zones to fetch. Likely a bug?");
336 for(CKRecordZoneID* zoneID in self.recordZoneIDs) {
337 FakeCKZone* zone = ckdb[zoneID];
339 // Only really supports a single zone failure
340 ckksnotice("fakeck", zoneID, "Fetched for a missing zone %@", zoneID);
341 NSError* zoneNotFoundError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain
342 code:CKErrorZoneNotFound
344 NSError* error = [[CKPrettyError alloc] initWithDomain:CKErrorDomain
345 code:CKErrorPartialFailure
346 userInfo:@{CKPartialErrorsByItemIDKey: @{zoneID:zoneNotFoundError}}];
348 self.fetchRecordZoneChangesCompletionBlock(error);
352 ++zone.fetchRecordZoneChangesOperationCount;
353 [zone.fetchRecordZoneChangesTimestamps addObject: [NSDate date]];
355 bool networkReachable = [FakeCKFetchRecordZoneChangesOperation isNetworkReachable];
356 if (!networkReachable) {
357 NSError *networkError = [NSError errorWithDomain:CKErrorDomain code:CKErrorNetworkFailure userInfo:NULL];
358 self.fetchRecordZoneChangesCompletionBlock(networkError);
362 // Not precisely correct in the case of multiple zone fetches.
363 NSError* mockError = [zone popFetchChangesError];
365 self.fetchRecordZoneChangesCompletionBlock(mockError);
369 // Extract the database at the last time they asked
370 CKServerChangeToken* fetchToken = self.configurationsByRecordZoneID[zoneID].previousServerChangeToken;
371 __block NSMutableDictionary<CKRecordID*, CKRecord*>* lastDatabase = nil;
372 __block NSDictionary<CKRecordID*, CKRecord*>* currentDatabase = nil;
373 __block CKServerChangeToken* currentChangeToken = nil;
374 __block bool moreComing = false;
376 __block NSError* opError = nil;
378 dispatch_sync(zone.queue, ^{
379 lastDatabase = fetchToken ? zone.pastDatabases[fetchToken] : nil;
381 // You can fetch with the current change token; that's fine
382 if([fetchToken isEqual:zone.currentChangeToken]) {
383 lastDatabase = zone.currentDatabase;
386 currentDatabase = zone.currentDatabase;
387 currentChangeToken = zone.currentChangeToken;
389 if (zone.limitFetchTo != nil) {
390 currentDatabase = zone.pastDatabases[zone.limitFetchTo];
391 currentChangeToken = zone.limitFetchTo;
392 zone.limitFetchTo = nil;
393 opError = zone.limitFetchError;
398 ckksnotice("fakeck", zone.zoneID, "FakeCKFetchRecordZoneChangesOperation(%@): database is currently %@ change token %@ database then: %@", zone.zoneID, currentDatabase, fetchToken, lastDatabase);
400 if(!lastDatabase && fetchToken) {
401 ckksnotice("fakeck", zone.zoneID, "no database for this change token: failing fetch with 'CKErrorChangeTokenExpired'");
402 self.fetchRecordZoneChangesCompletionBlock([[CKPrettyError alloc]
403 initWithDomain:CKErrorDomain
404 code:CKErrorPartialFailure userInfo:@{CKPartialErrorsByItemIDKey:
405 @{zoneID:[[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorChangeTokenExpired userInfo:@{}]}
410 [currentDatabase enumerateKeysAndObjectsUsingBlock:^(CKRecordID * _Nonnull recordID, CKRecord * _Nonnull record, BOOL * _Nonnull stop) {
411 id last = [lastDatabase objectForKey: recordID];
412 if(!last || ![record isEqual:last]) {
413 self.recordChangedBlock(record);
417 // iterate through lastDatabase, and delete items that aren't in database
418 [lastDatabase enumerateKeysAndObjectsUsingBlock:^(CKRecordID * _Nonnull recordID, CKRecord * _Nonnull record, BOOL * _Nonnull stop) {
420 id current = [currentDatabase objectForKey: recordID];
422 self.recordWithIDWasDeletedBlock(recordID, [record recordType]);
426 self.recordZoneChangeTokensUpdatedBlock(zoneID, currentChangeToken, nil);
427 self.recordZoneFetchCompletionBlock(zoneID, currentChangeToken, nil, moreComing, opError);
430 if(self.blockAfterFetch) {
431 self.blockAfterFetch();
434 self.fetchRecordZoneChangesCompletionBlock(nil);
437 +(FakeCKDatabase*) ckdb {
438 // Shouldn't ever be called: must be mocked out.
439 @throw [NSException exceptionWithName:NSInternalInconsistencyException
440 reason:[NSString stringWithFormat:@"+ckdb[] must be mocked out for use"]
445 @implementation FakeCKFetchRecordsOperation
446 @synthesize database = _database;
447 @synthesize recordIDs = _recordIDs;
448 @synthesize desiredKeys = _desiredKeys;
449 @synthesize configuration = _configuration;
451 @synthesize perRecordProgressBlock = _perRecordProgressBlock;
452 @synthesize perRecordCompletionBlock = _perRecordCompletionBlock;
454 @synthesize fetchRecordsCompletionBlock = _fetchRecordsCompletionBlock;
456 - (instancetype)init {
457 if((self = [super init])) {
462 - (instancetype)initWithRecordIDs:(NSArray<CKRecordID *> *)recordIDs {
463 if((self = [super init])) {
464 _recordIDs = recordIDs;
470 FakeCKDatabase* ckdb = [FakeCKFetchRecordsOperation ckdb];
472 // Doesn't call the per-record progress block
473 NSMutableDictionary<CKRecordID*, CKRecord*>* records = [NSMutableDictionary dictionary];
474 NSError* operror = nil;
476 for(CKRecordID* recordID in self.recordIDs) {
477 CKRecordZoneID* zoneID = recordID.zoneID;
478 FakeCKZone* zone = ckdb[zoneID];
481 ckksnotice("fakeck", zoneID, "Fetched for a missing zone %@", zoneID);
482 NSError* zoneNotFoundError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain
483 code:CKErrorZoneNotFound
485 NSError* error = [[CKPrettyError alloc] initWithDomain:CKErrorDomain
486 code:CKErrorPartialFailure
487 userInfo:@{CKPartialErrorsByItemIDKey: @{zoneID:zoneNotFoundError}}];
489 // Not strictly right, but good enough for now
490 self.fetchRecordsCompletionBlock(nil, error);
494 CKRecord* record = zone.currentDatabase[recordID];
496 if(self.perRecordCompletionBlock) {
497 self.perRecordCompletionBlock(record, recordID, nil);
499 records[recordID] = record;
501 secerror("fakeck: Should be an error fetching %@", recordID);
504 operror = [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorPartialFailure userInfo:nil];
507 // There really should be a better way to do this...
508 NSMutableDictionary* newDictionary = [operror.userInfo mutableCopy] ?: [NSMutableDictionary dictionary];
509 NSMutableDictionary* newPartials = newDictionary[CKPartialErrorsByItemIDKey] ?: [NSMutableDictionary dictionary];
510 newPartials[recordID] = [[CKPrettyError alloc] initWithDomain:operror.domain code:CKErrorUnknownItem
511 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Mock CloudKit: no record of %@", recordID]}];
512 newDictionary[CKPartialErrorsByItemIDKey] = newPartials;
514 operror = [[CKPrettyError alloc] initWithDomain:operror.domain code:operror.code userInfo:newDictionary];
516 /// TODO: do this better
517 if(self.perRecordCompletionBlock) {
518 self.perRecordCompletionBlock(nil, recordID, newPartials[zoneID]);
523 if(self.fetchRecordsCompletionBlock) {
524 self.fetchRecordsCompletionBlock(records, operror);
528 +(FakeCKDatabase*) ckdb {
529 // Shouldn't ever be called: must be mocked out.
530 @throw [NSException exceptionWithName:NSInternalInconsistencyException
531 reason:[NSString stringWithFormat:@"+ckdb[] must be mocked out for use"]
537 @implementation FakeCKQueryOperation
538 @synthesize query = _query;
539 @synthesize cursor = _cursor;
540 @synthesize zoneID = _zoneID;
541 @synthesize resultsLimit = _resultsLimit;
542 @synthesize desiredKeys = _desiredKeys;
543 @synthesize recordFetchedBlock = _recordFetchedBlock;
544 @synthesize queryCompletionBlock = _queryCompletionBlock;
546 - (instancetype)initWithQuery:(CKQuery *)query {
547 if((self = [super init])) {
554 FakeCKDatabase* ckdb = [FakeCKFetchRecordsOperation ckdb];
556 FakeCKZone* zone = ckdb[self.zoneID];
558 ckksnotice("fakeck", self.zoneID, "Queried a missing zone %@", self.zoneID);
560 // I'm really not sure if this is right, but...
561 NSError* zoneNotFoundError = [[CKPrettyError alloc] initWithDomain:CKErrorDomain
562 code:CKErrorZoneNotFound
564 CKErrorRetryAfterKey: @(0.2),
566 self.queryCompletionBlock(nil, zoneNotFoundError);
570 NSMutableArray<CKRecord*>* matches = [NSMutableArray array];
571 for(CKRecordID* recordID in zone.currentDatabase.keyEnumerator) {
572 CKRecord* record = zone.currentDatabase[recordID];
574 if([self.query.recordType isEqualToString: record.recordType] &&
575 [self.query.predicate evaluateWithObject:record]) {
577 [matches addObject:record];
578 self.recordFetchedBlock(record);
582 if(self.queryCompletionBlock) {
583 // The query cursor will be non-null if there are more than self.resultsLimit classes. Don't implement this.
584 self.queryCompletionBlock(nil, nil);
589 +(FakeCKDatabase*) ckdb {
590 // Shouldn't ever be called: must be mocked out.
591 @throw [NSException exceptionWithName:NSInternalInconsistencyException
592 reason:[NSString stringWithFormat:@"+ckdb[] must be mocked out for use"]
599 // Do literally nothing
600 @implementation FakeAPSConnection
601 @synthesize delegate;
603 @synthesize enabledTopics;
604 @synthesize opportunisticTopics;
605 @synthesize darkWakeTopics;
607 - (id)initWithEnvironmentName:(NSString *)environmentName namedDelegatePort:(NSString*)namedDelegatePort queue:(dispatch_queue_t)queue {
608 if(self = [super init]) {
614 // Do literally nothing
616 @implementation FakeNSNotificationCenter
617 + (instancetype)defaultCenter {
618 return [[FakeNSNotificationCenter alloc] init];
620 - (void)addObserver:(id)observer selector:(SEL)aSelector name:(nullable NSNotificationName)aName object:(nullable id)anObject {
622 - (void)removeObserver:(id)observer {
626 @implementation FakeNSDistributedNotificationCenter
627 + (instancetype)defaultCenter
629 return [[FakeNSDistributedNotificationCenter alloc] init];
631 - (void)addObserver:(id)observer selector:(SEL)aSelector name:(nullable NSNotificationName)aName object:(nullable id)anObject {
633 - (void)removeObserver:(id)observer {
635 - (void)postNotificationName:(NSNotificationName)name object:(nullable NSString *)object userInfo:(nullable NSDictionary *)userInfo options:(NSDistributedNotificationOptions)options
641 @interface FakeCKZone ()
642 @property NSMutableArray<NSError*>* fetchErrors;
645 @implementation FakeCKZone
646 - (instancetype)initZone: (CKRecordZoneID*) zoneID {
647 if(self = [super init]) {
650 _currentDatabase = [[NSMutableDictionary alloc] init];
651 _pastDatabases = [[NSMutableDictionary alloc] init];
653 _fetchErrors = [[NSMutableArray alloc] init];
655 _queue = dispatch_queue_create("fake-ckzone", DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
658 _fetchRecordZoneChangesOperationCount = 0;
659 _fetchRecordZoneChangesTimestamps = [[NSMutableArray alloc] init];
660 dispatch_sync(_queue, ^{
661 [self _onqueueRollChangeToken];
667 - (void)_onqueueRollChangeToken {
668 dispatch_assert_queue(self.queue);
670 NSData* changeToken = [[[NSUUID UUID] UUIDString] dataUsingEncoding:NSUTF8StringEncoding];
671 self.currentChangeToken = [[CKServerChangeToken alloc] initWithData: changeToken];
674 - (void)addToZone: (CKKSCKRecordHolder*) item zoneID: (CKRecordZoneID*) zoneID {
675 dispatch_sync(self.queue, ^{
676 [self _onqueueAddToZone:item zoneID:zoneID];
680 - (CKRecord*)_onqueueAddToZone:(CKKSCKRecordHolder*)item zoneID:(CKRecordZoneID*)zoneID {
681 dispatch_assert_queue(self.queue);
683 CKRecord* record = [item CKRecordWithZoneID: zoneID];
685 secnotice("fake-cloudkit", "adding item to zone(%@): %@", zoneID.zoneName, item);
686 secnotice("fake-cloudkit", "new record: %@", record);
688 [self _onqueueAddToZone: record];
691 item.storedCKRecord = record;
695 - (void)addToZone: (CKRecord*) record {
696 dispatch_sync(self.queue, ^{
697 [self _onqueueAddToZone:record];
701 - (CKRecord*)_onqueueAddToZone:(CKRecord*)record {
702 dispatch_assert_queue(self.queue);
704 // Save off this current databse
705 self.pastDatabases[self.currentChangeToken] = [self.currentDatabase mutableCopy];
707 [self _onqueueRollChangeToken];
709 record.etag = [self.currentChangeToken description];
710 ckksnotice("fakeck", self.zoneID, "change tag: %@ %@", record.recordChangeTag, record.recordID);
711 record.modificationDate = [NSDate date];
712 self.currentDatabase[record.recordID] = record;
716 - (NSError * _Nullable)errorFromSavingRecord:(CKRecord*) record {
717 CKRecord* existingRecord = self.currentDatabase[record.recordID];
719 // First, implement CKKS-specific server-side checks
720 if([record.recordType isEqualToString:SecCKRecordCurrentKeyType]) {
721 CKReference* parentKey = record[SecCKRecordParentKeyRefKey];
723 CKRecord* existingParentKey = self.currentDatabase[parentKey.recordID];
725 if(!existingParentKey) {
726 ckksnotice("fakeck", self.zoneID, "bad sync key reference! Fail the write: %@ %@", record, existingRecord);
728 return [FakeCKZone internalPluginError:@"CloudkitKeychainService" code:CKKSServerMissingRecord description:@"synckey record: record not found"];
733 if(existingRecord && ![existingRecord.recordChangeTag isEqualToString: record.recordChangeTag]) {
734 ckksnotice("fakeck", self.zoneID, "change tag mismatch! Fail the write: %@ %@", record, existingRecord);
736 // TODO: doesn't yet support CKRecordChangedErrorAncestorRecordKey, since I don't understand it
737 return [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorServerRecordChanged
738 userInfo:@{CKRecordChangedErrorClientRecordKey:record,
739 CKRecordChangedErrorServerRecordKey:existingRecord}];
742 if(!existingRecord && record.etag != nil) {
743 ckksnotice("fakeck", self.zoneID, "update to a record that doesn't exist! Fail the write: %@", record);
744 return [[CKPrettyError alloc] initWithDomain:CKErrorDomain code:CKErrorUnknownItem
750 - (void)addCKRecordToZone:(CKRecord*) record {
751 if([self errorFromSavingRecord: record]) {
752 ckksnotice("fakeck", self.zoneID, "change tag mismatch! Fail the write!");
755 [self addToZone: record];
758 - (void)deleteFromHistory:(CKRecordID*)recordID {
759 for(NSMutableDictionary* pastDatabase in self.pastDatabases.objectEnumerator) {
760 [pastDatabase removeObjectForKey:recordID];
762 [self.currentDatabase removeObjectForKey:recordID];
766 - (NSError*)deleteCKRecordIDFromZone:(CKRecordID*) recordID {
767 // todo: fail somehow
768 dispatch_sync(self.queue, ^{
769 ckksnotice("fakeck", self.zoneID, "Change token before server-deleted record is : %@", self.currentChangeToken);
771 self.pastDatabases[self.currentChangeToken] = [self.currentDatabase mutableCopy];
772 [self _onqueueRollChangeToken];
774 [self.currentDatabase removeObjectForKey: recordID];
776 ckksnotice("fakeck", self.zoneID, "Change token after server-deleted record is : %@", self.currentChangeToken);
781 - (void)failNextFetchWith: (NSError*) fetchChangesError {
782 @synchronized(self.fetchErrors) {
783 [self.fetchErrors addObject: fetchChangesError];
787 - (NSError * _Nullable)popFetchChangesError {
788 NSError* error = nil;
789 @synchronized(self.fetchErrors) {
790 if(self.fetchErrors.count > 0) {
791 error = self.fetchErrors[0];
792 [self.fetchErrors removeObjectAtIndex:0];
798 + (NSError*)internalPluginError:(NSString*)serverDomain code:(NSInteger)code description:(NSString*)desc
800 // Note: uses SecCKKSContainerName, but that's probably okay
801 NSError* extensionError = [[CKPrettyError alloc] initWithDomain:serverDomain
804 CKErrorServerDescriptionKey: desc,
805 NSLocalizedDescriptionKey: desc,
807 NSError* internalError = [[CKPrettyError alloc] initWithDomain:CKInternalErrorDomain
808 code:CKErrorInternalPluginError
809 userInfo:@{CKErrorServerDescriptionKey: desc,
810 NSLocalizedDescriptionKey: desc,
811 NSUnderlyingErrorKey: extensionError,
813 NSError* error = [[CKPrettyError alloc] initWithDomain:CKErrorDomain
814 code:CKErrorServerRejectedRequest
815 userInfo:@{NSUnderlyingErrorKey: internalError,
816 CKErrorServerDescriptionKey: desc,
817 NSLocalizedDescriptionKey: desc,
818 CKContainerIDKey: SecCKKSContainerName,
825 @implementation FakeCKKSNotifier
826 +(void)post:(NSString*)notification {
828 // This isn't actually fake, but XCTest likes NSNotificationCenter a whole lot.
829 // These notifications shouldn't escape this process, so it's perfect.
830 ckksnotice_global("ckks", "sending fake NSNotification %@", notification);
831 [[NSNotificationCenter defaultCenter] postNotificationName:notification object:nil];