crossmate

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

RemoteCoordinateValidationTests.swift (9267B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// H2 regression coverage: remote Moves/Journal records and realtime frames
      9 /// carry attacker-controlled coordinates, and every Core Data cell/journal
     10 /// sink converts them with trapping `Int16` initializers. A single poisoned
     11 /// value used to crash cache replay on every subsequent fetch — a persistent
     12 /// remote denial of service. These tests pin the validation that keeps
     13 /// malformed coordinates out of persistence at each sink.
     14 @Suite("Remote coordinate validation")
     15 struct RemoteCoordinateValidationTests {
     16 
     17     private func cell(_ letter: String) -> TimestampedCell {
     18         TimestampedCell(
     19             letter: letter,
     20             mark: .none,
     21             updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
     22             authorID: "alice"
     23         )
     24     }
     25 
     26     @Test("isPersistable accepts in-grid and rejects out-of-grid coordinates")
     27     func isPersistableBounds() {
     28         let width: Int16 = 3
     29         let height: Int16 = 5
     30         #expect(GridPosition(row: 0, col: 0).isPersistable(gridWidth: width, gridHeight: height))
     31         #expect(GridPosition(row: 4, col: 2).isPersistable(gridWidth: width, gridHeight: height))
     32         // One past each edge.
     33         #expect(!GridPosition(row: 5, col: 0).isPersistable(gridWidth: width, gridHeight: height))
     34         #expect(!GridPosition(row: 0, col: 3).isPersistable(gridWidth: width, gridHeight: height))
     35         #expect(!GridPosition(row: -1, col: 0).isPersistable(gridWidth: width, gridHeight: height))
     36         #expect(!GridPosition(row: 0, col: -1).isPersistable(gridWidth: width, gridHeight: height))
     37         // Zero dimensions (legacy rows) skip the shape check but still
     38         // require Int16 representability.
     39         #expect(GridPosition(row: 500, col: 500).isPersistable(gridWidth: 0, gridHeight: 0))
     40         #expect(!GridPosition(row: 32768, col: 0).isPersistable(gridWidth: 0, gridHeight: 0))
     41         #expect(!GridPosition(row: -1, col: 0).isPersistable(gridWidth: 0, gridHeight: 0))
     42         #expect(GridPosition(row: 32767, col: 32767).isPersistable(gridWidth: 0, gridHeight: 0))
     43     }
     44 
     45     @Test("MovesCodec.decode drops Int16-unrepresentable coordinates")
     46     func movesCodecDropsUnrepresentable() throws {
     47         let cells: [GridPosition: TimestampedCell] = [
     48             GridPosition(row: 0, col: 0): cell("A"),
     49             GridPosition(row: 32768, col: 0): cell("B"),
     50             GridPosition(row: 0, col: Int.max): cell("C"),
     51             GridPosition(row: -1, col: 0): cell("D"),
     52             GridPosition(row: 32767, col: 32767): cell("E"),
     53         ]
     54 
     55         let decoded = try MovesCodec.decode(MovesCodec.encode(cells))
     56 
     57         #expect(decoded.count == 2)
     58         #expect(decoded[GridPosition(row: 0, col: 0)]?.letter == "A")
     59         #expect(decoded[GridPosition(row: 32767, col: 32767)]?.letter == "E")
     60     }
     61 
     62     @Test("JournalCodec.decode drops Int16-unrepresentable coordinates")
     63     func journalCodecDropsUnrepresentable() throws {
     64         let values = [
     65             journalValue(seq: 0, row: 0, col: 1, letter: "A"),
     66             journalValue(seq: 1, row: 32768, col: 0, letter: "B"),
     67             journalValue(seq: 2, row: 0, col: -5, letter: "C"),
     68         ]
     69 
     70         let decoded = try JournalCodec.decode(JournalCodec.encode(values))
     71 
     72         #expect(decoded.count == 1)
     73         #expect(decoded.first?.position == GridPosition(row: 0, col: 1))
     74         #expect(decoded.first?.state.letter == "A")
     75     }
     76 
     77     @Test("parseMovesRecord strips poisoned coordinates from a hostile record")
     78     func parseMovesRecordStripsPoisonedCells() throws {
     79         let gameID = UUID()
     80         let value = MovesValue(
     81             gameID: gameID,
     82             authorID: "bob",
     83             deviceID: "d1",
     84             cells: [
     85                 GridPosition(row: 1, col: 1): cell("A"),
     86                 GridPosition(row: 40_000, col: 0): cell("B"),
     87             ],
     88             updatedAt: Date(timeIntervalSince1970: 1_700_000_000)
     89         )
     90         let record = try RecordSerializer.movesRecord(
     91             from: value,
     92             zone: RecordSerializer.zoneID(for: gameID),
     93             systemFields: nil
     94         )
     95 
     96         let parsed = try #require(RecordSerializer.parseMovesRecord(record))
     97 
     98         #expect(parsed.cells.count == 1)
     99         #expect(parsed.cells[GridPosition(row: 1, col: 1)]?.letter == "A")
    100     }
    101 }
    102 
    103 /// The Core Data sinks: realtime edits merged into `MovesEntity` state and
    104 /// remote journals cached as replay rows, both against a 3×3 grid.
    105 @Suite("Remote coordinate validation — store sinks", .serialized)
    106 @MainActor
    107 struct RemoteCoordinateStoreSinkTests {
    108 
    109     private static let puzzleSource = """
    110     Title: Test Puzzle
    111     Author: Test
    112 
    113 
    114     ABC
    115     D#E
    116     FGH
    117 
    118 
    119     A1. Across 1 ~ ABC
    120     A4. Across 4 ~ DE
    121     A5. Across 5 ~ FGH
    122 
    123     D1. Down 1 ~ ADF
    124     D2. Down 2 ~ BG
    125     D3. Down 3 ~ CEH
    126     """
    127 
    128     private func makeGame(in ctx: NSManagedObjectContext) throws -> UUID {
    129         let gameID = UUID()
    130         let xd = try XD.parse(Self.puzzleSource)
    131         let puzzle = Puzzle(xd: xd)
    132         let entity = GameEntity(context: ctx)
    133         entity.id = gameID
    134         entity.title = "Shared"
    135         entity.puzzleSource = Self.puzzleSource
    136         entity.createdAt = Date()
    137         entity.updatedAt = Date()
    138         entity.ckRecordName = "game-\(gameID.uuidString)"
    139         entity.populateCachedSummaryFields(from: puzzle)
    140         try ctx.save()
    141         return gameID
    142     }
    143 
    144     private func edit(gameID: UUID, row: Int, col: Int, letter: String) -> RealtimeCellEdit {
    145         RealtimeCellEdit(
    146             gameID: gameID,
    147             authorID: "alice",
    148             deviceID: "remote-device",
    149             row: row,
    150             col: col,
    151             letter: letter,
    152             mark: .none,
    153             updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
    154             cellAuthorID: "alice"
    155         )
    156     }
    157 
    158     @Test("Realtime edits with malformed coordinates are rejected, valid ones apply")
    159     func realtimeEditsRejectMalformedCoordinates() throws {
    160         let persistence = makeTestPersistence()
    161         let store = makeTestStore(persistence: persistence)
    162         let gameID = try makeGame(in: persistence.viewContext)
    163 
    164         let applied = store.applyRealtimeCellEdits([
    165             edit(gameID: gameID, row: 0, col: 0, letter: "Q"),
    166             edit(gameID: gameID, row: 3, col: 0, letter: "X"),   // one past the grid
    167             edit(gameID: gameID, row: 0, col: -1, letter: "X"),  // negative
    168             edit(gameID: gameID, row: 40_000, col: 0, letter: "X"), // would trap Int16
    169         ])
    170 
    171         #expect(applied == 1)
    172         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    173         let rows = try persistence.viewContext.fetch(req)
    174         #expect(rows.count == 1)
    175         let cells = try MovesCodec.decode(try #require(rows.first?.cells))
    176         #expect(cells.count == 1)
    177         #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "Q")
    178     }
    179 
    180     @Test("A batch of only malformed realtime edits applies nothing")
    181     func allMalformedRealtimeBatchAppliesNothing() throws {
    182         let persistence = makeTestPersistence()
    183         let store = makeTestStore(persistence: persistence)
    184         let gameID = try makeGame(in: persistence.viewContext)
    185 
    186         let applied = store.applyRealtimeCellEdits([
    187             edit(gameID: gameID, row: 3, col: 3, letter: "X"),
    188             edit(gameID: gameID, row: Int.max, col: 0, letter: "X"),
    189         ])
    190 
    191         #expect(applied == 0)
    192         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    193         #expect(try persistence.viewContext.fetch(req).isEmpty)
    194     }
    195 
    196     @Test("Replay cache skips remote journal entries outside the grid")
    197     func replayCacheSkipsOutOfGridEntries() async throws {
    198         let persistence = makeTestPersistence()
    199         let store = makeTestStore(persistence: persistence)
    200         let gameID = try makeGame(in: persistence.viewContext)
    201 
    202         let journal = DeviceJournal(
    203             key: JournalDeviceKey(authorID: "bob", deviceID: "dev-b"),
    204             entries: [
    205                 journalValue(seq: 0, row: 0, col: 2, letter: "A"),
    206                 journalValue(seq: 1, row: 100, col: 0, letter: "B"),   // out of grid
    207                 journalValue(seq: 2, row: 40_000, col: 0, letter: "C"), // would trap Int16
    208             ]
    209         )
    210 
    211         await store.cacheRemoteJournals([journal], forGameID: gameID)
    212 
    213         let cached = try #require(await store.cachedRemoteJournals(forGameID: gameID))
    214         #expect(cached.count == 1)
    215         #expect(cached.first?.entries.count == 1)
    216         #expect(cached.first?.entries.first?.position == GridPosition(row: 0, col: 2))
    217     }
    218 }
    219 
    220 /// Shared journal fixture builder for both suites above.
    221 private func journalValue(seq: Int64, row: Int, col: Int, letter: String) -> JournalValue {
    222     JournalValue(
    223         seq: seq,
    224         timestamp: Date(timeIntervalSince1970: TimeInterval(1_700_000_000 + seq)),
    225         position: GridPosition(row: row, col: col),
    226         state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "alice"),
    227         actingAuthorID: "alice",
    228         kind: .input,
    229         targetSeq: nil,
    230         batchID: nil,
    231         prevSeqAtCell: nil,
    232         direction: nil
    233     )
    234 }