Skip to content
Open
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
28 changes: 28 additions & 0 deletions Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,18 @@
saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments))
continue
}
} else {
// NB: Emit 'permissionFailure' if saving to shared database with no parent reference.
if databaseScope == .shared,
recordToSave.parent == nil,
recordToSave.share == nil
{
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 +241,26 @@
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, delete the shared records and all associated records.
if databaseScope == .shared, let shareToDelete = recordToDelete as? CKShare {
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
93 changes: 92 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,95 @@
}
#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 deletingShareDeletesSharedRecords() 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
)
)
let (saveResults, _) = try syncEngine.shared.database.modifyRecords(
saving: [share, recordA, recordB]
)

print(saveResults)

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: []
)
)
"""
}
}
}
}
#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