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 }