crossmate

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

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 }