PushCredentialRotationTests.swift (7783B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// H4 coverage: rotating a game's push credentials when a participant departs, 9 /// and the generation guard that stops a stale device's Game-record re-push 10 /// from resurrecting the superseded credential. 11 @Suite("Push credential rotation", .isolatedNotificationState) 12 @MainActor 13 struct PushCredentialRotationTests { 14 15 private let gameID = UUID(uuidString: "AABBCCDD-1111-0000-0000-111122223333")! 16 17 private var zoneID: CKRecordZone.ID { 18 RecordSerializer.zoneID(for: gameID, ownerName: CKCurrentUserDefaultName) 19 } 20 21 private func credentialBlob(gen: Int64?) throws -> String { 22 var creds = try GamePushCredentials.fresh() 23 creds.gen = gen 24 return try creds.encoded() 25 } 26 27 private func gameRecord(blob: String?) -> CKRecord { 28 let record = CKRecord( 29 recordType: "Game", 30 recordID: CKRecord.ID(recordName: "game-\(gameID.uuidString)", zoneID: zoneID) 31 ) 32 record["title"] = "Test" 33 if let blob { 34 record["pushCredential"] = blob.data(using: .utf8) 35 } 36 return record 37 } 38 39 // MARK: - GamePushCredentials generation 40 41 @Test("a legacy blob without a generation reports generation 1") 42 func legacyBlobGeneration() throws { 43 let legacy = """ 44 {"ver":1,"credID":"11111111-2222-3333-4444-555555555555","secret":"abc"} 45 """ 46 let decoded = try #require(GamePushCredentials.decode(legacy)) 47 #expect(decoded.generation == 1) 48 } 49 50 @Test("rotation replaces every field and bumps the generation") 51 func rotationReplacesEverything() throws { 52 let original = try GamePushCredentials.fresh() 53 #expect(original.generation == 1) 54 55 let rotated = try GamePushCredentials.rotated(after: original) 56 #expect(rotated.generation == 2) 57 #expect(rotated.credID != original.credID) 58 #expect(rotated.secret != original.secret) 59 #expect(rotated.contentKey != nil) 60 #expect(rotated.contentKey != original.contentKey) 61 62 let roundTripped = GamePushCredentials.decode(try rotated.encoded()) 63 #expect(roundTripped == rotated) 64 } 65 66 // MARK: - Inbound generation guard 67 68 @Test("an inbound record at a higher generation is adopted") 69 func higherGenerationAdopted() throws { 70 let ctx = makeTestPersistence().container.viewContext 71 let gen2 = try credentialBlob(gen: 2) 72 let gen3 = try credentialBlob(gen: 3) 73 74 var contentKeyChanges = 0 75 let entity = RecordSerializer.applyGameRecord( 76 gameRecord(blob: gen2), 77 to: ctx, 78 onContentKeyChange: { _ in contentKeyChanges += 1 } 79 ) 80 #expect(entity.notification == gen2) 81 #expect(contentKeyChanges == 1) 82 83 _ = RecordSerializer.applyGameRecord( 84 gameRecord(blob: gen3), 85 to: ctx, 86 onContentKeyChange: { _ in contentKeyChanges += 1 } 87 ) 88 #expect(entity.notification == gen3) 89 #expect(contentKeyChanges == 2) 90 #expect(!entity.hasPendingSave) 91 } 92 93 @Test("an inbound record at a lower generation is kept out and healed") 94 func lowerGenerationKeptOut() throws { 95 let ctx = makeTestPersistence().container.viewContext 96 let gen2 = try credentialBlob(gen: 2) 97 let gen1 = try credentialBlob(gen: 1) 98 99 let entity = RecordSerializer.applyGameRecord(gameRecord(blob: gen2), to: ctx) 100 101 var staleRecords: [String] = [] 102 var contentKeyChanges = 0 103 _ = RecordSerializer.applyGameRecord( 104 gameRecord(blob: gen1), 105 to: ctx, 106 onContentKeyChange: { _ in contentKeyChanges += 1 }, 107 onStaleCredentials: { staleRecords.append($0) } 108 ) 109 #expect(entity.notification == gen2) 110 #expect(contentKeyChanges == 0) 111 #expect(staleRecords == ["game-\(gameID.uuidString)"]) 112 #expect(entity.hasPendingSave, "the kept-newer credential must re-push to heal the server") 113 } 114 115 @Test("an inbound record missing the credential never clears a local one") 116 func absentCredentialKeptOut() throws { 117 let ctx = makeTestPersistence().container.viewContext 118 let gen1 = try credentialBlob(gen: 1) 119 120 let entity = RecordSerializer.applyGameRecord(gameRecord(blob: gen1), to: ctx) 121 122 var staleRecords: [String] = [] 123 _ = RecordSerializer.applyGameRecord( 124 gameRecord(blob: nil), 125 to: ctx, 126 onStaleCredentials: { staleRecords.append($0) } 127 ) 128 #expect(entity.notification == gen1) 129 #expect(staleRecords == ["game-\(gameID.uuidString)"]) 130 } 131 132 // MARK: - Share roster departure detection 133 134 @Test("a roster shrink is detected once; baselines and growth never fire") 135 func shareRosterShrinkDetection() throws { 136 let ctx = makeTestPersistence().container.viewContext 137 _ = RecordSerializer.applyGameRecord(gameRecord(blob: nil), to: ctx) 138 139 func apply(_ accepted: [String]) -> Bool { 140 SyncEngine.applyShareRoster( 141 gameID: gameID, 142 zoneID: zoneID, 143 acceptedParticipants: accepted, 144 databaseScope: .private, 145 in: ctx 146 ) 147 } 148 149 // First sighting seeds the baseline silently (an app update must not 150 // rotate every shared game). 151 #expect(!apply(["alice", "bob"])) 152 // Unchanged roster: nothing. 153 #expect(!apply(["alice", "bob"])) 154 // Bob left or was removed. 155 #expect(apply(["alice"])) 156 // Re-observing the shrunken roster is idempotent. 157 #expect(!apply(["alice"])) 158 // Growth (Carol accepted) is not a departure. 159 #expect(!apply(["alice", "carol"])) 160 // Alice's departure from the grown roster fires again. 161 #expect(apply(["carol"])) 162 } 163 164 @Test("a share for an unknown game records nothing and never fires") 165 func shareRosterUnknownGame() throws { 166 let ctx = makeTestPersistence().container.viewContext 167 #expect(!SyncEngine.applyShareRoster( 168 gameID: gameID, 169 zoneID: zoneID, 170 acceptedParticipants: ["alice"], 171 databaseScope: .private, 172 in: ctx 173 )) 174 } 175 176 // MARK: - Store rotation 177 178 @Test("rotating replaces the stored credential; a game without one is a no-op") 179 func storeRotation() throws { 180 let persistence = makeTestPersistence() 181 let store = makeTestStore(persistence: persistence, authorIDProvider: { "alice" }) 182 let ctx = persistence.container.viewContext 183 184 let gameID = UUID() 185 let entity = GameEntity(context: ctx) 186 entity.id = gameID 187 entity.title = "Test" 188 entity.puzzleSource = "x" 189 entity.createdAt = Date() 190 entity.updatedAt = Date() 191 entity.ckRecordName = "game-\(gameID.uuidString)" 192 entity.ckZoneName = "game-\(gameID.uuidString)" 193 entity.databaseScope = 1 194 try ctx.save() 195 196 // Nothing minted yet — nothing a departed device could hold. 197 #expect(store.rotatePushCredentials(for: gameID) == nil) 198 199 let original = try #require(store.ensurePushCredentials(for: gameID)) 200 let rotated = try #require(store.rotatePushCredentials(for: gameID)) 201 #expect(rotated.generation == original.generation + 1) 202 #expect(rotated.credID != original.credID) 203 #expect(rotated.secret != original.secret) 204 #expect(rotated.contentKey != nil) 205 #expect(rotated.contentKey != original.contentKey) 206 207 let persisted = GamePushCredentials.decode(store.notification(for: gameID)) 208 #expect(persisted == rotated) 209 } 210 }