GameStorePushAddressTests.swift (9190B)
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 @discardableResult 31 private func makeGame( 32 scope: Int16, 33 in ctx: NSManagedObjectContext 34 ) throws -> UUID { 35 let gameID = UUID() 36 let entity = GameEntity(context: ctx) 37 entity.id = gameID 38 entity.title = "Test" 39 entity.puzzleSource = Self.puzzleSource 40 entity.createdAt = Date() 41 entity.updatedAt = Date() 42 entity.ckRecordName = "game-\(gameID.uuidString)" 43 entity.ckZoneName = "game-\(gameID.uuidString)" 44 entity.databaseScope = scope 45 try ctx.save() 46 return gameID 47 } 48 49 /// Inserts a Player row carrying a stale (pre-derivation) address, standing 50 /// in for a record that synced before this build. 51 private func makeStalePlayerRow( 52 gameID: UUID, 53 address: String, 54 in ctx: NSManagedObjectContext 55 ) throws { 56 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 57 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 58 req.fetchLimit = 1 59 let game = try #require(try ctx.fetch(req).first) 60 let player = PlayerEntity(context: ctx) 61 player.game = game 62 player.authorID = Self.authorID 63 player.name = "alice" 64 player.ckRecordName = RecordSerializer.recordName( 65 forPlayerInGame: gameID, 66 authorID: Self.authorID 67 ) 68 player.pushAddress = address 69 player.updatedAt = Date() 70 try ctx.save() 71 } 72 73 private func playerRowCount(in ctx: NSManagedObjectContext) throws -> Int { 74 try ctx.count(for: NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")) 75 } 76 77 // MARK: - Derivation 78 79 @Test("deriveGameAddress is deterministic, URL-safe, and per-game/per-secret distinct") 80 func derivationProperties() { 81 let g1 = UUID() 82 let g2 = UUID() 83 84 let a1 = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g1) 85 let a1Again = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g1) 86 let a2 = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: g2) 87 let a1OtherSecret = RecordSerializer.deriveGameAddress(secret: "other-secret", gameID: g1) 88 89 #expect(a1 == a1Again) // deterministic 90 #expect(a1 != a2) // scoped per game 91 #expect(a1 != a1OtherSecret) // scoped per secret 92 #expect(!a1.isEmpty) 93 94 let allowed = CharacterSet(charactersIn: 95 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") 96 #expect(a1.unicodeScalars.allSatisfy(allowed.contains)) 97 } 98 99 // MARK: - setPushAddress (open burst) 100 101 @Test("setPushAddress stamps the derived address and is stable") 102 func setPushAddressDerivesStably() throws { 103 let persistence = makeTestPersistence() 104 let store = makeTestStore(persistence: persistence) 105 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 106 107 let expected = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID) 108 let first = try #require( 109 store.setPushAddress(gameID: gameID, authorID: Self.authorID, secret: Self.secret) 110 ) 111 #expect(first == expected) 112 let second = try #require( 113 store.setPushAddress(gameID: gameID, authorID: Self.authorID, secret: Self.secret) 114 ) 115 #expect(second == first) 116 } 117 118 @Test("setPushAddress returns nil when the game is missing") 119 func setPushAddressMissingGame() { 120 let persistence = makeTestPersistence() 121 let store = makeTestStore(persistence: persistence) 122 #expect( 123 store.setPushAddress(gameID: UUID(), authorID: Self.authorID, secret: Self.secret) == nil 124 ) 125 } 126 127 // MARK: - reconcileLocalPushAddresses (registration sweep) 128 129 @Test("reconcile derives shared games only, and never fabricates a Player row") 130 func reconcileDerivesSharedGamesWithoutFabricating() throws { 131 let persistence = makeTestPersistence() 132 let store = makeTestStore(persistence: persistence) 133 let shared1 = try makeGame(scope: 1, in: persistence.viewContext) 134 let shared2 = try makeGame(scope: 1, in: persistence.viewContext) 135 _ = try makeGame(scope: 0, in: persistence.viewContext) // local-only, excluded 136 137 let result = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret) 138 139 // Both shared games contribute their derived address for registration, 140 // even with no local Player row to publish from. 141 #expect(Set(result.bindings.map(\.address)) == [ 142 RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: shared1), 143 RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: shared2) 144 ]) 145 // Each binding carries a freshly-minted shared push credential, and the 146 // game now advertises it for participants to converge on. 147 #expect(result.bindings.allSatisfy { $0.credentials != nil }) 148 #expect(GamePushCredentials.decode(store.notification(for: shared1)) != nil) 149 // No existing rows, so nothing to republish — and crucially, no bare row 150 // was fabricated (the CKError 14 / clobber regression). 151 #expect(result.republishGameIDs.isEmpty) 152 #expect(try playerRowCount(in: persistence.viewContext) == 0) 153 } 154 155 @Test("reconcile migrates a stale row to the derived address and lists it") 156 func reconcileMigratesStaleRow() throws { 157 let persistence = makeTestPersistence() 158 let store = makeTestStore(persistence: persistence) 159 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 160 try makeStalePlayerRow( 161 gameID: gameID, 162 address: "stale-minted-token", 163 in: persistence.viewContext 164 ) 165 166 let derived = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID) 167 let first = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret) 168 #expect(first.bindings.map(\.address) == [derived]) 169 #expect(first.republishGameIDs == [gameID]) 170 171 let player = try #require( 172 persistence.viewContext.registeredObjects 173 .compactMap { $0 as? PlayerEntity } 174 .first 175 ) 176 #expect(player.pushAddress == derived) 177 // The migration must not have wiped the row's display name. 178 #expect(player.name == "alice") 179 180 // Idempotent: the address now matches, so nothing is republished. 181 let second = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret) 182 #expect(second.bindings.map(\.address) == [derived]) 183 #expect(second.republishGameIDs.isEmpty) 184 // The credential is minted once and stable across reconciles. 185 let firstCred = try #require(first.bindings.first?.credentials) 186 let secondCred = try #require(second.bindings.first?.credentials) 187 #expect(firstCred.credID == secondCred.credID) 188 #expect(firstCred.secret == secondCred.secret) 189 } 190 191 // MARK: - ensurePushCredentials (minting) 192 193 @Test("ensurePushCredentials mints once for a shared game and is stable") 194 func ensurePushCredentialsMintsStably() throws { 195 let persistence = makeTestPersistence() 196 let store = makeTestStore(persistence: persistence) 197 let gameID = try makeGame(scope: 1, in: persistence.viewContext) 198 199 let first = try #require(store.ensurePushCredentials(for: gameID)) 200 let second = try #require(store.ensurePushCredentials(for: gameID)) 201 #expect(first == second) 202 // The minted secret decodes to a 256-bit HMAC key. 203 #expect(Data(base64URLEncoded: first.secret)?.count == 32) 204 } 205 206 @Test("ensurePushCredentials returns nil for a non-shared or missing game") 207 func ensurePushCredentialsNonShared() throws { 208 let persistence = makeTestPersistence() 209 let store = makeTestStore(persistence: persistence) 210 let localOnly = try makeGame(scope: 0, in: persistence.viewContext) 211 #expect(store.ensurePushCredentials(for: localOnly) == nil) 212 #expect(store.ensurePushCredentials(for: UUID()) == nil) 213 } 214 }