Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Sources/APNSTestServer/APNSTestServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Decodable>(as type: T.Type) throws -> T {
try JSONDecoder().decode(type, from: payload)
Expand Down Expand Up @@ -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(
Expand All @@ -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) }

Expand Down
33 changes: 19 additions & 14 deletions Sources/APNSURLSession/APNSURLSessionClientConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContinuousClock>


/// 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``.
Expand All @@ -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<APNSClock: Clock>(
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
}
}
}

13 changes: 9 additions & 4 deletions Sources/APNSURLSession/APNSUrlSessionClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
96 changes: 96 additions & 0 deletions Tests/APNSTests/APNSURLSessionClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duration>()
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)
Expand Down