EngagementCoordinatorTests.swift (19223B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("EngagementCoordinator") 7 struct EngagementCoordinatorTests { 8 @Test("room payload encodes v2 room credentials") 9 func roomPayloadRoundTrip() throws { 10 let createdAt = Date(timeIntervalSince1970: 100) 11 let expiresAt = Date(timeIntervalSince1970: 700) 12 let room = EngagementRoomCredentials( 13 roomID: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, 14 secret: Data(repeating: 7, count: 32).base64URLEncodedString(), 15 createdAt: createdAt, 16 expiresAt: expiresAt 17 ) 18 19 let decoded = EngagementRoomCredentials.decode(try room.encoded()) 20 21 #expect(decoded == room) 22 #expect(decoded?.ver == 2) 23 } 24 25 @Test("addressee matches author-wide and device-specific hails") 26 func addresseeMatching() { 27 #expect(EngagementAddressee.parse("alice")?.matches(authorID: "alice", deviceID: "phone") == true) 28 #expect(EngagementAddressee.parse("alice:phone")?.matches(authorID: "alice", deviceID: "phone") == true) 29 #expect(EngagementAddressee.parse("alice:pad")?.matches(authorID: "alice", deviceID: "phone") == false) 30 #expect(EngagementAddressee.parse("bob:phone")?.matches(authorID: "alice", deviceID: "phone") == false) 31 #expect(EngagementAddressee.parse(nil) == nil) 32 } 33 34 @Test("socket signatures are stable") 35 func socketSignature() throws { 36 let secret = Data(repeating: 1, count: 32).base64URLEncodedString() 37 let roomID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! 38 let payload = EngagementSocketAuthenticator.signaturePayload( 39 roomID: roomID, 40 authorID: "alice", 41 deviceID: "deviceA", 42 timestamp: "1000", 43 nonce: "nonce" 44 ) 45 46 let first = try EngagementSocketAuthenticator.signature(payload: payload, secret: secret) 47 let second = try EngagementSocketAuthenticator.signature(payload: payload, secret: secret) 48 49 #expect(first == second) 50 #expect(!first.isEmpty) 51 } 52 53 @Test("socket URL preserves configured WebSocket endpoint") 54 @MainActor 55 func socketURL() throws { 56 let room = EngagementRoomCredentials( 57 roomID: UUID(uuidString: "23232323-2323-2323-2323-232323232323")!, 58 secret: Data(repeating: 2, count: 32).base64URLEncodedString(), 59 createdAt: Date(timeIntervalSince1970: 100), 60 expiresAt: Date(timeIntervalSince1970: 700) 61 ) 62 63 let maybeURL = try EngagementHost.socketURL( 64 room: room, 65 authorID: "alice", 66 deviceID: "deviceA", 67 baseURL: URL(string: "wss://example.org") 68 ) 69 let url = try #require(maybeURL) 70 71 #expect(url.scheme == "wss") 72 #expect(url.host() == "example.org") 73 #expect(url.path == "/rooms/23232323-2323-2323-2323-232323232323/socket") 74 let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems 75 #expect(queryItems?.contains(URLQueryItem(name: "authorID", value: "alice")) == true) 76 // The room secret must never travel in the URL; the worker verifies 77 // the signature against the secret registered at room registration. 78 #expect(queryItems?.contains { $0.name == "secret" } == false) 79 #expect(queryItems?.contains { $0.name == "signature" } == true) 80 } 81 82 @Test("registration request posts the secret in the body over HTTPS") 83 @MainActor 84 func registrationRequest() throws { 85 let room = EngagementRoomCredentials( 86 roomID: UUID(uuidString: "24242424-2424-2424-2424-242424242424")!, 87 secret: Data(repeating: 4, count: 32).base64URLEncodedString(), 88 createdAt: Date(timeIntervalSince1970: 100), 89 expiresAt: Date(timeIntervalSince1970: 700) 90 ) 91 92 let maybeRequest = try EngagementHost.registrationRequest( 93 room: room, 94 baseURL: URL(string: "wss://example.org") 95 ) 96 let request = try #require(maybeRequest) 97 98 #expect(request.url?.scheme == "https") 99 #expect(request.url?.host() == "example.org") 100 #expect(request.url?.path == "/rooms/24242424-2424-2424-2424-242424242424/register") 101 #expect(request.url?.query() == nil) 102 #expect(request.httpMethod == "POST") 103 let body = try JSONDecoder().decode([String: String].self, from: try #require(request.httpBody)) 104 #expect(body == ["secret": room.secret]) 105 } 106 107 @Test("debug message envelope round trips") 108 func debugMessageRoundTrip() throws { 109 let sentAt = Date(timeIntervalSince1970: 123) 110 let message = EngagementMessage(text: "hello", sentAt: sentAt) 111 112 let decoded = try #require(EngagementMessage.decode(try message.encodedData())) 113 114 #expect(decoded.kind == .debugText) 115 #expect(decoded.text == "hello") 116 #expect(decoded.sentAt == sentAt) 117 #expect(decoded.ver == 1) 118 } 119 120 @Test("cell edit message envelope round trips") 121 func cellEditMessageRoundTrip() throws { 122 let edit = RealtimeCellEdit( 123 gameID: UUID(uuidString: "12121212-1212-1212-1212-121212121212")!, 124 authorID: "alice", 125 deviceID: "deviceA", 126 row: 1, 127 col: 2, 128 letter: "Z", 129 mark: .pencil(checked: nil), 130 updatedAt: Date(timeIntervalSince1970: 456), 131 cellAuthorID: "alice" 132 ) 133 let message = EngagementMessage(cellEdit: edit, sentAt: Date(timeIntervalSince1970: 789)) 134 135 let decoded = try #require(EngagementMessage.decode(try message.encodedData())) 136 137 #expect(decoded.kind == .cellEdit) 138 #expect(decoded.text == "") 139 #expect(decoded.cellEdit == edit) 140 #expect(decoded.sentAt == Date(timeIntervalSince1970: 789)) 141 } 142 143 @Test("an oversized frame is rejected before JSON decoding") 144 func oversizedFrameIsRejected() { 145 #expect(EngagementMessage.decode(Data(count: EngagementMessage.maxEncodedFrameBytes + 1)) == nil) 146 } 147 148 @Test("a boundary-size edit batch round trips and an over-count batch is rejected") 149 func batchEditCountIsBounded() throws { 150 let atCap = EngagementMessage(cellEdits: makeEdits(count: EngagementMessage.maxBatchEdits)) 151 let atCapData = try atCap.encodedData() 152 #expect(atCapData.count <= EngagementMessage.maxEncodedFrameBytes) 153 #expect(EngagementMessage.decode(atCapData)?.cellEdits?.count == EngagementMessage.maxBatchEdits) 154 155 let overCap = EngagementMessage(cellEdits: makeEdits(count: EngagementMessage.maxBatchEdits + 1)) 156 #expect(EngagementMessage.decode(try overCap.encodedData()) == nil) 157 } 158 159 @Test("frames with oversized strings or auth maps are rejected") 160 func stringAndAuthBoundsAreEnforced() throws { 161 func rejected(_ message: EngagementMessage) throws -> Bool { 162 try EngagementMessage.decode(message.encodedData()) == nil 163 } 164 165 var longLetter = makeEdits(count: 1)[0] 166 longLetter.letter = String(repeating: "A", count: EngagementMessage.maxLetterLength + 1) 167 #expect(try rejected(EngagementMessage(cellEdit: longLetter))) 168 169 var longAuthor = makeEdits(count: 1)[0] 170 longAuthor.authorID = String(repeating: "a", count: EngagementMessage.maxIdentifierLength + 1) 171 #expect(try rejected(EngagementMessage(cellEdit: longAuthor))) 172 173 let longText = String(repeating: "x", count: EngagementMessage.maxTextLength + 1) 174 #expect(try rejected(EngagementMessage(text: longText))) 175 176 let manyTags = Dictionary(uniqueKeysWithValues: (0...EngagementMessage.maxAuthTags).map { 177 ("author-\($0)", "tag") 178 }) 179 #expect(try rejected(EngagementMessage(text: "hi", auth: manyTags))) 180 181 let longTag = ["bob": String(repeating: "t", count: EngagementMessage.maxIdentifierLength + 1)] 182 #expect(try rejected(EngagementMessage(text: "hi", auth: longTag))) 183 } 184 185 @Test("bulk gestures chunk at the batch cap with order preserved") 186 func batchChunking() { 187 let edits = makeEdits(count: EngagementMessage.maxBatchEdits * 2 + 5) 188 let chunks = EngagementMessage.batchChunks(edits) 189 190 #expect(chunks.map(\.count) == [EngagementMessage.maxBatchEdits, EngagementMessage.maxBatchEdits, 5]) 191 #expect(chunks.flatMap { $0 } == edits) 192 #expect(EngagementMessage.batchChunks([]).isEmpty) 193 194 let small = makeEdits(count: 3) 195 #expect(EngagementMessage.batchChunks(small) == [small]) 196 } 197 198 @Test("reconcile connects to the advertised room when a peer is present") 199 @MainActor 200 func reconcileConnectsToCreds() async throws { 201 let gameID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! 202 let host = MockEngagementHost() 203 let coordinator = makeCoordinator(host: host) 204 let room = roomCredentials() 205 206 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 207 208 #expect(host.connections.count == 1) 209 #expect(host.connections.first?.room == room) 210 #expect(host.connections.first?.authorID == "alice") 211 #expect(host.connections.first?.deviceID == "deviceA") 212 } 213 214 @Test("reconcile does not connect without a present peer or without creds") 215 @MainActor 216 func reconcileSkipsWithoutPeerOrCreds() async throws { 217 let gameID = UUID(uuidString: "34343434-3434-3434-3434-343434343434")! 218 let host = MockEngagementHost() 219 let coordinator = makeCoordinator(host: host) 220 let room = roomCredentials() 221 222 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: false) 223 await coordinator.reconcile(gameID: gameID, creds: nil, hasPeer: true) 224 225 #expect(host.connections.isEmpty) 226 } 227 228 @Test("send is skipped until the channel opens") 229 @MainActor 230 func sendIsSkippedUntilChannelOpens() async throws { 231 let gameID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")! 232 let host = MockEngagementHost() 233 let coordinator = makeCoordinator(host: host) 234 235 await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) 236 let engagementID = try #require(host.connections.first?.engagementID) 237 238 await coordinator.sendDebugMessage(gameID: gameID, text: "too early") 239 #expect(host.sentMessages.isEmpty) 240 241 #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID) 242 await coordinator.sendDebugMessage(gameID: gameID, text: "now ok") 243 #expect(host.sentMessages.count == 1) 244 #expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.text == "now ok") 245 } 246 247 @Test("a channel resolves to its game while connecting and live, and not after close") 248 @MainActor 249 func channelGameBinding() async throws { 250 let gameID = UUID(uuidString: "56565656-5656-5656-5656-565656565656")! 251 let host = MockEngagementHost() 252 let coordinator = makeCoordinator(host: host) 253 254 #expect(await coordinator.gameID(for: UUID()) == nil) 255 256 await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) 257 let engagementID = try #require(host.connections.first?.engagementID) 258 #expect(await coordinator.gameID(for: engagementID) == gameID) 259 260 #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID) 261 #expect(await coordinator.gameID(for: engagementID) == gameID) 262 263 #expect(await coordinator.channelClosed(engagementID: engagementID) == gameID) 264 #expect(await coordinator.gameID(for: engagementID) == nil) 265 } 266 267 @Test("reconcile migrates to a rotated room and drops the old connection") 268 @MainActor 269 func reconcileMigratesWhenCredsChange() async throws { 270 let gameID = UUID(uuidString: "66666666-6666-6666-6666-666666666666")! 271 let host = MockEngagementHost() 272 let coordinator = makeCoordinator(host: host) 273 let roomA = roomCredentials(roomID: UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")!) 274 let roomB = roomCredentials(roomID: UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!) 275 276 await coordinator.reconcile(gameID: gameID, creds: roomA, hasPeer: true) 277 let engagementA = try #require(host.connections.first?.engagementID) 278 #expect(await coordinator.channelOpened(engagementID: engagementA) == gameID) 279 280 await coordinator.reconcile(gameID: gameID, creds: roomB, hasPeer: true) 281 282 #expect(host.connections.count == 2) 283 #expect(host.connections.last?.room == roomB) 284 #expect(host.disconnected == [engagementA]) 285 } 286 287 @Test("reconcile leaves an unchanged room alone") 288 @MainActor 289 func reconcileNoOpWhenAlreadyConnected() async throws { 290 let gameID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")! 291 let host = MockEngagementHost() 292 let coordinator = makeCoordinator(host: host) 293 let room = roomCredentials() 294 295 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 296 let engagementID = try #require(host.connections.first?.engagementID) 297 #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID) 298 299 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 300 301 #expect(host.connections.count == 1) 302 #expect(host.disconnected.isEmpty) 303 } 304 305 @Test("reconcile tears down a live channel when the peer leaves") 306 @MainActor 307 func reconcileTearsDownWhenPeerLeaves() async throws { 308 let gameID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")! 309 let host = MockEngagementHost() 310 let coordinator = makeCoordinator(host: host) 311 let room = roomCredentials() 312 313 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 314 let engagementID = try #require(host.connections.first?.engagementID) 315 #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID) 316 317 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: false) 318 319 #expect(host.disconnected == [engagementID]) 320 } 321 322 @Test("reconcile surfaces a room rejected by the worker") 323 @MainActor 324 func reconcileSurfacesRoomRejection() async throws { 325 let gameID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! 326 let host = MockEngagementHost() 327 host.connectError = EngagementHostError.roomSecretMismatch 328 let coordinator = makeCoordinator(host: host) 329 330 let outcome = await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) 331 332 #expect(outcome == .roomRejected) 333 #expect(host.connections.isEmpty) 334 } 335 336 @Test("transient connect failures do not report a rejected room") 337 @MainActor 338 func transientConnectFailureIsNotRejection() async throws { 339 let gameID = UUID(uuidString: "45454545-4545-4545-4545-454545454545")! 340 let host = MockEngagementHost() 341 host.connectError = EngagementHostError.registrationFailed(statusCode: 503) 342 let coordinator = makeCoordinator(host: host) 343 344 let outcome = await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) 345 346 #expect(outcome == .reconciled) 347 #expect(host.connections.isEmpty) 348 } 349 350 @Test("a stale connecting attempt is swept and retried on the next reconcile") 351 @MainActor 352 func staleConnectingDemotesOnReconcile() async throws { 353 let gameID = UUID(uuidString: "99999999-9999-9999-9999-999999999999")! 354 let host = MockEngagementHost() 355 let clock = TestClock(time: Date(timeIntervalSince1970: 10_000)) 356 let coordinator = makeCoordinator(host: host, now: { clock.now }, connectionTimeout: 30) 357 let room = roomCredentials() 358 359 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 360 let firstEngagementID = try #require(host.connections.first?.engagementID) 361 362 clock.advance(by: 31) 363 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 364 365 #expect(host.disconnected == [firstEngagementID]) 366 #expect(host.connections.count == 2) 367 } 368 369 @MainActor 370 private func makeCoordinator( 371 host: MockEngagementHost, 372 now: @escaping @Sendable () -> Date = Date.init, 373 connectionTimeout: TimeInterval = 30 374 ) -> EngagementCoordinator { 375 EngagementCoordinator( 376 host: host, 377 localAuthorID: { "alice" }, 378 localDeviceID: "deviceA", 379 now: now, 380 connectionTimeout: connectionTimeout 381 ) 382 } 383 384 private func makeEdits(count: Int) -> [RealtimeCellEdit] { 385 (0..<count).map { index in 386 RealtimeCellEdit( 387 gameID: UUID(uuidString: "12121212-1212-1212-1212-121212121212")!, 388 authorID: "alice", 389 deviceID: "deviceA", 390 row: index / 128, 391 col: index % 128, 392 letter: "A", 393 mark: .pen(checked: nil), 394 updatedAt: Date(timeIntervalSince1970: 456), 395 cellAuthorID: "alice" 396 ) 397 } 398 } 399 400 private func roomCredentials( 401 roomID: UUID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")!, 402 expiresAt: Date = .distantFuture 403 ) -> EngagementRoomCredentials { 404 EngagementRoomCredentials( 405 roomID: roomID, 406 secret: Data(repeating: 3, count: 32).base64URLEncodedString(), 407 createdAt: Date(timeIntervalSince1970: 1_000), 408 expiresAt: expiresAt 409 ) 410 } 411 } 412 413 private final class TestClock: @unchecked Sendable { 414 private let lock = NSLock() 415 private var current: Date 416 417 init(time: Date) { 418 self.current = time 419 } 420 421 var now: Date { 422 lock.lock() 423 defer { lock.unlock() } 424 return current 425 } 426 427 func advance(by seconds: TimeInterval) { 428 lock.lock() 429 defer { lock.unlock() } 430 current = current.addingTimeInterval(seconds) 431 } 432 } 433 434 @MainActor 435 private final class MockEngagementHost: EngagementTransporting, @unchecked Sendable { 436 struct Connection: Equatable { 437 var engagementID: UUID 438 var room: EngagementRoomCredentials 439 var authorID: String 440 var deviceID: String 441 } 442 443 var connections: [Connection] = [] 444 var disconnected: [UUID] = [] 445 var sentMessages: [(engagementID: UUID, message: Data)] = [] 446 var connectError: Error? 447 448 func connect( 449 engagementID: UUID, 450 room: EngagementRoomCredentials, 451 authorID: String, 452 deviceID: String 453 ) async throws { 454 if let connectError { 455 throw connectError 456 } 457 connections.append(Connection( 458 engagementID: engagementID, 459 room: room, 460 authorID: authorID, 461 deviceID: deviceID 462 )) 463 } 464 465 func send(engagementID: UUID, message: Data) async throws { 466 sentMessages.append((engagementID, message)) 467 } 468 469 func disconnect(engagementID: UUID) { 470 disconnected.append(engagementID) 471 } 472 }