diff --git a/.spi.yml b/.spi.yml index a00ad8a..c0e1e27 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,4 @@ version: 1 builder: configs: - - documentation_targets: [WebPush] + - documentation_targets: [WebPush, WebPushTesting] diff --git a/Sources/WebPush/VAPID/VAPIDConfiguration.swift b/Sources/WebPush/VAPID/VAPIDConfiguration.swift index 7f6569d..21d6b28 100644 --- a/Sources/WebPush/VAPID/VAPIDConfiguration.swift +++ b/Sources/WebPush/VAPID/VAPIDConfiguration.swift @@ -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, + deprecatedKeys: Set? = nil + ) { + self.primaryKey = primaryKey + self.keys = keys + self.deprecatedKeys = deprecatedKeys + } } } diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 1845c92..bdaa825 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -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… @@ -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 ?? []) @@ -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) } @@ -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. @@ -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 { @@ -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 { @@ -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, diff --git a/Sources/WebPushTesting/VAPIDConfiguration+Testing.swift b/Sources/WebPushTesting/VAPIDConfiguration+Testing.swift index dd4a23a..3f6ef55 100644 --- a/Sources/WebPushTesting/VAPIDConfiguration+Testing.swift +++ b/Sources/WebPushTesting/VAPIDConfiguration+Testing.swift @@ -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")) } diff --git a/Sources/WebPushTesting/VAPIDKey+Testing.swift b/Sources/WebPushTesting/VAPIDKey+Testing.swift new file mode 100644 index 0000000..b441a84 --- /dev/null +++ b/Sources/WebPushTesting/VAPIDKey+Testing.swift @@ -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 +} diff --git a/Sources/WebPushTesting/WebPushManager+Testing.swift b/Sources/WebPushTesting/WebPushManager+Testing.swift index 08f195d..e3000ef 100644 --- a/Sources/WebPushTesting/WebPushManager+Testing.swift +++ b/Sources/WebPushTesting/WebPushManager+Testing.swift @@ -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. diff --git a/Tests/WebPushTests/VAPIDConfigurationTests.swift b/Tests/WebPushTests/VAPIDConfigurationTests.swift index 32342b8..bda875e 100644 --- a/Tests/WebPushTests/VAPIDConfigurationTests.swift +++ b/Tests/WebPushTests/VAPIDConfigurationTests.swift @@ -10,22 +10,19 @@ import Crypto import Foundation import Testing @testable import WebPush +import WebPushTesting @Suite("VAPID Configuration Tests") struct VAPIDConfigurationTests { @Suite struct Initialization { - let key1 = try! VAPID.Key(base64URLEncoded: "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=") - let key2 = try! VAPID.Key(base64URLEncoded: "wyQaGWNwvXKzVmPIhkqVQvQ+FKx1SNqHJ+re8n2ORrk=") - let key3 = try! VAPID.Key(base64URLEncoded: "bcZgo/p2WFqXaKFzmYaDKO/gARjWvGi3oXyHM2QNlfE=") - @Test func primaryKeyOnly() { let config = VAPID.Configuration( - key: key1, + key: .mockedKey1, contactInformation: .email("test@email.com") ) - #expect(config.primaryKey == key1) - #expect(config.keys == [key1]) + #expect(config.primaryKey == .mockedKey1) + #expect(config.keys == [.mockedKey1]) #expect(config.deprecatedKeys == nil) #expect(config.contactInformation == .email("test@email.com")) #expect(config.expirationDuration == .hours(22)) @@ -34,14 +31,14 @@ struct VAPIDConfigurationTests { @Test func emptyDeprecatedKeys() { let config = VAPID.Configuration( - key: key1, + key: .mockedKey1, deprecatedKeys: [], contactInformation: .url(URL(string: "https://example.com")!), expirationDuration: .hours(24), validityDuration: .hours(12) ) - #expect(config.primaryKey == key1) - #expect(config.keys == [key1]) + #expect(config.primaryKey == .mockedKey1) + #expect(config.keys == [.mockedKey1]) #expect(config.deprecatedKeys == nil) #expect(config.contactInformation == .url(URL(string: "https://example.com")!)) #expect(config.expirationDuration == .hours(24)) @@ -50,13 +47,13 @@ struct VAPIDConfigurationTests { @Test func deprecatedKeys() { let config = VAPID.Configuration( - key: key1, - deprecatedKeys: [key2, key3], + key: .mockedKey1, + deprecatedKeys: [.mockedKey2, .mockedKey3], contactInformation: .email("test@email.com") ) - #expect(config.primaryKey == key1) - #expect(config.keys == [key1]) - #expect(config.deprecatedKeys == [key2, key3]) + #expect(config.primaryKey == .mockedKey1) + #expect(config.keys == [.mockedKey1]) + #expect(config.deprecatedKeys == [.mockedKey2, .mockedKey3]) #expect(config.contactInformation == .email("test@email.com")) #expect(config.expirationDuration == .hours(22)) #expect(config.validityDuration == .hours(20)) @@ -64,15 +61,15 @@ struct VAPIDConfigurationTests { @Test func deprecatedAndPrimaryKeys() { let config = VAPID.Configuration( - key: key1, - deprecatedKeys: [key2, key3, key1], + key: .mockedKey1, + deprecatedKeys: [.mockedKey2, .mockedKey3, .mockedKey1], contactInformation: .url(URL(string: "https://example.com")!), expirationDuration: .hours(24), validityDuration: .hours(12) ) - #expect(config.primaryKey == key1) - #expect(config.keys == [key1]) - #expect(config.deprecatedKeys == [key2, key3]) + #expect(config.primaryKey == .mockedKey1) + #expect(config.keys == [.mockedKey1]) + #expect(config.deprecatedKeys == [.mockedKey2, .mockedKey3]) #expect(config.contactInformation == .url(URL(string: "https://example.com")!)) #expect(config.expirationDuration == .hours(24)) #expect(config.validityDuration == .hours(12)) @@ -81,12 +78,12 @@ struct VAPIDConfigurationTests { @Test func multipleKeys() throws { let config = try VAPID.Configuration( primaryKey: nil, - keys: [key1, key2], + keys: [.mockedKey1, .mockedKey2], deprecatedKeys: nil, contactInformation: .email("test@email.com") ) #expect(config.primaryKey == nil) - #expect(config.keys == [key1, key2]) + #expect(config.keys == [.mockedKey1, .mockedKey2]) #expect(config.deprecatedKeys == nil) #expect(config.contactInformation == .email("test@email.com")) #expect(config.expirationDuration == .hours(22)) @@ -98,7 +95,7 @@ struct VAPIDConfigurationTests { try VAPID.Configuration( primaryKey: nil, keys: [], - deprecatedKeys: [key2, key3], + deprecatedKeys: [.mockedKey2, .mockedKey3], contactInformation: .email("test@email.com") ) } @@ -107,12 +104,12 @@ struct VAPIDConfigurationTests { @Test func multipleAndDeprecatedKeys() throws { let config = try VAPID.Configuration( primaryKey: nil, - keys: [key1, key2], - deprecatedKeys: [key2], + keys: [.mockedKey1, .mockedKey2], + deprecatedKeys: [.mockedKey2], contactInformation: .email("test@email.com") ) #expect(config.primaryKey == nil) - #expect(config.keys == [key1, key2]) + #expect(config.keys == [.mockedKey1, .mockedKey2]) #expect(config.deprecatedKeys == nil) #expect(config.contactInformation == .email("test@email.com")) #expect(config.expirationDuration == .hours(22)) @@ -121,16 +118,16 @@ struct VAPIDConfigurationTests { @Test func multipleAndPrimaryKeys() throws { let config = try VAPID.Configuration( - primaryKey: key1, - keys: [key2], - deprecatedKeys: [key2, key3, key1], + primaryKey: .mockedKey1, + keys: [.mockedKey2], + deprecatedKeys: [.mockedKey2, .mockedKey3, .mockedKey1], contactInformation: .url(URL(string: "https://example.com")!), expirationDuration: .hours(24), validityDuration: .hours(12) ) - #expect(config.primaryKey == key1) - #expect(config.keys == [key1, key2]) - #expect(config.deprecatedKeys == [key3]) + #expect(config.primaryKey == .mockedKey1) + #expect(config.keys == [.mockedKey1, .mockedKey2]) + #expect(config.deprecatedKeys == [.mockedKey3]) #expect(config.contactInformation == .url(URL(string: "https://example.com")!)) #expect(config.expirationDuration == .hours(24)) #expect(config.validityDuration == .hours(12)) @@ -139,21 +136,17 @@ struct VAPIDConfigurationTests { @Suite struct Updates { - let key1 = try! VAPID.Key(base64URLEncoded: "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=") - let key2 = try! VAPID.Key(base64URLEncoded: "wyQaGWNwvXKzVmPIhkqVQvQ+FKx1SNqHJ+re8n2ORrk=") - let key3 = try! VAPID.Key(base64URLEncoded: "bcZgo/p2WFqXaKFzmYaDKO/gARjWvGi3oXyHM2QNlfE=") - @Test func primaryKeyOnly() throws { - var config = VAPID.Configuration(key: key1, contactInformation: .email("test@email.com")) + var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com")) - try config.updateKeys(primaryKey: key2, keys: [], deprecatedKeys: nil) - #expect(config.primaryKey == key2) - #expect(config.keys == [key2]) + try config.updateKeys(primaryKey: .mockedKey2, keys: [], deprecatedKeys: nil) + #expect(config.primaryKey == .mockedKey2) + #expect(config.keys == [.mockedKey2]) #expect(config.deprecatedKeys == nil) } @Test func noKeys() throws { - var config = VAPID.Configuration(key: key1, contactInformation: .email("test@email.com")) + var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com")) #expect(throws: VAPID.ConfigurationError.keysNotProvided) { try config.updateKeys(primaryKey: nil, keys: [], deprecatedKeys: nil) } @@ -161,59 +154,55 @@ struct VAPIDConfigurationTests { try config.updateKeys(primaryKey: nil, keys: [], deprecatedKeys: []) } #expect(throws: VAPID.ConfigurationError.keysNotProvided) { - try config.updateKeys(primaryKey: nil, keys: [], deprecatedKeys: [key1]) + try config.updateKeys(primaryKey: nil, keys: [], deprecatedKeys: [.mockedKey1]) } } @Test func multipleKeys() throws { - var config = VAPID.Configuration(key: key1, contactInformation: .email("test@email.com")) + var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com")) - try config.updateKeys(primaryKey: nil, keys: [key2], deprecatedKeys: nil) + try config.updateKeys(primaryKey: nil, keys: [.mockedKey2], deprecatedKeys: nil) #expect(config.primaryKey == nil) - #expect(config.keys == [key2]) + #expect(config.keys == [.mockedKey2]) #expect(config.deprecatedKeys == nil) - try config.updateKeys(primaryKey: nil, keys: [key2, key3], deprecatedKeys: nil) + try config.updateKeys(primaryKey: nil, keys: [.mockedKey2, .mockedKey3], deprecatedKeys: nil) #expect(config.primaryKey == nil) - #expect(config.keys == [key2, key3]) + #expect(config.keys == [.mockedKey2, .mockedKey3]) #expect(config.deprecatedKeys == nil) } @Test func multipleAndDeprecatedKeys() throws { - var config = VAPID.Configuration(key: key1, contactInformation: .email("test@email.com")) + var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com")) - try config.updateKeys(primaryKey: nil, keys: [key2], deprecatedKeys: [key2, key3]) + try config.updateKeys(primaryKey: nil, keys: [.mockedKey2], deprecatedKeys: [.mockedKey2, .mockedKey3]) #expect(config.primaryKey == nil) - #expect(config.keys == [key2]) - #expect(config.deprecatedKeys == [key3]) + #expect(config.keys == [.mockedKey2]) + #expect(config.deprecatedKeys == [.mockedKey3]) - try config.updateKeys(primaryKey: nil, keys: [key2, key3], deprecatedKeys: [key2, key3]) + try config.updateKeys(primaryKey: nil, keys: [.mockedKey2, .mockedKey3], deprecatedKeys: [.mockedKey2, .mockedKey3]) #expect(config.primaryKey == nil) - #expect(config.keys == [key2, key3]) + #expect(config.keys == [.mockedKey2, .mockedKey3]) #expect(config.deprecatedKeys == nil) } @Test func multipleAndPrimaryKeys() throws { - var config = VAPID.Configuration(key: key1, contactInformation: .email("test@email.com")) + var config = VAPID.Configuration(key: .mockedKey1, contactInformation: .email("test@email.com")) - try config.updateKeys(primaryKey: key2, keys: [key3], deprecatedKeys: [key1, key2, key3]) - #expect(config.primaryKey == key2) - #expect(config.keys == [key2, key3]) - #expect(config.deprecatedKeys == [key1]) + try config.updateKeys(primaryKey: .mockedKey2, keys: [.mockedKey3], deprecatedKeys: [.mockedKey1, .mockedKey2, .mockedKey3]) + #expect(config.primaryKey == .mockedKey2) + #expect(config.keys == [.mockedKey2, .mockedKey3]) + #expect(config.deprecatedKeys == [.mockedKey1]) - try config.updateKeys(primaryKey: key2, keys: [key3], deprecatedKeys: [key2, key3]) - #expect(config.primaryKey == key2) - #expect(config.keys == [key2, key3]) + try config.updateKeys(primaryKey: .mockedKey2, keys: [.mockedKey3], deprecatedKeys: [.mockedKey2, .mockedKey3]) + #expect(config.primaryKey == .mockedKey2) + #expect(config.keys == [.mockedKey2, .mockedKey3]) #expect(config.deprecatedKeys == nil) } } @Suite struct Coding { - let key1 = try! VAPID.Key(base64URLEncoded: "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=") - let key2 = try! VAPID.Key(base64URLEncoded: "wyQaGWNwvXKzVmPIhkqVQvQ+FKx1SNqHJ+re8n2ORrk=") - let key3 = try! VAPID.Key(base64URLEncoded: "bcZgo/p2WFqXaKFzmYaDKO/gARjWvGi3oXyHM2QNlfE=") - func encode(_ configuration: VAPID.Configuration) throws -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] @@ -222,7 +211,7 @@ struct VAPIDConfigurationTests { @Test func encodesPrimaryKeyOnly() async throws { #expect( - try encode(.init(key: key1, contactInformation: .email("test@example.com"))) == + try encode(.init(key: .mockedKey1, contactInformation: .email("test@example.com"))) == """ { "contactInformation" : "mailto:test@example.com", @@ -237,9 +226,9 @@ struct VAPIDConfigurationTests { @Test func encodesMultipleKeysWithoutDuplicates() async throws { #expect( try encode(.init( - primaryKey: key1, - keys: [key2], - deprecatedKeys: [key1, key2, key3], + primaryKey: .mockedKey1, + keys: [.mockedKey2], + deprecatedKeys: [.mockedKey1, .mockedKey2, .mockedKey3], contactInformation: .email("test@example.com"), expirationDuration: .hours(1), validityDuration: .hours(10) @@ -274,7 +263,7 @@ struct VAPIDConfigurationTests { """.utf8 )) == VAPID.Configuration( - key: key1, + key: .mockedKey1, contactInformation: .email("test@example.com") ) ) @@ -299,9 +288,9 @@ struct VAPIDConfigurationTests { """.utf8 )) == VAPID.Configuration( - primaryKey: key1, - keys: [key2], - deprecatedKeys: [key1, key2, key3], + primaryKey: .mockedKey1, + keys: [.mockedKey2], + deprecatedKeys: [.mockedKey1, .mockedKey2, .mockedKey3], contactInformation: .email("test@example.com"), expirationDuration: .hours(1), validityDuration: .hours(10) diff --git a/Tests/WebPushTests/WebPushManagerTests.swift b/Tests/WebPushTests/WebPushManagerTests.swift index adc21bb..e181a24 100644 --- a/Tests/WebPushTests/WebPushManagerTests.swift +++ b/Tests/WebPushTests/WebPushManagerTests.swift @@ -13,6 +13,7 @@ import Logging import ServiceLifecycle import Testing @testable import WebPush +import WebPushTesting @Suite("WebPush Manager") struct WebPushManagerTests { @@ -41,6 +42,141 @@ struct WebPushManagerTests { group.cancelAll() } } + + @Test func managerCanCreateThreadPool() async throws { + let manager = WebPushManager(vapidConfiguration: .makeTesting(), eventLoopGroupProvider: .createNew) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } + + @Test func managerCanBeMocked() async throws { + let manager = WebPushManager.makeMockedManager { _, _, _, _ in } + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } + + /// Enable when https://github.com/swiftlang/swift-testing/blob/jgrynspan/exit-tests-proposal/Documentation/Proposals/NNNN-exit-tests.md gets accepted. +// @Test func managerCatchesIncorrectValidity() async throws { +// await #expect(exitsWith: .failure) { +// var configuration = VAPID.Configuration(key: .init(), contactInformation: .email("test@example.com")) +// configuration.validityDuration = .days(2) +// let _ = WebPushManager(vapidConfiguration: configuration) +// } +// } + + @Test func managerConstructsAValidKeyLookup() async throws { + let configuration = try VAPID.Configuration(primaryKey: .mockedKey1, keys: [.mockedKey2], deprecatedKeys: [.mockedKey3], contactInformation: .email("test@example.com")) + let manager = WebPushManager(vapidConfiguration: configuration) + #expect(await manager.vapidKeyLookup == [ + .mockedKeyID1 : .mockedKey1, + .mockedKeyID2 : .mockedKey2, + .mockedKeyID3 : .mockedKey3, + ]) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } + + /// This is needed to cover the `uniquingKeysWith` safety call completely. + @Test func managerConstructsAValidKeyLookupFromQuestionableConfiguration() async throws { + var configuration = VAPID.Configuration.mocked + configuration.unsafeUpdateKeys(primaryKey: .mockedKey1, keys: [.mockedKey1], deprecatedKeys: [.mockedKey1]) + let manager = WebPushManager(vapidConfiguration: configuration) + #expect(await manager.vapidKeyLookup == [.mockedKeyID1 : .mockedKey1]) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } + } + + @Suite("VAPID Key Retrieval") struct VAPIDKeyRetrieval { + @Test func retrievesPrimaryKey() async { + let manager = WebPushManager(vapidConfiguration: .mocked) + #expect(manager.nextVAPIDKeyID == .mockedKeyID1) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } + + @Test func alwaysRetrievesPrimaryKey() async throws { + var configuration = VAPID.Configuration.mocked + try configuration.updateKeys(primaryKey: .mockedKey1, keys: [.mockedKey2], deprecatedKeys: [.mockedKey3]) + let manager = WebPushManager(vapidConfiguration: configuration) + for _ in 0..<100_000 { + #expect(manager.nextVAPIDKeyID == .mockedKeyID1) + } + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } + + @Test func retrievesFallbackKeys() async throws { + var configuration = VAPID.Configuration.mocked + try configuration.updateKeys(primaryKey: nil, keys: [.mockedKey1, .mockedKey2]) + let manager = WebPushManager(vapidConfiguration: configuration) + var keyCounts: [VAPID.Key.ID : Int] = [:] + for _ in 0..<100_000 { + keyCounts[manager.nextVAPIDKeyID, default: 0] += 1 + } + #expect(abs(keyCounts[.mockedKeyID1, default: 0] - keyCounts[.mockedKeyID2, default: 0]) < 1_000) /// If this test fails, increase this number accordingly + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } + + @Test func neverRetrievesDeprecatedKeys() async throws { + var configuration = VAPID.Configuration.mocked + try configuration.updateKeys(primaryKey: nil, keys: [.mockedKey1, .mockedKey2], deprecatedKeys: [.mockedKey3]) + let manager = WebPushManager(vapidConfiguration: configuration) + for _ in 0..<100_000 { + #expect(manager.nextVAPIDKeyID != .mockedKeyID3) + } + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } + + @Test func keyStatus() async throws { + var configuration = VAPID.Configuration.mocked + try configuration.updateKeys(primaryKey: .mockedKey1, keys: [.mockedKey2], deprecatedKeys: [.mockedKey3]) + let manager = WebPushManager(vapidConfiguration: configuration) + #expect(manager.keyStatus(for: .mockedKeyID1) == .valid) + #expect(manager.keyStatus(for: .mockedKeyID2) == .valid) + #expect(manager.keyStatus(for: .mockedKeyID3) == .deprecated) + #expect(manager.keyStatus(for: .mockedKeyID4) == .unknown) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() + } + } } @Suite("Sending Messages")