crossmate

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

InboundRecordIdentityTests.swift (8136B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 @Suite("Inbound game record identity")
      9 @MainActor
     10 struct InboundRecordIdentityTests {
     11 
     12     private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")!
     13 
     14     private func movesRecord(in zoneID: CKRecordZone.ID) throws -> CKRecord {
     15         try RecordSerializer.movesRecord(
     16             from: MovesValue(
     17                 gameID: gameID,
     18                 authorID: "alice",
     19                 deviceID: "devicea",
     20                 cells: [:],
     21                 updatedAt: .now
     22             ),
     23             zone: zoneID,
     24             systemFields: nil
     25         )
     26     }
     27 
     28     @Test("per-game records require their matching zone and CloudKit creator")
     29     func recordRequiresMatchingZoneAndCreator() throws {
     30         let matchingZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner")
     31         let matching = try movesRecord(in: matchingZone)
     32         #expect(
     33             RecordSerializer.isTrustedGameScopedRecord(
     34                 matching,
     35                 creatorUserRecordName: "alice"
     36             )
     37         )
     38         #expect(
     39             !RecordSerializer.isTrustedGameScopedRecord(
     40                 matching,
     41                 creatorUserRecordName: "mallory"
     42             )
     43         )
     44 
     45         let foreignZone = RecordSerializer.zoneID(for: UUID(), ownerName: "owner")
     46         let crossZone = try movesRecord(in: foreignZone)
     47         #expect(
     48             !RecordSerializer.isTrustedGameScopedRecord(
     49                 crossZone,
     50                 creatorUserRecordName: "alice"
     51             )
     52         )
     53     }
     54 
     55     @Test("self-created records round-trip as the creator placeholder and stay trusted")
     56     func recordAcceptsCreatorPlaceholder() throws {
     57         // CloudKit reports the creator of a record the fetching user created
     58         // themselves as `CKCurrentUserDefaultName`, not their concrete record
     59         // name. Rejecting the placeholder would drop every self-authored
     60         // record on a fresh install or second device. Accepting it stays
     61         // safe: a remote participant's record never arrives carrying it.
     62         let zone = RecordSerializer.zoneID(for: gameID, ownerName: "owner")
     63         let record = try movesRecord(in: zone)
     64         #expect(
     65             RecordSerializer.isTrustedGameScopedRecord(
     66                 record,
     67                 creatorUserRecordName: CKCurrentUserDefaultName
     68             )
     69         )
     70         #expect(
     71             !RecordSerializer.isTrustedGameScopedRecord(
     72                 record,
     73                 creatorUserRecordName: nil
     74             )
     75         )
     76     }
     77 
     78     @Test("root game record must be named for its zone")
     79     func rootGameRecordRequiresMatchingZone() {
     80         let record = CKRecord(
     81             recordType: "Game",
     82             recordID: CKRecord.ID(
     83                 recordName: RecordSerializer.recordName(forGameID: gameID),
     84                 zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner")
     85             )
     86         )
     87         #expect(
     88             !RecordSerializer.isTrustedGameScopedRecord(
     89                 record,
     90                 creatorUserRecordName: "owner"
     91             )
     92         )
     93     }
     94 
     95     @Test("deletions are confined to the record's game zone")
     96     func deletionRequiresMatchingZone() {
     97         let recordName = RecordSerializer.recordName(
     98             forMovesInGame: gameID,
     99             authorID: "alice",
    100             deviceID: "devicea"
    101         )
    102         let valid = CKRecord.ID(
    103             recordName: recordName,
    104             zoneID: RecordSerializer.zoneID(for: gameID, ownerName: "owner")
    105         )
    106         #expect(RecordSerializer.isTrustedGameScopedDeletion(recordID: valid, recordType: "Moves"))
    107 
    108         let forged = CKRecord.ID(
    109             recordName: recordName,
    110             zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner")
    111         )
    112         #expect(!RecordSerializer.isTrustedGameScopedDeletion(recordID: forged, recordType: "Moves"))
    113     }
    114 
    115     @Test("ping deletions follow the same zone confinement as ping records")
    116     func pingDeletionConfinement() {
    117         let pingName = RecordSerializer.recordName(
    118             forPingInGame: gameID,
    119             authorID: "alice",
    120             deviceID: "devicea",
    121             eventTimestampMs: 1_700_000_000_000
    122         )
    123         // A ping in its own game's zone is fine.
    124         let inGameZone = CKRecord.ID(
    125             recordName: pingName,
    126             zoneID: RecordSerializer.zoneID(for: gameID, ownerName: "owner")
    127         )
    128         #expect(
    129             RecordSerializer.isTrustedGameScopedDeletion(recordID: inGameZone, recordType: "Ping")
    130         )
    131         // A friend-mailbox ping names a game outside its zone by design.
    132         let inFriendZone = CKRecord.ID(
    133             recordName: pingName,
    134             zoneID: CKRecordZone.ID(zoneName: "friend-pair", ownerName: "owner")
    135         )
    136         #expect(
    137             RecordSerializer.isTrustedGameScopedDeletion(recordID: inFriendZone, recordType: "Ping")
    138         )
    139         // A ping in a *different* game's zone is not.
    140         let crossGameZone = CKRecord.ID(
    141             recordName: pingName,
    142             zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner")
    143         )
    144         #expect(
    145             !RecordSerializer.isTrustedGameScopedDeletion(recordID: crossGameZone, recordType: "Ping")
    146         )
    147     }
    148 
    149     @Test("a private game's row is found under either owner-name spelling")
    150     func privateGameLookupIgnoresOwnerSpelling() throws {
    151         let persistence = makeTestPersistence()
    152         let ctx = persistence.viewContext
    153         let name = RecordSerializer.recordName(forGameID: gameID)
    154         // CloudKit round-trips the current user's own zones with either the
    155         // `CKCurrentUserDefaultName` placeholder or their concrete record ID.
    156         // Both spellings must resolve to the same private row, or every
    157         // fetch after the first would spawn a duplicate game.
    158         let placeholderZone = RecordSerializer.zoneID(for: gameID)
    159         let concreteZone = RecordSerializer.zoneID(for: gameID, ownerName: "_myOwnRecordID")
    160 
    161         let first = CKRecord(
    162             recordType: "Game",
    163             recordID: CKRecord.ID(recordName: name, zoneID: placeholderZone)
    164         )
    165         first["title"] = "Original" as CKRecordValue
    166         let second = CKRecord(
    167             recordType: "Game",
    168             recordID: CKRecord.ID(recordName: name, zoneID: concreteZone)
    169         )
    170         second["title"] = "Round-tripped" as CKRecordValue
    171 
    172         let firstEntity = RecordSerializer.applyGameRecord(first, to: ctx, databaseScope: .private)
    173         let secondEntity = RecordSerializer.applyGameRecord(second, to: ctx, databaseScope: .private)
    174 
    175         #expect(firstEntity.objectID == secondEntity.objectID)
    176         #expect(secondEntity.title == "Round-tripped")
    177     }
    178 
    179     @Test("same-named records in distinct owner zones do not share a local row")
    180     func gameLookupIncludesZoneOwner() throws {
    181         let persistence = makeTestPersistence()
    182         let ctx = persistence.viewContext
    183         let firstZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner-a")
    184         let secondZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner-b")
    185 
    186         let first = CKRecord(
    187             recordType: "Game",
    188             recordID: CKRecord.ID(
    189                 recordName: RecordSerializer.recordName(forGameID: gameID),
    190                 zoneID: firstZone
    191             )
    192         )
    193         first["title"] = "First" as CKRecordValue
    194         let second = CKRecord(
    195             recordType: "Game",
    196             recordID: CKRecord.ID(
    197                 recordName: RecordSerializer.recordName(forGameID: gameID),
    198                 zoneID: secondZone
    199             )
    200         )
    201         second["title"] = "Second" as CKRecordValue
    202 
    203         let firstEntity = RecordSerializer.applyGameRecord(first, to: ctx, databaseScope: .shared)
    204         let secondEntity = RecordSerializer.applyGameRecord(second, to: ctx, databaseScope: .shared)
    205 
    206         #expect(firstEntity.objectID != secondEntity.objectID)
    207         #expect(firstEntity.title == "First")
    208         #expect(secondEntity.title == "Second")
    209     }
    210 }