From ae6ac3e4cac0c1a7ef9f295a61d74ac7a021097f Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 20 Dec 2024 05:18:08 -0800 Subject: [PATCH 1/5] Added Urgency test coverage --- Tests/WebPushTests/WebPushManagerTests.swift | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Tests/WebPushTests/WebPushManagerTests.swift b/Tests/WebPushTests/WebPushManagerTests.swift index abc1af2..9e4b4d2 100644 --- a/Tests/WebPushTests/WebPushManagerTests.swift +++ b/Tests/WebPushTests/WebPushManagerTests.swift @@ -482,4 +482,44 @@ struct WebPushManagerTests { #expect(try JSONDecoder().decode(WebPushManager.Expiration.self, from: Data("60".utf8)) == .minutes(1)) } } + + @Suite struct Urgency { + @Test func comparison() { + #expect(WebPushManager.Urgency(rawValue: "invalid") < WebPushManager.Urgency.veryLow) + #expect(WebPushManager.Urgency(rawValue: "invalid") < WebPushManager.Urgency.low) + #expect(WebPushManager.Urgency(rawValue: "invalid") < WebPushManager.Urgency.normal) + #expect(WebPushManager.Urgency(rawValue: "invalid") < WebPushManager.Urgency.high) + + #expect(WebPushManager.Urgency.veryLow < WebPushManager.Urgency.low) + #expect(WebPushManager.Urgency.veryLow < WebPushManager.Urgency.normal) + #expect(WebPushManager.Urgency.veryLow < WebPushManager.Urgency.high) + + #expect(WebPushManager.Urgency.low < WebPushManager.Urgency.normal) + #expect(WebPushManager.Urgency.low < WebPushManager.Urgency.high) + + #expect(WebPushManager.Urgency.normal < WebPushManager.Urgency.high) + } + + @Test func stringEncoding() { + #expect("\(WebPushManager.Urgency(rawValue: "future-value"))" == "future-value") + #expect("\(WebPushManager.Urgency.veryLow)" == "very-low") + #expect("\(WebPushManager.Urgency.low)" == "low") + #expect("\(WebPushManager.Urgency.normal)" == "normal") + #expect("\(WebPushManager.Urgency.high)" == "high") + } + + @Test func coding() throws { + #expect(String(decoding: try JSONEncoder().encode(WebPushManager.Urgency(rawValue: "future-value")), as: UTF8.self) == "\"future-value\"") + #expect(String(decoding: try JSONEncoder().encode(WebPushManager.Urgency.veryLow), as: UTF8.self) == "\"very-low\"") + #expect(String(decoding: try JSONEncoder().encode(WebPushManager.Urgency.low), as: UTF8.self) == "\"low\"") + #expect(String(decoding: try JSONEncoder().encode(WebPushManager.Urgency.normal), as: UTF8.self) == "\"normal\"") + #expect(String(decoding: try JSONEncoder().encode(WebPushManager.Urgency.high), as: UTF8.self) == "\"high\"") + + #expect(try JSONDecoder().decode(WebPushManager.Urgency.self, from: Data("\"future-value\"".utf8)) == .init(rawValue: "future-value")) + #expect(try JSONDecoder().decode(WebPushManager.Urgency.self, from: Data("\"very-low\"".utf8)) == .veryLow) + #expect(try JSONDecoder().decode(WebPushManager.Urgency.self, from: Data("\"low\"".utf8)) == .low) + #expect(try JSONDecoder().decode(WebPushManager.Urgency.self, from: Data("\"normal\"".utf8)) == .normal) + #expect(try JSONDecoder().decode(WebPushManager.Urgency.self, from: Data("\"high\"".utf8)) == .high) + } + } } From 2f99f7c35a492c6743dc449e706b9ec157a8e5f6 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 20 Dec 2024 05:22:09 -0800 Subject: [PATCH 2/5] Added methods to mock the private key used for encryption --- Sources/WebPush/WebPushManager.swift | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 5911be0..55396df 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -135,7 +135,7 @@ public actor WebPushManager: Sendable { } deinit { - if !didShutdown, case let .httpClient(httpClient) = executor { + if !didShutdown, case let .httpClient(httpClient, _) = executor { try? httpClient.syncShutdown() } } @@ -260,11 +260,12 @@ public actor WebPushManager: Sendable { logger: Logger? = nil ) async throws { switch executor { - case .httpClient(let httpClient): + case .httpClient(let httpClient, let privateKey): var logger = logger ?? backgroundActivityLogger - logger[metadataKey: "message"] = "\(message)" + logger[metadataKey: "message"] = ".data(\(message.base64URLEncodedString()))" try await execute( httpClient: httpClient, + applicationServerECDHPrivateKey: privateKey, data: message, subscriber: subscriber, expiration: expiration, @@ -345,9 +346,10 @@ public actor WebPushManager: Sendable { var logger = logger logger[metadataKey: "message"] = "\(message)" switch executor { - case .httpClient(let httpClient): + case .httpClient(let httpClient, let privateKey): try await execute( httpClient: httpClient, + applicationServerECDHPrivateKey: privateKey, data: message.data, subscriber: subscriber, expiration: expiration, @@ -367,6 +369,7 @@ public actor WebPushManager: Sendable { /// Send a message via HTTP Client, mocked or otherwise, encrypting it on the way. /// - Parameters: /// - httpClient: The protocol implementing HTTP-like functionality. + /// - applicationServerECDHPrivateKey: The private key to use for the key exchange. If nil, one will be generated. /// - message: The message to send as raw data. /// - subscriber: The subscriber to sign the message against. /// - expiration: The expiration of the message. @@ -374,6 +377,7 @@ public actor WebPushManager: Sendable { /// - logger: The logger to use for status updates. func execute( httpClient: some HTTPClientProtocol, + applicationServerECDHPrivateKey: P256.KeyAgreement.PrivateKey?, data message: some DataProtocol, subscriber: some SubscriberProtocol, expiration: Expiration, @@ -398,7 +402,7 @@ public actor WebPushManager: Sendable { /// Prepare authorization, private keys, and payload ahead of time to bail early if they can't be created. let authorization = try loadCurrentVAPIDAuthorizationHeader(endpoint: subscriber.endpoint, signingKey: signingKey) - let applicationServerECDHPrivateKey = P256.KeyAgreement.PrivateKey() + let applicationServerECDHPrivateKey = applicationServerECDHPrivateKey ?? P256.KeyAgreement.PrivateKey() /// Perform key exchange between the user agent's public key and our private key, deriving a shared secret. let userAgent = subscriber.userAgentKeyMaterial @@ -511,7 +515,7 @@ extension WebPushManager: Service { } onCancelOrGracefulShutdown: { [backgroundActivityLogger, executor] in backgroundActivityLogger.debug("Shutting down WebPushManager") do { - if case let .httpClient(httpClient) = executor { + if case let .httpClient(httpClient, _) = executor { try httpClient.syncShutdown() } } catch { @@ -715,10 +719,17 @@ extension WebPushManager { /// An internal type representing the executor for a push message. package enum Executor: Sendable { + /// Use an HTTP client and optional private key to send an encrypted payload to a subscriber. + /// + /// This is used in tests to capture the encrypted request and make sure it is well-formed. + case httpClient(any HTTPClientProtocol, P256.KeyAgreement.PrivateKey?) + /// Use an HTTP client to send an encrypted payload to a subscriber. /// /// This is used in tests to capture the encrypted request and make sure it is well-formed. - case httpClient(any HTTPClientProtocol) + package static func httpClient(_ httpClient: any HTTPClientProtocol) -> Self { + .httpClient(httpClient, nil) + } /// Use a handler to capture the original message. /// From 7d9ad31782cabca48e39ab39bedb6f74d9c59319 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 20 Dec 2024 05:22:33 -0800 Subject: [PATCH 3/5] Added more WebPushManager tests --- Tests/WebPushTests/WebPushManagerTests.swift | 184 ++++++++++++++++++- 1 file changed, 175 insertions(+), 9 deletions(-) diff --git a/Tests/WebPushTests/WebPushManagerTests.swift b/Tests/WebPushTests/WebPushManagerTests.swift index 9e4b4d2..14e0170 100644 --- a/Tests/WebPushTests/WebPushManagerTests.swift +++ b/Tests/WebPushTests/WebPushManagerTests.swift @@ -161,14 +161,16 @@ struct WebPushManagerTests { func decrypt( request: HTTPClientRequest, userAgentPrivateKey: P256.KeyAgreement.PrivateKey, - userAgentKeyMaterial: UserAgentKeyMaterial + userAgentKeyMaterial: UserAgentKeyMaterial, + expectedReadableBytes: Int = 4096, + expectedRecordSize: Int = 4010 ) async throws -> [UInt8] { var body = try #require(try await request.body?.collect(upTo: 16*1024)) - #expect(body.readableBytes == 4096) + #expect(body.readableBytes == expectedReadableBytes) let salt = body.readBytes(length: 16) let recordSize = body.readInteger(as: UInt32.self) - #expect(try #require(recordSize) == 4010) + #expect(try #require(recordSize) == expectedRecordSize) let keyIDSize = body.readInteger(as: UInt8.self) let keyID = body.readBytes(length: Int(keyIDSize ?? 0)) @@ -342,8 +344,74 @@ struct WebPushManagerTests { } } - @Test func sendMessageToNotFoundPushServerError() async throws { - await confirmation { requestWasMade in + @Test func sendMessageToSubscriberWithInvalidVAPIDKey() async throws { + await confirmation(expectedCount: 0) { requestWasMade in + let vapidConfiguration = VAPID.Configuration.mockedConfiguration + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: .mockedKeyID2 + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + requestWasMade() + return HTTPClientResponse(status: .created) + })) + ) + + await #expect(throws: VAPID.ConfigurationError.matchingKeyNotFound) { + try await manager.send(string: "hello", to: subscriber) + } + } + } + + @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 + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient( + MockHTTPClient({ request in + requestWasMade() + return HTTPClientResponse(status: .created) + }), + privateKey + ) + ) + + await #expect(throws: BadSubscriberError()) { + try await manager.send(string: "hello", to: subscriber) + } + } + } + + @Test func sendSizeLimitMessageSucceeds() async throws { + try await confirmation { requestWasMade in let vapidConfiguration = VAPID.Configuration.makeTesting() let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) @@ -356,6 +424,104 @@ struct WebPushManagerTests { vapidKeyID: vapidConfiguration.primaryKey!.id ) + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + try validateAuthotizationHeader( + request: request, + vapidConfiguration: vapidConfiguration, + origin: "https://example.com" + ) + #expect(request.method == .POST) + #expect(request.headers["Content-Encoding"] == ["aes128gcm"]) + #expect(request.headers["Content-Type"] == ["application/octet-stream"]) + #expect(request.headers["TTL"] == ["2592000"]) + #expect(request.headers["Urgency"] == ["high"]) + #expect(request.headers["Topic"] == []) // TODO: Update when topic is added + + let message = try await decrypt( + request: request, + userAgentPrivateKey: subscriberPrivateKey, + userAgentKeyMaterial: subscriber.userAgentKeyMaterial, + expectedReadableBytes: 4096, + expectedRecordSize: 4010 + ) + + #expect(message == Array(repeating: 0, count: 3993)) + + requestWasMade() + return HTTPClientResponse(status: .created) + })) + ) + + try await manager.send(data: Array(repeating: 0, count: 3993), to: subscriber) + } + } + + @Test func sendExtraLargeMessageCouldSucceed() async throws { + try await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.makeTesting() + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: vapidConfiguration.primaryKey!.id + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + try validateAuthotizationHeader( + request: request, + vapidConfiguration: vapidConfiguration, + origin: "https://example.com" + ) + #expect(request.method == .POST) + #expect(request.headers["Content-Encoding"] == ["aes128gcm"]) + #expect(request.headers["Content-Type"] == ["application/octet-stream"]) + #expect(request.headers["TTL"] == ["2592000"]) + #expect(request.headers["Urgency"] == ["high"]) + #expect(request.headers["Topic"] == []) // TODO: Update when topic is added + + let message = try await decrypt( + request: request, + userAgentPrivateKey: subscriberPrivateKey, + userAgentKeyMaterial: subscriber.userAgentKeyMaterial, + expectedReadableBytes: 4097, + expectedRecordSize: 4011 + ) + + #expect(message == Array(repeating: 0, count: 3994)) + + requestWasMade() + return HTTPClientResponse(status: .created) + })) + ) + + try await manager.send(data: Array(repeating: 0, count: 3994), to: subscriber) + } + } + + @Test func sendMessageToNotFoundPushServerError() async throws { + await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.mockedConfiguration + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: .mockedKeyID1 + ) + let manager = WebPushManager( vapidConfiguration: vapidConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), @@ -373,7 +539,7 @@ struct WebPushManagerTests { @Test func sendMessageToGonePushServerError() async throws { await confirmation { requestWasMade in - let vapidConfiguration = VAPID.Configuration.makeTesting() + let vapidConfiguration = VAPID.Configuration.mockedConfiguration let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) @@ -382,7 +548,7 @@ struct WebPushManagerTests { let subscriber = Subscriber( endpoint: URL(string: "https://example.com/subscriber")!, userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: vapidConfiguration.primaryKey!.id + vapidKeyID: .mockedKeyID1 ) let manager = WebPushManager( @@ -402,7 +568,7 @@ struct WebPushManagerTests { @Test func sendMessageToUnknownPushServerError() async throws { await confirmation { requestWasMade in - let vapidConfiguration = VAPID.Configuration.makeTesting() + let vapidConfiguration = VAPID.Configuration.mockedConfiguration let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) @@ -411,7 +577,7 @@ struct WebPushManagerTests { let subscriber = Subscriber( endpoint: URL(string: "https://example.com/subscriber")!, userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: vapidConfiguration.primaryKey!.id + vapidKeyID: .mockedKeyID1 ) let manager = WebPushManager( From 3f7b28a0943b19779d6fd2e720c3b1bd3e24bee3 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 20 Dec 2024 05:26:12 -0800 Subject: [PATCH 4/5] Added dedicated error for payloads that are too large --- .../WebPush/Errors/MessageTooLargeError.swift | 20 +++++++++++++ Sources/WebPush/WebPushManager.swift | 4 ++- Tests/WebPushTests/WebPushManagerTests.swift | 29 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 Sources/WebPush/Errors/MessageTooLargeError.swift diff --git a/Sources/WebPush/Errors/MessageTooLargeError.swift b/Sources/WebPush/Errors/MessageTooLargeError.swift new file mode 100644 index 0000000..8569f13 --- /dev/null +++ b/Sources/WebPush/Errors/MessageTooLargeError.swift @@ -0,0 +1,20 @@ +// +// MessageTooLargeError.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-13. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +import Foundation + +/// The message was too large, and could not be delivered to the push service. +/// +/// - SeeAlso: ``WebPushManager/maximumMessageSize`` +public struct MessageTooLargeError: LocalizedError, Hashable { + public init() {} + + public var errorDescription: String? { + "The message was too large, and could not be delivered to the push service." + } +} diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 55396df..37c6cbb 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -494,7 +494,9 @@ public actor WebPushManager: Sendable { switch response.status { case .created: break case .notFound, .gone: throw BadSubscriberError() - // TODO: 413 payload too large - log.error and throw error + case .payloadTooLarge: + logger.error("The encrypted payload was too large and was rejected by the push service.") + throw MessageTooLargeError() // TODO: 429 too many requests, 500 internal server error, 503 server shutting down - check config and perform a retry after a delay? default: throw HTTPError(response: response) } diff --git a/Tests/WebPushTests/WebPushManagerTests.swift b/Tests/WebPushTests/WebPushManagerTests.swift index 14e0170..3630eb5 100644 --- a/Tests/WebPushTests/WebPushManagerTests.swift +++ b/Tests/WebPushTests/WebPushManagerTests.swift @@ -508,6 +508,35 @@ struct WebPushManagerTests { } } + @Test func sendExtraLargeMessageFails() async throws { + await confirmation { requestWasMade in + let vapidConfiguration = VAPID.Configuration.makeTesting() + + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: vapidConfiguration.primaryKey!.id + ) + + let manager = WebPushManager( + vapidConfiguration: vapidConfiguration, + backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), + executor: .httpClient(MockHTTPClient({ request in + requestWasMade() + return HTTPClientResponse(status: .payloadTooLarge) + })) + ) + + await #expect(throws: MessageTooLargeError()) { + try await manager.send(data: Array(repeating: 0, count: 3994), to: subscriber) + } + } + } + @Test func sendMessageToNotFoundPushServerError() async throws { await confirmation { requestWasMade in let vapidConfiguration = VAPID.Configuration.mockedConfiguration From 446b8bcff34fea23c278b576b590f4717315052c Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 20 Dec 2024 06:10:18 -0800 Subject: [PATCH 5/5] Added testing coverage for WebPushTesting module --- Sources/WebPush/WebPushManager.swift | 16 ++ .../WebPushTesting/Subscriber+Testing.swift | 53 ++++++ .../WebPushManager+Testing.swift | 1 + Tests/WebPushTests/WebPushManagerTests.swift | 176 ++++++++---------- 4 files changed, 151 insertions(+), 95 deletions(-) create mode 100644 Sources/WebPushTesting/Subscriber+Testing.swift diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 37c6cbb..50b1ee7 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -681,6 +681,8 @@ extension WebPushManager.Urgency: Codable { extension WebPushManager { /// An internal type representing a push message, accessible when using ``/WebPushTesting``. + /// + /// - Warning: Never switch on the message type, as values may be added to it over time. public enum _Message: Sendable, CustomStringConvertible { /// A message originally sent via ``WebPushManager/send(data:to:expiration:urgency:)`` case data(Data) @@ -707,6 +709,20 @@ extension WebPushManager { } } + /// The string value from a ``string(_:)`` message. + public var string: String? { + guard case let .string(string) = self + else { return nil } + return string + } + + /// The json value from a ``json(_:)`` message. + public func json(as: JSON.Type = JSON.self) -> JSON? { + guard case let .json(json) = self + else { return nil } + return json as? JSON + } + public var description: String { switch self { case .data(let data): diff --git a/Sources/WebPushTesting/Subscriber+Testing.swift b/Sources/WebPushTesting/Subscriber+Testing.swift new file mode 100644 index 0000000..b86df66 --- /dev/null +++ b/Sources/WebPushTesting/Subscriber+Testing.swift @@ -0,0 +1,53 @@ +// +// Subscriber+Testing.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2024-12-20. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +@preconcurrency import Crypto +import Foundation +import WebPush + +extension Subscriber { + /// A mocked subscriber to send messages to. + public static let mockedSubscriber = Subscriber( + endpoint: URL(string: "https://example.com/subscriber")!, + userAgentKeyMaterial: .mockedKeyMaterial, + vapidKeyID: .mockedKeyID1 + ) + + /// Make a mocked subscriber with a unique private key and salt. + static func makeMockedSubscriber(endpoint: URL = URL(string: "https://example.com/subscriber")!) -> (subscriber: Subscriber, privateKey: P256.KeyAgreement.PrivateKey) { + let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) + var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) + for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } + + let subscriber = Subscriber( + endpoint: endpoint, + userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), + vapidKeyID: .mockedKeyID1 + ) + + return (subscriber, subscriberPrivateKey) + } +} + +extension SubscriberProtocol where Self == Subscriber { + /// A mocked subscriber to send messages to. + public static func mockedSubscriber() -> Subscriber { + .mockedSubscriber + } +} + +extension UserAgentKeyMaterial { + /// The private key component of ``mockedKeyMaterial``. + public static let mockedKeyMaterialPrivateKey = try! P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64Encoded: "BS2nTTf5wAdVvi5Om3AjSmlsCpz91XgK+uCLaIJ0T/M=")!) + + /// A mocked user-agent-key material to attach to a subscriber. + public static let mockedKeyMaterial = try! UserAgentKeyMaterial( + publicKey: "BMXVxJELqTqIqMka5N8ujvW6RXI9zo_xr5BQ6XGDkrsukNVPyKRMEEfzvQGeUdeZaWAaAs2pzyv1aoHEXYMtj1M", + authenticationSecret: "IzODAQZN6BbGvmm7vWQJXg" + ) +} diff --git a/Sources/WebPushTesting/WebPushManager+Testing.swift b/Sources/WebPushTesting/WebPushManager+Testing.swift index 1cd6979..0243ea0 100644 --- a/Sources/WebPushTesting/WebPushManager+Testing.swift +++ b/Sources/WebPushTesting/WebPushManager+Testing.swift @@ -11,6 +11,7 @@ import WebPush extension WebPushManager { /// A push message in its original form, either ``/Foundation/Data``, ``/Swift/String``, or ``/Foundation/Encodable``. + /// - Warning: Never switch on the message type, as values may be added to it over time. public typealias Message = _Message /// Create a mocked web push manager. diff --git a/Tests/WebPushTests/WebPushManagerTests.swift b/Tests/WebPushTests/WebPushManagerTests.swift index 3630eb5..86e3afe 100644 --- a/Tests/WebPushTests/WebPushManagerTests.swift +++ b/Tests/WebPushTests/WebPushManagerTests.swift @@ -13,7 +13,7 @@ import Logging import ServiceLifecycle import Testing @testable import WebPush -import WebPushTesting +@testable import WebPushTesting @Suite("WebPush Manager") struct WebPushManagerTests { @@ -346,20 +346,11 @@ struct WebPushManagerTests { @Test func sendMessageToSubscriberWithInvalidVAPIDKey() async throws { await confirmation(expectedCount: 0) { requestWasMade in - let vapidConfiguration = VAPID.Configuration.mockedConfiguration - - let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) - var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) - for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } - - let subscriber = Subscriber( - endpoint: URL(string: "https://example.com/subscriber")!, - userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: .mockedKeyID2 - ) + var subscriber = Subscriber.mockedSubscriber + subscriber.vapidKeyID = .mockedKeyID2 let manager = WebPushManager( - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient(MockHTTPClient({ request in requestWasMade() @@ -412,25 +403,15 @@ struct WebPushManagerTests { @Test func sendSizeLimitMessageSucceeds() async throws { try await confirmation { requestWasMade in - let vapidConfiguration = VAPID.Configuration.makeTesting() - - let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) - var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) - for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } - - let subscriber = Subscriber( - endpoint: URL(string: "https://example.com/subscriber")!, - userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: vapidConfiguration.primaryKey!.id - ) + let (subscriber, subscriberPrivateKey) = Subscriber.makeMockedSubscriber() let manager = WebPushManager( - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient(MockHTTPClient({ request in try validateAuthotizationHeader( request: request, - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, origin: "https://example.com" ) #expect(request.method == .POST) @@ -461,25 +442,15 @@ struct WebPushManagerTests { @Test func sendExtraLargeMessageCouldSucceed() async throws { try await confirmation { requestWasMade in - let vapidConfiguration = VAPID.Configuration.makeTesting() - - let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) - var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) - for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } - - let subscriber = Subscriber( - endpoint: URL(string: "https://example.com/subscriber")!, - userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: vapidConfiguration.primaryKey!.id - ) + let (subscriber, subscriberPrivateKey) = Subscriber.makeMockedSubscriber() let manager = WebPushManager( - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient(MockHTTPClient({ request in try validateAuthotizationHeader( request: request, - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, origin: "https://example.com" ) #expect(request.method == .POST) @@ -510,20 +481,8 @@ struct WebPushManagerTests { @Test func sendExtraLargeMessageFails() async throws { await confirmation { requestWasMade in - let vapidConfiguration = VAPID.Configuration.makeTesting() - - let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) - var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) - for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } - - let subscriber = Subscriber( - endpoint: URL(string: "https://example.com/subscriber")!, - userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: vapidConfiguration.primaryKey!.id - ) - let manager = WebPushManager( - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient(MockHTTPClient({ request in requestWasMade() @@ -532,27 +491,15 @@ struct WebPushManagerTests { ) await #expect(throws: MessageTooLargeError()) { - try await manager.send(data: Array(repeating: 0, count: 3994), to: subscriber) + try await manager.send(data: Array(repeating: 0, count: 3994), to: .mockedSubscriber()) } } } @Test func sendMessageToNotFoundPushServerError() async throws { await confirmation { requestWasMade in - let vapidConfiguration = VAPID.Configuration.mockedConfiguration - - let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) - var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) - for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } - - let subscriber = Subscriber( - endpoint: URL(string: "https://example.com/subscriber")!, - userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: .mockedKeyID1 - ) - let manager = WebPushManager( - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient(MockHTTPClient({ request in requestWasMade() @@ -561,27 +508,15 @@ struct WebPushManagerTests { ) await #expect(throws: BadSubscriberError()) { - try await manager.send(string: "hello", to: subscriber) + try await manager.send(string: "hello", to: .mockedSubscriber()) } } } @Test func sendMessageToGonePushServerError() async throws { await confirmation { requestWasMade in - let vapidConfiguration = VAPID.Configuration.mockedConfiguration - - let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) - var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) - for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } - - let subscriber = Subscriber( - endpoint: URL(string: "https://example.com/subscriber")!, - userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: .mockedKeyID1 - ) - let manager = WebPushManager( - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient(MockHTTPClient({ request in requestWasMade() @@ -590,27 +525,15 @@ struct WebPushManagerTests { ) await #expect(throws: BadSubscriberError()) { - try await manager.send(string: "hello", to: subscriber) + try await manager.send(string: "hello", to: .mockedSubscriber()) } } } @Test func sendMessageToUnknownPushServerError() async throws { await confirmation { requestWasMade in - let vapidConfiguration = VAPID.Configuration.mockedConfiguration - - let subscriberPrivateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: false) - var authenticationSecret: [UInt8] = Array(repeating: 0, count: 16) - for index in authenticationSecret.indices { authenticationSecret[index] = .random(in: .min ... .max) } - - let subscriber = Subscriber( - endpoint: URL(string: "https://example.com/subscriber")!, - userAgentKeyMaterial: UserAgentKeyMaterial(publicKey: subscriberPrivateKey.publicKey, authenticationSecret: Data(authenticationSecret)), - vapidKeyID: .mockedKeyID1 - ) - let manager = WebPushManager( - vapidConfiguration: vapidConfiguration, + vapidConfiguration: .mockedConfiguration, backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }), executor: .httpClient(MockHTTPClient({ request in requestWasMade() @@ -619,7 +542,70 @@ struct WebPushManagerTests { ) await #expect(throws: HTTPError.self) { - try await manager.send(string: "hello", to: subscriber) + try await manager.send(string: "hello", to: .mockedSubscriber()) + } + } + } + } + + @Suite("Sending Mocked Messages") + struct SendingMockedMessages { + @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.string == "hello") + #expect(subscriber.endpoint.absoluteString == "https://example.com/subscriber") + #expect(subscriber.vapidKeyID == .mockedKeyID1) + #expect(expiration == .recommendedMaximum) + #expect(urgency == .high) + requestWasMade() + } + + try await manager.send(string: "hello", to: .mockedSubscriber()) + } + } + + @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 + try #expect(message.data == Data("hello".utf8Bytes)) + #expect(subscriber.endpoint.absoluteString == "https://example.com/subscriber") + #expect(subscriber.vapidKeyID == .mockedKeyID1) + #expect(expiration == .recommendedMaximum) + #expect(urgency == .high) + requestWasMade() + } + + try await manager.send(data: "hello".utf8Bytes, to: .mockedSubscriber()) + } + } + + @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.json() == ["hello" : "world"]) + #expect(subscriber.endpoint.absoluteString == "https://example.com/subscriber") + #expect(subscriber.vapidKeyID == .mockedKeyID1) + #expect(expiration == .recommendedMaximum) + #expect(urgency == .high) + requestWasMade() + } + + try await manager.send(json: ["hello" : "world"], to: .mockedSubscriber()) + } + } + + @Test func sendPropagatedMockedFailure() async throws { + await confirmation { requestWasMade in + struct CustomError: Error {} + + let manager = WebPushManager.makeMockedManager(backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) })) { _, _, _, _ in + requestWasMade() + throw CustomError() + } + + await #expect(throws: CustomError.self) { + try await manager.send(data: Array(repeating: 0, count: 3994), to: .mockedSubscriber()) } } }