crossmate

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

PushClientTests.swift (8623B)


      1 import CryptoKit
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 @Suite("PushClient registration", .serialized)
      8 @MainActor
      9 struct PushClientTests {
     10     @Test("failed unregister is retried on the next reconcile")
     11     func failedUnregisterRetries() async throws {
     12         PushClientURLProtocol.reset()
     13         PushClientURLProtocol.setHandler { request, deleteAttempt in
     14             let status: Int
     15             if request.httpMethod == "DELETE" {
     16                 status = deleteAttempt == 1 ? 500 : 204
     17             } else {
     18                 status = 204
     19             }
     20             return HTTPURLResponse(
     21                 url: request.url!,
     22                 statusCode: status,
     23                 httpVersion: nil,
     24                 headerFields: nil
     25             )!
     26         }
     27         defer { PushClientURLProtocol.reset() }
     28 
     29         let client = makeClient()
     30         let old = PushAddressBinding(address: "old")
     31         let new = PushAddressBinding(address: "new")
     32 
     33         client.testingSetAPNsToken(Data([0x01, 0x02, 0x03]))
     34         client.testingSetAddresses([old])
     35         await client.testingReconcile()
     36         PushClientURLProtocol.resetRequests()
     37 
     38         client.testingSetAddresses([new])
     39         await client.testingReconcile()
     40 
     41         #expect(PushClientURLProtocol.deleteAttempts == 1)
     42 
     43         PushClientURLProtocol.resetRequests()
     44         await client.testingReconcile()
     45 
     46         #expect(PushClientURLProtocol.deleteAttempts == 1)
     47     }
     48 
     49     @Test("successful unregister advances registration state")
     50     func successfulUnregisterAdvancesState() async throws {
     51         PushClientURLProtocol.reset()
     52         PushClientURLProtocol.setHandler { request, _ in
     53             HTTPURLResponse(
     54                 url: request.url!,
     55                 statusCode: 204,
     56                 httpVersion: nil,
     57                 headerFields: nil
     58             )!
     59         }
     60         defer { PushClientURLProtocol.reset() }
     61 
     62         let client = makeClient()
     63         let old = PushAddressBinding(address: "old")
     64         let new = PushAddressBinding(address: "new")
     65 
     66         client.testingSetAPNsToken(Data([0x01, 0x02, 0x03]))
     67         client.testingSetAddresses([old])
     68         await client.testingReconcile()
     69         PushClientURLProtocol.resetRequests()
     70 
     71         client.testingSetAddresses([new])
     72         await client.testingReconcile()
     73 
     74         #expect(PushClientURLProtocol.deleteAttempts == 1)
     75 
     76         PushClientURLProtocol.resetRequests()
     77         await client.testingReconcile()
     78 
     79         #expect(PushClientURLProtocol.deleteAttempts == 0)
     80     }
     81 
     82     @Test("broadcast payload includes sender name and injected puzzle title")
     83     func broadcastPayloadCarriesSenderName() async throws {
     84         PushClientURLProtocol.reset()
     85         PushClientURLProtocol.setHandler { request, _ in
     86             let status = request.url?.path == "/publish" ? 200 : 204
     87             return HTTPURLResponse(
     88                 url: request.url!,
     89                 statusCode: status,
     90                 httpVersion: nil,
     91                 headerFields: nil
     92             )!
     93         }
     94         defer { PushClientURLProtocol.reset() }
     95 
     96         let gameID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
     97         let contentKey = SymmetricKey(data: Data(repeating: 7, count: 32))
     98         let credentials = GamePushCredentials(
     99             credID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
    100             secret: Data(repeating: 3, count: 32).base64URLEncodedString()
    101         )
    102         let client = makeClient()
    103         client.gameCredentialResolver = { _ in credentials }
    104         client.contentKeyResolver = { _ in contentKey }
    105 
    106         await client.publish(
    107             kind: "nudge",
    108             gameID: gameID,
    109             addressees: [],
    110             title: "Crossmate",
    111             puzzleTitle: "Saturday Puzzle",
    112             broadcast: true,
    113             broadcastPayload: PushPayload(event: .nudge, playerName: "Alice"),
    114             body: PuzzleNotificationText.nudgeBody(
    115                 playerName: "Alice",
    116                 puzzleTitle: "Saturday Puzzle"
    117             )
    118         )
    119 
    120         let publishRequest = try #require(PushClientURLProtocol.requests.last { $0.url?.path == "/publish" })
    121         let body = try #require(publishRequest.body)
    122         let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
    123         #expect(json["alertBody"] as? String == PushClient.genericAlertBody)
    124 
    125         let encrypted = try #require(json["enc"] as? String)
    126         let payload = try #require(PushPayloadCipher.open(encrypted, key: contentKey))
    127         #expect(payload == PushPayload(
    128             event: .nudge,
    129             puzzleTitle: "Saturday Puzzle",
    130             playerName: "Alice"
    131         ))
    132     }
    133 
    134     private func makeClient() -> PushClient {
    135         let config = URLSessionConfiguration.ephemeral
    136         config.protocolClasses = [PushClientURLProtocol.self]
    137         let session = URLSession(configuration: config)
    138         return PushClient(
    139             baseURL: URL(string: "https://push.test")!,
    140             environment: .sandbox,
    141             deviceID: "device",
    142             session: session,
    143             authorizationHeaders: { _, _, _ in [:] }
    144         )
    145     }
    146 }
    147 
    148 private final class PushClientURLProtocol: URLProtocol {
    149     private static let state = PushClientURLProtocolState()
    150 
    151     static var deleteAttempts: Int {
    152         state.deleteAttempts
    153     }
    154 
    155     static var requests: [PushClientRecordedRequest] {
    156         state.requests
    157     }
    158 
    159     static func reset() {
    160         state.reset()
    161     }
    162 
    163     static func resetRequests() {
    164         state.resetRequests()
    165     }
    166 
    167     static func setHandler(_ handler: @escaping @Sendable (URLRequest, Int) -> HTTPURLResponse) {
    168         state.setHandler(handler)
    169     }
    170 
    171     override class func canInit(with request: URLRequest) -> Bool {
    172         true
    173     }
    174 
    175     override class func canonicalRequest(for request: URLRequest) -> URLRequest {
    176         request
    177     }
    178 
    179     override func startLoading() {
    180         let response = Self.state.response(for: request)
    181         client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
    182         client?.urlProtocol(self, didLoad: Data())
    183         client?.urlProtocolDidFinishLoading(self)
    184     }
    185 
    186     override func stopLoading() {}
    187 }
    188 
    189 private struct PushClientRecordedRequest: Sendable {
    190     let url: URL?
    191     let method: String?
    192     let body: Data?
    193 }
    194 
    195 private final class PushClientURLProtocolState: @unchecked Sendable {
    196     private let lock = NSLock()
    197     private var handler: (@Sendable (URLRequest, Int) -> HTTPURLResponse)?
    198     private var requestDeleteAttempts = 0
    199     private var recordedRequests: [PushClientRecordedRequest] = []
    200 
    201     var deleteAttempts: Int {
    202         lock.lock()
    203         defer { lock.unlock() }
    204         return requestDeleteAttempts
    205     }
    206 
    207     var requests: [PushClientRecordedRequest] {
    208         lock.lock()
    209         defer { lock.unlock() }
    210         return recordedRequests
    211     }
    212 
    213     func reset() {
    214         lock.lock()
    215         handler = nil
    216         requestDeleteAttempts = 0
    217         recordedRequests = []
    218         lock.unlock()
    219     }
    220 
    221     func resetRequests() {
    222         lock.lock()
    223         requestDeleteAttempts = 0
    224         recordedRequests = []
    225         lock.unlock()
    226     }
    227 
    228     func setHandler(_ next: @escaping @Sendable (URLRequest, Int) -> HTTPURLResponse) {
    229         lock.lock()
    230         handler = next
    231         lock.unlock()
    232     }
    233 
    234     func response(for request: URLRequest) -> HTTPURLResponse {
    235         let handler: (@Sendable (URLRequest, Int) -> HTTPURLResponse)?
    236         let deleteAttempt: Int
    237         lock.lock()
    238         recordedRequests.append(PushClientRecordedRequest(
    239             url: request.url,
    240             method: request.httpMethod,
    241             body: request.httpBody ?? request.httpBodyStream?.readAllData()
    242         ))
    243         if request.httpMethod == "DELETE" {
    244             requestDeleteAttempts += 1
    245             deleteAttempt = requestDeleteAttempts
    246         } else {
    247             deleteAttempt = 0
    248         }
    249         handler = self.handler
    250         lock.unlock()
    251 
    252         return handler?(request, deleteAttempt) ?? HTTPURLResponse(
    253             url: request.url!,
    254             statusCode: 204,
    255             httpVersion: nil,
    256             headerFields: nil
    257         )!
    258     }
    259 }
    260 
    261 private extension InputStream {
    262     func readAllData() -> Data {
    263         open()
    264         defer { close() }
    265 
    266         var data = Data()
    267         var buffer = [UInt8](repeating: 0, count: 4096)
    268         while hasBytesAvailable {
    269             let count = read(&buffer, maxLength: buffer.count)
    270             if count <= 0 { break }
    271             data.append(buffer, count: count)
    272         }
    273         return data
    274     }
    275 }