From 703d2243aef79d8cb0ab6af3f5995bb8b6d8c200 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 16 Dec 2024 01:34:52 -0800 Subject: [PATCH 1/3] Added documentation to VAPID durations --- .../WebPush/VAPID/VAPIDConfiguration.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/WebPush/VAPID/VAPIDConfiguration.swift b/Sources/WebPush/VAPID/VAPIDConfiguration.swift index b07e887..0ab55cb 100644 --- a/Sources/WebPush/VAPID/VAPIDConfiguration.swift +++ b/Sources/WebPush/VAPID/VAPIDConfiguration.swift @@ -259,13 +259,18 @@ extension VAPID.Configuration { case unknown } + /// A duration in seconds used to express when VAPID tokens will expire. public struct Duration: Hashable, Comparable, Codable, ExpressibleByIntegerLiteral, AdditiveArithmetic, Sendable { + /// The number of seconds represented by this duration. public let seconds: Int + /// Initialize a duration with a number of seconds. + @inlinable public init(seconds: Int) { self.seconds = seconds } + @inlinable public static func < (lhs: Self, rhs: Self) -> Bool { lhs.seconds < rhs.seconds } @@ -280,37 +285,49 @@ extension VAPID.Configuration { try container.encode(self.seconds) } + @inlinable public init(integerLiteral value: Int) { self.seconds = value } + @inlinable public static func - (lhs: Self, rhs: Self) -> Self { Self(seconds: lhs.seconds - rhs.seconds) } + @inlinable public static func + (lhs: Self, rhs: Self) -> Self { Self(seconds: lhs.seconds + rhs.seconds) } + /// Make a duration with a number of seconds. + @inlinable public static func seconds(_ seconds: Int) -> Self { Self(seconds: seconds) } + /// Make a duration with a number of minutes. + @inlinable public static func minutes(_ minutes: Int) -> Self { - Self(seconds: minutes*60) + .seconds(minutes*60) } + /// Make a duration with a number of hours. + @inlinable public static func hours(_ hours: Int) -> Self { - Self(seconds: hours*60*60) + .minutes(hours*60) } + /// Make a duration with a number of days. + @inlinable public static func days(_ days: Int) -> Self { - Self(seconds: days*24*60*60) + .hours(days*24) } } } extension Date { + /// Helper to add a duration to a date. func adding(_ duration: VAPID.Configuration.Duration) -> Self { addingTimeInterval(TimeInterval(duration.seconds)) } From 7e559be156d02890c1486f6d4b7879805537a0dc Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 16 Dec 2024 01:50:38 -0800 Subject: [PATCH 2/3] Added an explicit Expiration type to the WebPushManager --- Sources/WebPush/WebPushManager.swift | 104 ++++++++++++++++-- .../WebPushManager+Testing.swift | 2 +- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index acf892d..7f8082b 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -199,7 +199,7 @@ public actor WebPushManager: Sendable { public func send( data message: some DataProtocol, to subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration = .days(30), + expiration: Expiration = .recommendedMaximum, urgency: Urgency = .high ) async throws { switch executor { @@ -228,7 +228,7 @@ public actor WebPushManager: Sendable { public func send( string message: some StringProtocol, to subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration = .days(30), + expiration: Expiration = .recommendedMaximum, urgency: Urgency = .high ) async throws { try await routeMessage( @@ -251,7 +251,7 @@ public actor WebPushManager: Sendable { public func send( json message: some Encodable&Sendable, to subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration = .days(30), + expiration: Expiration = .recommendedMaximum, urgency: Urgency = .high ) async throws { try await routeMessage( @@ -271,7 +271,7 @@ public actor WebPushManager: Sendable { func routeMessage( message: _Message, to subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration, + expiration: Expiration, urgency: Urgency ) async throws { switch executor { @@ -304,7 +304,7 @@ public actor WebPushManager: Sendable { httpClient: some HTTPClientProtocol, data message: some DataProtocol, subscriber: some SubscriberProtocol, - expiration: VAPID.Configuration.Duration, + expiration: Expiration, urgency: Urgency ) async throws { guard let signingKey = vapidKeyLookup[subscriber.vapidKeyID] @@ -377,13 +377,19 @@ public actor WebPushManager: Sendable { /// Attach the header with our public key and salt, along with the authentication tag. let requestContent = contentCodingHeader + encryptedRecord.ciphertext + encryptedRecord.tag + if expiration < Expiration.dropIfUndeliverable { + logger.error("The message expiration must be greater than or equal to \(Expiration.dropIfUndeliverable) seconds.", metadata: ["expiration": "\(expiration)"]) + } else if expiration >= Expiration.recommendedMaximum { + logger.warning("The message expiration should be less than \(Expiration.recommendedMaximum) seconds.", metadata: ["expiration": "\(expiration)"]) + } + /// Add the VAPID authorization and corrent content encoding and type. var request = HTTPClientRequest(url: subscriber.endpoint.absoluteURL.absoluteString) request.method = .POST request.headers.add(name: "Authorization", value: authorization) request.headers.add(name: "Content-Encoding", value: "aes128gcm") request.headers.add(name: "Content-Type", value: "application/octet-stream") - request.headers.add(name: "TTL", value: "\(expiration.seconds)") + request.headers.add(name: "TTL", value: "\(max(expiration, .dropIfUndeliverable).seconds)") request.headers.add(name: "Urgency", value: "\(urgency)") request.body = .bytes(ByteBuffer(bytes: requestContent)) @@ -424,6 +430,90 @@ extension WebPushManager: Service { // MARK: - Public Types +extension WebPushManager { + /// A duration in seconds used to express when push messages will expire. + /// + /// - SeeAlso: [RFC 8030 Generic Event Delivery Using HTTP §5.2. Push Message Time-To-Live](https://datatracker.ietf.org/doc/html/rfc8030#section-5.2) + public struct Expiration: Hashable, Comparable, Codable, ExpressibleByIntegerLiteral, AdditiveArithmetic, Sendable { + /// The number of seconds represented by this expiration. + public let seconds: Int + + /// The recommended maximum expiration duration push services are expected to support. + /// + /// - Note: A time of 30 days was chosen to match the maximum Apple Push Notification Services (APNS) accepts, but there is otherwise no recommended value here. Note that other services are instead limited to 4 weeks, or 28 days. + public static let recommendedMaximum: Self = .days(30) + + /// The message will be delivered immediately, otherwise it'll be dropped. + /// + /// A Push message with a zero TTL is immediately delivered if the user agent is available to receive the message. After delivery, the push service is permitted to immediately remove a push message with a zero TTL. This might occur before the user agent acknowledges receipt of the message by performing an HTTP DELETE on the push message resource. Consequently, an application server cannot rely on receiving acknowledgement receipts for zero TTL push messages. + /// + /// If the user agent is unavailable, a push message with a zero TTL expires and is never delivered. + /// + /// - SeeAlso: [RFC 8030 Generic Event Delivery Using HTTP §5.2. Push Message Time-To-Live](https://datatracker.ietf.org/doc/html/rfc8030#section-5.2) + public static let dropIfUndeliverable: Self = .zero + + /// Initialize an expiration with a number of seconds. + @inlinable + public init(seconds: Int) { + self.seconds = seconds + } + + @inlinable + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.seconds < rhs.seconds + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.seconds = try container.decode(Int.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.seconds) + } + + @inlinable + public init(integerLiteral value: Int) { + self.seconds = value + } + + @inlinable + public static func - (lhs: Self, rhs: Self) -> Self { + Self(seconds: lhs.seconds - rhs.seconds) + } + + @inlinable + public static func + (lhs: Self, rhs: Self) -> Self { + Self(seconds: lhs.seconds + rhs.seconds) + } + + /// Make an expiration with a number of seconds. + @inlinable + public static func seconds(_ seconds: Int) -> Self { + Self(seconds: seconds) + } + + /// Make an expiration with a number of minutes. + @inlinable + public static func minutes(_ minutes: Int) -> Self { + .seconds(minutes*60) + } + + /// Make an expiration with a number of hours. + @inlinable + public static func hours(_ hours: Int) -> Self { + .minutes(hours*60) + } + + /// Make an expiration with a number of days. + @inlinable + public static func days(_ days: Int) -> Self { + .hours(days*24) + } + } +} + extension WebPushManager { public struct Urgency: Hashable, Comparable, Sendable, CustomStringConvertible { let rawValue: String @@ -495,7 +585,7 @@ extension WebPushManager { case handler(@Sendable ( _ message: _Message, _ subscriber: Subscriber, - _ expiration: VAPID.Configuration.Duration, + _ expiration: Expiration, _ urgency: Urgency ) async throws -> Void) } diff --git a/Sources/WebPushTesting/WebPushManager+Testing.swift b/Sources/WebPushTesting/WebPushManager+Testing.swift index c7273ca..08f195d 100644 --- a/Sources/WebPushTesting/WebPushManager+Testing.swift +++ b/Sources/WebPushTesting/WebPushManager+Testing.swift @@ -28,7 +28,7 @@ extension WebPushManager { messageHandler: @escaping @Sendable ( _ message: Message, _ subscriber: Subscriber, - _ expiration: VAPID.Configuration.Duration, + _ expiration: Expiration, _ urgency: Urgency ) async throws -> Void ) -> WebPushManager { From ff71e174a4cecf1030d4d279c4691e2567003d40 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 16 Dec 2024 02:35:40 -0800 Subject: [PATCH 3/3] Added tests for durations and expirations --- .../VAPIDConfigurationTests.swift | 48 ++++++++++ Tests/WebPushTests/WebPushTests.swift | 93 +++++++++++++++---- 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/Tests/WebPushTests/VAPIDConfigurationTests.swift b/Tests/WebPushTests/VAPIDConfigurationTests.swift index 6e60b62..0244c2d 100644 --- a/Tests/WebPushTests/VAPIDConfigurationTests.swift +++ b/Tests/WebPushTests/VAPIDConfigurationTests.swift @@ -261,6 +261,54 @@ struct VAPIDConfigurationTests { ) } } + + @Suite + struct Duration { + @Test func makingDurations() { + #expect(VAPID.Configuration.Duration.zero.seconds == 0) + + #expect(VAPID.Configuration.Duration(seconds: 15).seconds == 15) + #expect(VAPID.Configuration.Duration(seconds: -15).seconds == -15) + + #expect((15 as VAPID.Configuration.Duration).seconds == 15) + #expect((-15 as VAPID.Configuration.Duration).seconds == -15) + + #expect(VAPID.Configuration.Duration.seconds(15).seconds == 15) + #expect(VAPID.Configuration.Duration.seconds(-15).seconds == -15) + + #expect(VAPID.Configuration.Duration.minutes(15).seconds == 900) + #expect(VAPID.Configuration.Duration.minutes(-15).seconds == -900) + + #expect(VAPID.Configuration.Duration.hours(15).seconds == 54_000) + #expect(VAPID.Configuration.Duration.hours(-15).seconds == -54_000) + + #expect(VAPID.Configuration.Duration.days(15).seconds == 1_296_000) + #expect(VAPID.Configuration.Duration.days(-15).seconds == -1_296_000) + } + + @Test func arithmatic() { + let base: VAPID.Configuration.Duration = 15 + #expect((base + 15).seconds == 30) + #expect((base - 15).seconds == 0) + + #expect((base - .seconds(30)) == -15) + #expect((base + .minutes(2)) == 135) + #expect((base + .minutes(2) + .hours(1)) == 3_735) + #expect((base + .minutes(2) + .hours(1) + .days(2)) == 176_535) + #expect((base + .seconds(45) + .minutes(59)) == .hours(1)) + } + + @Test func comparison() { + #expect(VAPID.Configuration.Duration.seconds(75) < VAPID.Configuration.Duration.minutes(2)) + #expect(VAPID.Configuration.Duration.seconds(175) > VAPID.Configuration.Duration.minutes(2)) + } + + @Test func coding() throws { + #expect(String(decoding: try JSONEncoder().encode(VAPID.Configuration.Duration(60)), as: UTF8.self) == "60") + + #expect(try JSONDecoder().decode(VAPID.Configuration.Duration.self, from: Data("60".utf8)) == .minutes(1)) + } + } } @Suite("Contact Information Coding") diff --git a/Tests/WebPushTests/WebPushTests.swift b/Tests/WebPushTests/WebPushTests.swift index 48f1353..9a25d46 100644 --- a/Tests/WebPushTests/WebPushTests.swift +++ b/Tests/WebPushTests/WebPushTests.swift @@ -6,31 +6,88 @@ // Copyright © 2024 Mochi Development, Inc. All rights reserved. // +import Foundation import Logging import ServiceLifecycle import Testing @testable import WebPush -@Test func webPushManagerInitializesOnItsOwn() async throws { - let manager = WebPushManager(vapidConfiguration: .makeTesting()) - await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await manager.run() +@Suite("WebPush Manager") +struct WebPushManagerTests { + @Test func webPushManagerInitializesOnItsOwn() async throws { + let manager = WebPushManager(vapidConfiguration: .makeTesting()) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await manager.run() + } + group.cancelAll() } - group.cancelAll() } -} - -@Test func webPushManagerInitializesAsService() async throws { - let logger = Logger(label: "ServiceLogger", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }) - let manager = WebPushManager( - vapidConfiguration: .makeTesting(), - logger: logger - ) - await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await ServiceGroup(services: [manager], logger: logger).run() + + @Test func webPushManagerInitializesAsService() async throws { + let logger = Logger(label: "ServiceLogger", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }) + let manager = WebPushManager( + vapidConfiguration: .makeTesting(), + logger: logger + ) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await ServiceGroup(services: [manager], logger: logger).run() + } + group.cancelAll() + } + } + + @Suite + struct Expiration { + @Test func stableConstants() { + #expect(WebPushManager.Expiration.dropIfUndeliverable == 0) + #expect(WebPushManager.Expiration.recommendedMaximum == 2_592_000) + } + + @Test func makingExpirations() { + #expect(WebPushManager.Expiration.zero.seconds == 0) + + #expect(WebPushManager.Expiration(seconds: 15).seconds == 15) + #expect(WebPushManager.Expiration(seconds: -15).seconds == -15) + + #expect((15 as WebPushManager.Expiration).seconds == 15) + #expect((-15 as WebPushManager.Expiration).seconds == -15) + + #expect(WebPushManager.Expiration.seconds(15).seconds == 15) + #expect(WebPushManager.Expiration.seconds(-15).seconds == -15) + + #expect(WebPushManager.Expiration.minutes(15).seconds == 900) + #expect(WebPushManager.Expiration.minutes(-15).seconds == -900) + + #expect(WebPushManager.Expiration.hours(15).seconds == 54_000) + #expect(WebPushManager.Expiration.hours(-15).seconds == -54_000) + + #expect(WebPushManager.Expiration.days(15).seconds == 1_296_000) + #expect(WebPushManager.Expiration.days(-15).seconds == -1_296_000) + } + + @Test func arithmatic() { + let base: WebPushManager.Expiration = 15 + #expect((base + 15).seconds == 30) + #expect((base - 15).seconds == 0) + + #expect((base - .seconds(30)) == -15) + #expect((base + .minutes(2)) == 135) + #expect((base + .minutes(2) + .hours(1)) == 3_735) + #expect((base + .minutes(2) + .hours(1) + .days(2)) == 176_535) + #expect((base + .seconds(45) + .minutes(59)) == .hours(1)) + } + + @Test func comparison() { + #expect(WebPushManager.Expiration.seconds(75) < WebPushManager.Expiration.minutes(2)) + #expect(WebPushManager.Expiration.seconds(175) > WebPushManager.Expiration.minutes(2)) + } + + @Test func coding() throws { + #expect(String(decoding: try JSONEncoder().encode(WebPushManager.Expiration(60)), as: UTF8.self) == "60") + + #expect(try JSONDecoder().decode(WebPushManager.Expiration.self, from: Data("60".utf8)) == .minutes(1)) } - group.cancelAll() } }