Skip to content

Full Test Coverage #42

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 9 commits into from
Dec 22, 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 Sources/WebPush/Errors/BadSubscriberError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
2 changes: 1 addition & 1 deletion Sources/WebPush/Errors/Base64URLDecodingError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
20 changes: 16 additions & 4 deletions Sources/WebPush/Errors/HTTPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
2 changes: 1 addition & 1 deletion Sources/WebPush/Errors/MessageTooLargeError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
17 changes: 14 additions & 3 deletions Sources/WebPush/Errors/UserAgentKeyMaterialError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
2 changes: 1 addition & 1 deletion Sources/WebPush/Errors/VAPIDConfigurationError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 31 additions & 9 deletions Sources/WebPush/WebPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions Tests/WebPushTests/BytesTests.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
58 changes: 58 additions & 0 deletions Tests/WebPushTests/ErrorTests.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
Loading
Loading