Skip to content

Commit 9e1ffbd

Browse files
authored
Bring APNSURLSessionClient to parity: honor injected clock, session injection, user-agent (#251)
1 parent 1a7136b commit 9e1ffbd

4 files changed

Lines changed: 128 additions & 19 deletions

File tree

Sources/APNSTestServer/APNSTestServer.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public final class APNSTestServer: @unchecked Sendable {
7575
public let collapseID: String?
7676
public let apnsID: UUID
7777
public let payload: Data
78+
public let authorization: String?
7879

7980
public func decodedPayload<T: Decodable>(as type: T.Type) throws -> T {
8081
try JSONDecoder().decode(type, from: payload)
@@ -783,6 +784,7 @@ public final class APNSTestServer: @unchecked Sendable {
783784
let priority = headers.first(name: "apns-priority")
784785
let expiration = headers.first(name: "apns-expiration")
785786
let collapseID = headers.first(name: "apns-collapse-id")
787+
let authorization = headers.first(name: "authorization")
786788

787789
// Store the notification
788790
let notification = SentNotification(
@@ -793,7 +795,8 @@ public final class APNSTestServer: @unchecked Sendable {
793795
expiration: expiration,
794796
collapseID: collapseID,
795797
apnsID: apnsID,
796-
payload: payload
798+
payload: payload,
799+
authorization: authorization
797800
)
798801
sentNotificationsBox.withLockedValue { $0.append(notification) }
799802

Sources/APNSURLSession/APNSURLSessionClientConfiguration.swift

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@ public struct APNSURLSessionClientConfiguration {
2222
case jwt(privateKey: P256.Signing.PrivateKey, teamIdentifier: String, keyIdentifier: String)
2323
}
2424

25-
/// The authentication method used by the ``APNSURLSessionClient``.
26-
public var authenticationMethod: AuthenticationMethod
27-
2825
/// The environment used by the ``APNSURLSessionClient``.
2926
public var environment: APNSEnvironment
30-
31-
private let authenticationTokenManager: APNSAuthenticationTokenManager<ContinuousClock>
32-
27+
28+
/// Type-erased access to the generic ``APNSAuthenticationTokenManager``.
29+
///
30+
/// The token manager is generic over its ``Clock``, but this configuration is not, so the
31+
/// concrete, caller-injected clock is captured in this closure at ``init`` time rather than
32+
/// stored as a typed property.
33+
private let nextValidTokenClosure: @Sendable () async throws -> String
34+
3335
internal func nextValidToken() async throws -> String {
34-
try await authenticationTokenManager.nextValidToken
36+
try await nextValidTokenClosure()
3537
}
3638

3739
/// Initializes a new ``APNSClient.Configuration``.
@@ -41,22 +43,25 @@ public struct APNSURLSessionClientConfiguration {
4143
/// - privateKey: The private encryption key obtained through the developer portal.
4244
/// - keyIdentifier: The private encryption key identifier obtained through the developer portal.
4345
/// - teamIdentifier: The team id.
44-
public init(
46+
/// - clock: The clock used to determine when a generated authentication token has expired.
47+
public init<APNSClock: Clock>(
4548
environment: APNSEnvironment,
4649
privateKey: P256.Signing.PrivateKey,
4750
keyIdentifier: String,
4851
teamIdentifier: String,
49-
clock: any Clock = ContinuousClock()
50-
) {
51-
self.authenticationMethod = .jwt(privateKey: privateKey, teamIdentifier: teamIdentifier, keyIdentifier: keyIdentifier)
52+
clock: APNSClock = ContinuousClock()
53+
) where APNSClock.Duration == Duration {
5254
self.environment = environment
53-
54-
self.authenticationTokenManager = APNSAuthenticationTokenManager(
55+
56+
let authenticationTokenManager = APNSAuthenticationTokenManager(
5557
privateKey: privateKey,
5658
teamIdentifier: teamIdentifier,
5759
keyIdentifier: keyIdentifier,
58-
clock: ContinuousClock()
60+
clock: clock
5961
)
62+
self.nextValidTokenClosure = {
63+
try await authenticationTokenManager.nextValidToken
64+
}
6065
}
6166
}
6267

Sources/APNSURLSession/APNSUrlSessionClient.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ enum APNSUrlSessionClientError: Error {
99
public struct APNSURLSessionClient: APNSClientProtocol {
1010

1111
private let configuration: APNSURLSessionClientConfiguration
12-
12+
13+
/// The `URLSession` used to make requests to APNs.
14+
let session: URLSession
15+
1316
let encoder = JSONEncoder()
1417
let decoder = JSONDecoder()
15-
16-
public init(configuration: APNSURLSessionClientConfiguration) {
18+
19+
public init(configuration: APNSURLSessionClientConfiguration, session: URLSession = .shared) {
1720
self.configuration = configuration
21+
self.session = session
1822
}
1923

2024
public func send(
@@ -26,6 +30,7 @@ public struct APNSURLSessionClient: APNSClientProtocol {
2630
urlRequest.httpMethod = "POST"
2731
/// Set headers
2832
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
33+
urlRequest.setValue("APNS/swift-urlsession", forHTTPHeaderField: "user-agent")
2934
for (header, value) in request.headers {
3035
urlRequest.setValue(value, forHTTPHeaderField: header)
3136
}
@@ -36,7 +41,7 @@ public struct APNSURLSessionClient: APNSClientProtocol {
3641
urlRequest.httpBody = try encoder.encode(request.message)
3742

3843
/// Make request
39-
let (data, response) = try await URLSession.shared.data(for: urlRequest)
44+
let (data, response) = try await session.data(for: urlRequest)
4045

4146
/// Unwrap response
4247
guard let response = response as? HTTPURLResponse else {

Tests/APNSTests/APNSURLSessionClientTests.swift

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,102 @@ final class APNSURLSessionClientTests: XCTestCase {
104104
}
105105
}
106106

107+
func testSendAlert_propagatesAllHeaders() async throws {
108+
let apnsID = UUID()
109+
var alert = APNSAlertNotification(
110+
alert: .init(title: .raw("title")),
111+
expiration: .immediately,
112+
priority: .immediately,
113+
topic: "com.example.app",
114+
payload: EmptyPayload(),
115+
apnsID: apnsID
116+
)
117+
alert.collapseID = "collapse-123"
118+
119+
_ = try await client.sendAlertNotification(alert, deviceToken: Self.validDeviceToken)
120+
121+
let sent = try XCTUnwrap(server.getSentNotifications().first)
122+
XCTAssertEqual(sent.deviceToken, Self.validDeviceToken)
123+
XCTAssertEqual(sent.pushType, "alert")
124+
XCTAssertEqual(sent.topic, "com.example.app")
125+
XCTAssertEqual(sent.priority, "10")
126+
XCTAssertEqual(sent.expiration, "0")
127+
XCTAssertEqual(sent.collapseID, "collapse-123")
128+
XCTAssertEqual(sent.apnsID, apnsID)
129+
}
130+
131+
func testSendAlert_unregisteredCarriesTimestamp() async throws {
132+
do {
133+
_ = try await client.sendAlertNotification(
134+
Self.makeAlert(),
135+
deviceToken: APNSTestServer.unregisteredDeviceToken
136+
)
137+
XCTFail("Expected an APNSError to be thrown")
138+
} catch let error as APNSError {
139+
XCTAssertEqual(error.responseStatus, 410)
140+
XCTAssertEqual(error.reason, .unregistered)
141+
let timestamp = try XCTUnwrap(error.timestamp)
142+
XCTAssertEqual(
143+
timestamp.timeIntervalSince1970,
144+
Double(APNSTestServer.unregisteredTimestampMilliseconds) / 1000,
145+
accuracy: 0.001
146+
)
147+
}
148+
}
149+
150+
func testInjectedClockDrivesTokenRefresh() async throws {
151+
let clock = TestClock<Duration>()
152+
let testServer = server!
153+
let clockedClient = APNSURLSessionClient(
154+
configuration: .init(
155+
environment: .custom(url: "http://127.0.0.1", port: testServer.port),
156+
privateKey: try P256.Signing.PrivateKey(pemRepresentation: Self.jwtPrivateKey),
157+
keyIdentifier: "MY_KEY_ID",
158+
teamIdentifier: "MY_TEAM_ID",
159+
clock: clock
160+
)
161+
)
162+
163+
_ = try await clockedClient.sendAlertNotification(
164+
Self.makeAlert(),
165+
deviceToken: Self.validDeviceToken
166+
)
167+
168+
// Advance past the manager's 55 minute refresh window so a new token must be minted.
169+
clock.now = clock.now.advanced(by: .init(secondsComponent: 3360, attosecondsComponent: 0))
170+
171+
_ = try await clockedClient.sendAlertNotification(
172+
Self.makeAlert(),
173+
deviceToken: Self.validDeviceToken
174+
)
175+
176+
let sent = testServer.getSentNotifications()
177+
XCTAssertEqual(sent.count, 2)
178+
let firstAuthorization = try XCTUnwrap(sent[0].authorization)
179+
let secondAuthorization = try XCTUnwrap(sent[1].authorization)
180+
XCTAssertNotEqual(firstAuthorization, secondAuthorization)
181+
}
182+
183+
func testSendAlert_customSessionIsUsed() async throws {
184+
let customSession = URLSession(configuration: .ephemeral)
185+
let customClient = APNSURLSessionClient(
186+
configuration: .init(
187+
environment: .custom(url: "http://127.0.0.1", port: server.port),
188+
privateKey: try P256.Signing.PrivateKey(pemRepresentation: Self.jwtPrivateKey),
189+
keyIdentifier: "MY_KEY_ID",
190+
teamIdentifier: "MY_TEAM_ID"
191+
),
192+
session: customSession
193+
)
194+
195+
let response = try await customClient.sendAlertNotification(
196+
Self.makeAlert(),
197+
deviceToken: Self.validDeviceToken
198+
)
199+
200+
XCTAssertNotNil(response.apnsID)
201+
}
202+
107203
// MARK: - Helpers
108204

109205
private static let validDeviceToken = String(repeating: "a", count: 64)

0 commit comments

Comments
 (0)