diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index 7bf55819..74388699 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -88,7 +88,9 @@ package final class MockCloudDatabase: CloudDatabase { case .ifServerRecordUnchanged: for recordToSave in recordsToSave { if let share = recordToSave as? CKShare { - let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) + let isSavingRootRecord = recordsToSave.contains(where: { + $0.share?.recordID == share.recordID + }) let shareWasPreviouslySaved = storage[share.recordID.zoneID]?[share.recordID] != nil guard shareWasPreviouslySaved || isSavingRootRecord else { @@ -102,7 +104,7 @@ package final class MockCloudDatabase: CloudDatabase { continue } } - + guard storage[recordToSave.recordID.zoneID] != nil else { saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) @@ -124,6 +126,33 @@ package final class MockCloudDatabase: CloudDatabase { return } + func root(of record: CKRecord) -> CKRecord { + guard let parent = record.parent + else { return record } + return (storage[parent.recordID.zoneID]?[parent.recordID]).map(root) ?? record + } + func share(for rootRecord: CKRecord) -> CKShare? { + for (_, record) in storage[rootRecord.recordID.zoneID] ?? [:] { + guard record.recordID == rootRecord.share?.recordID + else { continue } + return record as? CKShare + } + return nil + } + let rootRecord = root(of: recordToSave) + let share = share(for: rootRecord) + let isSavingShare = recordsToSave.contains { $0.recordID == share?.recordID } + if + !isSavingShare, + !(recordToSave is CKShare), + let share, + !(share.publicPermission == .readWrite + || share.currentUserParticipant?.permission == .readWrite) + { + saveResults[recordToSave.recordID] = .failure(CKError(.permissionFailure)) + return + } + guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } copy._recordChangeTag = UUID().uuidString @@ -137,7 +166,7 @@ package final class MockCloudDatabase: CloudDatabase { } } - // TODO: this should merge copy's values into storage but not sure how right now. + // TODO: this should merge copy's values into storage but not sure how right now. storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) } diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift index 07bee97f..82d4819d 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift @@ -79,7 +79,7 @@ package final class MockSyncEngine: SyncEngineProtocol { } } - state.remove(pendingRecordZoneChanges: recordIDsSkipped.map { .saveRecord($0) }) + state.remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, diff --git a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift index e728a8bf..62c617b7 100644 --- a/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Metadatabase.swift @@ -116,7 +116,7 @@ func defaultMetadatabase( ) .execute(db) } - migrator.registerMigration("Create PendingRecodZoneChanges Table") { db in + migrator.registerMigration("Create PendingRecordZoneChanges Table") { db in try SQLQueryExpression(""" CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" ( "pendingRecordZoneChange" BLOB NOT NULL diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index ad271a90..35c9e6ce 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -356,7 +356,7 @@ .select(\.pendingRecordZoneChange) .fetchAll(db) } - let changesByIsPrivate = Dictionary.init(grouping: pendingRecordZoneChanges) { + let changesByIsPrivate = Dictionary(grouping: pendingRecordZoneChanges) { switch $0 { case .deleteRecord(let recordID), .saveRecord(let recordID): recordID.zoneID.ownerName == CKCurrentUserDefaultName @@ -1265,10 +1265,32 @@ try open(table) } + case .permissionFailure: + guard + let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey, + let table = tablesByName[failedRecord.recordType] + else { continue } + func open(_: T.Type) async throws { + do { + let serverRecord = try await container.sharedCloudDatabase.record(for: failedRecord.recordID) + upsertFromServerRecord(serverRecord, force: true) + } catch let error as CKError where error.code == .unknownItem { + try await userDatabase.write { db in + try T + .where { SQLQueryExpression("\($0.primaryKey) = \(bind: recordPrimaryKey)") } + .delete() + .execute(db) + } + } + } + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await open(table) + } + case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled, .batchRequestFailed, .internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement, - .permissionFailure, .invalidArguments, .resultsTruncated, .assetFileNotFound, + .invalidArguments, .resultsTruncated, .assetFileNotFound, .assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired, .badDatabase, .quotaExceeded, .limitExceeded, .userDeletedZone, .tooManyParticipants, .alreadyShared, .managedAccountRestricted, .participantMayNeedVerification, @@ -1338,7 +1360,10 @@ } } - private func upsertFromServerRecord(_ serverRecord: CKRecord) { + private func upsertFromServerRecord( + _ serverRecord: CKRecord, + force: Bool = false + ) { withErrorReporting(.sqliteDataCloudKitFailure) { guard let table = tablesByName[serverRecord.recordType] else { @@ -1376,7 +1401,7 @@ func open(_: T.Type) throws { var columnNames = T.TableColumns.writableColumns.map(\.name) - if let metadata, let allFields = metadata._lastKnownServerRecordAllFields { + if !force, let metadata, let allFields = metadata._lastKnownServerRecordAllFields { let row = try userDatabase.read { db in try T.find(SQLQueryExpression("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift new file mode 100644 index 00000000..3207d88b --- /dev/null +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingPermissionsTests.swift @@ -0,0 +1,469 @@ +import CloudKit +import CustomDump +import Foundation +import InlineSnapshotTesting +import OrderedCollections +import SharingGRDB +import SnapshotTestingCustomDump +import Testing + +extension BaseCloudKitTests { + @MainActor + final class SharingPermissionsTests: BaseCloudKitTests, @unchecked Sendable { + /// Inserting record into shared record when user does not have permission should be rejected. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func insertRecordInReadOnlyRemindersList() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.all.fetchCount(db) == 0) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + /// Delete record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteReminderInReadOnlyRemindersList() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + + _ = try syncEngine.modifyRecords( + scope: .shared, + saving: [reminderRecord, remindersListRecord] + ) + + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + + let share = CKShare( + rootRecord: freshRemindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(freshRemindersListRecord.recordID.recordName)", + zoneID: freshRemindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: share + ) + ) + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.find(1).delete().execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.count().fetchOne(db) == 1) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + remindersListID: 1, + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + /// Editing record in shared record when user does not have permission. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editReminderInReadOnlyRemindersList() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) + ) + reminderRecord.setValue(1, forKey: "id", at: now) + reminderRecord.setValue("Get milk", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.setValue(false, forKey: "isCompleted", at: now) + reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) + _ = try syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] + ) + + let freshRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + let share = CKShare( + rootRecord: freshRemindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(freshRemindersListRecord.recordID.recordName)", + zoneID: freshRemindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readOnly + share.currentUserParticipant?.permission = .readOnly + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: share + ) + ) + + try await self.userDatabase.userWrite { db in + let error = #expect(throws: DatabaseError.self) { + try Reminder.update { $0.isCompleted = true }.execute(db) + } + #expect(error?.message == SyncEngine.writePermissionError) + try #expect(Reminder.where(\.isCompleted).fetchCount(db) == 0) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + // Edit a record while locally we think we have permission, but CloudKit has newer permissions + // that are read only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createRecordWhenLocalHasPermissionsButCloudKitDoesNot() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readWrite + share.currentUserParticipant?.permission = .readWrite + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + freshShare.publicPermission = .readOnly + freshShare.currentUserParticipant?.permission = .readOnly + let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await self.userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await self.userDatabase.userRead { db in + try #expect(Reminder.all.fetchCount(db) == 0) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + + // Edit a record while locally we think we have permission, but CloudKit has newer permissions + // that are read only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editRecordWhenLocalHasPermissionsButCloudKitDoesNot() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + share.publicPermission = .readWrite + share.currentUserParticipant?.permission = .readWrite + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + freshShare.publicPermission = .readOnly + freshShare.currentUserParticipant?.permission = .readOnly + let _ = try syncEngine.modifyRecords(scope: .shared, saving: [freshShare]) + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await self.userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Business" }.execute(db) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await self.userDatabase.userRead { db in + try #expect(RemindersList.find(1).fetchOne(db) == RemindersList(id: 1, title: "Personal")) + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + } +} diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 58c7e2a1..671de0fa 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -897,272 +897,6 @@ extension BaseCloudKitTests { """ } } - - /// Inserting record into shared record when user does not have permission should be rejected. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func insertRecordInReadOnlyRemindersList() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - ) - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - let share = CKShare( - rootRecord: remindersListRecord, - shareID: CKRecord.ID( - recordName: "share-\(remindersListRecord.recordID.recordName)", - zoneID: remindersListRecord.recordID.zoneID - ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share - ) - ) - - - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } - } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.all.fetchCount(db) == 0) - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), - id: 1, - title: "Personal" - ) - ] - ) - ) - """ - } - } - - /// Delete record in shared record when user does not have permission. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteReminderInReadOnlyRemindersList() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - ) - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - let share = CKShare( - rootRecord: remindersListRecord, - shareID: CKRecord.ID( - recordName: "share-\(remindersListRecord.recordID.recordName)", - zoneID: remindersListRecord.recordID.zoneID - ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share - ) - ) - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - try await syncEngine.modifyRecords(scope: .shared, saving: [reminderRecord]).notify() - - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try Reminder.find(1).delete().execute(db) - } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.count().fetchOne(db) == 1) - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:reminders/external.zone/external.owner), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), - share: nil, - id: 1, - remindersListID: 1, - title: "Get milk" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), - id: 1, - title: "Personal" - ) - ] - ) - ) - """ - } - } - - /// Editing record in shared record when user does not have permission. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func editReminderInReadOnlyRemindersList() async throws { - let externalZone = CKRecordZone( - zoneID: CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - ) - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - let share = CKShare( - rootRecord: remindersListRecord, - shareID: CKRecord.ID( - recordName: "share-\(remindersListRecord.recordID.recordName)", - zoneID: remindersListRecord.recordID.zoneID - ) - ) - share.publicPermission = .readOnly - share.currentUserParticipant?.permission = .readOnly - - try await syncEngine - .acceptShare( - metadata: ShareMetadata( - containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share - ) - ) - let reminderRecord = CKRecord( - recordType: Reminder.tableName, - recordID: Reminder.recordID(for: 1, zoneID: externalZone.zoneID) - ) - reminderRecord.setValue(1, forKey: "id", at: now) - reminderRecord.setValue("Get milk", forKey: "title", at: now) - reminderRecord.setValue(1, forKey: "remindersListID", at: now) - reminderRecord.setValue(false, forKey: "isCompleted", at: now) - reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - try await syncEngine.modifyRecords(scope: .shared, saving: [reminderRecord]).notify() - - try await self.userDatabase.userWrite { db in - let error = #expect(throws: DatabaseError.self) { - try Reminder.update { $0.isCompleted = true }.execute(db) - } - #expect(error?.message == SyncEngine.writePermissionError) - try #expect(Reminder.where(\.isCompleted).fetchCount(db) == 0) - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:reminders/external.zone/external.owner), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Get milk" - ), - [2]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), - id: 1, - title: "Personal" - ) - ] - ) - ) - """ - } - } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift index 1d757710..769d8c2c 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -26,6 +26,8 @@ extension BaseCloudKitTests { } } + try await Task.sleep(for: .seconds(0.5)) + try await userDatabase.userRead { db in let remindersListMetadata = try #require(try RemindersList.metadata(for: 1).fetchOne(db)) #expect(remindersListMetadata.lastKnownServerRecord == nil) @@ -103,6 +105,8 @@ extension BaseCloudKitTests { try RemindersList.find(1).delete().execute(db) } + try await Task.sleep(for: .seconds(0.5)) + try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -139,6 +143,7 @@ extension BaseCloudKitTests { try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title += "!" }.execute(db) } + try await Task.sleep(for: .seconds(0.5)) try await userDatabase.read { db in try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) @@ -209,6 +214,7 @@ extension BaseCloudKitTests { } } + try await Task.sleep(for: .seconds(0.5)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .shared) @@ -337,6 +343,7 @@ extension BaseCloudKitTests { try RemindersList.find(1).delete().execute(db) } + try await Task.sleep(for: .seconds(0.5)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .private)