diff --git a/Sources/APNSTestServer/APNSTestServer.swift b/Sources/APNSTestServer/APNSTestServer.swift index 930dd8b..5c20fc0 100644 --- a/Sources/APNSTestServer/APNSTestServer.swift +++ b/Sources/APNSTestServer/APNSTestServer.swift @@ -75,6 +75,7 @@ public final class APNSTestServer: @unchecked Sendable { public let collapseID: String? public let apnsID: UUID public let payload: Data + public let authorization: String? public func decodedPayload(as type: T.Type) throws -> T { try JSONDecoder().decode(type, from: payload) @@ -783,6 +784,7 @@ public final class APNSTestServer: @unchecked Sendable { let priority = headers.first(name: "apns-priority") let expiration = headers.first(name: "apns-expiration") let collapseID = headers.first(name: "apns-collapse-id") + let authorization = headers.first(name: "authorization") // Store the notification let notification = SentNotification( @@ -793,7 +795,8 @@ public final class APNSTestServer: @unchecked Sendable { expiration: expiration, collapseID: collapseID, apnsID: apnsID, - payload: payload + payload: payload, + authorization: authorization ) sentNotificationsBox.withLockedValue { $0.append(notification) } diff --git a/Sources/APNSURLSession/APNSURLSessionClientConfiguration.swift b/Sources/APNSURLSession/APNSURLSessionClientConfiguration.swift index 501a6cc..6169b25 100644 --- a/Sources/APNSURLSession/APNSURLSessionClientConfiguration.swift +++ b/Sources/APNSURLSession/APNSURLSessionClientConfiguration.swift @@ -22,16 +22,18 @@ public struct APNSURLSessionClientConfiguration { case jwt(privateKey: P256.Signing.PrivateKey, teamIdentifier: String, keyIdentifier: String) } - /// The authentication method used by the ``APNSURLSessionClient``. - public var authenticationMethod: AuthenticationMethod - /// The environment used by the ``APNSURLSessionClient``. public var environment: APNSEnvironment - - private let authenticationTokenManager: APNSAuthenticationTokenManager - + + /// Type-erased access to the generic ``APNSAuthenticationTokenManager``. + /// + /// The token manager is generic over its ``Clock``, but this configuration is not, so the + /// concrete, caller-injected clock is captured in this closure at ``init`` time rather than + /// stored as a typed property. + private let nextValidTokenClosure: @Sendable () async throws -> String + internal func nextValidToken() async throws -> String { - try await authenticationTokenManager.nextValidToken + try await nextValidTokenClosure() } /// Initializes a new ``APNSClient.Configuration``. @@ -41,22 +43,25 @@ public struct APNSURLSessionClientConfiguration { /// - privateKey: The private encryption key obtained through the developer portal. /// - keyIdentifier: The private encryption key identifier obtained through the developer portal. /// - teamIdentifier: The team id. - public init( + /// - clock: The clock used to determine when a generated authentication token has expired. + public init( environment: APNSEnvironment, privateKey: P256.Signing.PrivateKey, keyIdentifier: String, teamIdentifier: String, - clock: any Clock = ContinuousClock() - ) { - self.authenticationMethod = .jwt(privateKey: privateKey, teamIdentifier: teamIdentifier, keyIdentifier: keyIdentifier) + clock: APNSClock = ContinuousClock() + ) where APNSClock.Duration == Duration { self.environment = environment - - self.authenticationTokenManager = APNSAuthenticationTokenManager( + + let authenticationTokenManager = APNSAuthenticationTokenManager( privateKey: privateKey, teamIdentifier: teamIdentifier, keyIdentifier: keyIdentifier, - clock: ContinuousClock() + clock: clock ) + self.nextValidTokenClosure = { + try await authenticationTokenManager.nextValidToken + } } } diff --git a/Sources/APNSURLSession/APNSUrlSessionClient.swift b/Sources/APNSURLSession/APNSUrlSessionClient.swift index b7adf3a..7e2e39c 100644 --- a/Sources/APNSURLSession/APNSUrlSessionClient.swift +++ b/Sources/APNSURLSession/APNSUrlSessionClient.swift @@ -9,12 +9,16 @@ enum APNSUrlSessionClientError: Error { public struct APNSURLSessionClient: APNSClientProtocol { private let configuration: APNSURLSessionClientConfiguration - + + /// The `URLSession` used to make requests to APNs. + let session: URLSession + let encoder = JSONEncoder() let decoder = JSONDecoder() - - public init(configuration: APNSURLSessionClientConfiguration) { + + public init(configuration: APNSURLSessionClientConfiguration, session: URLSession = .shared) { self.configuration = configuration + self.session = session } public func send( @@ -26,6 +30,7 @@ public struct APNSURLSessionClient: APNSClientProtocol { urlRequest.httpMethod = "POST" /// Set headers urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("APNS/swift-urlsession", forHTTPHeaderField: "user-agent") for (header, value) in request.headers { urlRequest.setValue(value, forHTTPHeaderField: header) } @@ -36,7 +41,7 @@ public struct APNSURLSessionClient: APNSClientProtocol { urlRequest.httpBody = try encoder.encode(request.message) /// Make request - let (data, response) = try await URLSession.shared.data(for: urlRequest) + let (data, response) = try await session.data(for: urlRequest) /// Unwrap response guard let response = response as? HTTPURLResponse else { diff --git a/Tests/APNSTests/APNSURLSessionClientTests.swift b/Tests/APNSTests/APNSURLSessionClientTests.swift index c500cbe..21f67cc 100644 --- a/Tests/APNSTests/APNSURLSessionClientTests.swift +++ b/Tests/APNSTests/APNSURLSessionClientTests.swift @@ -104,6 +104,102 @@ final class APNSURLSessionClientTests: XCTestCase { } } + func testSendAlert_propagatesAllHeaders() async throws { + let apnsID = UUID() + var alert = APNSAlertNotification( + alert: .init(title: .raw("title")), + expiration: .immediately, + priority: .immediately, + topic: "com.example.app", + payload: EmptyPayload(), + apnsID: apnsID + ) + alert.collapseID = "collapse-123" + + _ = try await client.sendAlertNotification(alert, deviceToken: Self.validDeviceToken) + + let sent = try XCTUnwrap(server.getSentNotifications().first) + XCTAssertEqual(sent.deviceToken, Self.validDeviceToken) + XCTAssertEqual(sent.pushType, "alert") + XCTAssertEqual(sent.topic, "com.example.app") + XCTAssertEqual(sent.priority, "10") + XCTAssertEqual(sent.expiration, "0") + XCTAssertEqual(sent.collapseID, "collapse-123") + XCTAssertEqual(sent.apnsID, apnsID) + } + + func testSendAlert_unregisteredCarriesTimestamp() async throws { + do { + _ = try await client.sendAlertNotification( + Self.makeAlert(), + deviceToken: APNSTestServer.unregisteredDeviceToken + ) + XCTFail("Expected an APNSError to be thrown") + } catch let error as APNSError { + XCTAssertEqual(error.responseStatus, 410) + XCTAssertEqual(error.reason, .unregistered) + let timestamp = try XCTUnwrap(error.timestamp) + XCTAssertEqual( + timestamp.timeIntervalSince1970, + Double(APNSTestServer.unregisteredTimestampMilliseconds) / 1000, + accuracy: 0.001 + ) + } + } + + func testInjectedClockDrivesTokenRefresh() async throws { + let clock = TestClock() + let testServer = server! + let clockedClient = APNSURLSessionClient( + configuration: .init( + environment: .custom(url: "http://127.0.0.1", port: testServer.port), + privateKey: try P256.Signing.PrivateKey(pemRepresentation: Self.jwtPrivateKey), + keyIdentifier: "MY_KEY_ID", + teamIdentifier: "MY_TEAM_ID", + clock: clock + ) + ) + + _ = try await clockedClient.sendAlertNotification( + Self.makeAlert(), + deviceToken: Self.validDeviceToken + ) + + // Advance past the manager's 55 minute refresh window so a new token must be minted. + clock.now = clock.now.advanced(by: .init(secondsComponent: 3360, attosecondsComponent: 0)) + + _ = try await clockedClient.sendAlertNotification( + Self.makeAlert(), + deviceToken: Self.validDeviceToken + ) + + let sent = testServer.getSentNotifications() + XCTAssertEqual(sent.count, 2) + let firstAuthorization = try XCTUnwrap(sent[0].authorization) + let secondAuthorization = try XCTUnwrap(sent[1].authorization) + XCTAssertNotEqual(firstAuthorization, secondAuthorization) + } + + func testSendAlert_customSessionIsUsed() async throws { + let customSession = URLSession(configuration: .ephemeral) + let customClient = APNSURLSessionClient( + configuration: .init( + environment: .custom(url: "http://127.0.0.1", port: server.port), + privateKey: try P256.Signing.PrivateKey(pemRepresentation: Self.jwtPrivateKey), + keyIdentifier: "MY_KEY_ID", + teamIdentifier: "MY_TEAM_ID" + ), + session: customSession + ) + + let response = try await customClient.sendAlertNotification( + Self.makeAlert(), + deviceToken: Self.validDeviceToken + ) + + XCTAssertNotNil(response.apnsID) + } + // MARK: - Helpers private static let validDeviceToken = String(repeating: "a", count: 64)