Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,17 @@
saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments))
continue
}
} else if databaseScope == .shared,
recordToSave.parent == nil,
recordToSave.share == nil
{
// NB: Emit 'permissionFailure' if saving to shared database with no parent reference
// or share reference.
saveResults[recordToSave.recordID] = .failure(CKError(.permissionFailure))
continue
}

// NB: Emit 'zoneNotFound' error if saving record with a zone not found in database.
guard storage[recordToSave.recordID.zoneID] != nil
else {
saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound))
Expand Down Expand Up @@ -231,8 +240,30 @@
deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation))
continue
}
let recordToDelete = storage[recordIDToDelete.zoneID]?[recordIDToDelete]
storage[recordIDToDelete.zoneID]?[recordIDToDelete] = nil
deleteResults[recordIDToDelete] = .success(())

// NB: If deleting a share that the current user owns, delete the shared records and all
// associated records.
if databaseScope == .shared,
let shareToDelete = recordToDelete as? CKShare,
shareToDelete.recordID.zoneID.ownerName == CKCurrentUserDefaultName
{
func deleteRecords(referencing recordID: CKRecord.ID) {
for recordToDelete in (storage[recordIDToDelete.zoneID] ?? [:]).values {
guard
recordToDelete.share?.recordID == recordID
|| recordToDelete.parent?.recordID == recordID
else {
continue
}
storage[recordIDToDelete.zoneID]?[recordToDelete.recordID] = nil
deleteRecords(referencing: recordToDelete.recordID)
}
}
deleteRecords(referencing: shareToDelete.recordID)
}
}

return (saveResults: saveResults, deleteResults: deleteResults)
Expand Down
186 changes: 185 additions & 1 deletion Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,9 @@

let newShare = try syncEngine.private.database.record(for: CKRecord.ID(recordName: "share"))
let (saveResults, _) = try syncEngine.private.database.modifyRecords(saving: [newShare])
_ = try saveResults.values.first?.get()
#expect(throws: Never.self) {
_ = try saveResults.values.first?.get()
}
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
Expand All @@ -421,6 +423,188 @@
}
#expect(error?.code == .unknownItem)
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func saveSharedRecordWithoutParent() async throws {
let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1"))
let (saveResults, _) = try syncEngine.shared.database.modifyRecords(saving: [record])
let error = #expect(throws: CKError.self) {
_ = try saveResults.values.first?.get()
}
#expect(error?.code == .permissionFailure)
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func deletingShareOwnedByCurrentUserDeletesShareAndDoesNotDeleteAssociatedData() async throws {
let zone = syncEngine.defaultZone
_ = try syncEngine.private.database.modifyRecordZones(saving: [zone])

let recordA = CKRecord(
recordType: "A",
recordID: CKRecord.ID(recordName: "A1", zoneID: zone.zoneID)
)
let recordB = CKRecord(
recordType: "B",
recordID: CKRecord.ID(recordName: "B1", zoneID: zone.zoneID)
)
recordB.parent = CKRecord.Reference(recordID: recordA.recordID, action: .none)
let share = CKShare(
rootRecord: recordA,
shareID: CKRecord.ID(recordName: "share", zoneID: zone.zoneID)
)
_ = try syncEngine.private.database.modifyRecords(saving: [share, recordA, recordB])

assertInlineSnapshot(of: container, as: .customDump) {
"""
MockCloudContainer(
privateCloudDatabase: MockCloudDatabase(
databaseScope: .private,
storage: [
[0]: CKRecord(
recordID: CKRecord.ID(A1/zone/__defaultOwner__),
recordType: "A",
parent: nil,
share: CKReference(recordID: CKRecord.ID(share/zone/__defaultOwner__))
),
[1]: CKRecord(
recordID: CKRecord.ID(B1/zone/__defaultOwner__),
recordType: "B",
parent: CKReference(recordID: CKRecord.ID(A1/zone/__defaultOwner__)),
share: nil
),
[2]: CKRecord(
recordID: CKRecord.ID(share/zone/__defaultOwner__),
recordType: "cloudkit.share",
parent: nil,
share: nil
)
]
),
sharedCloudDatabase: MockCloudDatabase(
databaseScope: .shared,
storage: []
)
)
"""
}

_ = try syncEngine.private.database.modifyRecords(deleting: [share.recordID])

assertInlineSnapshot(of: container, as: .customDump) {
"""
MockCloudContainer(
privateCloudDatabase: MockCloudDatabase(
databaseScope: .private,
storage: [
[0]: CKRecord(
recordID: CKRecord.ID(A1/zone/__defaultOwner__),
recordType: "A",
parent: nil,
share: CKReference(recordID: CKRecord.ID(share/zone/__defaultOwner__))
),
[1]: CKRecord(
recordID: CKRecord.ID(B1/zone/__defaultOwner__),
recordType: "B",
parent: CKReference(recordID: CKRecord.ID(A1/zone/__defaultOwner__)),
share: nil
)
]
),
sharedCloudDatabase: MockCloudDatabase(
databaseScope: .shared,
storage: []
)
)
"""
}
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func deletingShareNotOwnedByCurrentUserDeletesOnlyShareAndNotAssociatedRecords() async throws {
let externalZone = CKRecordZone(
zoneID: CKRecordZone.ID(zoneName: "external.zone", ownerName: "external.owner")
)
_ = try syncEngine.shared.database.modifyRecordZones(saving: [externalZone])

let recordA = CKRecord(
recordType: "A",
recordID: CKRecord.ID(recordName: "A1", zoneID: externalZone.zoneID)
)
let recordB = CKRecord(
recordType: "B",
recordID: CKRecord.ID(recordName: "B1", zoneID: externalZone.zoneID)
)
recordB.parent = CKRecord.Reference(recordID: recordA.recordID, action: .none)
let share = CKShare(
rootRecord: recordA,
shareID: CKRecord.ID(recordName: "share", zoneID: externalZone.zoneID)
)
_ = try syncEngine.shared.database.modifyRecords(saving: [share, recordA, recordB])

assertInlineSnapshot(of: container, as: .customDump) {
"""
MockCloudContainer(
privateCloudDatabase: MockCloudDatabase(
databaseScope: .private,
storage: []
),
sharedCloudDatabase: MockCloudDatabase(
databaseScope: .shared,
storage: [
[0]: CKRecord(
recordID: CKRecord.ID(A1/external.zone/external.owner),
recordType: "A",
parent: nil,
share: CKReference(recordID: CKRecord.ID(share/external.zone/external.owner))
),
[1]: CKRecord(
recordID: CKRecord.ID(B1/external.zone/external.owner),
recordType: "B",
parent: CKReference(recordID: CKRecord.ID(A1/external.zone/external.owner)),
share: nil
),
[2]: CKRecord(
recordID: CKRecord.ID(share/external.zone/external.owner),
recordType: "cloudkit.share",
parent: nil,
share: nil
)
]
)
)
"""
}

_ = try syncEngine.shared.database.modifyRecords(deleting: [share.recordID])

assertInlineSnapshot(of: container, as: .customDump) {
"""
MockCloudContainer(
privateCloudDatabase: MockCloudDatabase(
databaseScope: .private,
storage: []
),
sharedCloudDatabase: MockCloudDatabase(
databaseScope: .shared,
storage: [
[0]: CKRecord(
recordID: CKRecord.ID(A1/external.zone/external.owner),
recordType: "A",
parent: nil,
share: CKReference(recordID: CKRecord.ID(share/external.zone/external.owner))
),
[1]: CKRecord(
recordID: CKRecord.ID(B1/external.zone/external.owner),
recordType: "B",
parent: CKReference(recordID: CKRecord.ID(A1/external.zone/external.owner)),
share: nil
)
]
)
)
"""
}
}
}
}
#endif
47 changes: 26 additions & 21 deletions Tests/SQLiteDataTests/CloudKitTests/SharingPermissionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,33 +113,35 @@
reminderRecord.setValue("Get milk", forKey: "title", at: now)
reminderRecord.setValue(1, forKey: "remindersListID", at: now)
reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none)
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 syncEngine.modifyRecords(
scope: .shared,
saving: [reminderRecord, remindersListRecord]
saving: [reminderRecord, remindersListRecord, share]
)

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
)
let freshShare = try #require(
syncEngine.shared.database.record(for: share.recordID) as? CKShare
)
share.publicPermission = .readOnly
share.currentUserParticipant?.permission = .readOnly

try await syncEngine
.acceptShare(
metadata: ShareMetadata(
containerIdentifier: container.containerIdentifier!,
hierarchicalRootRecordID: freshRemindersListRecord.recordID,
rootRecord: freshRemindersListRecord,
share: share
share: freshShare
)
)

Expand Down Expand Up @@ -216,31 +218,34 @@
reminderRecord.setValue(1, forKey: "remindersListID", at: now)
reminderRecord.setValue(false, forKey: "isCompleted", at: now)
reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none)
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 syncEngine.modifyRecords(
scope: .shared,
saving: [remindersListRecord, reminderRecord]
saving: [remindersListRecord, reminderRecord, share]
)

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
)
let freshShare = try #require(
syncEngine.shared.database.record(for: share.recordID) as? CKShare
)
share.publicPermission = .readOnly
share.currentUserParticipant?.permission = .readOnly

try await syncEngine
.acceptShare(
metadata: ShareMetadata(
containerIdentifier: container.containerIdentifier!,
hierarchicalRootRecordID: freshRemindersListRecord.recordID,
rootRecord: freshRemindersListRecord,
share: share
share: freshShare
)
)

Expand Down
Loading