From 833f0eecb13baf86628b1b3f1508f5b63faadaef Mon Sep 17 00:00:00 2001 From: Kyle Browning Date: Wed, 29 Oct 2025 20:20:38 -0700 Subject: [PATCH 1/4] Implement broadcast channels --- BROADCAST_CHANNELS.md | 146 ++++++++++++ Package.swift | 1 + Sources/APNS/APNSBroadcastClient.swift | 184 +++++++++++++++ .../Broadcast/APNSBroadcastChannel.swift | 41 ++++ .../Broadcast/APNSBroadcastChannelList.swift | 23 ++ .../APNSBroadcastClientProtocol.swift | 97 ++++++++ .../Broadcast/APNSBroadcastEnvironment.swift | 35 +++ .../APNSBroadcastMessageStoragePolicy.swift | 21 ++ .../Broadcast/APNSBroadcastRequest.swift | 77 +++++++ .../Broadcast/APNSBroadcastResponse.swift | 29 +++ Sources/APNSCore/EmptyPayload.swift | 2 +- .../APNSBroadcastTestServer.swift | 210 ++++++++++++++++++ .../APNSBroadcastChannelListTests.swift | 56 +++++ .../Broadcast/APNSBroadcastChannelTests.swift | 68 ++++++ .../Broadcast/APNSBroadcastClientTests.swift | 172 ++++++++++++++ 15 files changed, 1161 insertions(+), 1 deletion(-) create mode 100644 BROADCAST_CHANNELS.md create mode 100644 Sources/APNS/APNSBroadcastClient.swift create mode 100644 Sources/APNSCore/Broadcast/APNSBroadcastChannel.swift create mode 100644 Sources/APNSCore/Broadcast/APNSBroadcastChannelList.swift create mode 100644 Sources/APNSCore/Broadcast/APNSBroadcastClientProtocol.swift create mode 100644 Sources/APNSCore/Broadcast/APNSBroadcastEnvironment.swift create mode 100644 Sources/APNSCore/Broadcast/APNSBroadcastMessageStoragePolicy.swift create mode 100644 Sources/APNSCore/Broadcast/APNSBroadcastRequest.swift create mode 100644 Sources/APNSCore/Broadcast/APNSBroadcastResponse.swift create mode 100644 Sources/APNSTestServer/APNSBroadcastTestServer.swift create mode 100644 Tests/APNSTests/Broadcast/APNSBroadcastChannelListTests.swift create mode 100644 Tests/APNSTests/Broadcast/APNSBroadcastChannelTests.swift create mode 100644 Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift diff --git a/BROADCAST_CHANNELS.md b/BROADCAST_CHANNELS.md new file mode 100644 index 00000000..ce094e81 --- /dev/null +++ b/BROADCAST_CHANNELS.md @@ -0,0 +1,146 @@ +# Broadcast Channels Support + +This implementation adds support for iOS 18+ broadcast push notifications to APNSwift. + +## What's Implemented + +### Core Types (APNSCore) + +- **APNSBroadcastEnvironment**: Production and sandbox broadcast environments +- **APNSBroadcastMessageStoragePolicy**: Enum for message storage options (none or most recent) +- **APNSBroadcastChannel**: Represents a broadcast channel configuration +- **APNSBroadcastChannelList**: List of channel IDs +- **APNSBroadcastRequest**: Generic request type for all broadcast operations +- **APNSBroadcastResponse**: Generic response type +- **APNSBroadcastClientProtocol**: Protocol defining broadcast operations + +### Client (APNS) + +- **APNSBroadcastClient**: Full implementation with HTTP method routing for: + - POST /channels (create) + - GET /channels (list all) + - GET /channels/{id} (read) + - DELETE /channels/{id} (delete) + +### Test Infrastructure (APNSTestServer) + +- **APNSBroadcastTestServer**: Real SwiftNIO HTTP server that mocks Apple's broadcast API + - In-memory channel storage + - Proper HTTP method handling + - Error responses (404, 400) + - Request ID generation + +### Tests + +- **APNSBroadcastChannelTests**: Unit tests for encoding/decoding channels (4 tests) +- **APNSBroadcastChannelListTests**: Unit tests for channel lists (3 tests) +- **APNSBroadcastClientTests**: Integration tests with mock server (9 tests) + +## Usage Example + +```swift +import APNS +import APNSCore +import Crypto + +// Create a broadcast client +let client = APNSBroadcastClient( + authenticationMethod: .jwt( + privateKey: try P256.Signing.PrivateKey(pemRepresentation: privateKey), + keyIdentifier: "YOUR_KEY_ID", + teamIdentifier: "YOUR_TEAM_ID" + ), + environment: .production, // or .development + eventLoopGroupProvider: .createNew, + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder() +) + +// Create a new broadcast channel +let channel = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored) +let response = try await client.create(channel: channel, apnsRequestID: nil) +let channelID = response.body.channelID! + +// Read channel info +let channelInfo = try await client.read(channelID: channelID, apnsRequestID: nil) + +// List all channels +let allChannels = try await client.readAllChannelIDs(apnsRequestID: nil) +print("Channels: \\(allChannels.body.channels)") + +// Delete a channel +try await client.delete(channelID: channelID, apnsRequestID: nil) + +// Shutdown when done +try await client.shutdown() +``` + +## Testing with Mock Server + +The mock server allows you to test broadcast functionality without hitting real Apple servers: + +```swift +import APNSTestServer + +// Start mock server on random port +let server = APNSBroadcastTestServer() +try await server.start(port: 0) + +// Create client pointing to mock server +let client = APNSBroadcastClient( + authenticationMethod: .jwt(...), + environment: .custom(url: "http://127.0.0.1", port: server.port), + eventLoopGroupProvider: .createNew, + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder() +) + +// Use client... + +// Cleanup +try await client.shutdown() +try await server.shutdown() +``` + +## Architecture Decisions + +1. **Kept internal access control**: The `APNSPushType.Configuration` enum remains internal to avoid breaking the public API + +2. **String-based HTTP methods**: APNSCore uses string-based HTTP methods to avoid depending on NIOHTTP1 + +3. **Generic request/response types**: Allows type-safe operations while maintaining flexibility + +4. **Real NIO server for testing**: The mock server uses actual SwiftNIO HTTP server components for realistic testing + +5. **Protocol-based client**: Allows for easy mocking and testing in consumer code + +## Running Tests + +```bash +# Run all tests +swift test + +# Run only broadcast tests +swift test --filter Broadcast + +# Run unit tests only +swift test --filter APNSBroadcastChannelTests +swift test --filter APNSBroadcastChannelListTests + +# Run integration tests +swift test --filter APNSBroadcastClientTests +``` + +## What's Left to Do + +1. **Documentation**: Add DocC documentation for all public APIs +2. **Send notifications to channels**: Implement sending push notifications to broadcast channels (separate from channel management) +3. **Error handling improvements**: Add more specific error types for broadcast operations +4. **Rate limiting**: Consider adding rate limiting for test server +5. **Swift 6 consideration**: Maintainer asked about making this Swift 6-only - decision pending + +## References + +- [Apple Push Notification service documentation](https://developer.apple.com/documentation/usernotifications) +- Issue: https://github.com/swift-server-community/APNSwift/issues/205 +- Original WIP branch: https://github.com/eliperkins/APNSwift/tree/channels diff --git a/Package.swift b/Package.swift index 412cdb10..bcc8dc05 100644 --- a/Package.swift +++ b/Package.swift @@ -39,6 +39,7 @@ let package = Package( dependencies: [ .target(name: "APNSCore"), .target(name: "APNS"), + .target(name: "APNSTestServer"), ] ), .target( diff --git a/Sources/APNS/APNSBroadcastClient.swift b/Sources/APNS/APNSBroadcastClient.swift new file mode 100644 index 00000000..1aab038f --- /dev/null +++ b/Sources/APNS/APNSBroadcastClient.swift @@ -0,0 +1,184 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import APNSCore +import AsyncHTTPClient +import struct Foundation.Date +import struct Foundation.UUID +import NIOConcurrencyHelpers +import NIOCore +import NIOHTTP1 +import NIOSSL +import NIOTLS +import NIOPosix + +/// A client for managing Apple Push Notification broadcast channels. +public final class APNSBroadcastClient: APNSBroadcastClientProtocol { + + /// The broadcast environment to use. + private let environment: APNSBroadcastEnvironment + + /// The ``HTTPClient`` used by the APNS broadcast client. + private let httpClient: HTTPClient + + /// The decoder for the responses from APNs. + private let responseDecoder: Decoder + + /// The encoder for the requests to APNs. + @usableFromInline + /* private */ internal let requestEncoder: Encoder + + /// The authentication token manager. + private let authenticationTokenManager: APNSAuthenticationTokenManager? + + /// The ByteBufferAllocator + @usableFromInline + /* private */ internal let byteBufferAllocator: ByteBufferAllocator + + /// Default ``HTTPHeaders`` which will be adapted for each request. This saves some allocations. + private let defaultRequestHeaders: HTTPHeaders = { + var headers = HTTPHeaders() + headers.reserveCapacity(10) + headers.add(name: "content-type", value: "application/json") + headers.add(name: "user-agent", value: "APNS/swift-nio") + return headers + }() + + /// Initializes a new APNSBroadcastClient. + /// + /// The client will create an internal ``HTTPClient`` which is used to make requests to APNs broadcast API. + /// + /// - Parameters: + /// - authenticationMethod: The authentication method to use. + /// - environment: The broadcast environment (production or sandbox). + /// - eventLoopGroupProvider: Specify how EventLoopGroup will be created. + /// - responseDecoder: The decoder for the responses from APNs. + /// - requestEncoder: The encoder for the requests to APNs. + /// - byteBufferAllocator: The `ByteBufferAllocator`. + public init( + authenticationMethod: APNSClientConfiguration.AuthenticationMethod, + environment: APNSBroadcastEnvironment, + eventLoopGroupProvider: NIOEventLoopGroupProvider, + responseDecoder: Decoder, + requestEncoder: Encoder, + byteBufferAllocator: ByteBufferAllocator = .init() + ) { + self.environment = environment + self.byteBufferAllocator = byteBufferAllocator + self.responseDecoder = responseDecoder + self.requestEncoder = requestEncoder + + var tlsConfiguration = TLSConfiguration.makeClientConfiguration() + switch authenticationMethod.method { + case .jwt(let privateKey, let teamIdentifier, let keyIdentifier): + self.authenticationTokenManager = APNSAuthenticationTokenManager( + privateKey: privateKey, + teamIdentifier: teamIdentifier, + keyIdentifier: keyIdentifier, + clock: ContinuousClock() + ) + case .tls(let privateKey, let certificateChain): + self.authenticationTokenManager = nil + tlsConfiguration.privateKey = privateKey + tlsConfiguration.certificateChain = certificateChain + } + + var httpClientConfiguration = HTTPClient.Configuration() + httpClientConfiguration.tlsConfiguration = tlsConfiguration + httpClientConfiguration.httpVersion = .automatic + + switch eventLoopGroupProvider { + case .shared(let eventLoopGroup): + self.httpClient = HTTPClient( + eventLoopGroupProvider: .shared(eventLoopGroup), + configuration: httpClientConfiguration + ) + case .createNew: + self.httpClient = HTTPClient( + configuration: httpClientConfiguration + ) + } + } + + /// Shuts down the client gracefully. + public func shutdown() async throws { + try await self.httpClient.shutdown() + } +} + +extension APNSBroadcastClient: Sendable where Decoder: Sendable, Encoder: Sendable {} + +// MARK: - Broadcast operations + +extension APNSBroadcastClient { + + public func send( + _ request: APNSBroadcastRequest + ) async throws -> APNSBroadcastResponse { + var headers = self.defaultRequestHeaders + + // Add request ID if present + if let apnsRequestID = request.apnsRequestID { + headers.add(name: "apns-request-id", value: apnsRequestID.uuidString.lowercased()) + } + + // Authorization token + if let authenticationTokenManager = self.authenticationTokenManager { + let token = try await authenticationTokenManager.nextValidToken + headers.add(name: "authorization", value: token) + } + + // Build the request URL + let requestURL = "\(self.environment.url):\(self.environment.port)\(request.operation.path)" + + // Create HTTP request + var httpClientRequest = HTTPClientRequest(url: requestURL) + httpClientRequest.method = HTTPMethod(rawValue: request.operation.httpMethod) + httpClientRequest.headers = headers + + // Add body for operations that require it (e.g., create) + if let message = request.message { + var byteBuffer = self.byteBufferAllocator.buffer(capacity: 0) + try self.requestEncoder.encode(message, into: &byteBuffer) + httpClientRequest.body = .bytes(byteBuffer) + } + + // Execute the request + let response = try await self.httpClient.execute(httpClientRequest, deadline: .distantFuture) + + // Extract request ID from response + let apnsRequestID = response.headers.first(name: "apns-request-id").flatMap { UUID(uuidString: $0) } + + // Handle successful responses + if response.status == .ok || response.status == .created { + let body = try await response.body.collect(upTo: 1024 * 1024) // 1MB max + let responseBody = try responseDecoder.decode(ResponseBody.self, from: body) + return APNSBroadcastResponse(apnsRequestID: apnsRequestID, body: responseBody) + } + + // Handle error responses + let body = try await response.body.collect(upTo: 1024) + let errorResponse = try responseDecoder.decode(APNSErrorResponse.self, from: body) + + let error = APNSError( + responseStatus: Int(response.status.code), + apnsID: nil, + apnsUniqueID: nil, + apnsResponse: errorResponse, + timestamp: errorResponse.timestampInSeconds.flatMap { Date(timeIntervalSince1970: $0) } + ) + + throw error + } +} diff --git a/Sources/APNSCore/Broadcast/APNSBroadcastChannel.swift b/Sources/APNSCore/Broadcast/APNSBroadcastChannel.swift new file mode 100644 index 00000000..6aa32039 --- /dev/null +++ b/Sources/APNSCore/Broadcast/APNSBroadcastChannel.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Represents a broadcast channel configuration. +public struct APNSBroadcastChannel: Codable, Sendable { + enum CodingKeys: String, CodingKey { + case channelID = "channel-id" + case messageStoragePolicy = "message-storage-policy" + } + + /// The unique identifier for the broadcast channel (only present in responses). + public let channelID: String? + + /// The message storage policy for this channel. + public let messageStoragePolicy: APNSBroadcastMessageStoragePolicy + + /// Creates a new broadcast channel configuration. + /// + /// - Parameter messageStoragePolicy: The storage policy for messages in this channel. + public init(messageStoragePolicy: APNSBroadcastMessageStoragePolicy) { + self.channelID = nil + self.messageStoragePolicy = messageStoragePolicy + } + + /// Internal initializer used for decoding responses that include channel ID. + public init(channelID: String?, messageStoragePolicy: APNSBroadcastMessageStoragePolicy) { + self.channelID = channelID + self.messageStoragePolicy = messageStoragePolicy + } +} diff --git a/Sources/APNSCore/Broadcast/APNSBroadcastChannelList.swift b/Sources/APNSCore/Broadcast/APNSBroadcastChannelList.swift new file mode 100644 index 00000000..0ec7e050 --- /dev/null +++ b/Sources/APNSCore/Broadcast/APNSBroadcastChannelList.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Represents the list of all broadcast channel IDs. +public struct APNSBroadcastChannelList: Codable, Sendable { + /// The array of channel IDs. + public let channels: [String] + + public init(channels: [String]) { + self.channels = channels + } +} diff --git a/Sources/APNSCore/Broadcast/APNSBroadcastClientProtocol.swift b/Sources/APNSCore/Broadcast/APNSBroadcastClientProtocol.swift new file mode 100644 index 00000000..28c54586 --- /dev/null +++ b/Sources/APNSCore/Broadcast/APNSBroadcastClientProtocol.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import struct Foundation.UUID + +/// Protocol defining the broadcast channel management operations. +public protocol APNSBroadcastClientProtocol: Sendable { + /// Sends a broadcast channel management request. + /// + /// - Parameter request: The broadcast request to send. + /// - Returns: A response containing the result. + func send( + _ request: APNSBroadcastRequest + ) async throws -> APNSBroadcastResponse +} + +extension APNSBroadcastClientProtocol { + /// Creates a new broadcast channel. + /// + /// - Parameters: + /// - channel: The channel configuration. + /// - apnsRequestID: An optional request ID for tracking. + /// - Returns: The created channel information. + public func create( + channel: APNSBroadcastChannel, + apnsRequestID: UUID? = nil + ) async throws -> APNSBroadcastResponse { + let request = APNSBroadcastRequest( + operation: .create, + message: channel, + apnsRequestID: apnsRequestID + ) + return try await send(request) + } + + /// Reads information about an existing broadcast channel. + /// + /// - Parameters: + /// - channelID: The ID of the channel to read. + /// - apnsRequestID: An optional request ID for tracking. + /// - Returns: The channel information. + public func read( + channelID: String, + apnsRequestID: UUID? = nil + ) async throws -> APNSBroadcastResponse { + let request = APNSBroadcastRequest( + operation: .read(channelID: channelID), + message: nil, + apnsRequestID: apnsRequestID + ) + return try await send(request) + } + + /// Deletes an existing broadcast channel. + /// + /// - Parameters: + /// - channelID: The ID of the channel to delete. + /// - apnsRequestID: An optional request ID for tracking. + /// - Returns: An empty response. + public func delete( + channelID: String, + apnsRequestID: UUID? = nil + ) async throws -> APNSBroadcastResponse { + let request = APNSBroadcastRequest( + operation: .delete(channelID: channelID), + message: nil, + apnsRequestID: apnsRequestID + ) + return try await send(request) + } + + /// Lists all broadcast channel IDs. + /// + /// - Parameter apnsRequestID: An optional request ID for tracking. + /// - Returns: A list of all channel IDs. + public func readAllChannelIDs( + apnsRequestID: UUID? = nil + ) async throws -> APNSBroadcastResponse { + let request = APNSBroadcastRequest( + operation: .listAll, + message: nil, + apnsRequestID: apnsRequestID + ) + return try await send(request) + } +} diff --git a/Sources/APNSCore/Broadcast/APNSBroadcastEnvironment.swift b/Sources/APNSCore/Broadcast/APNSBroadcastEnvironment.swift new file mode 100644 index 00000000..1f889d2e --- /dev/null +++ b/Sources/APNSCore/Broadcast/APNSBroadcastEnvironment.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// The APNs broadcast environment. +public struct APNSBroadcastEnvironment: Sendable { + /// The production APNs broadcast environment. + public static let production = Self(url: "https://api-manage-broadcast.push.apple.com", port: 2196) + + /// The development/sandbox APNs broadcast environment. + public static let development = Self(url: "https://api-manage-broadcast.sandbox.push.apple.com", port: 2195) + + /// Creates an APNs broadcast environment with a custom URL. + /// + /// - Note: This is mostly used for testing purposes. + public static func custom(url: String, port: Int = 443) -> Self { + Self(url: url, port: port) + } + + /// The environment's URL. + public let url: String + + /// The environment's port. + public let port: Int +} diff --git a/Sources/APNSCore/Broadcast/APNSBroadcastMessageStoragePolicy.swift b/Sources/APNSCore/Broadcast/APNSBroadcastMessageStoragePolicy.swift new file mode 100644 index 00000000..538e6441 --- /dev/null +++ b/Sources/APNSCore/Broadcast/APNSBroadcastMessageStoragePolicy.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// The storage policy for broadcast channel messages. +public enum APNSBroadcastMessageStoragePolicy: Int, Codable, Sendable { + /// No messages are stored. + case noMessageStored = 0 + /// Only the most recent message is stored. + case mostRecentMessageStored = 1 +} diff --git a/Sources/APNSCore/Broadcast/APNSBroadcastRequest.swift b/Sources/APNSCore/Broadcast/APNSBroadcastRequest.swift new file mode 100644 index 00000000..4f48bfaa --- /dev/null +++ b/Sources/APNSCore/Broadcast/APNSBroadcastRequest.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import struct Foundation.UUID + +/// Represents a request to the APNs broadcast channel management API. +public struct APNSBroadcastRequest: Sendable where Message: Sendable { + /// The type of broadcast operation to perform. + public enum Operation: Sendable { + /// Create a new broadcast channel. + case create + /// Read an existing broadcast channel. + case read(channelID: String) + /// Delete an existing broadcast channel. + case delete(channelID: String) + /// List all broadcast channels. + case listAll + + /// The HTTP method as a string. + public var httpMethod: String { + switch self { + case .create: + return "POST" + case .read, .listAll: + return "GET" + case .delete: + return "DELETE" + } + } + + /// The path for this operation. + public var path: String { + switch self { + case .create, .listAll: + return "/channels" + case .read(let channelID), .delete(let channelID): + return "/channels/\(channelID)" + } + } + } + + /// The operation to perform. + public let operation: Operation + + /// The message payload for operations that require a body (e.g., create). + public let message: Message? + + /// An optional request ID for tracking. + public let apnsRequestID: UUID? + + /// Creates a broadcast request. + /// + /// - Parameters: + /// - operation: The type of operation to perform. + /// - message: The message payload (required for create operations). + /// - apnsRequestID: An optional request ID for tracking. + public init( + operation: Operation, + message: Message? = nil, + apnsRequestID: UUID? = nil + ) { + self.operation = operation + self.message = message + self.apnsRequestID = apnsRequestID + } +} diff --git a/Sources/APNSCore/Broadcast/APNSBroadcastResponse.swift b/Sources/APNSCore/Broadcast/APNSBroadcastResponse.swift new file mode 100644 index 00000000..9e2d3fb7 --- /dev/null +++ b/Sources/APNSCore/Broadcast/APNSBroadcastResponse.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import struct Foundation.UUID + +/// Represents a response from a broadcast channel operation. +public struct APNSBroadcastResponse: Sendable where Body: Sendable { + /// The request ID returned by APNs. + public let apnsRequestID: UUID? + + /// The response body. + public let body: Body + + public init(apnsRequestID: UUID?, body: Body) { + self.apnsRequestID = apnsRequestID + self.body = body + } +} diff --git a/Sources/APNSCore/EmptyPayload.swift b/Sources/APNSCore/EmptyPayload.swift index f81de23a..4ba84b61 100644 --- a/Sources/APNSCore/EmptyPayload.swift +++ b/Sources/APNSCore/EmptyPayload.swift @@ -11,6 +11,6 @@ // //===----------------------------------------------------------------------===// -public struct EmptyPayload: Encodable, Sendable { +public struct EmptyPayload: Codable, Sendable { public init() {} } diff --git a/Sources/APNSTestServer/APNSBroadcastTestServer.swift b/Sources/APNSTestServer/APNSBroadcastTestServer.swift new file mode 100644 index 00000000..6713835f --- /dev/null +++ b/Sources/APNSTestServer/APNSBroadcastTestServer.swift @@ -0,0 +1,210 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOPosix +import NIOHTTP1 +import struct Foundation.UUID +import struct Foundation.Data +import class Foundation.JSONSerialization + +/// A lightweight mock server that simulates Apple's broadcast push notification API. +/// +/// This server is useful for testing APNSBroadcastClient without hitting real Apple servers. +/// It maintains an in-memory store of channels and responds to all standard operations: +/// - POST /channels (create) +/// - GET /channels (list all) +/// - GET /channels/{id} (read) +/// - DELETE /channels/{id} (delete) +public final class APNSBroadcastTestServer: @unchecked Sendable { + private let group: EventLoopGroup + private var channel: Channel? + private var channels: [String: MockChannel] = [:] + + public var port: Int { + guard let channel = channel else { + return 0 + } + return channel.localAddress?.port ?? 0 + } + + struct MockChannel: Codable { + let channelID: String + let messageStoragePolicy: Int + + enum CodingKeys: String, CodingKey { + case channelID = "channel-id" + case messageStoragePolicy = "message-storage-policy" + } + } + + public init() { + self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + } + + /// Starts the server on the specified port. + public func start(port: Int = 0) async throws { + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler(BroadcastRequestHandler(server: self)) + } + } + + self.channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get() + } + + /// Stops the server. + public func shutdown() async throws { + try await channel?.close() + try await group.shutdownGracefully() + } + + fileprivate func handleRequest(method: HTTPMethod, uri: String, body: ByteBuffer?) -> (status: HTTPResponseStatus, body: String) { + // Parse the URI + let components = uri.split(separator: "/") + + switch (method, components.count) { + case (.POST, 1) where components[0] == "channels": + // Create channel + return createChannel(body: body) + + case (.GET, 1) where components[0] == "channels": + // List all channels + return listChannels() + + case (.GET, 2) where components[0] == "channels": + // Read specific channel + let channelID = String(components[1]) + return readChannel(channelID: channelID) + + case (.DELETE, 2) where components[0] == "channels": + // Delete specific channel + let channelID = String(components[1]) + return deleteChannel(channelID: channelID) + + default: + return (.notFound, "{\"reason\":\"NotFound\"}") + } + } + + private func createChannel(body: ByteBuffer?) -> (status: HTTPResponseStatus, body: String) { + guard var body = body else { + return (.badRequest, "{\"reason\":\"BadRequest\"}") + } + + guard let bytes = body.readBytes(length: body.readableBytes) else { + return (.badRequest, "{\"reason\":\"BadRequest\"}") + } + + let data = Data(bytes) + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let policy = json["message-storage-policy"] as? Int else { + return (.badRequest, "{\"reason\":\"BadRequest\"}") + } + + let channelID = UUID().uuidString + let channel = MockChannel(channelID: channelID, messageStoragePolicy: policy) + channels[channelID] = channel + + let responseJSON = """ + {"channel-id":"\(channelID)","message-storage-policy":\(policy)} + """ + return (.created, responseJSON) + } + + private func listChannels() -> (status: HTTPResponseStatus, body: String) { + let channelIDs = Array(channels.keys) + let channelsJSON = channelIDs.map { "\"\($0)\"" }.joined(separator: ",") + return (.ok, "{\"channels\":[\(channelsJSON)]}") + } + + private func readChannel(channelID: String) -> (status: HTTPResponseStatus, body: String) { + guard let channel = channels[channelID] else { + return (.notFound, "{\"reason\":\"NotFound\"}") + } + + let responseJSON = """ + {"channel-id":"\(channel.channelID)","message-storage-policy":\(channel.messageStoragePolicy)} + """ + return (.ok, responseJSON) + } + + private func deleteChannel(channelID: String) -> (status: HTTPResponseStatus, body: String) { + guard channels.removeValue(forKey: channelID) != nil else { + return (.notFound, "{\"reason\":\"NotFound\"}") + } + + return (.ok, "{}") + } +} + +private final class BroadcastRequestHandler: ChannelInboundHandler { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + private let server: APNSBroadcastTestServer + private var method: HTTPMethod? + private var uri: String? + private var bodyBuffer: ByteBuffer? + + init(server: APNSBroadcastTestServer) { + self.server = server + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = unwrapInboundIn(data) + + switch part { + case .head(let head): + self.method = head.method + self.uri = head.uri + self.bodyBuffer = nil + + case .body(var buffer): + if self.bodyBuffer == nil { + self.bodyBuffer = buffer + } else { + self.bodyBuffer?.writeBuffer(&buffer) + } + + case .end: + guard let method = self.method, let uri = self.uri else { + return + } + + let (status, body) = server.handleRequest(method: method, uri: uri, body: bodyBuffer) + + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/json") + headers.add(name: "content-length", value: String(body.utf8.count)) + headers.add(name: "apns-request-id", value: UUID().uuidString) + + let responseHead = HTTPResponseHead(version: .http1_1, status: status, headers: headers) + context.write(wrapOutboundOut(.head(responseHead)), promise: nil) + + var buffer = context.channel.allocator.buffer(capacity: body.utf8.count) + buffer.writeString(body) + context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + + context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil) + + self.method = nil + self.uri = nil + self.bodyBuffer = nil + } + } +} diff --git a/Tests/APNSTests/Broadcast/APNSBroadcastChannelListTests.swift b/Tests/APNSTests/Broadcast/APNSBroadcastChannelListTests.swift new file mode 100644 index 00000000..4b348f83 --- /dev/null +++ b/Tests/APNSTests/Broadcast/APNSBroadcastChannelListTests.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import APNSCore +import XCTest + +final class APNSBroadcastChannelListTests: XCTestCase { + func testDecode() throws { + let jsonString = """ + {"channels":["channel-1","channel-2","channel-3"]} + """ + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let channelList = try decoder.decode(APNSBroadcastChannelList.self, from: data) + + XCTAssertEqual(channelList.channels.count, 3) + XCTAssertEqual(channelList.channels[0], "channel-1") + XCTAssertEqual(channelList.channels[1], "channel-2") + XCTAssertEqual(channelList.channels[2], "channel-3") + } + + func testDecode_emptyList() throws { + let jsonString = """ + {"channels":[]} + """ + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let channelList = try decoder.decode(APNSBroadcastChannelList.self, from: data) + + XCTAssertEqual(channelList.channels.count, 0) + } + + func testEncode() throws { + let channelList = APNSBroadcastChannelList(channels: ["channel-1", "channel-2"]) + let encoder = JSONEncoder() + let data = try encoder.encode(channelList) + + let expectedJSONString = """ + {"channels":["channel-1","channel-2"]} + """ + let jsonObject1 = try JSONSerialization.jsonObject(with: data) as! NSDictionary + let jsonObject2 = try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!) as! NSDictionary + XCTAssertEqual(jsonObject1, jsonObject2) + } +} diff --git a/Tests/APNSTests/Broadcast/APNSBroadcastChannelTests.swift b/Tests/APNSTests/Broadcast/APNSBroadcastChannelTests.swift new file mode 100644 index 00000000..b7084d6e --- /dev/null +++ b/Tests/APNSTests/Broadcast/APNSBroadcastChannelTests.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import APNSCore +import XCTest + +final class APNSBroadcastChannelTests: XCTestCase { + func testEncode() throws { + let channel = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored) + let encoder = JSONEncoder() + let data = try encoder.encode(channel) + + let expectedJSONString = """ + {"message-storage-policy":1} + """ + let jsonObject1 = try JSONSerialization.jsonObject(with: data) as! NSDictionary + let jsonObject2 = try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!) as! NSDictionary + XCTAssertEqual(jsonObject1, jsonObject2) + } + + func testEncode_noMessageStored() throws { + let channel = APNSBroadcastChannel(messageStoragePolicy: .noMessageStored) + let encoder = JSONEncoder() + let data = try encoder.encode(channel) + + let expectedJSONString = """ + {"message-storage-policy":0} + """ + let jsonObject1 = try JSONSerialization.jsonObject(with: data) as! NSDictionary + let jsonObject2 = try JSONSerialization.jsonObject(with: expectedJSONString.data(using: .utf8)!) as! NSDictionary + XCTAssertEqual(jsonObject1, jsonObject2) + } + + func testDecode() throws { + let jsonString = """ + {"channel-id":"test-channel-123","message-storage-policy":1} + """ + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let channel = try decoder.decode(APNSBroadcastChannel.self, from: data) + + XCTAssertEqual(channel.channelID, "test-channel-123") + XCTAssertEqual(channel.messageStoragePolicy, .mostRecentMessageStored) + } + + func testDecode_withoutChannelID() throws { + let jsonString = """ + {"message-storage-policy":0} + """ + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let channel = try decoder.decode(APNSBroadcastChannel.self, from: data) + + XCTAssertNil(channel.channelID) + XCTAssertEqual(channel.messageStoragePolicy, .noMessageStored) + } +} diff --git a/Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift b/Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift new file mode 100644 index 00000000..2e7a4c63 --- /dev/null +++ b/Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift @@ -0,0 +1,172 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import APNSCore +import APNS +import APNSTestServer +import Crypto +import XCTest + +final class APNSBroadcastClientTests: XCTestCase { + var server: APNSBroadcastTestServer! + var client: APNSBroadcastClient! + + override func setUp() async throws { + try await super.setUp() + + // Start the mock server + server = APNSBroadcastTestServer() + try await server.start(port: 0) + + // Create a client pointing to the mock server + let serverPort = server.port + client = APNSBroadcastClient( + authenticationMethod: .jwt( + privateKey: try! P256.Signing.PrivateKey(pemRepresentation: jwtPrivateKey), + keyIdentifier: "MY_KEY_ID", + teamIdentifier: "MY_TEAM_ID" + ), + environment: .custom(url: "http://127.0.0.1", port: serverPort), + eventLoopGroupProvider: .createNew, + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder() + ) + } + + override func tearDown() async throws { + try await client?.shutdown() + try await server?.shutdown() + try await super.tearDown() + } + + func testCreateChannel() async throws { + let channel = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored) + let response = try await client.create(channel: channel, apnsRequestID: nil) + + XCTAssertNotNil(response.apnsRequestID) + XCTAssertNotNil(response.body.channelID) + XCTAssertEqual(response.body.messageStoragePolicy, .mostRecentMessageStored) + } + + func testCreateChannel_noMessageStored() async throws { + let channel = APNSBroadcastChannel(messageStoragePolicy: .noMessageStored) + let response = try await client.create(channel: channel, apnsRequestID: nil) + + XCTAssertNotNil(response.apnsRequestID) + XCTAssertNotNil(response.body.channelID) + XCTAssertEqual(response.body.messageStoragePolicy, .noMessageStored) + } + + func testReadChannel() async throws { + // First, create a channel + let channel = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored) + let createResponse = try await client.create(channel: channel, apnsRequestID: nil) + let channelID = createResponse.body.channelID! + + // Now read it back + let readResponse = try await client.read(channelID: channelID, apnsRequestID: nil) + + XCTAssertNotNil(readResponse.apnsRequestID) + XCTAssertEqual(readResponse.body.channelID, channelID) + XCTAssertEqual(readResponse.body.messageStoragePolicy, .mostRecentMessageStored) + } + + func testReadChannel_notFound() async throws { + do { + _ = try await client.read(channelID: "non-existent-channel", apnsRequestID: nil) + XCTFail("Expected error to be thrown") + } catch let error as APNSError { + XCTAssertEqual(error.responseStatus, 404) + } + } + + func testDeleteChannel() async throws { + // First, create a channel + let channel = APNSBroadcastChannel(messageStoragePolicy: .noMessageStored) + let createResponse = try await client.create(channel: channel, apnsRequestID: nil) + let channelID = createResponse.body.channelID! + + // Delete it + let deleteResponse = try await client.delete(channelID: channelID, apnsRequestID: nil) + XCTAssertNotNil(deleteResponse.apnsRequestID) + + // Verify it's gone + do { + _ = try await client.read(channelID: channelID, apnsRequestID: nil) + XCTFail("Expected error to be thrown") + } catch let error as APNSError { + XCTAssertEqual(error.responseStatus, 404) + } + } + + func testDeleteChannel_notFound() async throws { + do { + _ = try await client.delete(channelID: "non-existent-channel", apnsRequestID: nil) + XCTFail("Expected error to be thrown") + } catch let error as APNSError { + XCTAssertEqual(error.responseStatus, 404) + } + } + + func testListAllChannels() async throws { + // Create a few channels + let channel1 = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored) + let channel2 = APNSBroadcastChannel(messageStoragePolicy: .noMessageStored) + let channel3 = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored) + + let response1 = try await client.create(channel: channel1, apnsRequestID: nil) + let response2 = try await client.create(channel: channel2, apnsRequestID: nil) + let response3 = try await client.create(channel: channel3, apnsRequestID: nil) + + let channelID1 = response1.body.channelID! + let channelID2 = response2.body.channelID! + let channelID3 = response3.body.channelID! + + // List all channels + let listResponse = try await client.readAllChannelIDs(apnsRequestID: nil) + + XCTAssertNotNil(listResponse.apnsRequestID) + XCTAssertEqual(listResponse.body.channels.count, 3) + XCTAssertTrue(listResponse.body.channels.contains(channelID1)) + XCTAssertTrue(listResponse.body.channels.contains(channelID2)) + XCTAssertTrue(listResponse.body.channels.contains(channelID3)) + } + + func testListAllChannels_empty() async throws { + let listResponse = try await client.readAllChannelIDs(apnsRequestID: nil) + + XCTAssertNotNil(listResponse.apnsRequestID) + XCTAssertEqual(listResponse.body.channels.count, 0) + } + + func testRequestID() async throws { + let requestID = UUID() + let channel = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored) + let response = try await client.create(channel: channel, apnsRequestID: requestID) + + // The server returns its own request ID, but we verify the client can handle custom IDs + XCTAssertNotNil(response.apnsRequestID) + } + + // MARK: - Helper + + private let jwtPrivateKey = """ + -----BEGIN PRIVATE KEY----- + MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg2sD+kukkA8GZUpmm + jRa4fJ9Xa/JnIG4Hpi7tNO66+OGgCgYIKoZIzj0DAQehRANCAATZp0yt0btpR9kf + ntp4oUUzTV0+eTELXxJxFvhnqmgwGAm1iVW132XLrdRG/ntlbQ1yzUuJkHtYBNve + y+77Vzsd + -----END PRIVATE KEY----- + """ +} From 05b704de5586ef1b25d920e17671700a9a80888c Mon Sep 17 00:00:00 2001 From: Kyle Browning Date: Wed, 29 Oct 2025 21:09:58 -0700 Subject: [PATCH 2/4] more tests --- BROADCAST_CHANNELS.md | 40 +- .../APNSBroadcastTestServer.swift | 210 --------- Sources/APNSTestServer/APNSTestServer.swift | 435 +++++++++++++++++- .../APNSClientIntegrationTests.swift | 313 +++++++++++++ .../APNSTestServerValidationTests.swift | 430 +++++++++++++++++ .../Broadcast/APNSBroadcastClientTests.swift | 4 +- 6 files changed, 1211 insertions(+), 221 deletions(-) delete mode 100644 Sources/APNSTestServer/APNSBroadcastTestServer.swift create mode 100644 Tests/APNSTests/APNSClientIntegrationTests.swift create mode 100644 Tests/APNSTests/APNSTestServerValidationTests.swift diff --git a/BROADCAST_CHANNELS.md b/BROADCAST_CHANNELS.md index ce094e81..668723b1 100644 --- a/BROADCAST_CHANNELS.md +++ b/BROADCAST_CHANNELS.md @@ -24,8 +24,11 @@ This implementation adds support for iOS 18+ broadcast push notifications to APN ### Test Infrastructure (APNSTestServer) -- **APNSBroadcastTestServer**: Real SwiftNIO HTTP server that mocks Apple's broadcast API +- **APNSTestServer**: Unified real SwiftNIO HTTP server that mocks both: + - Apple's regular push notification API (`POST /3/device/{token}`) + - Apple's broadcast channel API (`POST/GET/DELETE /channels[/{id}]`) - In-memory channel storage + - Notification recording with full metadata - Proper HTTP method handling - Error responses (404, 400) - Request ID generation @@ -34,7 +37,10 @@ This implementation adds support for iOS 18+ broadcast push notifications to APN - **APNSBroadcastChannelTests**: Unit tests for encoding/decoding channels (4 tests) - **APNSBroadcastChannelListTests**: Unit tests for channel lists (3 tests) -- **APNSBroadcastClientTests**: Integration tests with mock server (9 tests) +- **APNSBroadcastClientTests**: Broadcast channel integration tests (9 tests) +- **APNSClientIntegrationTests**: Push notification integration tests (10 tests) + - Alert, Background, VoIP, FileProvider, Complication notifications + - Header validation, multiple notifications ## Usage Example @@ -77,17 +83,17 @@ try await client.shutdown() ## Testing with Mock Server -The mock server allows you to test broadcast functionality without hitting real Apple servers: +The unified `APNSTestServer` allows you to test both broadcast channels AND regular push notifications without hitting real Apple servers: ```swift import APNSTestServer // Start mock server on random port -let server = APNSBroadcastTestServer() +let server = APNSTestServer() try await server.start(port: 0) -// Create client pointing to mock server -let client = APNSBroadcastClient( +// Test broadcast channels +let broadcastClient = APNSBroadcastClient( authenticationMethod: .jwt(...), environment: .custom(url: "http://127.0.0.1", port: server.port), eventLoopGroupProvider: .createNew, @@ -95,10 +101,28 @@ let client = APNSBroadcastClient( requestEncoder: JSONEncoder() ) -// Use client... +// Test regular push notifications +let pushClient = APNSClient( + configuration: .init( + authenticationMethod: .jwt(...), + environment: .custom(url: "http://127.0.0.1", port: server.port) + ), + eventLoopGroupProvider: .createNew, + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder() +) + +// Send notifications and verify +let notification = APNSAlertNotification(...) +try await pushClient.sendAlertNotification(notification, deviceToken: "device-token") + +let sent = server.getSentNotifications() +XCTAssertEqual(sent.count, 1) +XCTAssertEqual(sent[0].pushType, "alert") // Cleanup -try await client.shutdown() +try await broadcastClient.shutdown() +try await pushClient.shutdown() try await server.shutdown() ``` diff --git a/Sources/APNSTestServer/APNSBroadcastTestServer.swift b/Sources/APNSTestServer/APNSBroadcastTestServer.swift deleted file mode 100644 index 6713835f..00000000 --- a/Sources/APNSTestServer/APNSBroadcastTestServer.swift +++ /dev/null @@ -1,210 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the APNSwift open source project -// -// Copyright (c) 2024 the APNSwift project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of APNSwift project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import NIOCore -import NIOPosix -import NIOHTTP1 -import struct Foundation.UUID -import struct Foundation.Data -import class Foundation.JSONSerialization - -/// A lightweight mock server that simulates Apple's broadcast push notification API. -/// -/// This server is useful for testing APNSBroadcastClient without hitting real Apple servers. -/// It maintains an in-memory store of channels and responds to all standard operations: -/// - POST /channels (create) -/// - GET /channels (list all) -/// - GET /channels/{id} (read) -/// - DELETE /channels/{id} (delete) -public final class APNSBroadcastTestServer: @unchecked Sendable { - private let group: EventLoopGroup - private var channel: Channel? - private var channels: [String: MockChannel] = [:] - - public var port: Int { - guard let channel = channel else { - return 0 - } - return channel.localAddress?.port ?? 0 - } - - struct MockChannel: Codable { - let channelID: String - let messageStoragePolicy: Int - - enum CodingKeys: String, CodingKey { - case channelID = "channel-id" - case messageStoragePolicy = "message-storage-policy" - } - } - - public init() { - self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - } - - /// Starts the server on the specified port. - public func start(port: Int = 0) async throws { - let bootstrap = ServerBootstrap(group: group) - .serverChannelOption(ChannelOptions.backlog, value: 256) - .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelInitializer { channel in - channel.pipeline.configureHTTPServerPipeline().flatMap { - channel.pipeline.addHandler(BroadcastRequestHandler(server: self)) - } - } - - self.channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get() - } - - /// Stops the server. - public func shutdown() async throws { - try await channel?.close() - try await group.shutdownGracefully() - } - - fileprivate func handleRequest(method: HTTPMethod, uri: String, body: ByteBuffer?) -> (status: HTTPResponseStatus, body: String) { - // Parse the URI - let components = uri.split(separator: "/") - - switch (method, components.count) { - case (.POST, 1) where components[0] == "channels": - // Create channel - return createChannel(body: body) - - case (.GET, 1) where components[0] == "channels": - // List all channels - return listChannels() - - case (.GET, 2) where components[0] == "channels": - // Read specific channel - let channelID = String(components[1]) - return readChannel(channelID: channelID) - - case (.DELETE, 2) where components[0] == "channels": - // Delete specific channel - let channelID = String(components[1]) - return deleteChannel(channelID: channelID) - - default: - return (.notFound, "{\"reason\":\"NotFound\"}") - } - } - - private func createChannel(body: ByteBuffer?) -> (status: HTTPResponseStatus, body: String) { - guard var body = body else { - return (.badRequest, "{\"reason\":\"BadRequest\"}") - } - - guard let bytes = body.readBytes(length: body.readableBytes) else { - return (.badRequest, "{\"reason\":\"BadRequest\"}") - } - - let data = Data(bytes) - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let policy = json["message-storage-policy"] as? Int else { - return (.badRequest, "{\"reason\":\"BadRequest\"}") - } - - let channelID = UUID().uuidString - let channel = MockChannel(channelID: channelID, messageStoragePolicy: policy) - channels[channelID] = channel - - let responseJSON = """ - {"channel-id":"\(channelID)","message-storage-policy":\(policy)} - """ - return (.created, responseJSON) - } - - private func listChannels() -> (status: HTTPResponseStatus, body: String) { - let channelIDs = Array(channels.keys) - let channelsJSON = channelIDs.map { "\"\($0)\"" }.joined(separator: ",") - return (.ok, "{\"channels\":[\(channelsJSON)]}") - } - - private func readChannel(channelID: String) -> (status: HTTPResponseStatus, body: String) { - guard let channel = channels[channelID] else { - return (.notFound, "{\"reason\":\"NotFound\"}") - } - - let responseJSON = """ - {"channel-id":"\(channel.channelID)","message-storage-policy":\(channel.messageStoragePolicy)} - """ - return (.ok, responseJSON) - } - - private func deleteChannel(channelID: String) -> (status: HTTPResponseStatus, body: String) { - guard channels.removeValue(forKey: channelID) != nil else { - return (.notFound, "{\"reason\":\"NotFound\"}") - } - - return (.ok, "{}") - } -} - -private final class BroadcastRequestHandler: ChannelInboundHandler { - typealias InboundIn = HTTPServerRequestPart - typealias OutboundOut = HTTPServerResponsePart - - private let server: APNSBroadcastTestServer - private var method: HTTPMethod? - private var uri: String? - private var bodyBuffer: ByteBuffer? - - init(server: APNSBroadcastTestServer) { - self.server = server - } - - func channelRead(context: ChannelHandlerContext, data: NIOAny) { - let part = unwrapInboundIn(data) - - switch part { - case .head(let head): - self.method = head.method - self.uri = head.uri - self.bodyBuffer = nil - - case .body(var buffer): - if self.bodyBuffer == nil { - self.bodyBuffer = buffer - } else { - self.bodyBuffer?.writeBuffer(&buffer) - } - - case .end: - guard let method = self.method, let uri = self.uri else { - return - } - - let (status, body) = server.handleRequest(method: method, uri: uri, body: bodyBuffer) - - var headers = HTTPHeaders() - headers.add(name: "content-type", value: "application/json") - headers.add(name: "content-length", value: String(body.utf8.count)) - headers.add(name: "apns-request-id", value: UUID().uuidString) - - let responseHead = HTTPResponseHead(version: .http1_1, status: status, headers: headers) - context.write(wrapOutboundOut(.head(responseHead)), promise: nil) - - var buffer = context.channel.allocator.buffer(capacity: body.utf8.count) - buffer.writeString(body) - context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) - - context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil) - - self.method = nil - self.uri = nil - self.bodyBuffer = nil - } - } -} diff --git a/Sources/APNSTestServer/APNSTestServer.swift b/Sources/APNSTestServer/APNSTestServer.swift index 98bdde75..1d7d91da 100644 --- a/Sources/APNSTestServer/APNSTestServer.swift +++ b/Sources/APNSTestServer/APNSTestServer.swift @@ -1 +1,434 @@ -struct APNSTestServer {} +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOPosix +import NIOHTTP1 +import struct Foundation.UUID +import struct Foundation.Data +import class Foundation.JSONSerialization +import class Foundation.JSONDecoder +import struct Foundation.CharacterSet + +/// A comprehensive mock server that simulates Apple Push Notification service APIs. +/// +/// This server supports both: +/// - **Regular push notifications**: `POST /3/device/{token}` +/// - **Broadcast channels**: `POST/GET/DELETE /channels[/{id}]` +/// +/// ## Usage +/// +/// ```swift +/// let server = APNSTestServer() +/// try await server.start(port: 0) +/// +/// // Use server.port to configure your APNS clients +/// let client = APNSClient( +/// configuration: .init( +/// authenticationMethod: .jwt(...), +/// environment: .custom(url: "http://127.0.0.1", port: server.port) +/// ), +/// ... +/// ) +/// +/// // Cleanup +/// try await server.shutdown() +/// ``` +public final class APNSTestServer: @unchecked Sendable { + private let group: EventLoopGroup + private var channel: Channel? + private var broadcastChannels: [String: MockBroadcastChannel] = [:] + private var sentNotifications: [SentNotification] = [] + + public var port: Int { + guard let channel = channel else { + return 0 + } + return channel.localAddress?.port ?? 0 + } + + /// Represents a notification that was sent to the server. + public struct SentNotification { + public let deviceToken: String + public let pushType: String? + public let topic: String? + public let priority: String? + public let expiration: String? + public let collapseID: String? + public let apnsID: UUID + public let payload: Data + + public func decodedPayload(as type: T.Type) throws -> T { + try JSONDecoder().decode(type, from: payload) + } + } + + struct MockBroadcastChannel: Codable { + let channelID: String + let messageStoragePolicy: Int + + enum CodingKeys: String, CodingKey { + case channelID = "channel-id" + case messageStoragePolicy = "message-storage-policy" + } + } + + public init() { + self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + } + + /// Starts the server on the specified port. + /// + /// - Parameter port: The port to bind to. Use 0 for a random available port. + public func start(port: Int = 0) async throws { + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler(APNSRequestHandler(server: self)) + } + } + + self.channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get() + } + + /// Stops the server. + public func shutdown() async throws { + try await channel?.close() + try await group.shutdownGracefully() + } + + /// Returns all notifications sent to this server. + public func getSentNotifications() -> [SentNotification] { + return sentNotifications + } + + /// Clears all sent notifications. + public func clearSentNotifications() { + sentNotifications.removeAll() + } + + fileprivate func handleRequest( + method: HTTPMethod, + uri: String, + headers: HTTPHeaders, + body: ByteBuffer? + ) -> (status: HTTPResponseStatus, headers: HTTPHeaders, body: String) { + // Parse the URI + let components = uri.split(separator: "/") + + // Broadcast channel endpoints + switch (method, components.count) { + case (.POST, 1) where components[0] == "channels": + return handleCreateChannel(body: body) + + case (.GET, 1) where components[0] == "channels": + return handleListChannels() + + case (.GET, 2) where components[0] == "channels": + let channelID = String(components[1]) + return handleReadChannel(channelID: channelID) + + case (.DELETE, 2) where components[0] == "channels": + let channelID = String(components[1]) + return handleDeleteChannel(channelID: channelID) + + // Regular push notification endpoint: POST /3/device/{token} + case (.POST, 3) where components[0] == "3" && components[1] == "device": + let deviceToken = String(components[2]) + return handlePushNotification(deviceToken: deviceToken, headers: headers, body: body) + + // Handle POST /3/device with missing token + case (.POST, 2) where components[0] == "3" && components[1] == "device": + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"MissingDeviceToken\"}") + + // Handle wrong HTTP method for /3/device/{token} + case (_, 3) where components[0] == "3" && components[1] == "device": + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.methodNotAllowed, responseHeaders, "{\"reason\":\"MethodNotAllowed\"}") + + // Handle bad path (e.g., /3/devices instead of /3/device) + case (.POST, _) where components.count >= 1 && components[0] == "3": + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.notFound, responseHeaders, "{\"reason\":\"BadPath\"}") + + default: + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.notFound, responseHeaders, "{\"reason\":\"NotFound\"}") + } + } + + // MARK: - Broadcast Channel Handlers + + private func handleCreateChannel(body: ByteBuffer?) -> (status: HTTPResponseStatus, headers: HTTPHeaders, body: String) { + guard var body = body else { + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/json") + return (.badRequest, headers, "{\"reason\":\"BadRequest\"}") + } + + guard let bytes = body.readBytes(length: body.readableBytes) else { + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/json") + return (.badRequest, headers, "{\"reason\":\"BadRequest\"}") + } + + let data = Data(bytes) + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let policy = json["message-storage-policy"] as? Int else { + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/json") + return (.badRequest, headers, "{\"reason\":\"BadRequest\"}") + } + + let channelID = UUID().uuidString + let channel = MockBroadcastChannel(channelID: channelID, messageStoragePolicy: policy) + broadcastChannels[channelID] = channel + + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/json") + + let responseJSON = """ + {"channel-id":"\(channelID)","message-storage-policy":\(policy)} + """ + return (.created, headers, responseJSON) + } + + private func handleListChannels() -> (status: HTTPResponseStatus, headers: HTTPHeaders, body: String) { + let channelIDs = Array(broadcastChannels.keys) + let channelsJSON = channelIDs.map { "\"\($0)\"" }.joined(separator: ",") + + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/json") + + return (.ok, headers, "{\"channels\":[\(channelsJSON)]}") + } + + private func handleReadChannel(channelID: String) -> (status: HTTPResponseStatus, headers: HTTPHeaders, body: String) { + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/json") + + guard let channel = broadcastChannels[channelID] else { + return (.notFound, headers, "{\"reason\":\"NotFound\"}") + } + + let responseJSON = """ + {"channel-id":"\(channel.channelID)","message-storage-policy":\(channel.messageStoragePolicy)} + """ + return (.ok, headers, responseJSON) + } + + private func handleDeleteChannel(channelID: String) -> (status: HTTPResponseStatus, headers: HTTPHeaders, body: String) { + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/json") + + guard broadcastChannels.removeValue(forKey: channelID) != nil else { + return (.notFound, headers, "{\"reason\":\"NotFound\"}") + } + + return (.ok, headers, "{}") + } + + // MARK: - Push Notification Handler + + private func handlePushNotification( + deviceToken: String, + headers: HTTPHeaders, + body: ByteBuffer? + ) -> (status: HTTPResponseStatus, headers: HTTPHeaders, body: String) { + // Validate device token (Apple requires exactly 64 hexadecimal characters) + let isValidHex = deviceToken.count == 64 && deviceToken.allSatisfy { $0.isHexDigit } + if !isValidHex { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"BadDeviceToken\"}") + } + + // Validate required topic header + guard headers.contains(name: "apns-topic") else { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"MissingTopic\"}") + } + + // Validate push type if present + if let pushType = headers.first(name: "apns-push-type") { + let validPushTypes = ["alert", "background", "location", "voip", "complication", + "fileprovider", "mdm", "liveactivity", "pushtotalk", "widgets"] + if !validPushTypes.contains(pushType) { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"InvalidPushType\"}") + } + } + + // Validate priority if present + if let priority = headers.first(name: "apns-priority") { + if priority != "5" && priority != "10" { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"BadPriority\"}") + } + } + + // Validate expiration if present (must be valid Unix timestamp or 0) + if let expiration = headers.first(name: "apns-expiration") { + if Int(expiration) == nil { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"BadExpirationDate\"}") + } + } + + // Validate collapse-id if present (max 64 bytes) + if let collapseID = headers.first(name: "apns-collapse-id") { + if collapseID.utf8.count > 64 { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"BadCollapseId\"}") + } + } + + // Validate payload exists + guard var body = body else { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"PayloadEmpty\"}") + } + + guard let bytes = body.readBytes(length: body.readableBytes) else { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"PayloadEmpty\"}") + } + + let payload = Data(bytes) + + // Validate payload size (Apple's limit is 4KB for most notifications) + if payload.count > 4096 { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"PayloadTooLarge\"}") + } + + // Validate that it's valid JSON + guard (try? JSONSerialization.jsonObject(with: payload)) != nil else { + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + return (.badRequest, responseHeaders, "{\"reason\":\"PayloadEmpty\"}") + } + + // Extract headers + let pushType = headers.first(name: "apns-push-type") + let topic = headers.first(name: "apns-topic") + let priority = headers.first(name: "apns-priority") + let expiration = headers.first(name: "apns-expiration") + let collapseID = headers.first(name: "apns-collapse-id") + let apnsID = headers.first(name: "apns-id").flatMap { UUID(uuidString: $0) } ?? UUID() + + // Store the notification + let notification = SentNotification( + deviceToken: deviceToken, + pushType: pushType, + topic: topic, + priority: priority, + expiration: expiration, + collapseID: collapseID, + apnsID: apnsID, + payload: payload + ) + sentNotifications.append(notification) + + // Return success + var responseHeaders = HTTPHeaders() + responseHeaders.add(name: "content-type", value: "application/json") + responseHeaders.add(name: "apns-id", value: apnsID.uuidString.lowercased()) + + return (.ok, responseHeaders, "{}") + } +} + +private final class APNSRequestHandler: ChannelInboundHandler { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + private let server: APNSTestServer + private var method: HTTPMethod? + private var uri: String? + private var headers: HTTPHeaders? + private var bodyBuffer: ByteBuffer? + + init(server: APNSTestServer) { + self.server = server + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = unwrapInboundIn(data) + + switch part { + case .head(let head): + self.method = head.method + self.uri = head.uri + self.headers = head.headers + self.bodyBuffer = nil + + case .body(var buffer): + if self.bodyBuffer == nil { + self.bodyBuffer = buffer + } else { + self.bodyBuffer?.writeBuffer(&buffer) + } + + case .end: + guard let method = self.method, + let uri = self.uri, + let headers = self.headers else { + return + } + + let (status, responseHeaders, body) = server.handleRequest( + method: method, + uri: uri, + headers: headers, + body: bodyBuffer + ) + + var finalHeaders = responseHeaders + if !finalHeaders.contains(name: "apns-request-id") { + finalHeaders.add(name: "apns-request-id", value: UUID().uuidString) + } + finalHeaders.add(name: "content-length", value: String(body.utf8.count)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: status, headers: finalHeaders) + context.write(wrapOutboundOut(.head(responseHead)), promise: nil) + + var buffer = context.channel.allocator.buffer(capacity: body.utf8.count) + buffer.writeString(body) + context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + + context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil) + + self.method = nil + self.uri = nil + self.headers = nil + self.bodyBuffer = nil + } + } +} diff --git a/Tests/APNSTests/APNSClientIntegrationTests.swift b/Tests/APNSTests/APNSClientIntegrationTests.swift new file mode 100644 index 00000000..b65677b2 --- /dev/null +++ b/Tests/APNSTests/APNSClientIntegrationTests.swift @@ -0,0 +1,313 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import APNSCore +import APNS +import APNSTestServer +import Crypto +import XCTest + +final class APNSClientIntegrationTests: XCTestCase { + var server: APNSTestServer! + var client: APNSClient! + + override func setUp() async throws { + try await super.setUp() + + // Start the mock server + server = APNSTestServer() + try await server.start(port: 0) + + // Create a client pointing to the mock server + let serverPort = server.port + client = APNSClient( + configuration: .init( + authenticationMethod: .jwt( + privateKey: try! P256.Signing.PrivateKey(pemRepresentation: jwtPrivateKey), + keyIdentifier: "MY_KEY_ID", + teamIdentifier: "MY_TEAM_ID" + ), + environment: .custom(url: "http://127.0.0.1", port: serverPort) + ), + eventLoopGroupProvider: .createNew, + responseDecoder: JSONDecoder(), + requestEncoder: JSONEncoder() + ) + } + + override func tearDown() async throws { + try await client?.shutdown() + try await server?.shutdown() + try await super.tearDown() + } + + // MARK: - Alert Notifications + + func testSendAlertNotification() async throws { + struct Payload: Encodable { + let customKey = "customValue" + } + + let notification = APNSAlertNotification( + alert: .init(title: .raw("Test Title"), body: .raw("Test Body")), + expiration: .immediately, + priority: .immediately, + topic: "com.example.app", + payload: Payload() + ) + + let response = try await client.sendAlertNotification( + notification, + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + + XCTAssertNotNil(response.apnsID) + + // Verify the server received it + let sent = server.getSentNotifications() + XCTAssertEqual(sent.count, 1) + + let sentNotification = sent[0] + XCTAssertEqual(sentNotification.deviceToken, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + XCTAssertEqual(sentNotification.pushType, "alert") + XCTAssertEqual(sentNotification.topic, "com.example.app") + XCTAssertEqual(sentNotification.priority, "10") + + // Verify payload is valid JSON + XCTAssertNotNil(try? JSONSerialization.jsonObject(with: sentNotification.payload)) + } + + func testSendAlertNotification_withBadge() async throws { + let notification = APNSAlertNotification( + alert: .init(title: .raw("Badge Test")), + expiration: .immediately, + priority: .immediately, + topic: "com.example.app", + payload: EmptyPayload(), + badge: 5 + ) + + _ = try await client.sendAlertNotification( + notification, + deviceToken: "1111111111111111111111111111111111111111111111111111111111111111" + ) + + let sent = server.getSentNotifications() + XCTAssertEqual(sent.count, 1) + XCTAssertEqual(sent[0].deviceToken, "1111111111111111111111111111111111111111111111111111111111111111") + XCTAssertEqual(sent[0].pushType, "alert") + } + + func testSendAlertNotification_withSound() async throws { + let notification = APNSAlertNotification( + alert: .init(title: .raw("Sound Test")), + expiration: .immediately, + priority: .immediately, + topic: "com.example.app", + payload: EmptyPayload(), + sound: .default + ) + + _ = try await client.sendAlertNotification( + notification, + deviceToken: "2222222222222222222222222222222222222222222222222222222222222222" + ) + + let sent = server.getSentNotifications() + XCTAssertEqual(sent.count, 1) + XCTAssertEqual(sent[0].deviceToken, "2222222222222222222222222222222222222222222222222222222222222222") + } + + // MARK: - Background Notifications + + func testSendBackgroundNotification() async throws { + struct BackgroundPayload: Encodable { + let data = "background-data" + } + + let notification = APNSBackgroundNotification( + expiration: .immediately, + topic: "com.example.app", + payload: BackgroundPayload() + ) + + let response = try await client.sendBackgroundNotification( + notification, + deviceToken: "3333333333333333333333333333333333333333333333333333333333333333" + ) + + XCTAssertNotNil(response.apnsID) + + let sent = server.getSentNotifications() + XCTAssertEqual(sent.count, 1) + XCTAssertEqual(sent[0].pushType, "background") + XCTAssertEqual(sent[0].deviceToken, "3333333333333333333333333333333333333333333333333333333333333333") + XCTAssertEqual(sent[0].topic, "com.example.app") + } + + // MARK: - VoIP Notifications + + func testSendVoIPNotification() async throws { + struct VoIPPayload: Encodable { + let callID = "call-123" + } + + let notification = APNSVoIPNotification( + expiration: .immediately, + priority: .immediately, + topic: "com.example.app.voip", + payload: VoIPPayload() + ) + + _ = try await client.sendVoIPNotification( + notification, + deviceToken: "4444444444444444444444444444444444444444444444444444444444444444" + ) + + let sent = server.getSentNotifications() + XCTAssertEqual(sent.count, 1) + XCTAssertEqual(sent[0].pushType, "voip") + XCTAssertEqual(sent[0].topic, "com.example.app.voip") + XCTAssertEqual(sent[0].deviceToken, "4444444444444444444444444444444444444444444444444444444444444444") + } + + // MARK: - File Provider Notifications + + func testSendFileProviderNotification() async throws { + let notification = APNSFileProviderNotification( + expiration: .immediately, + topic: "com.example.app.pushkit.fileprovider", + payload: EmptyPayload() + ) + + _ = try await client.sendFileProviderNotification( + notification, + deviceToken: "5555555555555555555555555555555555555555555555555555555555555555" + ) + + let sent = server.getSentNotifications() + XCTAssertEqual(sent.count, 1) + XCTAssertEqual(sent[0].pushType, "fileprovider") + } + + // MARK: - Complication Notifications + + func testSendComplicationNotification() async throws { + let notification = APNSComplicationNotification( + expiration: .immediately, + priority: .immediately, + topic: "com.example.app.complication", + payload: EmptyPayload() + ) + + _ = try await client.sendComplicationNotification( + notification, + deviceToken: "6666666666666666666666666666666666666666666666666666666666666666" + ) + + let sent = server.getSentNotifications() + XCTAssertEqual(sent.count, 1) + XCTAssertEqual(sent[0].pushType, "complication") + } + + // MARK: - Multiple Notifications + + func testSendMultipleNotifications() async throws { + server.clearSentNotifications() + + // Send 3 different notifications + let alert = APNSAlertNotification( + alert: .init(title: .raw("Alert")), + expiration: .immediately, + priority: .immediately, + topic: "com.example.app", + payload: EmptyPayload() + ) + + let background = APNSBackgroundNotification( + expiration: .immediately, + topic: "com.example.app", + payload: EmptyPayload() + ) + + struct VoIPPayload: Encodable {} + let voip = APNSVoIPNotification( + expiration: .immediately, + priority: .immediately, + topic: "com.example.app.voip", + payload: VoIPPayload() + ) + + _ = try await client.sendAlertNotification(alert, deviceToken: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + _ = try await client.sendBackgroundNotification(background, deviceToken: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + _ = try await client.sendVoIPNotification(voip, deviceToken: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + + let sent = server.getSentNotifications() + XCTAssertEqual(sent.count, 3) + + XCTAssertEqual(sent[0].deviceToken, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + XCTAssertEqual(sent[0].pushType, "alert") + + XCTAssertEqual(sent[1].deviceToken, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + XCTAssertEqual(sent[1].pushType, "background") + + XCTAssertEqual(sent[2].deviceToken, "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + XCTAssertEqual(sent[2].pushType, "voip") + } + + // MARK: - Header Validation + + func testAPNSID() async throws { + let testID = UUID() + let notification = APNSAlertNotification( + alert: .init(title: .raw("ID Test")), + expiration: .immediately, + priority: .immediately, + topic: "com.example.app", + payload: EmptyPayload(), + apnsID: testID + ) + + _ = try await client.sendAlertNotification(notification, deviceToken: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd") + + let sent = server.getSentNotifications() + XCTAssertEqual(sent[0].apnsID, testID) + } + + func testExpiration() async throws { + let notification = APNSAlertNotification( + alert: .init(title: .raw("Expiration Test")), + expiration: .timeIntervalSince1970InSeconds(1234567890), + priority: .immediately, + topic: "com.example.app", + payload: EmptyPayload() + ) + + _ = try await client.sendAlertNotification(notification, deviceToken: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + + let sent = server.getSentNotifications() + XCTAssertEqual(sent[0].expiration, "1234567890") + } + + // MARK: - Helper + + private let jwtPrivateKey = """ + -----BEGIN PRIVATE KEY----- + MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg2sD+kukkA8GZUpmm + jRa4fJ9Xa/JnIG4Hpi7tNO66+OGgCgYIKoZIzj0DAQehRANCAATZp0yt0btpR9kf + ntp4oUUzTV0+eTELXxJxFvhnqmgwGAm1iVW132XLrdRG/ntlbQ1yzUuJkHtYBNve + y+77Vzsd + -----END PRIVATE KEY----- + """ +} diff --git a/Tests/APNSTests/APNSTestServerValidationTests.swift b/Tests/APNSTests/APNSTestServerValidationTests.swift new file mode 100644 index 00000000..90deed9c --- /dev/null +++ b/Tests/APNSTests/APNSTestServerValidationTests.swift @@ -0,0 +1,430 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the APNSwift open source project +// +// Copyright (c) 2024 the APNSwift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of APNSwift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import APNSCore +import APNS +import APNSTestServer +import Crypto +import XCTest +import NIOCore +import NIOHTTP1 +import AsyncHTTPClient + +final class APNSTestServerValidationTests: XCTestCase { + var server: APNSTestServer! + var httpClient: HTTPClient! + + override func setUp() async throws { + try await super.setUp() + + // Start the mock server + server = APNSTestServer() + try await server.start(port: 0) + + // Create raw HTTP client to test error cases + httpClient = HTTPClient(eventLoopGroupProvider: .singleton) + } + + override func tearDown() async throws { + try await httpClient?.shutdown() + try await server?.shutdown() + try await super.tearDown() + } + + // MARK: - BadDeviceToken Tests + + func testBadDeviceToken_tooShort() async throws { + let response = try await sendRawNotification( + deviceToken: "abc123", // Only 6 chars, needs 64 + topic: "com.example.app", + pushType: "alert" + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("BadDeviceToken")) + } + + func testBadDeviceToken_tooLong() async throws { + let response = try await sendRawNotification( + deviceToken: String(repeating: "a", count: 65), // 65 chars, needs 64 + topic: "com.example.app", + pushType: "alert" + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("BadDeviceToken")) + } + + func testBadDeviceToken_nonHexCharacters() async throws { + let response = try await sendRawNotification( + deviceToken: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", // 64 chars but not hex + topic: "com.example.app", + pushType: "alert" + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("BadDeviceToken")) + } + + func testBadDeviceToken_empty() async throws { + let response = try await sendRawNotification( + deviceToken: "", + topic: "com.example.app", + pushType: "alert" + ) + + // Empty device token results in /3/device/ which is MissingDeviceToken + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("MissingDeviceToken")) + } + + // MARK: - MissingTopic Tests + + func testMissingTopic() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: nil, // Missing topic + pushType: "alert" + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("MissingTopic")) + } + + // MARK: - InvalidPushType Tests + + func testInvalidPushType() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "invalid-type" + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("InvalidPushType")) + } + + func testInvalidPushType_empty() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "" + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("InvalidPushType")) + } + + // MARK: - BadPriority Tests + + func testBadPriority_invalidNumber() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + priority: "3" // Invalid, must be 5 or 10 + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("BadPriority")) + } + + func testBadPriority_invalidString() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + priority: "high" // Invalid, must be 5 or 10 + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("BadPriority")) + } + + // MARK: - BadExpirationDate Tests + + func testBadExpirationDate_nonNumeric() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + expiration: "not-a-number" + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("BadExpirationDate")) + } + + func testBadExpirationDate_float() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + expiration: "123.456" // Should be integer + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("BadExpirationDate")) + } + + // MARK: - BadCollapseId Tests + + func testBadCollapseId_tooLong() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + collapseID: String(repeating: "a", count: 65) // Max is 64 bytes + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("BadCollapseId")) + } + + func testBadCollapseId_exactly64Bytes() async throws { + // This should PASS - exactly 64 bytes is valid + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + collapseID: String(repeating: "a", count: 64) + ) + + XCTAssertEqual(response.status, .ok) + } + + // MARK: - PayloadEmpty Tests + + func testPayloadEmpty_noBody() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + body: nil // No body + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("PayloadEmpty")) + } + + func testPayloadEmpty_invalidJSON() async throws { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + body: "not valid json" + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("PayloadEmpty")) + } + + // MARK: - PayloadTooLarge Tests + + func testPayloadTooLarge() async throws { + // Create a payload larger than 4096 bytes + let largePayload = "{\"data\":\"" + String(repeating: "x", count: 5000) + "\"}" + + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + body: largePayload + ) + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(response.body.contains("PayloadTooLarge")) + } + + func testPayloadExactly4096Bytes() async throws { + // Create a payload exactly 4096 bytes - should PASS + let exactSize = 4096 - "{\"data\":\"\"}".count + let payload = "{\"data\":\"" + String(repeating: "x", count: exactSize) + "\"}" + + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + body: payload + ) + + XCTAssertEqual(response.status, .ok) + } + + // MARK: - MissingDeviceToken Tests + + func testMissingDeviceToken() async throws { + var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device") + request.method = .POST + request.headers.add(name: "apns-topic", value: "com.example.app") + request.headers.add(name: "apns-push-type", value: "alert") + request.headers.add(name: "content-type", value: "application/json") + request.body = .bytes(ByteBuffer(string: "{}")) + + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024) + let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? "" + + XCTAssertEqual(response.status, .badRequest) + XCTAssertTrue(bodyString.contains("MissingDeviceToken")) + } + + // MARK: - MethodNotAllowed Tests + + func testMethodNotAllowed_GET() async throws { + var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + request.method = .GET + + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024) + let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? "" + + XCTAssertEqual(response.status, .methodNotAllowed) + XCTAssertTrue(bodyString.contains("MethodNotAllowed")) + } + + func testMethodNotAllowed_PUT() async throws { + var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + request.method = .PUT + + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024) + let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? "" + + XCTAssertEqual(response.status, .methodNotAllowed) + XCTAssertTrue(bodyString.contains("MethodNotAllowed")) + } + + func testMethodNotAllowed_DELETE() async throws { + var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + request.method = .DELETE + + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024) + let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? "" + + XCTAssertEqual(response.status, .methodNotAllowed) + XCTAssertTrue(bodyString.contains("MethodNotAllowed")) + } + + // MARK: - BadPath Tests + + func testBadPath_devices() async throws { + var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/devices/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + request.method = .POST + request.headers.add(name: "apns-topic", value: "com.example.app") + request.headers.add(name: "apns-push-type", value: "alert") + request.headers.add(name: "content-type", value: "application/json") + request.body = .bytes(ByteBuffer(string: "{}")) + + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024) + let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? "" + + XCTAssertEqual(response.status, .notFound) + XCTAssertTrue(bodyString.contains("BadPath")) + } + + func testBadPath_wrongVersion() async throws { + var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/2/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + request.method = .POST + request.headers.add(name: "apns-topic", value: "com.example.app") + request.headers.add(name: "apns-push-type", value: "alert") + request.headers.add(name: "content-type", value: "application/json") + request.body = .bytes(ByteBuffer(string: "{}")) + + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024) + let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? "" + + // Wrong version falls through to generic NotFound (not /3/...) + XCTAssertEqual(response.status, .notFound) + XCTAssertTrue(bodyString.contains("NotFound")) + } + + // MARK: - Valid Push Types Test + + func testValidPushTypes() async throws { + let validTypes = ["alert", "background", "location", "voip", "complication", + "fileprovider", "mdm", "liveactivity", "pushtotalk", "widgets"] + + for pushType in validTypes { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: pushType + ) + + XCTAssertEqual(response.status, .ok, "Push type '\(pushType)' should be valid") + } + } + + // MARK: - Valid Priorities Test + + func testValidPriorities() async throws { + for priority in ["5", "10"] { + let response = try await sendRawNotification( + deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + topic: "com.example.app", + pushType: "alert", + priority: priority + ) + + XCTAssertEqual(response.status, .ok, "Priority '\(priority)' should be valid") + } + } + + // MARK: - Helper Methods + + private func sendRawNotification( + deviceToken: String, + topic: String?, + pushType: String?, + priority: String? = nil, + expiration: String? = nil, + collapseID: String? = nil, + body: String? = "{}" + ) async throws -> (status: HTTPResponseStatus, body: String) { + var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/\(deviceToken)") + request.method = .POST + + if let topic = topic { + request.headers.add(name: "apns-topic", value: topic) + } + if let pushType = pushType { + request.headers.add(name: "apns-push-type", value: pushType) + } + if let priority = priority { + request.headers.add(name: "apns-priority", value: priority) + } + if let expiration = expiration { + request.headers.add(name: "apns-expiration", value: expiration) + } + if let collapseID = collapseID { + request.headers.add(name: "apns-collapse-id", value: collapseID) + } + + request.headers.add(name: "content-type", value: "application/json") + + if let body = body { + request.body = .bytes(ByteBuffer(string: body)) + } + + let response = try await httpClient.execute(request, timeout: .seconds(30)) + let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024) + let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? "" + + return (response.status, bodyString) + } +} diff --git a/Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift b/Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift index 2e7a4c63..96f517d9 100644 --- a/Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift +++ b/Tests/APNSTests/Broadcast/APNSBroadcastClientTests.swift @@ -19,14 +19,14 @@ import Crypto import XCTest final class APNSBroadcastClientTests: XCTestCase { - var server: APNSBroadcastTestServer! + var server: APNSTestServer! var client: APNSBroadcastClient! override func setUp() async throws { try await super.setUp() // Start the mock server - server = APNSBroadcastTestServer() + server = APNSTestServer() try await server.start(port: 0) // Create a client pointing to the mock server From 658833f72371f2f09e9f76c95d73b7702e8e07d5 Mon Sep 17 00:00:00 2001 From: Kyle Browning Date: Thu, 30 Oct 2025 14:32:55 -0700 Subject: [PATCH 3/4] fix tests --- .github/workflows/swift.yml | 6 +++--- Sources/APNS/APNSBroadcastClient.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index f868cdb2..aacaa0ae 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -4,21 +4,21 @@ on: jobs: focal: container: - image: swiftlang/swift:nightly-6.0-focal + image: swift:6.2-bookworm runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - run: swift test thread: container: - image: swiftlang/swift:nightly-6.0-focal + image: swift:6.2-bookworm runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - run: swift test --sanitize=thread address: container: - image: swiftlang/swift:nightly-6.0-focal + image: swift:6.2-bookworm runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 diff --git a/Sources/APNS/APNSBroadcastClient.swift b/Sources/APNS/APNSBroadcastClient.swift index 1aab038f..e6ada6bc 100644 --- a/Sources/APNS/APNSBroadcastClient.swift +++ b/Sources/APNS/APNSBroadcastClient.swift @@ -24,7 +24,7 @@ import NIOTLS import NIOPosix /// A client for managing Apple Push Notification broadcast channels. -public final class APNSBroadcastClient: APNSBroadcastClientProtocol { +public final class APNSBroadcastClient: APNSBroadcastClientProtocol { /// The broadcast environment to use. private let environment: APNSBroadcastEnvironment From 7c32fea6a7b422fe7f0b176a0bda31fbd8e574da Mon Sep 17 00:00:00 2001 From: Kyle Browning Date: Fri, 31 Oct 2025 09:35:18 -0700 Subject: [PATCH 4/4] Fix 510 --- Package@swift-5.10.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package@swift-5.10.swift b/Package@swift-5.10.swift index 7ffa8b09..a5ff6611 100644 --- a/Package@swift-5.10.swift +++ b/Package@swift-5.10.swift @@ -39,6 +39,7 @@ let package = Package( dependencies: [ .target(name: "APNSCore"), .target(name: "APNS"), + .target(name: "APNSTestServer"), ] ), .target(