crossmate

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

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 }