crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }