InboundRecordIdentityTests.swift (11924B)
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 211 // MARK: - Own-owner shared rows 212 213 /// The shape that made a puzzle unopenable on a real device: a 214 /// `databaseScope == 1` row holding the owner placeholder. Both scopes' 215 /// predicates miss it — private wants `ckZoneOwnerName == NIL`, shared 216 /// wants a concrete owner — so the arriving Game record forks a second 217 /// row instead of filling this one in, and the empty row then shadows the 218 /// real game in every `id`-keyed lookup. Nothing may create it. 219 @Test("an own-owner shared row is unreachable from either scope's lookup") 220 func ownOwnerSharedRowIsUnmatchable() throws { 221 let persistence = makeTestPersistence() 222 let ctx = persistence.viewContext 223 let name = RecordSerializer.recordName(forGameID: gameID) 224 225 let stranded = GameEntity(context: ctx) 226 stranded.id = gameID 227 stranded.ckRecordName = name 228 stranded.ckZoneName = name 229 stranded.ckZoneOwnerName = CKCurrentUserDefaultName 230 stranded.databaseScope = 1 231 stranded.title = "Joining\u{2026}" 232 stranded.puzzleSource = "" 233 stranded.createdAt = .now 234 stranded.updatedAt = .now 235 try ctx.save() 236 237 // The private Game record that should have healed it forks instead. 238 let record = CKRecord( 239 recordType: "Game", 240 recordID: CKRecord.ID( 241 recordName: name, 242 zoneID: RecordSerializer.zoneID(for: gameID) 243 ) 244 ) 245 record["title"] = "Real Puzzle" as CKRecordValue 246 let applied = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: .private) 247 248 #expect(applied.objectID != stranded.objectID) 249 #expect(stranded.puzzleSource == "") 250 251 // Two rows now answer to the same game id — which is what breaks the 252 // `fetchLimit = 1` lookups in `GameStore.loadGame(id:)`. 253 let byID = NSFetchRequest<GameEntity>(entityName: "GameEntity") 254 byID.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 255 #expect(try ctx.fetch(byID).count == 2) 256 } 257 258 @Test("constructJoinedGame refuses a zone the current user owns") 259 func constructJoinedGameRefusesOwnOwnerZone() throws { 260 let persistence = makeTestPersistence() 261 let store = makeTestStore(persistence: persistence) 262 let ownZone = RecordSerializer.zoneID(for: gameID) 263 264 #expect(throws: GameStore.LoadError.sharedZoneOwnedByCurrentUser) { 265 try store.constructJoinedGame( 266 gameID: gameID, 267 zoneID: ownZone, 268 source: Self.puzzleSource 269 ) 270 } 271 272 let all = NSFetchRequest<GameEntity>(entityName: "GameEntity") 273 #expect(try persistence.viewContext.fetch(all).isEmpty) 274 } 275 276 @Test("constructJoinedGame keeps the share owner's concrete record name") 277 func constructJoinedGameStoresConcreteOwner() throws { 278 let persistence = makeTestPersistence() 279 let store = makeTestStore(persistence: persistence) 280 let sharedZone = RecordSerializer.zoneID(for: gameID, ownerName: "_owner") 281 282 try store.constructJoinedGame( 283 gameID: gameID, 284 zoneID: sharedZone, 285 source: Self.puzzleSource 286 ) 287 288 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 289 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 290 let entity = try #require(try persistence.viewContext.fetch(req).first) 291 #expect(entity.databaseScope == 1) 292 #expect(entity.ckZoneOwnerName == "_owner") 293 } 294 295 private static let puzzleSource = """ 296 Title: Test Puzzle 297 Author: Test 298 299 300 AB 301 CD 302 303 304 A1. Top row ~ AB 305 A3. Bottom row ~ CD 306 307 D1. Left column ~ AC 308 D2. Right column ~ BD 309 """ 310 }