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
21 changes: 0 additions & 21 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,3 @@ jobs:
steps:
- uses: actions/checkout@v1
- run: ASAN_OPTIONS=detect_leaks=0 swift test --sanitize=address
focal-5-10:
container:
image: swift:5.10-focal
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- run: swift test
thread-5-10:
container:
image: swift:5.10-focal
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- run: swift test --sanitize=thread
address-5-10:
container:
image: swift:5.10-focal
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- run: ASAN_OPTIONS=detect_leaks=0 swift test --sanitize=address
95 changes: 44 additions & 51 deletions Tests/APNSTests/APNSAuthenticationTokenManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,49 +13,42 @@
//===----------------------------------------------------------------------===//

@testable import APNSCore
import Crypto
import XCTest
import Foundation
import NIOConcurrencyHelpers
import Testing

final class APNSAuthenticationTokenManagerTests: XCTestCase {
struct APNSAuthenticationTokenManagerTests {
private static let signingKey = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIPnrjgMs/LOp9W5R2kQtdBfzyjCe2wICBOWgyCA6OwRDoAoGCCqGSM49
AwEHoUQDQgAEbWmxH/HLvIJIVUt8bB42ntiBZUSb6Bxx7F36mDSHssBaRBU0BYYj
NVeBKbgP2rVE/nOAexjhmWE2S5G98nkEPg==
-----END EC PRIVATE KEY-----

"""
private var clock: TestClock<Duration>!
private var tokenManager: APNSAuthenticationTokenManager<TestClock<Duration>>!
override func setUp() {
super.setUp()
clock = TestClock()
tokenManager = APNSAuthenticationTokenManager(
privateKey: try! .init(pemRepresentation: Self.signingKey),
private let clock: TestClock<Duration>
private let tokenManager: APNSAuthenticationTokenManager<TestClock<Duration>>

init() throws {
let clock = TestClock<Duration>()
self.clock = clock
self.tokenManager = APNSAuthenticationTokenManager(
privateKey: try .init(pemRepresentation: Self.signingKey),
teamIdentifier: "foo",
keyIdentifier: "bar",
clock: clock
)
}

override func tearDown() {
super.tearDown()

tokenManager = nil
clock = nil
}

func testToken() async throws {

@Test func token() async throws {
let token = try await tokenManager.nextValidToken

// We need to split twice here since the expected format of the token is
// "bearer encodedHeader.encodedPayload.ecnodedSignature"
let splitToken = try XCTUnwrap(token.split(separator: " ").last)
let splitToken = try #require(token.split(separator: " ").last)
.split(separator: ".")
let decodedHeader = try XCTUnwrap(base64URLDecoded(String(splitToken[0])))

let decodedHeader = try #require(base64URLDecoded(String(splitToken[0])))
let header = String(data: decodedHeader, encoding: .utf8)
let expectedHeader = """
{
Expand All @@ -64,9 +57,9 @@ final class APNSAuthenticationTokenManagerTests: XCTestCase {
"kid": "bar"
}
"""
XCTAssertEqual(header, expectedHeader)
let decodedPayload = try XCTUnwrap(base64URLDecoded(String(splitToken[1])))
#expect(header == expectedHeader)

let decodedPayload = try #require(base64URLDecoded(String(splitToken[1])))

// The expected `iat` is computed *after* token generation, so comparing raw strings
// is flaky across second boundaries. Instead, decode the JSON and assert `iss` plus
Expand All @@ -78,76 +71,76 @@ final class APNSAuthenticationTokenManagerTests: XCTestCase {
let payload = try JSONDecoder().decode(Payload.self, from: decodedPayload)
let now = Date().timeIntervalSince1970

XCTAssertEqual(payload.iss, "foo")
XCTAssertEqual(Double(payload.iat), now, accuracy: 5)
#expect(payload.iss == "foo")
#expect(abs(Double(payload.iat) - now) <= 5)
}

/// The refresh window is `[0, 55min)`: a token is still considered valid one
/// second before 55 minutes, and refreshed at exactly 55 minutes.
func testTokenReusedJustBeforeBoundaryAndRefreshedAtBoundary() async throws {
@Test func `Token reused just before boundary and refreshed at boundary`() async throws {
let token1 = try await tokenManager.nextValidToken

// 54:59 — still inside the window, so the cached token is returned.
clock.now = clock.now.advanced(by: .init(secondsComponent: 3299, attosecondsComponent: 0))
let reused = try await tokenManager.nextValidToken
XCTAssertEqual(token1, reused)
#expect(token1 == reused)

// 55:00 — `duration(to:)` is no longer `< 55min`, so a fresh token is generated.
clock.now = clock.now.advanced(by: .init(secondsComponent: 1, attosecondsComponent: 0))
let refreshed = try await tokenManager.nextValidToken
XCTAssertNotEqual(token1, refreshed)
#expect(token1 != refreshed)
}

/// A refreshed token must be a well-formed JWT with the correct claims — not merely
/// a different string (ECDSA signatures are randomized, so string inequality alone is weak).
func testRefreshedTokenIsStructurallyValid() async throws {
@Test func `Refreshed token is structurally valid`() async throws {
_ = try await tokenManager.nextValidToken

clock.now = clock.now.advanced(by: .init(secondsComponent: 3360, attosecondsComponent: 0))
let refreshed = try await tokenManager.nextValidToken

let segments = try XCTUnwrap(refreshed.split(separator: " ").last).split(separator: ".")
XCTAssertEqual(segments.count, 3, "Expected a `header.payload.signature` JWT")
let segments = try #require(refreshed.split(separator: " ").last).split(separator: ".")
#expect(segments.count == 3, "Expected a `header.payload.signature` JWT")

let header = try decodeSegment(segments[0])
XCTAssertTrue(header.contains("\"kid\": \"bar\""))
XCTAssertTrue(header.contains("\"alg\": \"ES256\""))
#expect(header.contains("\"kid\": \"bar\""))
#expect(header.contains("\"alg\": \"ES256\""))

let payload = try decodeSegment(segments[1])
XCTAssertTrue(payload.contains("\"iss\": \"foo\""))
XCTAssertFalse(payload.contains("\"kid\""), "kid must not be duplicated into the payload")
#expect(payload.contains("\"iss\": \"foo\""))
#expect(!payload.contains("\"kid\""), "kid must not be duplicated into the payload")
}

private func decodeSegment(_ segment: Substring) throws -> String {
let data = try XCTUnwrap(base64URLDecoded(String(segment)))
return try XCTUnwrap(String(data: data, encoding: .utf8))
let data = try #require(base64URLDecoded(String(segment)))
return try #require(String(data: data, encoding: .utf8))
}
}

final class TestClock<Duration: DurationProtocol & Hashable>: Clock {
struct Instant: InstantProtocol {
public var offset: Duration

public init(offset: Duration = .zero) {
self.offset = offset
}

public func advanced(by duration: Duration) -> Self {
.init(offset: self.offset + duration)
}

public func duration(to other: Self) -> Duration {
other.offset - self.offset
}

public static func < (lhs: Self, rhs: Self) -> Bool {
lhs.offset < rhs.offset
}
}

let minimumResolution: Duration = .zero
private let _now: NIOLockedValueBox<Instant>

var now: Instant {
get {
self._now.withLockedValue { $0 }
Expand All @@ -156,11 +149,11 @@ final class TestClock<Duration: DurationProtocol & Hashable>: Clock {
}
}


public init(now: Instant = .init()) {
self._now = .init(now)
}

public func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws {
try Task.checkCancellation()
try await Task.sleep(until: deadline, clock: self)
Expand Down
145 changes: 35 additions & 110 deletions Tests/APNSTests/APNSClientErrorPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,120 +15,54 @@
import APNSCore
import APNS
import APNSTestServer
import Crypto
import NIOPosix
import XCTest
import Foundation
import Testing

/// Exercises the NIO-based ``APNSClient``'s error-handling path against forced server responses,
/// via ``APNSTestServer/setResponseOverride(_:)``. These pin the behaviour that an undecodable
/// error body (empty, non-JSON, or oversized) must still surface as a typed `APNSError` with
/// `reason: nil`, rather than a raw `DecodingError`/`NIOTooManyBytesError`.
final class APNSClientErrorPathTests: XCTestCase {
var server: APNSTestServer!
var client: APNSClient<JSONDecoder, JSONEncoder>!

override func setUp() async throws {
try await super.setUp()

server = APNSTestServer()
try await server.start(port: 0)

client = APNSClient(
configuration: .init(
authenticationMethod: .jwt(
privateKey: try P256.Signing.PrivateKey(pemRepresentation: Self.jwtPrivateKey),
keyIdentifier: "MY_KEY_ID",
teamIdentifier: "MY_TEAM_ID"
),
environment: .custom(url: "http://127.0.0.1", port: server.port)
),
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup.singleton),
responseDecoder: JSONDecoder(),
requestEncoder: JSONEncoder()
)
}

override func tearDown() async throws {
try await client?.shutdown()
try await server?.shutdown()
client = nil
server = nil
try await super.tearDown()
}

func testForced500_emptyBody_yieldsTypedErrorWithNilReason() async throws {
server.setResponseOverride(.init(status: 500))

do {
_ = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: Self.validDeviceToken)
XCTFail("Expected an APNSError to be thrown")
} catch let error as APNSError {
XCTAssertEqual(error.responseStatus, 500)
XCTAssertNil(error.reason)
} catch {
XCTFail("Expected an APNSError, got \(type(of: error)): \(error)")
}
}

func testForced503_nonJSONBody_yieldsTypedErrorWithNilReason() async throws {
server.setResponseOverride(.init(status: 503, body: "<html>unavailable</html>"))

do {
_ = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: Self.validDeviceToken)
XCTFail("Expected an APNSError to be thrown")
} catch let error as APNSError {
XCTAssertEqual(error.responseStatus, 503)
XCTAssertNil(error.reason)
} catch {
XCTFail("Expected an APNSError, got \(type(of: error)): \(error)")
}
}

func testForced429_validBody_yieldsTooManyRequestsReason() async throws {
server.setResponseOverride(.init(status: 429, body: #"{"reason":"TooManyRequests"}"#))

do {
_ = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: Self.validDeviceToken)
XCTFail("Expected an APNSError to be thrown")
} catch let error as APNSError {
XCTAssertEqual(error.responseStatus, 429)
XCTAssertEqual(error.reason, .tooManyRequests)
}
}

func testForced400_oversizedBody_yieldsTypedErrorWithNilReason() async throws {
// Larger than the client's 1024-byte `collect(upTo:)` limit on the error path, pinning
// the `NIOTooManyBytesError` failure mode.
let garbage = String(repeating: "x", count: 2048)
server.setResponseOverride(.init(status: 400, body: garbage))

do {
_ = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: Self.validDeviceToken)
XCTFail("Expected an APNSError to be thrown")
} catch let error as APNSError {
XCTAssertEqual(error.responseStatus, 400)
XCTAssertNil(error.reason)
} catch {
XCTFail("Expected an APNSError, got \(type(of: error)): \(error)")
struct APNSClientErrorPathTests {

@Test(arguments: [
(UInt(500), String?.none),
(UInt(503), "<html>unavailable</html>"),
(UInt(400), String(repeating: "x", count: 2048)),
])
func `Undecodable error body yields typed error with nil reason`(status: UInt, body: String?) async throws {
try await TestFixtures.withClient { server, client in
server.setResponseOverride(.init(status: status, body: body))

let error = try await #require(throws: APNSError.self) {
try await client.sendAlertNotification(Self.makeAlert(), deviceToken: TestFixtures.validDeviceToken)
}
#expect(error.responseStatus == Int(status))
#expect(error.reason == nil)
}
}

func testForced400_channelNotRegistered_throughRealClient() async throws {
server.setResponseOverride(.init(status: 400, body: #"{"reason":"ChannelNotRegistered"}"#))

do {
_ = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: Self.validDeviceToken)
XCTFail("Expected an APNSError to be thrown")
} catch let error as APNSError {
XCTAssertEqual(error.responseStatus, 400)
XCTAssertEqual(error.reason, .channelNotRegistered)
@Test(arguments: [
(UInt(429), #"{"reason":"TooManyRequests"}"#, APNSError.ErrorReason.tooManyRequests),
(UInt(400), #"{"reason":"ChannelNotRegistered"}"#, APNSError.ErrorReason.channelNotRegistered),
])
func `Decodable error body yields typed error with reason`(
status: UInt,
body: String,
reason: APNSError.ErrorReason
) async throws {
try await TestFixtures.withClient { server, client in
server.setResponseOverride(.init(status: status, body: body))

let error = try await #require(throws: APNSError.self) {
try await client.sendAlertNotification(Self.makeAlert(), deviceToken: TestFixtures.validDeviceToken)
}
#expect(error.responseStatus == Int(status))
#expect(error.reason == reason)
}
}

// MARK: - Helpers

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

private static func makeAlert() -> APNSAlertNotification<EmptyPayload> {
APNSAlertNotification(
alert: .init(title: .raw("title")),
Expand All @@ -138,13 +72,4 @@ final class APNSClientErrorPathTests: XCTestCase {
payload: EmptyPayload()
)
}

private static let jwtPrivateKey = """
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg2sD+kukkA8GZUpmm
jRa4fJ9Xa/JnIG4Hpi7tNO66+OGgCgYIKoZIzj0DAQehRANCAATZp0yt0btpR9kf
ntp4oUUzTV0+eTELXxJxFvhnqmgwGAm1iVW132XLrdRG/ntlbQ1yzUuJkHtYBNve
y+77Vzsd
-----END PRIVATE KEY-----
"""
}
Loading
Loading