Skip to content

More Test Coverage #34

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

Merged
merged 4 commits into from
Dec 18, 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
2 changes: 1 addition & 1 deletion .spi.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [WebPush]
- documentation_targets: [WebPush, WebPushTesting]
11 changes: 11 additions & 0 deletions Sources/WebPush/VAPID/VAPIDConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ extension VoluntaryApplicationServerIdentification {
deprecatedKeys.subtract(keys)
self.deprecatedKeys = deprecatedKeys.isEmpty ? nil : deprecatedKeys
}

/// Internal method to set invalid state for validation that other components are resiliant to these configurations.
mutating func unsafeUpdateKeys(
primaryKey: Key? = nil,
keys: Set<Key>,
deprecatedKeys: Set<Key>? = nil
) {
self.primaryKey = primaryKey
self.keys = keys
self.deprecatedKeys = deprecatedKeys
}
}
}

Expand Down
62 changes: 57 additions & 5 deletions Sources/WebPush/WebPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,47 @@ import NIOCore
import NIOPosix
import ServiceLifecycle

/// A manager for sending push messages to subscribers.
///
/// You should instantiate and keep a reference to a single manager, passing a reference as a dependency to requests and other controllers that need to send messages. This is because the manager has an internal cache for managing connections to push services.
///
/// The manager should be installed as a service to wait for any in-flight messages to be sent before your application server shuts down.
public actor WebPushManager: Sendable {
/// The VAPID configuration used when configuring the manager.
public let vapidConfiguration: VAPID.Configuration

/// The maximum encrypted payload size guaranteed by the spec.
///
/// Currently the spec guarantees up to 4,096 encrypted bytes will always be successfull.
///
/// - Note: _Some_, but not all, push services allow an effective encrypted message size that is larger than this, as they misinterpreted the 4096 maximum payload size as the plaintext maximum size, and support the larger size to this day. This library will however warn if this threshold is surpassed and attempt sending the message anyways — it is up to the caller to make sure messages over this size are not regularly attempted, and for fallback mechanisms to be put in place should push result in an error.
public static let maximumEncryptedPayloadSize = 4096

/// The maximum message size allowed.
///
/// This is currently set to 3,993 plaintext bytes. See the discussion for ``maximumEncryptedPayloadSize`` for more information.
public static let maximumMessageSize = maximumEncryptedPayloadSize - 103

/// The internal logger to use when reporting status.
nonisolated let logger: Logger

/// The internal executor to use when delivering messages.
var executor: Executor

/// An internal lookup of keys as provided by the VAPID configuration.
let vapidKeyLookup: [VAPID.Key.ID : VAPID.Key]

/// An internal cache of `Authorization` header values for a combination of endpoint origin and VAPID key ID.
/// - SeeAlso: ``loadCurrentVAPIDAuthorizationHeader(endpoint:signingKey:)``
var vapidAuthorizationCache: [String : (authorization: String, validUntil: Date)] = [:]

/// Initialize a manager with a VAPID configuration.
///
/// - Note: On debug builds, this initializer will assert if VAPID authorization header expiration times are inconsistently set.
/// - Parameters:
/// - vapidConfiguration: The VAPID configuration to use when identifying the application server.
/// - logger: An optional parent logger to use for status updates.
/// - eventLoopGroupProvider: The event loop to use for the internal HTTP client.
public init(
vapidConfiguration: VAPID.Configuration,
// TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
Expand Down Expand Up @@ -63,17 +89,27 @@ public actor WebPushManager: Sendable {
}

/// Internal method to install a different executor for mocking.
///
///
/// Note that this must be called before ``run()`` is called or the client's syncShutdown won't be called.
/// - Parameters:
/// - vapidConfiguration: The VAPID configuration to use when identifying the application server.
/// - logger: The logger to use for status updates.
/// - executor: The executor to use when sending push messages.
package init(
vapidConfiguration: VAPID.Configuration,
// TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
logger: Logger,
executor: Executor
) {
assert(vapidConfiguration.validityDuration <= vapidConfiguration.expirationDuration, "The validity duration must be earlier than the expiration duration since it represents when the VAPID Authorization token will be refreshed ahead of it expiring.");
assert(vapidConfiguration.expirationDuration <= .hours(24), "The expiration duration must be less than 24 hours or else push endpoints will reject messages sent to them.");
precondition(!vapidConfiguration.keys.isEmpty, "VAPID.Configuration must have keys specified.")
if vapidConfiguration.validityDuration > vapidConfiguration.expirationDuration {
assertionFailure("The validity duration must be earlier than the expiration duration since it represents when the VAPID Authorization token will be refreshed ahead of it expiring.")
logger.error("The validity duration must be earlier than the expiration duration since it represents when the VAPID Authorization token will be refreshed ahead of it expiring. Run your application server with the same configuration in debug mode to catch this.")
}
if vapidConfiguration.expirationDuration > .hours(24) {
assertionFailure("The expiration duration must be less than 24 hours or else push endpoints will reject messages sent to them.")
logger.error("The expiration duration must be less than 24 hours or else push endpoints will reject messages sent to them. Run your application server with the same configuration in debug mode to catch this.")
}
precondition(!vapidConfiguration.keys.isEmpty, "VAPID.Configuration must have keys specified. Please report this as a bug with reproduction steps if encountered: https://github.com/mochidev/swift-webpush/issues.")

self.vapidConfiguration = vapidConfiguration
let allKeys = vapidConfiguration.keys + Array(vapidConfiguration.deprecatedKeys ?? [])
Expand Down Expand Up @@ -401,7 +437,7 @@ public actor WebPushManager: Sendable {
switch response.status {
case .created: break
case .notFound, .gone: throw BadSubscriberError()
// TODO: 413 payload too large - warn and throw error
// TODO: 413 payload too large - log.error and throw error
// 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 Down Expand Up @@ -520,6 +556,7 @@ extension WebPushManager {
///
/// - SeeAlso: [RFC 8030 Generic Event Delivery Using HTTP §5.3. Push Message Urgency](https://datatracker.ietf.org/doc/html/rfc8030#section-5.3)
public struct Urgency: Hashable, Comparable, Sendable, CustomStringConvertible {
/// The internal raw value that is encoded in this type's place when calling ``description``.
let rawValue: String

/// An urgency intended only for devices on power and Wi-Fi.
Expand All @@ -542,6 +579,7 @@ extension WebPushManager {
/// For instance, high ugency messages are ideal for incoming phone calls or time-sensitive alerts.
public static let high = Self(rawValue: "high")

/// An internal sort order for urgencies.
@usableFromInline
var comparableValue: Int {
switch self {
Expand Down Expand Up @@ -577,11 +615,17 @@ extension WebPushManager.Urgency: Codable {
// MARK: - Package Types

extension WebPushManager {
/// An internal type representing a push message, accessible when using ``/WebPushTesting``.
public enum _Message: Sendable {
/// A message originally sent via ``WebPushManager/send(data:to:expiration:urgency:)``
case data(Data)

/// A message originally sent via ``WebPushManager/send(string:to:expiration:urgency:)``
case string(String)
/// A message originally sent via ``WebPushManager/send(json:to:expiration:urgency:)
case json(any Encodable&Sendable)

/// The message, encoded as data.
var data: Data {
get throws {
switch self {
Expand All @@ -599,8 +643,16 @@ extension WebPushManager {
}
}

/// An internal type representing the executor for a push message.
package enum Executor: Sendable {
/// 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)

/// Use a handler to capture the original message.
///
/// This is used by ``/WebPushTesting`` to allow mocking a ``WebPushManager``.
case handler(@Sendable (
_ message: _Message,
_ subscriber: Subscriber,
Expand Down
9 changes: 1 addition & 8 deletions Sources/WebPushTesting/VAPIDConfiguration+Testing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,5 @@ import WebPush

extension VAPID.Configuration {
/// A mocked configuration useful when testing with the library, since the mocked manager doesn't make use of it anyways.
public static var mocked: Self {
/// Generated using `P256.Signing.PrivateKey(compactRepresentable: false).x963Representation.base64EncodedString()`.
let privateKey = try! P256.Signing.PrivateKey(x963Representation: Data(base64Encoded: "BGEhWik09/s/JNkl0OAcTIdRTb7AoLRZQQG4C96OhlcFVQYH5kMWUML3MZBG3gPXxN1Njn6uXulDysPGMDBR47SurTnyXnbuaJ7VDm3UsVYUs5kFoZM8VB5QtoKpgE7WyQ==")!)
return VAPID.Configuration(
key: .init(privateKey: privateKey),
contactInformation: .email("test@example.com")
)
}
public static let mocked = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@example.com"))
}
31 changes: 31 additions & 0 deletions Sources/WebPushTesting/VAPIDKey+Testing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// VAPIDKey+Testing.swift
// swift-webpush
//
// Created by Dimitri Bouniol on 2024-12-18.
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
//

import WebPush

extension VAPID.Key {
/// A mocked key guaranteed to not conflict with ``mockedKey2``, ``mockedKey3``, and ``mockedKey4``.
public static let mockedKey1 = try! VAPID.Key(base64URLEncoded: "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=")
/// A mocked key guaranteed to not conflict with ``mockedKey1``, ``mockedKey3``, and ``mockedKey4``.
public static let mockedKey2 = try! VAPID.Key(base64URLEncoded: "wyQaGWNwvXKzVmPIhkqVQvQ+FKx1SNqHJ+re8n2ORrk=")
/// A mocked key guaranteed to not conflict with ``mockedKey1``, ``mockedKey2``, and ``mockedKey4``.
public static let mockedKey3 = try! VAPID.Key(base64URLEncoded: "bcZgo/p2WFqXaKFzmYaDKO/gARjWvGi3oXyHM2QNlfE=")
/// A mocked key guaranteed to not conflict with ``mockedKey1``, ``mockedKey2``, and ``mockedKey3``.
public static let mockedKey4 = try! VAPID.Key(base64URLEncoded: "BGEhWik09/s/JNkl0OAcTIdRTb7AoLRZQQG4C96Ohlc=")
}

extension VAPID.Key.ID {
/// A mocked key ID that matches ``/VAPID/Key/mockedKey1``.
public static let mockedKeyID1 = VAPID.Key.mockedKey1.id
/// A mocked key ID that matches ``/VAPID/Key/mockedKey2``.
public static let mockedKeyID2 = VAPID.Key.mockedKey2.id
/// A mocked key ID that matches ``/VAPID/Key/mockedKey3``.
public static let mockedKeyID3 = VAPID.Key.mockedKey3.id
/// A mocked key ID that matches ``/VAPID/Key/mockedKey4``.
public static let mockedKeyID4 = VAPID.Key.mockedKey4.id
}
1 change: 1 addition & 0 deletions Sources/WebPushTesting/WebPushManager+Testing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Logging
import WebPush

extension WebPushManager {
/// A push message in its original form, either ``/Foundation/Data``, ``/Swift/String``, or ``/Foundation/Encodable``.
public typealias Message = _Message

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