GameStorePushAddressTests.swift (13786B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Push addressing is capability-based, but the per-(account, game) address is 9 /// now *derived* — `HMAC(accountSecret, gameID)` — rather than minted per 10 /// device. Every one of the account's devices computes the identical address 11 /// for a game, so there's nothing to converge and no random token to clobber. 12 /// These tests cover the derivation (`RecordSerializer.deriveGameAddress`), the 13 /// open-burst stamp (`setPushAddress`), and the registration sweep 14 /// (`reconcileLocalPushAddresses`) — which must never fabricate a bare row. 15 @Suite("GameStore push addressing", .isolatedNotificationState) 16 @MainActor 17 struct GameStorePushAddressTests { 18 19 private static let authorID = "alice" 20 private static let secret = "test-secret-0123456789" 21 private static let puzzleSource = """ 22 Title: Test Puzzle 23 Author: Test 24 25 26 AB 27 CD 28 29 30 A1. Top row ~ AB 31 A3. Bottom row ~ CD 32 33 D1. Left column ~ AC 34 D2. Right column ~ BD 35 """ 36 37 @discardableResult 38 private func makeGame( 39 scope: Int16, 40 in ctx: NSManagedObjectContext 41 ) throws -> UUID { 42 let gameID = UUID() 43 let entity = GameEntity(context: ctx) 44 entity.id = gameID 45 entity.title = "Test" 46 entity.puzzleSource = Self.puzzleSource 47 entity.createdAt = Date() 48 entity.updatedAt = Date() 49 entity.ckRecordName = "game-\(gameID.uuidString)" 50 entity.ckZoneName = "game-\(gameID.uuidString)" 51 entity.databaseScope = scope 52 try ctx.save() 53 return gameID 54 } 55 56 /// Inserts a Player row carrying a stale (pre-derivation) address, standing 57 /// in for a record that synced before this build. 58 private func makeStalePlayerRow( 59 gameID: UUID, 60 address: String, 61 in ctx: NSManagedObjectContext 62 ) throws { 63 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 64 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 65 req.fetchLimit = 1 66 let game = try #require(try ctx.fetch(req).first) 67 let player = PlayerEntity(context: ctx) 68 player.game = game 69 player.authorID = Self.authorID 70 player.name = "alice" 71 player.ckRecordName = RecordSerializer.recordName( 72 forPlayerInGame: gameID, 73 authorID: Self.authorID 74 ) 75 player.pushAddress = address 76 player.updatedAt = Date() 77 try ctx.save() 78 } 79 80 private func playerRowCount(in ctx: NSManagedObjectContext) throws -> Int { 81 try ctx.count(for: NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")) 82 } 83 84 private func playerRow(gameID: UUID, in ctx: NSManagedObjectContext) throws -> PlayerEntity { 85 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 86 req.predicate = NSPredicate( 87 format: "game.id == %@ AND authorID == %@", 88 gameID as CVarArg, 89 Self.authorID 90 ) 91 req.fetchLimit = 1 92 return try #require(try ctx.fetch(req).first) 93 } 94 95 // MARK: - Derivation 96 97 @Test("deriveGameAddress is deterministic, URL-safe, and per-game/per-secret distinct") 98 func derivationProperties() { 99 let g1 = UUID() 100 let g2 = UUID() 101 102 let a1 = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g1) 103 let a1Again = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g1) 104 let a2 = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g2) 105 let a1OtherSecret = RecordSerializer.deriveGameAddress(secret: "other-secret", gameID: g1) 106 107 #expect(a1 == a1Again) // deterministic 108 #expect(a1 != a2) // scoped per game 109 #expect(a1 != a1OtherSecret) // scoped per secret 110 #expect(!a1.isEmpty) 111 112 let allowed = CharacterSet(charactersIn: 113 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") 114 #expect(a1.unicodeScalars.allSatisfy(allowed.contains)) 115 } 116 117 // MARK: - setPushAddress (open burst) 118 119 @Test("setPushAddress stamps the derived address and is stable") 120 func setPushAddressDerivesStably() throws { 121 let persistence = makeTestPersistence() 122 let store = makeTestStore(persistence: persistence) 123 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 124 125 let expected = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID) 126 let first = try #require( 127 store.setPushAddress(gameID: gameID, authorID: Self.authorID, secret: Self.secret) 128 ) 129 #expect(first == expected) 130 let second = try #require( 131 store.setPushAddress(gameID: gameID, authorID: Self.authorID, secret: Self.secret) 132 ) 133 #expect(second == first) 134 } 135 136 @Test("setPushAddress returns nil when the game is missing") 137 func setPushAddressMissingGame() { 138 let persistence = makeTestPersistence() 139 let store = makeTestStore(persistence: persistence) 140 #expect( 141 store.setPushAddress(gameID: UUID(), authorID: Self.authorID, secret: Self.secret) == nil 142 ) 143 } 144 145 // MARK: - reconcileLocalPushAddresses (registration sweep) 146 147 @Test("reconcile derives shared games only, and never fabricates a Player row") 148 func reconcileDerivesSharedGamesWithoutFabricating() throws { 149 let persistence = makeTestPersistence() 150 let store = makeTestStore(persistence: persistence) 151 let shared1 = try makeGame(scope: 1, in: persistence.viewContext) 152 let shared2 = try makeGame(scope: 1, in: persistence.viewContext) 153 _ = try makeGame(scope: 0, in: persistence.viewContext) // local-only, excluded 154 155 let result = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret) 156 157 // Both shared games contribute their derived address for registration, 158 // even with no local Player row to publish from. 159 #expect(Set(result.bindings.map(\.address)) == [ 160 RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: shared1), 161 RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: shared2) 162 ]) 163 // Each binding carries a freshly-minted shared push credential, and the 164 // game now advertises it for participants to converge on. 165 #expect(result.bindings.allSatisfy { $0.credentials != nil }) 166 #expect(GamePushCredentials.decode(store.notification(for: shared1)) != nil) 167 // No existing rows, so nothing to republish — and crucially, no bare row 168 // was fabricated (the CKError 14 / clobber regression). 169 #expect(result.republishGameIDs.isEmpty) 170 #expect(try playerRowCount(in: persistence.viewContext) == 0) 171 } 172 173 @Test("reconcile mirrors freshly-minted content keys for the notification extension") 174 func reconcileMirrorsMintedContentKeys() async throws { 175 let url = FileManager.default.temporaryDirectory 176 .appendingPathComponent("content-key-directory-\(UUID().uuidString).json") 177 defer { try? FileManager.default.removeItem(at: url) } 178 179 try await ContentKeyDirectory.$testingFileURL.withValue(url) { 180 try await MainActor.run { 181 let persistence = makeTestPersistence() 182 let store = makeTestStore(persistence: persistence) 183 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 184 185 #expect(ContentKeyDirectory.load().isEmpty) 186 187 let result = store.reconcileLocalPushAddresses( 188 authorID: Self.authorID, 189 secret: Self.secret 190 ) 191 let credentials = try #require(result.bindings.first?.credentials) 192 let contentKey = try #require(credentials.contentKey) 193 194 #expect(ContentKeyDirectory.load()[gameID.uuidString] == contentKey) 195 } 196 } 197 } 198 199 @Test("reconcile migrates a stale row to the derived address and lists it") 200 func reconcileMigratesStaleRow() throws { 201 let persistence = makeTestPersistence() 202 let store = makeTestStore(persistence: persistence) 203 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 204 try makeStalePlayerRow( 205 gameID: gameID, 206 address: "stale-minted-token", 207 in: persistence.viewContext 208 ) 209 210 let derived = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID) 211 let first = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret) 212 #expect(first.bindings.map(\.address) == [derived]) 213 #expect(first.republishGameIDs == [gameID]) 214 215 let player = try #require( 216 persistence.viewContext.registeredObjects 217 .compactMap { $0 as? PlayerEntity } 218 .first 219 ) 220 #expect(player.pushAddress == derived) 221 // The migration must not have wiped the row's display name. 222 #expect(player.name == "alice") 223 224 // Idempotent: the address now matches, so nothing is republished. 225 let second = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret) 226 #expect(second.bindings.map(\.address) == [derived]) 227 #expect(second.republishGameIDs.isEmpty) 228 // The credential is minted once and stable across reconciles. 229 let firstCred = try #require(first.bindings.first?.credentials) 230 let secondCred = try #require(second.bindings.first?.credentials) 231 #expect(firstCred.credID == secondCred.credID) 232 #expect(firstCred.secret == secondCred.secret) 233 } 234 235 @Test("reconcile can refresh bindings without republishing stale Player rows") 236 func reconcileCanSkipPlayerRepublish() throws { 237 let persistence = makeTestPersistence() 238 let store = makeTestStore(persistence: persistence) 239 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 240 try makeStalePlayerRow( 241 gameID: gameID, 242 address: "stale-minted-token", 243 in: persistence.viewContext 244 ) 245 246 let derived = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID) 247 let result = store.reconcileLocalPushAddresses( 248 authorID: Self.authorID, 249 secret: Self.secret, 250 republishPlayerRows: false 251 ) 252 253 #expect(result.bindings.map(\.address) == [derived]) 254 #expect(result.republishGameIDs.isEmpty) 255 #expect(try playerRow(gameID: gameID, in: persistence.viewContext).pushAddress == "stale-minted-token") 256 #expect(GamePushCredentials.decode(store.notification(for: gameID)) != nil) 257 } 258 259 // MARK: - ensurePushCredentials (minting) 260 261 @Test("ensurePushCredentials mints once for a shared game and is stable") 262 func ensurePushCredentialsMintsStably() throws { 263 let persistence = makeTestPersistence() 264 let store = makeTestStore(persistence: persistence) 265 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 266 267 let first = try #require(store.ensurePushCredentials(for: gameID)) 268 let second = try #require(store.ensurePushCredentials(for: gameID)) 269 #expect(first == second) 270 // The minted secret decodes to a 256-bit HMAC key. 271 #expect(Data(base64URLEncoded: first.secret)?.count == 32) 272 } 273 274 @Test("constructJoinedGame seeds the invite-carried notification credential") 275 func constructJoinedGameSeedsNotificationCredential() async throws { 276 let url = FileManager.default.temporaryDirectory 277 .appendingPathComponent("joined-game-content-key-\(UUID().uuidString).json") 278 defer { try? FileManager.default.removeItem(at: url) } 279 280 try await ContentKeyDirectory.$testingFileURL.withValue(url) { 281 try await MainActor.run { 282 let persistence = makeTestPersistence() 283 let store = makeTestStore(persistence: persistence) 284 let gameID = UUID() 285 let credentials = try GamePushCredentials.fresh() 286 let encoded = try credentials.encoded() 287 let zoneID = CKRecordZone.ID( 288 zoneName: "game-\(gameID.uuidString)", 289 ownerName: "_owner" 290 ) 291 292 try store.constructJoinedGame( 293 gameID: gameID, 294 zoneID: zoneID, 295 source: Self.puzzleSource, 296 notification: encoded 297 ) 298 299 #expect(store.notification(for: gameID) == encoded) 300 #expect(ContentKeyDirectory.load()[gameID.uuidString] == credentials.contentKey) 301 } 302 } 303 } 304 305 @Test("ensurePushCredentials returns nil for a non-shared or missing game") 306 func ensurePushCredentialsNonShared() throws { 307 let persistence = makeTestPersistence() 308 let store = makeTestStore(persistence: persistence) 309 let localOnly = try makeGame(scope: 0, in: persistence.viewContext) 310 #expect(store.ensurePushCredentials(for: localOnly) == nil) 311 #expect(store.ensurePushCredentials(for: UUID()) == nil) 312 } 313 314 @Test("deleteGame requests a push registration refresh") 315 func deleteGameRequestsPushRegistrationRefresh() throws { 316 let persistence = makeTestPersistence() 317 let store = makeTestStore(persistence: persistence) 318 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 319 var refreshCount = 0 320 store.onPushRegistrationMayNeedRefresh = { 321 refreshCount += 1 322 } 323 324 try store.deleteGame(id: gameID) 325 326 #expect(refreshCount == 1) 327 } 328 }