crossmate

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

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 }