EngagementCoordinatorTests.swift (15010B)
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("reconcile connects to the advertised room when a peer is present") 144 @MainActor 145 func reconcileConnectsToCreds() async throws { 146 let gameID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! 147 let host = MockEngagementHost() 148 let coordinator = makeCoordinator(host: host) 149 let room = roomCredentials() 150 151 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 152 153 #expect(host.connections.count == 1) 154 #expect(host.connections.first?.room == room) 155 #expect(host.connections.first?.authorID == "alice") 156 #expect(host.connections.first?.deviceID == "deviceA") 157 } 158 159 @Test("reconcile does not connect without a present peer or without creds") 160 @MainActor 161 func reconcileSkipsWithoutPeerOrCreds() async throws { 162 let gameID = UUID(uuidString: "34343434-3434-3434-3434-343434343434")! 163 let host = MockEngagementHost() 164 let coordinator = makeCoordinator(host: host) 165 let room = roomCredentials() 166 167 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: false) 168 await coordinator.reconcile(gameID: gameID, creds: nil, hasPeer: true) 169 170 #expect(host.connections.isEmpty) 171 } 172 173 @Test("send is skipped until the channel opens") 174 @MainActor 175 func sendIsSkippedUntilChannelOpens() async throws { 176 let gameID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")! 177 let host = MockEngagementHost() 178 let coordinator = makeCoordinator(host: host) 179 180 await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) 181 let engagementID = try #require(host.connections.first?.engagementID) 182 183 await coordinator.sendDebugMessage(gameID: gameID, text: "too early") 184 #expect(host.sentMessages.isEmpty) 185 186 #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID) 187 await coordinator.sendDebugMessage(gameID: gameID, text: "now ok") 188 #expect(host.sentMessages.count == 1) 189 #expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.text == "now ok") 190 } 191 192 @Test("reconcile migrates to a rotated room and drops the old connection") 193 @MainActor 194 func reconcileMigratesWhenCredsChange() async throws { 195 let gameID = UUID(uuidString: "66666666-6666-6666-6666-666666666666")! 196 let host = MockEngagementHost() 197 let coordinator = makeCoordinator(host: host) 198 let roomA = roomCredentials(roomID: UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")!) 199 let roomB = roomCredentials(roomID: UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!) 200 201 await coordinator.reconcile(gameID: gameID, creds: roomA, hasPeer: true) 202 let engagementA = try #require(host.connections.first?.engagementID) 203 #expect(await coordinator.channelOpened(engagementID: engagementA) == gameID) 204 205 await coordinator.reconcile(gameID: gameID, creds: roomB, hasPeer: true) 206 207 #expect(host.connections.count == 2) 208 #expect(host.connections.last?.room == roomB) 209 #expect(host.disconnected == [engagementA]) 210 } 211 212 @Test("reconcile leaves an unchanged room alone") 213 @MainActor 214 func reconcileNoOpWhenAlreadyConnected() async throws { 215 let gameID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")! 216 let host = MockEngagementHost() 217 let coordinator = makeCoordinator(host: host) 218 let room = roomCredentials() 219 220 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 221 let engagementID = try #require(host.connections.first?.engagementID) 222 #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID) 223 224 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 225 226 #expect(host.connections.count == 1) 227 #expect(host.disconnected.isEmpty) 228 } 229 230 @Test("reconcile tears down a live channel when the peer leaves") 231 @MainActor 232 func reconcileTearsDownWhenPeerLeaves() async throws { 233 let gameID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")! 234 let host = MockEngagementHost() 235 let coordinator = makeCoordinator(host: host) 236 let room = roomCredentials() 237 238 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 239 let engagementID = try #require(host.connections.first?.engagementID) 240 #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID) 241 242 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: false) 243 244 #expect(host.disconnected == [engagementID]) 245 } 246 247 @Test("reconcile surfaces a room rejected by the worker") 248 @MainActor 249 func reconcileSurfacesRoomRejection() async throws { 250 let gameID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! 251 let host = MockEngagementHost() 252 host.connectError = EngagementHostError.roomSecretMismatch 253 let coordinator = makeCoordinator(host: host) 254 255 let outcome = await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) 256 257 #expect(outcome == .roomRejected) 258 #expect(host.connections.isEmpty) 259 } 260 261 @Test("transient connect failures do not report a rejected room") 262 @MainActor 263 func transientConnectFailureIsNotRejection() async throws { 264 let gameID = UUID(uuidString: "45454545-4545-4545-4545-454545454545")! 265 let host = MockEngagementHost() 266 host.connectError = EngagementHostError.registrationFailed(statusCode: 503) 267 let coordinator = makeCoordinator(host: host) 268 269 let outcome = await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) 270 271 #expect(outcome == .reconciled) 272 #expect(host.connections.isEmpty) 273 } 274 275 @Test("a stale connecting attempt is swept and retried on the next reconcile") 276 @MainActor 277 func staleConnectingDemotesOnReconcile() async throws { 278 let gameID = UUID(uuidString: "99999999-9999-9999-9999-999999999999")! 279 let host = MockEngagementHost() 280 let clock = TestClock(time: Date(timeIntervalSince1970: 10_000)) 281 let coordinator = makeCoordinator(host: host, now: { clock.now }, connectionTimeout: 30) 282 let room = roomCredentials() 283 284 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 285 let firstEngagementID = try #require(host.connections.first?.engagementID) 286 287 clock.advance(by: 31) 288 await coordinator.reconcile(gameID: gameID, creds: room, hasPeer: true) 289 290 #expect(host.disconnected == [firstEngagementID]) 291 #expect(host.connections.count == 2) 292 } 293 294 @MainActor 295 private func makeCoordinator( 296 host: MockEngagementHost, 297 now: @escaping @Sendable () -> Date = Date.init, 298 connectionTimeout: TimeInterval = 30 299 ) -> EngagementCoordinator { 300 EngagementCoordinator( 301 host: host, 302 localAuthorID: { "alice" }, 303 localDeviceID: "deviceA", 304 now: now, 305 connectionTimeout: connectionTimeout 306 ) 307 } 308 309 private func roomCredentials( 310 roomID: UUID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")!, 311 expiresAt: Date = .distantFuture 312 ) -> EngagementRoomCredentials { 313 EngagementRoomCredentials( 314 roomID: roomID, 315 secret: Data(repeating: 3, count: 32).base64URLEncodedString(), 316 createdAt: Date(timeIntervalSince1970: 1_000), 317 expiresAt: expiresAt 318 ) 319 } 320 } 321 322 private final class TestClock: @unchecked Sendable { 323 private let lock = NSLock() 324 private var current: Date 325 326 init(time: Date) { 327 self.current = time 328 } 329 330 var now: Date { 331 lock.lock() 332 defer { lock.unlock() } 333 return current 334 } 335 336 func advance(by seconds: TimeInterval) { 337 lock.lock() 338 defer { lock.unlock() } 339 current = current.addingTimeInterval(seconds) 340 } 341 } 342 343 @MainActor 344 private final class MockEngagementHost: EngagementTransporting, @unchecked Sendable { 345 struct Connection: Equatable { 346 var engagementID: UUID 347 var room: EngagementRoomCredentials 348 var authorID: String 349 var deviceID: String 350 } 351 352 var connections: [Connection] = [] 353 var disconnected: [UUID] = [] 354 var sentMessages: [(engagementID: UUID, message: Data)] = [] 355 var connectError: Error? 356 357 func connect( 358 engagementID: UUID, 359 room: EngagementRoomCredentials, 360 authorID: String, 361 deviceID: String 362 ) async throws { 363 if let connectError { 364 throw connectError 365 } 366 connections.append(Connection( 367 engagementID: engagementID, 368 room: room, 369 authorID: authorID, 370 deviceID: deviceID 371 )) 372 } 373 374 func send(engagementID: UUID, message: Data) async throws { 375 sentMessages.append((engagementID, message)) 376 } 377 378 func disconnect(engagementID: UUID) { 379 disconnected.append(engagementID) 380 } 381 }