CKKSTests+Coalesce.m   [plain text]


/*
 * Copyright (c) 2016 Apple Inc. All Rights Reserved.
 *
 * @APPLE_LICENSE_HEADER_START@
 *
 * This file contains Original Code and/or Modifications of Original Code
 * as defined in and that are subject to the Apple Public Source License
 * Version 2.0 (the 'License'). You may not use this file except in
 * compliance with the License. Please obtain a copy of the License at
 * http://www.opensource.apple.com/apsl/ and read it before using this
 * file.
 *
 * The Original Code and all software distributed under the License are
 * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
 * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
 * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
 * Please see the License for the specific language governing rights and
 * limitations under the License.
 *
 * @APPLE_LICENSE_HEADER_END@
 */

#if OCTAGON

#import "keychain/ckks/CKKS.h"
#import "keychain/ckks/tests/CKKSTests.h"
#import "keychain/ckks/tests/CloudKitMockXCTest.h"

// Break abstraction.
@interface CKKSKeychainView(test)
@property NSOperationQueue* operationQueue;
@end

@implementation CloudKitKeychainSyncingTests (CoalesceTests)
// These tests check that, if CKKS doesn't start processing an item before a new update comes in,
// each case is properly handled.

- (void)testCoalesceAddModifyItem {
    [self createAndSaveFakeKeyHierarchy: self.keychainZoneID]; // Make life easy for this test.

    NSString* account = @"account-delete-me";

    [self addGenericPassword: @"data" account: account];
    [self updateGenericPassword: @"otherdata" account:account];

    // We expect a single record to be uploaded.
    [self expectCKModifyItemRecords: 1 currentKeyPointerRecords: 1 zoneID:self.keychainZoneID];

    [self startCKKSSubsystem];
    OCMVerifyAllWithDelay(self.mockDatabase, 20);
}

- (void)testCoalesceAddModifyModifyItem {
    [self createAndSaveFakeKeyHierarchy: self.keychainZoneID]; // Make life easy for this test.

    NSString* account = @"account-delete-me";

    [self addGenericPassword: @"data" account: account];
    [self updateGenericPassword: @"otherdata" account:account];
    [self updateGenericPassword: @"again" account:account];

    // We expect a single record to be uploaded.
    [self expectCKModifyItemRecords: 1 currentKeyPointerRecords: 1 zoneID:self.keychainZoneID];

    [self startCKKSSubsystem];
    OCMVerifyAllWithDelay(self.mockDatabase, 20);
}

- (void)testCoalesceAddModifyDeleteItem {
    [self createAndSaveFakeKeyHierarchy: self.keychainZoneID]; // Make life easy for this test.

    NSString* account = @"account-delete-me";

    [self addGenericPassword: @"data" account: account];
    [self updateGenericPassword: @"otherdata" account:account];
    [self deleteGenericPassword: account];

    // We expect no uploads.
    [self startCKKSSubsystem];
    [self.keychainView waitUntilAllOperationsAreFinished];
    OCMVerifyAllWithDelay(self.mockDatabase, 20);
}

- (void)testCoalesceDeleteAddItem {
    [self createAndSaveFakeKeyHierarchy: self.keychainZoneID]; // Make life easy for this test.

    NSString* account = @"account-delete-me";

    [self addGenericPassword: @"data" account: account];

    // We expect a single record to be uploaded.
    [self expectCKModifyItemRecords: 1 currentKeyPointerRecords: 1 zoneID:self.keychainZoneID];
    [self startCKKSSubsystem];
    OCMVerifyAllWithDelay(self.mockDatabase, 20);
    [self waitForCKModifications];

    // Okay, now the delete/add. Note that this is not a coalescing operation, since the new item
    // has different contents. (This test used to upload the item to a different UUID, but no longer).

    self.keychainView.holdOutgoingQueueOperation = [CKKSResultOperation named:@"hold-outgoing-queue"
                                                                    withBlock:^{}];

    [self deleteGenericPassword: account];
    [self addGenericPassword: @"data_new_contents" account: account];

    [self expectCKModifyItemRecords:1 currentKeyPointerRecords:1 zoneID:self.keychainZoneID
                          checkItem:[self checkPasswordBlock:self.keychainZoneID account:account password:@"data_new_contents"]];

    [self.operationQueue addOperation:self.keychainView.holdOutgoingQueueOperation];
    OCMVerifyAllWithDelay(self.mockDatabase, 20);
}

- (void)testCoalesceReceiveModifyWhileDeletingItem {
    [self createAndSaveFakeKeyHierarchy: self.keychainZoneID];

    NSString* account = @"account-delete-me";

    [self addGenericPassword:@"data" account:account];

    // We expect a single record to be uploaded.
    __block CKRecord* itemRecord = nil;
    [self expectCKModifyItemRecords:1 currentKeyPointerRecords:1 zoneID:self.keychainZoneID
                          checkItem:^BOOL(CKRecord * _Nonnull record) {
        itemRecord = record;
        return YES;
    }];

    [self startCKKSSubsystem];
    OCMVerifyAllWithDelay(self.mockDatabase, 20);
    [self waitForCKModifications];

    // Now, we receive a modification from CK, but then delete the item locally before processing the IQE.

    XCTAssertNotNil(itemRecord, "Should have a record for the uploaded item");
    NSMutableDictionary* contents = [[self decryptRecord:itemRecord] mutableCopy];
    contents[@"v_Data"] = [@"updated" dataUsingEncoding:NSUTF8StringEncoding];

    CKRecord* recordUpdate = [self newRecord:itemRecord.recordID withNewItemData:contents];
    [self.keychainZone addCKRecordToZone:recordUpdate];

    self.keychainView.holdIncomingQueueOperation = [NSBlockOperation blockOperationWithBlock:^{}];

    // Ensure we wait for the whole fetch
    NSOperation* fetchOp = [self.keychainView.zoneChangeFetcher requestSuccessfulFetch:CKKSFetchBecauseTesting];
    [self.injectedManager.zoneChangeFetcher notifyZoneChange:nil];

    [fetchOp waitUntilFinished];

    // now, delete the item
    [self expectCKDeleteItemRecords:1 zoneID:self.keychainZoneID];
    [self deleteGenericPassword:account];

    [self.operationQueue addOperation:self.keychainView.holdIncomingQueueOperation];

    [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
    [self.keychainView waitForOperationsOfClass:[CKKSOutgoingQueueOperation class]];
    OCMVerifyAllWithDelay(self.mockDatabase, 20);

    // And the item shouldn't be present, since it was deleted via API after the item was fetched
    [self findGenericPassword:account expecting:errSecItemNotFound];
}

- (void)testCoalesceReceiveDeleteWhileModifyingItem {
    [self createAndSaveFakeKeyHierarchy: self.keychainZoneID];

    NSString* account = @"account-delete-me";

    [self startCKKSSubsystem];
    XCTAssertEqual(0, [self.keychainView.keyHierarchyConditions[SecCKKSZoneKeyStateReady] wait:20*NSEC_PER_SEC], @"key state should enter 'ready'");

    __block CKRecord* itemRecord = nil;
    [self expectCKModifyItemRecords:1 currentKeyPointerRecords:1 zoneID:self.keychainZoneID
                          checkItem:^BOOL(CKRecord * _Nonnull record) {
        itemRecord = record;
        return YES;
    }];

    [self addGenericPassword:@"data" account:account];

    OCMVerifyAllWithDelay(self.mockDatabase, 20);
    [self waitForCKModifications];

    // Ensure we fetch again, to prime the delete (due to insufficient mock CK)
    [self.injectedManager.zoneChangeFetcher notifyZoneChange:nil];
    [self.keychainView waitForFetchAndIncomingQueueProcessing];

    [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
    [self.keychainView waitForOperationsOfClass:[CKKSOutgoingQueueOperation class]];

    // Now, we receive a delete from CK, but after we modify the item locally
    self.keychainView.holdOutgoingQueueOperation = [NSBlockOperation blockOperationWithBlock:^{}];

    XCTAssertNotNil(itemRecord, "Should have an item record from the upload");
    [self.keychainZone deleteCKRecordIDFromZone:itemRecord.recordID];
    [self updateGenericPassword:@"new-password" account:account];

    [self.injectedManager.zoneChangeFetcher notifyZoneChange:nil];
    [self.keychainView waitForFetchAndIncomingQueueProcessing];
    [self findGenericPassword:account expecting:errSecItemNotFound];

    [self.operationQueue addOperation:self.keychainView.holdOutgoingQueueOperation];

    [self.keychainView waitForOperationsOfClass:[CKKSIncomingQueueOperation class]];
    [self.keychainView waitForOperationsOfClass:[CKKSOutgoingQueueOperation class]];
    OCMVerifyAllWithDelay(self.mockDatabase, 20);

    // And the item shouldn't be present, since it was deleted via CK after the API change
    [self findGenericPassword:account expecting:errSecItemNotFound];
}

@end

#endif