diff --git a/Sources/WebPush/Errors/BadSubscriberError.swift b/Sources/WebPush/Errors/BadSubscriberError.swift index 8751e1a..f17f8e9 100644 --- a/Sources/WebPush/Errors/BadSubscriberError.swift +++ b/Sources/WebPush/Errors/BadSubscriberError.swift @@ -11,7 +11,7 @@ import Foundation /// The subscription is no longer valid and should be removed and re-registered. /// /// - Warning: Do not continue to send notifications to invalid subscriptions or you'll risk being rate limited by push services. -public struct BadSubscriberError: LocalizedError, Hashable { +public struct BadSubscriberError: LocalizedError, Hashable, Sendable { public init() {} public var errorDescription: String? { diff --git a/Sources/WebPush/Errors/Base64URLDecodingError.swift b/Sources/WebPush/Errors/Base64URLDecodingError.swift index 1d09a54..ad4c8ae 100644 --- a/Sources/WebPush/Errors/Base64URLDecodingError.swift +++ b/Sources/WebPush/Errors/Base64URLDecodingError.swift @@ -9,7 +9,7 @@ import Foundation /// An error encountered while decoding Base64 data. -public struct Base64URLDecodingError: LocalizedError, Hashable { +public struct Base64URLDecodingError: LocalizedError, Hashable, Sendable { public init() {} public var errorDescription: String? { diff --git a/Sources/WebPush/Errors/HTTPError.swift b/Sources/WebPush/Errors/HTTPError.swift index 80a4b6e..50d8907 100644 --- a/Sources/WebPush/Errors/HTTPError.swift +++ b/Sources/WebPush/Errors/HTTPError.swift @@ -14,14 +14,26 @@ import Foundation /// - SeeAlso: [RFC 8030 Generic Event Delivery Using HTTP Push](https://datatracker.ietf.org/doc/html/rfc8030) /// - SeeAlso: [RFC 8292 Voluntary Application Server Identification (VAPID) for Web Push](https://datatracker.ietf.org/doc/html/rfc8292) /// - SeeAlso: [Sending web push notifications in web apps and browsers — Review responses for push notification errors](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers#Review-responses-for-push-notification-errors) -public struct HTTPError: LocalizedError { - let response: HTTPClientResponse +public struct HTTPError: LocalizedError, Sendable { + public let response: HTTPClientResponse + let capturedResponseDescription: String - init(response: HTTPClientResponse) { + public init(response: HTTPClientResponse) { self.response = response + self.capturedResponseDescription = "\(response)" } public var errorDescription: String? { - "A \(response.status) HTTP error was encountered: \(response)." + "A \(response.status) HTTP error was encountered: \(capturedResponseDescription)." + } +} + +extension HTTPError: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + "\(lhs.capturedResponseDescription)" == "\(rhs.capturedResponseDescription)" + } + + public func hash(into hasher: inout Hasher) { + hasher.combine("\(capturedResponseDescription)") } } diff --git a/Sources/WebPush/Errors/MessageTooLargeError.swift b/Sources/WebPush/Errors/MessageTooLargeError.swift index 8569f13..360330d 100644 --- a/Sources/WebPush/Errors/MessageTooLargeError.swift +++ b/Sources/WebPush/Errors/MessageTooLargeError.swift @@ -11,7 +11,7 @@ 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 struct MessageTooLargeError: LocalizedError, Hashable, Sendable { public init() {} public var errorDescription: String? { diff --git a/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift b/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift index 80e00ec..984c356 100644 --- a/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift +++ b/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift @@ -9,7 +9,7 @@ import Foundation /// An error encountered during ``VAPID/Configuration`` initialization or decoding. -public struct UserAgentKeyMaterialError: LocalizedError { +public struct UserAgentKeyMaterialError: LocalizedError, Sendable { enum Kind { case invalidPublicKey case invalidAuthenticationSecret @@ -31,9 +31,20 @@ public struct UserAgentKeyMaterialError: LocalizedError { public var errorDescription: String? { switch kind { case .invalidPublicKey: - "Subscriber Public Key (`\(UserAgentKeyMaterial.CodingKeys.publicKey)`) was invalid: \(underlyingError.localizedDescription)." + "Subscriber Public Key (`\(UserAgentKeyMaterial.CodingKeys.publicKey.stringValue)`) was invalid: \(underlyingError.localizedDescription)" case .invalidAuthenticationSecret: - "Subscriber Authentication Secret (`\(UserAgentKeyMaterial.CodingKeys.authenticationSecret)`) was invalid: \(underlyingError.localizedDescription)." + "Subscriber Authentication Secret (`\(UserAgentKeyMaterial.CodingKeys.authenticationSecret.stringValue)`) was invalid: \(underlyingError.localizedDescription)" } } } + +extension UserAgentKeyMaterialError: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.kind == rhs.kind && lhs.underlyingError.localizedDescription == rhs.underlyingError.localizedDescription + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(kind) + hasher.combine(underlyingError.localizedDescription) + } +} diff --git a/Sources/WebPush/Errors/VAPIDConfigurationError.swift b/Sources/WebPush/Errors/VAPIDConfigurationError.swift index f8cc251..bffb78a 100644 --- a/Sources/WebPush/Errors/VAPIDConfigurationError.swift +++ b/Sources/WebPush/Errors/VAPIDConfigurationError.swift @@ -10,7 +10,7 @@ import Foundation extension VAPID { /// An error encountered during ``VAPID/Configuration`` initialization or decoding. - public struct ConfigurationError: LocalizedError, Hashable { + public struct ConfigurationError: LocalizedError, Hashable, Sendable { enum Kind { case keysNotProvided case matchingKeyNotFound diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 50b1ee7..63ce326 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -260,12 +260,12 @@ public actor WebPushManager: Sendable { logger: Logger? = nil ) async throws { switch executor { - case .httpClient(let httpClient, let privateKey): + case .httpClient(let httpClient, let privateKeyProvider): var logger = logger ?? backgroundActivityLogger logger[metadataKey: "message"] = ".data(\(message.base64URLEncodedString()))" try await execute( httpClient: httpClient, - applicationServerECDHPrivateKey: privateKey, + privateKeyProvider: privateKeyProvider, data: message, subscriber: subscriber, expiration: expiration, @@ -346,10 +346,10 @@ public actor WebPushManager: Sendable { var logger = logger logger[metadataKey: "message"] = "\(message)" switch executor { - case .httpClient(let httpClient, let privateKey): + case .httpClient(let httpClient, let privateKeyProvider): try await execute( httpClient: httpClient, - applicationServerECDHPrivateKey: privateKey, + privateKeyProvider: privateKeyProvider, data: message.data, subscriber: subscriber, expiration: expiration, @@ -377,7 +377,7 @@ public actor WebPushManager: Sendable { /// - logger: The logger to use for status updates. func execute( httpClient: some HTTPClientProtocol, - applicationServerECDHPrivateKey: P256.KeyAgreement.PrivateKey?, + privateKeyProvider: Executor.KeyProvider, data message: some DataProtocol, subscriber: some SubscriberProtocol, expiration: Expiration, @@ -402,13 +402,13 @@ 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 = applicationServerECDHPrivateKey ?? P256.KeyAgreement.PrivateKey() + let 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 let sharedSecret: SharedSecret do { - sharedSecret = try applicationServerECDHPrivateKey.sharedSecretFromKeyAgreement(with: userAgent.publicKey) + (applicationServerECDHPrivateKey, sharedSecret) = try privateKeyProvider.sharedSecretFromKeyAgreement(with: userAgent.publicKey) } catch { logger.debug("A shared secret could not be derived from the subscriber's public key and the newly-generated private key.", metadata: ["error" : "\(error)"]) throw BadSubscriberError() @@ -689,6 +689,7 @@ extension WebPushManager { /// 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) @@ -737,16 +738,37 @@ extension WebPushManager { /// An internal type representing the executor for a push message. package enum Executor: Sendable { + /// A Private Key and Shared Secret provider. + package enum KeyProvider: Sendable { + /// Generate a new Private Key and Shared Secret when asked. + case generateNew + + /// Used a shared generator to provide a Private Key and Shared Secret when asked. + case shared(@Sendable (P256.KeyAgreement.PublicKey) throws -> (P256.KeyAgreement.PrivateKey, SharedSecret)) + + /// Generate the Private Key and Shared Secret against a provided Public Key. + func sharedSecretFromKeyAgreement(with publicKeyShare: P256.KeyAgreement.PublicKey) throws -> (P256.KeyAgreement.PrivateKey, SharedSecret) { + switch self { + case .generateNew: + let privateKey = P256.KeyAgreement.PrivateKey() + let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKeyShare) + return (privateKey, sharedSecret) + case .shared(let handler): + return try handler(publicKeyShare) + } + } + } + /// 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?) + case httpClient(any HTTPClientProtocol, KeyProvider) /// 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. package static func httpClient(_ httpClient: any HTTPClientProtocol) -> Self { - .httpClient(httpClient, nil) + .httpClient(httpClient, .generateNew) } /// Use a handler to capture the original message. diff --git a/Tests/WebPushTests/BytesTests.swift b/Tests/WebPushTests/BytesTests.swift new file mode 100644 index 0000000..088ad91 --- /dev/null +++ b/Tests/WebPushTests/BytesTests.swift @@ -0,0 +1,25 @@ +// +// BytesTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-22. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import WebPush + +@Suite struct BytesTests { + @Test func stringBytes() { + #expect("hello".utf8Bytes == [0x68, 0x65, 0x6c, 0x6c, 0x6f]) + #expect("hello"[...].utf8Bytes == [0x68, 0x65, 0x6c, 0x6c, 0x6f]) + } + + @Test func integerBytes() { + #expect(UInt8(0b11110000).bigEndianBytes == [0b11110000]) + #expect(UInt16(0b1111000010100101).bigEndianBytes == [0b11110000, 0b10100101]) + #expect(UInt32(0b11110000101001010000111101011010).bigEndianBytes == [0b11110000, 0b10100101, 0b000001111, 0b01011010]) + #expect(UInt64(0b1111000010100101000011110101101011001100100011110011001101110000).bigEndianBytes == [0b11110000, 0b10100101, 0b000001111, 0b01011010, 0b11001100, 0b10001111, 0b00110011, 0b01110000]) + } +} diff --git a/Tests/WebPushTests/ErrorTests.swift b/Tests/WebPushTests/ErrorTests.swift new file mode 100644 index 0000000..9663a10 --- /dev/null +++ b/Tests/WebPushTests/ErrorTests.swift @@ -0,0 +1,58 @@ +// +// ErrorTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-21. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import AsyncHTTPClient +import Foundation +import Testing +@testable import WebPush + +@Suite struct ErrorTests { + @Test func badSubscriberError() { + #expect(BadSubscriberError() == BadSubscriberError()) + #expect("\(BadSubscriberError().localizedDescription)" == "The subscription is no longer valid.") + } + + @Test func base64URLDecodingError() { + #expect(Base64URLDecodingError() == Base64URLDecodingError()) + #expect("\(Base64URLDecodingError().localizedDescription)" == "The Base64 data could not be decoded.") + } + + @Test func httpError() { + let response = HTTPClientResponse(status: .notFound) + #expect(HTTPError(response: response) == HTTPError(response: response)) + #expect(HTTPError(response: response).hashValue == HTTPError(response: response).hashValue) + #expect(HTTPError(response: response) != HTTPError(response: HTTPClientResponse(status: .internalServerError))) + #expect("\(HTTPError(response: response).localizedDescription)" == "A 404 Not Found HTTP error was encountered: \(response).") + } + + @Test func messageTooLargeError() { + #expect(MessageTooLargeError() == MessageTooLargeError()) + #expect("\(MessageTooLargeError().localizedDescription)" == "The message was too large, and could not be delivered to the push service.") + } + + @Test func userAgentKeyMaterialError() { + #expect(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()) == .invalidPublicKey(underlyingError: Base64URLDecodingError())) + #expect(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()).hashValue == UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()).hashValue) + #expect(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()) != .invalidPublicKey(underlyingError: BadSubscriberError())) + #expect(UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()) == .invalidAuthenticationSecret(underlyingError: Base64URLDecodingError())) + #expect(UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()).hashValue == UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()).hashValue) + #expect(UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()) != .invalidAuthenticationSecret(underlyingError: BadSubscriberError())) + #expect(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()) != .invalidAuthenticationSecret(underlyingError: Base64URLDecodingError())) + + #expect("\(UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError()).localizedDescription)" == "Subscriber Public Key (`p256dh`) was invalid: The Base64 data could not be decoded.") + #expect("\(UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError()).localizedDescription)" == "Subscriber Authentication Secret (`auth`) was invalid: The Base64 data could not be decoded.") + } + + @Test func vapidConfigurationError() { + #expect(VAPID.ConfigurationError.keysNotProvided == .keysNotProvided) + #expect(VAPID.ConfigurationError.matchingKeyNotFound == .matchingKeyNotFound) + #expect(VAPID.ConfigurationError.keysNotProvided != .matchingKeyNotFound) + #expect("\(VAPID.ConfigurationError.keysNotProvided.localizedDescription)" == "VAPID keys not found during initialization.") + #expect("\(VAPID.ConfigurationError.matchingKeyNotFound.localizedDescription)" == "A VAPID key for the subscriber was not found.") + } +} diff --git a/Tests/WebPushTests/SubscriberTests.swift b/Tests/WebPushTests/SubscriberTests.swift new file mode 100644 index 0000000..f6e281e --- /dev/null +++ b/Tests/WebPushTests/SubscriberTests.swift @@ -0,0 +1,203 @@ +// +// SubscriberTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-21. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import Crypto +import Foundation +import Testing +@testable import WebPush +import WebPushTesting + +@Suite struct SubscriberTests { + @Suite struct Initialization { + @Test func fromKeyMaterial() { + let privateKey = P256.KeyAgreement.PrivateKey() + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial( + publicKey: privateKey.publicKey, + authenticationSecret: Data() + ), + vapidKeyID: .mockedKeyID1 + ) + #expect(subscriber.endpoint == URL(string: "https://example.com/subscriber")!) + #expect(subscriber.userAgentKeyMaterial == UserAgentKeyMaterial( + publicKey: privateKey.publicKey, + authenticationSecret: Data() + )) + #expect(subscriber.vapidKeyID == .mockedKeyID1) + } + + @Test func fromOtherSubscriber() { + let subscriber = Subscriber(.mockedSubscriber()) + #expect(subscriber == .mockedSubscriber) + } + + @Test func identifiable() { + let subscriber = Subscriber.mockedSubscriber + #expect(subscriber.id == "https://example.com/subscriber") + } + } + + @Suite struct UserAgentKeyMaterialTests { + @Suite struct Initialization { + @Test func actualKeys() { + let privateKey = P256.KeyAgreement.PrivateKey() + let keyMaterial = UserAgentKeyMaterial( + publicKey: privateKey.publicKey, + authenticationSecret: Data() + ) + #expect(keyMaterial == UserAgentKeyMaterial( + publicKey: privateKey.publicKey, + authenticationSecret: Data() + )) + } + + @Test func strings() throws { + let privateKey = UserAgentKeyMaterial.mockedKeyMaterialPrivateKey + let keyMaterial = try UserAgentKeyMaterial( + publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M", + authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg" + ) + + #expect(keyMaterial == UserAgentKeyMaterial( + publicKey: privateKey.publicKey, + authenticationSecret: keyMaterial.authenticationSecret + )) + + #expect(throws: UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError())) { + try UserAgentKeyMaterial( + publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M", + authenticationSecret: "()" + ) + } + + #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError())) { + try UserAgentKeyMaterial( + publicKey: "()", + authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg" + ) + } + + #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError())) { + try UserAgentKeyMaterial( + publicKey: "()", + authenticationSecret: "()" + ) + } + + #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: CryptoKitError.incorrectParameterSize)) { + try UserAgentKeyMaterial( + publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYM", + authenticationSecret: "()" + ) + } + } + + @Test func hashes() throws { + let keyMaterial1 = try UserAgentKeyMaterial( + publicKey: "BPgjN_Qet3SrCclnXNri-jEHu31CsdeZmNH9xkNskR58jBpxcqXJFspAPBeahlvNqUVXvorTn9RKcXag_esAmG0", + authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg" + ) + let keyMaterial2 = try UserAgentKeyMaterial( + publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M", + authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg" + ) + var set: Set = [keyMaterial1, keyMaterial2] + #expect(set.count == 2) + set.insert(try UserAgentKeyMaterial( + publicKey: "BPgjN_Qet3SrCclnXNri-jEHu31CsdeZmNH9xkNskR58jBpxcqXJFspAPBeahlvNqUVXvorTn9RKcXag_esAmG0", + authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg" + )) + #expect(set.count == 2) + set.insert(try UserAgentKeyMaterial( + publicKey: "BPgjN_Qet3SrCclnXNri-jEHu31CsdeZmNH9xkNskR58jBpxcqXJFspAPBeahlvNqUVXvorTn9RKcXag_esAmG0", + authenticationSecret: "AzODAQZN6BbGvmm7vWQJXg" // first character: A + )) + #expect(set.count == 3) + } + } + + @Suite struct Coding { + @Test func encodes() async throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let result = String( + decoding: try encoder.encode(UserAgentKeyMaterial( + publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M", + authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg" + )), + as: UTF8.self + ) + + #expect(result == """ + { + "auth" : "IzODAQZN6BbGvmm7vWQJXg", + "p256dh" : "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M" + } + """) + } + + @Test func decodes() async throws { + #expect( + try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data(""" + { + "auth" : "IzODAQZN6BbGvmm7vWQJXg", + "p256dh" : "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M" + } + """.utf8 + )) == + UserAgentKeyMaterial( + publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M", + authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg" + ) + ) + + #expect(throws: UserAgentKeyMaterialError.invalidAuthenticationSecret(underlyingError: Base64URLDecodingError())) { + try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data(""" + { + "auth" : "()", + "p256dh" : "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M" + } + """.utf8 + )) + } + + #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError())) { + try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data(""" + { + "auth" : "IzODAQZN6BbGvmm7vWQJXg", + "p256dh" : "()" + } + """.utf8 + )) + } + + #expect(throws: UserAgentKeyMaterialError.invalidPublicKey(underlyingError: Base64URLDecodingError())) { + try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data(""" + { + "auth" : "()", + "p256dh" : "()" + } + """.utf8 + )) + } + + /// `UserAgentKeyMaterialError.invalidPublicKey(underlyingError: CryptoKitError.incorrectParameterSize)` on macOS, `UserAgentKeyMaterialError.invalidPublicKey(underlyingError: CryptoKitError.underlyingCoreCryptoError(error: 251658360))` on Linux + #expect(throws: UserAgentKeyMaterialError.self) { + try JSONDecoder().decode(UserAgentKeyMaterial.self, from: Data(""" + { + "auth" : "IzODAQZN6BbGvmm7vWQJXg", + "p256dh" : "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1A" + } + """.utf8 + )) + } + } + } + } +} diff --git a/Tests/WebPushTests/URLOriginTests.swift b/Tests/WebPushTests/URLOriginTests.swift new file mode 100644 index 0000000..bee6b44 --- /dev/null +++ b/Tests/WebPushTests/URLOriginTests.swift @@ -0,0 +1,55 @@ +// +// URLOriginTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-22. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import WebPush + +@Suite struct URLOriginTests { + @Test func httpURLs() { + #expect(URL(string: "http://example.com/subscriber")?.origin == "http://example.com") + #expect(URL(string: "http://example.com/")?.origin == "http://example.com") + #expect(URL(string: "http://example.com")?.origin == "http://example.com") + #expect(URL(string: "HtTp://Example.com/")?.origin == "http://Example.com") + #expect(URL(string: "http://example.com:80/")?.origin == "http://example.com") + #expect(URL(string: "http://example.com:8081/")?.origin == "http://example.com:8081") + #expect(URL(string: "http://example.com:443/")?.origin == "http://example.com:443") + #expect(URL(string: "http://host/")?.origin == "http://host") + #expect(URL(string: "http://user:pass@host/")?.origin == "http://host") + #expect(URL(string: "http://")?.origin == "http://") + #expect(URL(string: "http:///")?.origin == "http://") + #expect(URL(string: "http://じぃ.app/")?.origin == "http://じぃ.app") + #expect(URL(string: "http://xn--m8jyb.app/")?.origin == "http://じぃ.app") + } + + @Test func httpsURLs() { + #expect(URL(string: "https://example.com/subscriber")?.origin == "https://example.com") + #expect(URL(string: "https://example.com/")?.origin == "https://example.com") + #expect(URL(string: "https://example.com")?.origin == "https://example.com") + #expect(URL(string: "HtTps://Example.com/")?.origin == "https://Example.com") + #expect(URL(string: "https://example.com:443/")?.origin == "https://example.com") + #expect(URL(string: "https://example.com:4443/")?.origin == "https://example.com:4443") + #expect(URL(string: "https://example.com:80/")?.origin == "https://example.com:80") + #expect(URL(string: "https://host/")?.origin == "https://host") + #expect(URL(string: "https://user:pass@host/")?.origin == "https://host") + #expect(URL(string: "https://")?.origin == "https://") + #expect(URL(string: "https:///")?.origin == "https://") + #expect(URL(string: "https://じぃ.app/")?.origin == "https://じぃ.app") + #expect(URL(string: "https://xn--m8jyb.app/")?.origin == "https://じぃ.app") + } + + @Test func otherURLs() { + #expect(URL(string: "file://example.com/subscriber")?.origin == "null") + #expect(URL(string: "ftp://example.com/")?.origin == "null") + #expect(URL(string: "blob:example.com")?.origin == "null") + #expect(URL(string: "mailto:test@example.com")?.origin == "null") + #expect(URL(string: "example.com")?.origin == "null") + #expect(URL(string: "otherFile.html")?.origin == "null") + #expect(URL(string: "/subscriber")?.origin == "null") + } +} diff --git a/Tests/WebPushTests/VAPIDKeyTests.swift b/Tests/WebPushTests/VAPIDKeyTests.swift new file mode 100644 index 0000000..9e508fd --- /dev/null +++ b/Tests/WebPushTests/VAPIDKeyTests.swift @@ -0,0 +1,102 @@ +// +// VAPIDKeyTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-22. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import Crypto +import Foundation +import Testing +@testable import WebPush +import WebPushTesting + +@Suite("VAPID Key Tests") struct VAPIDKeyTests { + @Suite struct Initialization { + @Test func createNew() { + let key = VAPID.Key() + #expect(!key.id.description.isEmpty) + } + + @Test func privateKey() { + let privateKey = P256.Signing.PrivateKey() + let key = VAPID.Key(privateKey: privateKey) + #expect(key.id.description == privateKey.publicKey.x963Representation.base64URLEncodedString()) + } + + @Test func base64Representation() throws { + let key = try VAPID.Key(base64URLEncoded: "6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=") + #expect(key.id.description == "BKO3ND8PZ4w3TMdjUE-VFLmwKoawWnfU_fHtp2G55mgOQdCY9sf2b9LjVbmItinpRPMC4qv_9GE9bSDYJ0jaErE") + + #expect(throws: Base64URLDecodingError()) { + try VAPID.Key(base64URLEncoded: "()") + } + + #expect(throws: CryptoKitError.self) { + try VAPID.Key(base64URLEncoded: "AAAA") + } + } + } + + @Test func equality() throws { + let key1 = VAPID.Key.mockedKey1 + let key2 = VAPID.Key.mockedKey2 + let key3 = VAPID.Key(privateKey: try .init(rawRepresentation: Data(base64URLEncoded: "FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=")!)) + + #expect(key1 != key2) + #expect(key1 == .mockedKey1) + #expect(key1 == key3) + #expect(key1.hashValue == key3.hashValue) + } + + @Suite struct Coding { + @Test func encoding() throws { + #expect(String(decoding: try JSONEncoder().encode(VAPID.Key.mockedKey1), as: UTF8.self) == "\"FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=\"") + } + + @Test func decoding() throws { + #expect(try JSONDecoder().decode(VAPID.Key.self, from: Data("\"FniTgSrf0l+BdfeC6LiblKXBbY4LQm0S+4STNCoJI+0=\"".utf8)) == .mockedKey1) + + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(VAPID.Key.self, from: Data("{}".utf8)) + } + + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(VAPID.Key.self, from: Data("\"()\"".utf8)) + } + + #expect(throws: CryptoKitError.self) { + try JSONDecoder().decode(VAPID.Key.self, from: Data("\"\"".utf8)) + } + + #expect(throws: CryptoKitError.self) { + try JSONDecoder().decode(VAPID.Key.self, from: Data("\"AAAA\"".utf8)) + } + } + } + + @Suite struct Identification { + @Test func comparable() { + #expect([ + VAPID.Key.ID.mockedKeyID1, + VAPID.Key.ID.mockedKeyID2, + VAPID.Key.ID.mockedKeyID3, + VAPID.Key.ID.mockedKeyID4, + ].sorted() == [ + VAPID.Key.ID.mockedKeyID2, + VAPID.Key.ID.mockedKeyID4, + VAPID.Key.ID.mockedKeyID1, + VAPID.Key.ID.mockedKeyID3, + ]) + } + + @Test func encoding() throws { + #expect(String(decoding: try JSONEncoder().encode(VAPID.Key.ID.mockedKeyID1), as: UTF8.self) == "\"BLf3RZAljlexEovBgfZgFTjcEVUKBDr3lIH8quJioMdX4FweRdId_P72h613ptxtU-qSAyW3Tbt_3WgwGhOUxrs\"") + } + + @Test func decoding() throws { + #expect(try JSONDecoder().decode(VAPID.Key.ID.self, from: Data("\"BLf3RZAljlexEovBgfZgFTjcEVUKBDr3lIH8quJioMdX4FweRdId_P72h613ptxtU-qSAyW3Tbt_3WgwGhOUxrs\"".utf8)) == .mockedKeyID1) + } + } +} diff --git a/Tests/WebPushTests/WebPushManagerTests.swift b/Tests/WebPushTests/WebPushManagerTests.swift index 86e3afe..157d771 100644 --- a/Tests/WebPushTests/WebPushManagerTests.swift +++ b/Tests/WebPushTests/WebPushManagerTests.swift @@ -53,6 +53,16 @@ struct WebPushManagerTests { } } + @Test func managerUsesDefaultLogging() async throws { + let manager = WebPushManager(vapidConfiguration: .makeTesting()) + #expect(manager.backgroundActivityLogger.handler is PrintLogHandler) + } + + @Test func managerCanSupressLogging() async throws { + let manager = WebPushManager(vapidConfiguration: .makeTesting(), backgroundActivityLogger: nil) + #expect(manager.backgroundActivityLogger.handler is SwiftLogNoOpLogHandler) + } + @Test func managerCanBeMocked() async throws { let manager = WebPushManager.makeMockedManager { _, _, _, _ in } await withThrowingTaskGroup(of: Void.self) { group in @@ -63,6 +73,16 @@ struct WebPushManagerTests { } } + @Test func mockedManagerUsesDefaultLogging() async throws { + let manager = WebPushManager.makeMockedManager(messageHandler: { _, _, _, _ in }) + #expect(manager.backgroundActivityLogger.handler is PrintLogHandler) + } + + @Test func mockedManagerCanSupressLogging() async throws { + let manager = WebPushManager.makeMockedManager(backgroundActivityLogger: nil, messageHandler: { _, _, _, _ in }) + #expect(manager.backgroundActivityLogger.handler is SwiftLogNoOpLogHandler) + } + /// 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) { @@ -217,9 +237,12 @@ struct WebPushManagerTests { vapidKeyID: vapidConfiguration.primaryKey!.id ) + var logger = Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }) + logger.logLevel = .trace + let manager = WebPushManager( vapidConfiguration: vapidConfiguration, - backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + backgroundActivityLogger: logger, executor: .httpClient(MockHTTPClient({ request in try validateAuthotizationHeader( request: request, @@ -344,6 +367,23 @@ struct WebPushManagerTests { } } + @Test func sendSuccessfulMultipleMessages() async throws { + try await confirmation(expectedCount: 3) { requestWasMade in + let manager = WebPushManager( + vapidConfiguration: .mockedConfiguration, + backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + requestWasMade() + return HTTPClientResponse(status: .created) + })) + ) + + try await manager.send(string: "hello, world!", to: .mockedSubscriber()) + try await manager.send(data: [1, 2, 3], to: .mockedSubscriber()) + try await manager.send(json: ["hello" : "world"], to: .mockedSubscriber()) + } + } + @Test func sendMessageToSubscriberWithInvalidVAPIDKey() async throws { await confirmation(expectedCount: 0) { requestWasMade in var subscriber = Subscriber.mockedSubscriber @@ -364,39 +404,22 @@ struct WebPushManagerTests { } } - @Test(.disabled("Fails because we need a public/private key pair that fails to make a shared secret")) - func sendMessageToSubscriberWithInvalidUserAgentKey() async throws { - try await confirmation(expectedCount: 0) { requestWasMade in - let vapidConfiguration = VAPID.Configuration.mockedConfiguration - - let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64Encoded: "fkqlT3FL8B34XFAmm+o6hnIfhK/nT3tB6lirzzR06I0=")!) - let publicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: Data(base64Encoded: "Ahj5uud0fNhE6YUlt8zQ2vbh0gqBiyF1qakeTq5TQ7yY")!) - var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) - for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } - - let subscriber = Subscriber( - endpoint: URL(string: "https://example.com/subscriber")!, - userAgentKeyMaterial: UserAgentKeyMaterial( - publicKey: publicKey, - authenticationSecret: Data(authenticationSecret) - ), - vapidKeyID: .mockedKeyID1 - ) - + @Test func sendMessageToSubscriberWithInvalidUserAgentKey() async throws { + await confirmation(expectedCount: 0) { requestWasMade in let manager = WebPushManager( - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient( MockHTTPClient({ request in requestWasMade() return HTTPClientResponse(status: .created) }), - privateKey + .shared({ _ in throw CancellationError() }) ) ) await #expect(throws: BadSubscriberError()) { - try await manager.send(string: "hello", to: subscriber) + try await manager.send(string: "hello", to: .mockedSubscriber()) } } } @@ -444,9 +467,12 @@ struct WebPushManagerTests { try await confirmation { requestWasMade in let (subscriber, subscriberPrivateKey) = Subscriber.makeMockedSubscriber() + var logger = Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }) + logger.logLevel = .trace + let manager = WebPushManager( vapidConfiguration: .mockedConfiguration, - backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + backgroundActivityLogger: logger, executor: .httpClient(MockHTTPClient({ request in try validateAuthotizationHeader( request: request, @@ -553,7 +579,10 @@ struct WebPushManagerTests { @Test func sendSuccessfulTextMessage() async throws { try await confirmation { requestWasMade in let manager = WebPushManager.makeMockedManager(backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) })) { message, subscriber, expiration, urgency in + #expect(message.description == ".string(hello)") #expect(message.string == "hello") + try #expect(message.data == Data("hello".utf8)) + #expect(message.json(as: [String:String].self) == nil) #expect(subscriber.endpoint.absoluteString == "https://example.com/subscriber") #expect(subscriber.vapidKeyID == .mockedKeyID1) #expect(expiration == .recommendedMaximum) @@ -568,7 +597,10 @@ struct WebPushManagerTests { @Test func sendSuccessfulDataMessage() async throws { try await confirmation { requestWasMade in let manager = WebPushManager.makeMockedManager(backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) })) { message, subscriber, expiration, urgency in + #expect(message.description == ".data(aGVsbG8)") try #expect(message.data == Data("hello".utf8Bytes)) + #expect(message.string == nil) + #expect(message.json(as: [String:String].self) == nil) #expect(subscriber.endpoint.absoluteString == "https://example.com/subscriber") #expect(subscriber.vapidKeyID == .mockedKeyID1) #expect(expiration == .recommendedMaximum) @@ -583,7 +615,11 @@ struct WebPushManagerTests { @Test func sendSuccessfulJSONMessage() async throws { try await confirmation { requestWasMade in let manager = WebPushManager.makeMockedManager(backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) })) { message, subscriber, expiration, urgency in + #expect(message.description == ".json([\"hello\": \"world\"])") #expect(message.json() == ["hello" : "world"]) + #expect(message.string == nil) + try #expect(message.data == Data("{\"hello\":\"world\"}".utf8)) + #expect(message.json(as: [String].self) == nil) #expect(subscriber.endpoint.absoluteString == "https://example.com/subscriber") #expect(subscriber.vapidKeyID == .mockedKeyID1) #expect(expiration == .recommendedMaximum)