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 }