Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MessageTooLarge, more Mocks #40

Merged
merged 5 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Sources/WebPush/Errors/MessageTooLargeError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// MessageTooLargeError.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-13.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

import Foundation

/// The message was too large, and could not be delivered to the push service.
///
/// - SeeAlso: ``WebPushManager/maximumMessageSize``
public struct MessageTooLargeError: LocalizedError, Hashable {
public init() {}

public var errorDescription: String? {
"The message was too large, and could not be delivered to the push service."
}
}
45 changes: 37 additions & 8 deletions Sources/WebPush/WebPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public actor WebPushManager: Sendable {
}

deinit {
if !didShutdown, case let .httpClient(httpClient) = executor {
if !didShutdown, case let .httpClient(httpClient, _) = executor {
try? httpClient.syncShutdown()
}
}
Expand Down Expand Up @@ -260,11 +260,12 @@ public actor WebPushManager: Sendable {
logger: Logger? = nil
) async throws {
switch executor {
case .httpClient(let httpClient):
case .httpClient(let httpClient, let privateKey):
var logger = logger ?? backgroundActivityLogger
logger[metadataKey: "message"] = "\(message)"
logger[metadataKey: "message"] = ".data(\(message.base64URLEncodedString()))"
try await execute(
httpClient: httpClient,
applicationServerECDHPrivateKey: privateKey,
data: message,
subscriber: subscriber,
expiration: expiration,
Expand Down Expand Up @@ -345,9 +346,10 @@ public actor WebPushManager: Sendable {
var logger = logger
logger[metadataKey: "message"] = "\(message)"
switch executor {
case .httpClient(let httpClient):
case .httpClient(let httpClient, let privateKey):
try await execute(
httpClient: httpClient,
applicationServerECDHPrivateKey: privateKey,
data: message.data,
subscriber: subscriber,
expiration: expiration,
Expand All @@ -367,13 +369,15 @@ public actor WebPushManager: Sendable {
/// Send a message via HTTP Client, mocked or otherwise, encrypting it on the way.
/// - Parameters:
/// - httpClient: The protocol implementing HTTP-like functionality.
/// - applicationServerECDHPrivateKey: The private key to use for the key exchange. If nil, one will be generated.
/// - message: The message to send as raw data.
/// - subscriber: The subscriber to sign the message against.
/// - expiration: The expiration of the message.
/// - urgency: The urgency of the message.
/// - logger: The logger to use for status updates.
func execute(
httpClient: some HTTPClientProtocol,
applicationServerECDHPrivateKey: P256.KeyAgreement.PrivateKey?,
data message: some DataProtocol,
subscriber: some SubscriberProtocol,
expiration: Expiration,
Expand All @@ -398,7 +402,7 @@ public actor WebPushManager: Sendable {

/// Prepare authorization, private keys, and payload ahead of time to bail early if they can't be created.
let authorization = try loadCurrentVAPIDAuthorizationHeader(endpoint: subscriber.endpoint, signingKey: signingKey)
let applicationServerECDHPrivateKey = P256.KeyAgreement.PrivateKey()
let applicationServerECDHPrivateKey = applicationServerECDHPrivateKey ?? P256.KeyAgreement.PrivateKey()

/// Perform key exchange between the user agent's public key and our private key, deriving a shared secret.
let userAgent = subscriber.userAgentKeyMaterial
Expand Down Expand Up @@ -490,7 +494,9 @@ public actor WebPushManager: Sendable {
switch response.status {
case .created: break
case .notFound, .gone: throw BadSubscriberError()
// TODO: 413 payload too large - log.error and throw error
case .payloadTooLarge:
logger.error("The encrypted payload was too large and was rejected by the push service.")
throw MessageTooLargeError()
// TODO: 429 too many requests, 500 internal server error, 503 server shutting down - check config and perform a retry after a delay?
default: throw HTTPError(response: response)
}
Expand All @@ -511,7 +517,7 @@ extension WebPushManager: Service {
} onCancelOrGracefulShutdown: { [backgroundActivityLogger, executor] in
backgroundActivityLogger.debug("Shutting down WebPushManager")
do {
if case let .httpClient(httpClient) = executor {
if case let .httpClient(httpClient, _) = executor {
try httpClient.syncShutdown()
}
} catch {
Expand Down Expand Up @@ -675,6 +681,8 @@ extension WebPushManager.Urgency: Codable {

extension WebPushManager {
/// An internal type representing a push message, accessible when using ``/WebPushTesting``.
///
/// - Warning: Never switch on the message type, as values may be added to it over time.
public enum _Message: Sendable, CustomStringConvertible {
/// A message originally sent via ``WebPushManager/send(data:to:expiration:urgency:)``
case data(Data)
Expand All @@ -701,6 +709,20 @@ extension WebPushManager {
}
}

/// The string value from a ``string(_:)`` message.
public var string: String? {
guard case let .string(string) = self
else { return nil }
return string
}

/// The json value from a ``json(_:)`` message.
public func json<JSON: Encodable&Sendable>(as: JSON.Type = JSON.self) -> JSON? {
guard case let .json(json) = self
else { return nil }
return json as? JSON
}

public var description: String {
switch self {
case .data(let data):
Expand All @@ -715,10 +737,17 @@ extension WebPushManager {

/// An internal type representing the executor for a push message.
package enum Executor: Sendable {
/// Use an HTTP client and optional private key to send an encrypted payload to a subscriber.
///
/// This is used in tests to capture the encrypted request and make sure it is well-formed.
case httpClient(any HTTPClientProtocol, P256.KeyAgreement.PrivateKey?)

/// Use an HTTP client to send an encrypted payload to a subscriber.
///
/// This is used in tests to capture the encrypted request and make sure it is well-formed.
case httpClient(any HTTPClientProtocol)
package static func httpClient(_ httpClient: any HTTPClientProtocol) -> Self {
.httpClient(httpClient, nil)
}

/// Use a handler to capture the original message.
///
Expand Down
53 changes: 53 additions & 0 deletions Sources/WebPushTesting/Subscriber+Testing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// Subscriber+Testing.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-20.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

@preconcurrency import Crypto
import Foundation
import WebPush

extension Subscriber {
/// A mocked subscriber to send messages to.
public static let mockedSubscriber = Subscriber(
endpoint: URL(string: "https://example.com/subscriber")!,
userAgentKeyMaterial: .mockedKeyMaterial,
vapidKeyID: .mockedKeyID1
)

/// Make a mocked subscriber with a unique private key and salt.
static func makeMockedSubscriber(endpoint: URL = URL(string: "https://example.com/subscriber")!) -> (subscriber: Subscriber, privateKey: P256.KeyAgreement.PrivateKey) {
let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false)
var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16)
for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) }

let subscriber = Subscriber(
endpoint: endpoint,
userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)),
vapidKeyID: .mockedKeyID1
)

return (subscriber, subscriberPrivateKey)
}
}

extension SubscriberProtocol where Self == Subscriber {
/// A mocked subscriber to send messages to.
public static func mockedSubscriber() -> Subscriber {
.mockedSubscriber
}
}

extension UserAgentKeyMaterial {
/// The private key component of ``mockedKeyMaterial``.
public static let mockedKeyMaterialPrivateKey = try! P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64Encoded: "BS2nTTf5wAdVvi5Om3AjSmlsCpz91XgK+uCLaIJ0T/M=")!)

/// A mocked user-agent-key material to attach to a subscriber.
public static let mockedKeyMaterial = try! UserAgentKeyMaterial(
publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M",
authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg"
)
}
1 change: 1 addition & 0 deletions Sources/WebPushTesting/WebPushManager+Testing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import WebPush

extension WebPushManager {
/// A push message in its original form, either ``/Foundation/Data``, ``/Swift/String``, or ``/Foundation/Encodable``.
/// - Warning: Never switch on the message type, as values may be added to it over time.
public typealias Message = _Message

/// Create a mocked web push manager.
Expand Down
Loading
Loading