InboundRecordIdentityTests.swift (8136B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 @Suite("Inbound game record identity") 9 @MainActor 10 struct InboundRecordIdentityTests { 11 12 private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! 13 14 private func movesRecord(in zoneID: CKRecordZone.ID) throws -> CKRecord { 15 try RecordSerializer.movesRecord( 16 from: MovesValue( 17 gameID: gameID, 18 authorID: "alice", 19 deviceID: "devicea", 20 cells: [:], 21 updatedAt: .now 22 ), 23 zone: zoneID, 24 systemFields: nil 25 ) 26 } 27 28 @Test("per-game records require their matching zone and CloudKit creator") 29 func recordRequiresMatchingZoneAndCreator() throws { 30 let matchingZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner") 31 let matching = try movesRecord(in: matchingZone) 32 #expect( 33 RecordSerializer.isTrustedGameScopedRecord( 34 matching, 35 creatorUserRecordName: "alice" 36 ) 37 ) 38 #expect( 39 !RecordSerializer.isTrustedGameScopedRecord( 40 matching, 41 creatorUserRecordName: "mallory" 42 ) 43 ) 44 45 let foreignZone = RecordSerializer.zoneID(for: UUID(), ownerName: "owner") 46 let crossZone = try movesRecord(in: foreignZone) 47 #expect( 48 !RecordSerializer.isTrustedGameScopedRecord( 49 crossZone, 50 creatorUserRecordName: "alice" 51 ) 52 ) 53 } 54 55 @Test("self-created records round-trip as the creator placeholder and stay trusted") 56 func recordAcceptsCreatorPlaceholder() throws { 57 // CloudKit reports the creator of a record the fetching user created 58 // themselves as `CKCurrentUserDefaultName`, not their concrete record 59 // name. Rejecting the placeholder would drop every self-authored 60 // record on a fresh install or second device. Accepting it stays 61 // safe: a remote participant's record never arrives carrying it. 62 let zone = RecordSerializer.zoneID(for: gameID, ownerName: "owner") 63 let record = try movesRecord(in: zone) 64 #expect( 65 RecordSerializer.isTrustedGameScopedRecord( 66 record, 67 creatorUserRecordName: CKCurrentUserDefaultName 68 ) 69 ) 70 #expect( 71 !RecordSerializer.isTrustedGameScopedRecord( 72 record, 73 creatorUserRecordName: nil 74 ) 75 ) 76 } 77 78 @Test("root game record must be named for its zone") 79 func rootGameRecordRequiresMatchingZone() { 80 let record = CKRecord( 81 recordType: "Game", 82 recordID: CKRecord.ID( 83 recordName: RecordSerializer.recordName(forGameID: gameID), 84 zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner") 85 ) 86 ) 87 #expect( 88 !RecordSerializer.isTrustedGameScopedRecord( 89 record, 90 creatorUserRecordName: "owner" 91 ) 92 ) 93 } 94 95 @Test("deletions are confined to the record's game zone") 96 func deletionRequiresMatchingZone() { 97 let recordName = RecordSerializer.recordName( 98 forMovesInGame: gameID, 99 authorID: "alice", 100 deviceID: "devicea" 101 ) 102 let valid = CKRecord.ID( 103 recordName: recordName, 104 zoneID: RecordSerializer.zoneID(for: gameID, ownerName: "owner") 105 ) 106 #expect(RecordSerializer.isTrustedGameScopedDeletion(recordID: valid, recordType: "Moves")) 107 108 let forged = CKRecord.ID( 109 recordName: recordName, 110 zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner") 111 ) 112 #expect(!RecordSerializer.isTrustedGameScopedDeletion(recordID: forged, recordType: "Moves")) 113 } 114 115 @Test("ping deletions follow the same zone confinement as ping records") 116 func pingDeletionConfinement() { 117 let pingName = RecordSerializer.recordName( 118 forPingInGame: gameID, 119 authorID: "alice", 120 deviceID: "devicea", 121 eventTimestampMs: 1_700_000_000_000 122 ) 123 // A ping in its own game's zone is fine. 124 let inGameZone = CKRecord.ID( 125 recordName: pingName, 126 zoneID: RecordSerializer.zoneID(for: gameID, ownerName: "owner") 127 ) 128 #expect( 129 RecordSerializer.isTrustedGameScopedDeletion(recordID: inGameZone, recordType: "Ping") 130 ) 131 // A friend-mailbox ping names a game outside its zone by design. 132 let inFriendZone = CKRecord.ID( 133 recordName: pingName, 134 zoneID: CKRecordZone.ID(zoneName: "friend-pair", ownerName: "owner") 135 ) 136 #expect( 137 RecordSerializer.isTrustedGameScopedDeletion(recordID: inFriendZone, recordType: "Ping") 138 ) 139 // A ping in a *different* game's zone is not. 140 let crossGameZone = CKRecord.ID( 141 recordName: pingName, 142 zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner") 143 ) 144 #expect( 145 !RecordSerializer.isTrustedGameScopedDeletion(recordID: crossGameZone, recordType: "Ping") 146 ) 147 } 148 149 @Test("a private game's row is found under either owner-name spelling") 150 func privateGameLookupIgnoresOwnerSpelling() throws { 151 let persistence = makeTestPersistence() 152 let ctx = persistence.viewContext 153 let name = RecordSerializer.recordName(forGameID: gameID) 154 // CloudKit round-trips the current user's own zones with either the 155 // `CKCurrentUserDefaultName` placeholder or their concrete record ID. 156 // Both spellings must resolve to the same private row, or every 157 // fetch after the first would spawn a duplicate game. 158 let placeholderZone = RecordSerializer.zoneID(for: gameID) 159 let concreteZone = RecordSerializer.zoneID(for: gameID, ownerName: "_myOwnRecordID") 160 161 let first = CKRecord( 162 recordType: "Game", 163 recordID: CKRecord.ID(recordName: name, zoneID: placeholderZone) 164 ) 165 first["title"] = "Original" as CKRecordValue 166 let second = CKRecord( 167 recordType: "Game", 168 recordID: CKRecord.ID(recordName: name, zoneID: concreteZone) 169 ) 170 second["title"] = "Round-tripped" as CKRecordValue 171 172 let firstEntity = RecordSerializer.applyGameRecord(first, to: ctx, databaseScope: .private) 173 let secondEntity = RecordSerializer.applyGameRecord(second, to: ctx, databaseScope: .private) 174 175 #expect(firstEntity.objectID == secondEntity.objectID) 176 #expect(secondEntity.title == "Round-tripped") 177 } 178 179 @Test("same-named records in distinct owner zones do not share a local row") 180 func gameLookupIncludesZoneOwner() throws { 181 let persistence = makeTestPersistence() 182 let ctx = persistence.viewContext 183 let firstZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner-a") 184 let secondZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner-b") 185 186 let first = CKRecord( 187 recordType: "Game", 188 recordID: CKRecord.ID( 189 recordName: RecordSerializer.recordName(forGameID: gameID), 190 zoneID: firstZone 191 ) 192 ) 193 first["title"] = "First" as CKRecordValue 194 let second = CKRecord( 195 recordType: "Game", 196 recordID: CKRecord.ID( 197 recordName: RecordSerializer.recordName(forGameID: gameID), 198 zoneID: secondZone 199 ) 200 ) 201 second["title"] = "Second" as CKRecordValue 202 203 let firstEntity = RecordSerializer.applyGameRecord(first, to: ctx, databaseScope: .shared) 204 let secondEntity = RecordSerializer.applyGameRecord(second, to: ctx, databaseScope: .shared) 205 206 #expect(firstEntity.objectID != secondEntity.objectID) 207 #expect(firstEntity.title == "First") 208 #expect(secondEntity.title == "Second") 209 } 210 }