From cb9893fa79d2d8ca971ccab356087c6b023df8c1 Mon Sep 17 00:00:00 2001 From: Robert Bill <147130488+RobSlgm@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:14:49 +0200 Subject: [PATCH 1/3] Seal classes add further test cases --- WebPush.Test/WebPushClientTest.cs | 23 +++++++++++++++++++ WebPush/Model/EncryptionResult.cs | 4 +--- .../InvalidEncryptionDetailsException.cs | 2 +- WebPush/Model/PushSubscription.cs | 2 +- WebPush/Model/VapidDetails.cs | 2 +- WebPush/Model/WebPushException.cs | 2 +- WebPush/Model/WebPushOptions.cs | 2 +- WebPush/Util/EnumHelper.cs | 2 +- WebPush/VapidHelper.cs | 14 +++++++++++ WebPush/WebPushClient.cs | 10 ++------ WebPush/packages.lock.json | 18 --------------- 11 files changed, 46 insertions(+), 35 deletions(-) diff --git a/WebPush.Test/WebPushClientTest.cs b/WebPush.Test/WebPushClientTest.cs index ceffd2e..52fb7f7 100644 --- a/WebPush.Test/WebPushClientTest.cs +++ b/WebPush.Test/WebPushClientTest.cs @@ -36,6 +36,29 @@ public void InitializeTest() client = new WebPushClient(httpMessageHandlerMock.ToHttpClient()); } + [TestMethod] + public void TestBogusEndpoint() + { + var subscription = new PushSubscription("this is not a valid endpoint", TestPublicKey, TestPrivateKey); + Assert.ThrowsExactly(() => client.GenerateRequestDetails(subscription, @"test payload")); + } + + + [TestMethod] + public void TestMissingAuth() + { + var subscription = new PushSubscription(TestFcmEndpoint, TestPublicKey, string.Empty); + Assert.ThrowsExactly(() => client.GenerateRequestDetails(subscription, @"test payload")); + } + + [TestMethod] + public void TestMissingP256DH() + { + var subscription = new PushSubscription(TestFcmEndpoint, string.Empty, TestPrivateKey); + Assert.ThrowsExactly(() => client.GenerateRequestDetails(subscription, @"test payload")); + } + + [TestMethod] public void TestSetTopic() { diff --git a/WebPush/Model/EncryptionResult.cs b/WebPush/Model/EncryptionResult.cs index 2fed814..e9a096e 100644 --- a/WebPush/Model/EncryptionResult.cs +++ b/WebPush/Model/EncryptionResult.cs @@ -2,9 +2,7 @@ namespace WebPush.Model; -// @LogicSoftware -// Originally From: https://github.com/LogicSoftware/WebPushEncryption/blob/master/src/EncryptionResult.cs -public class EncryptionResult +public sealed class EncryptionResult { public required byte[] PublicKey { get; set; } public required byte[] Payload { get; set; } diff --git a/WebPush/Model/InvalidEncryptionDetailsException.cs b/WebPush/Model/InvalidEncryptionDetailsException.cs index dd46852..ff71d1b 100644 --- a/WebPush/Model/InvalidEncryptionDetailsException.cs +++ b/WebPush/Model/InvalidEncryptionDetailsException.cs @@ -2,7 +2,7 @@ namespace WebPush.Model; -public class InvalidEncryptionDetailsException : Exception +public sealed class InvalidEncryptionDetailsException : Exception { public InvalidEncryptionDetailsException(string message, PushSubscription pushSubscription) : base(message) diff --git a/WebPush/Model/PushSubscription.cs b/WebPush/Model/PushSubscription.cs index 8816f73..a5d2e4e 100644 --- a/WebPush/Model/PushSubscription.cs +++ b/WebPush/Model/PushSubscription.cs @@ -1,6 +1,6 @@ namespace WebPush; -public class PushSubscription +public sealed class PushSubscription { public PushSubscription(string endpoint, string p256dh, string auth) { diff --git a/WebPush/Model/VapidDetails.cs b/WebPush/Model/VapidDetails.cs index 82e5e89..860ced5 100644 --- a/WebPush/Model/VapidDetails.cs +++ b/WebPush/Model/VapidDetails.cs @@ -3,7 +3,7 @@ namespace WebPush; -public class VapidDetails +public sealed class VapidDetails { /// This should be a URL or a 'mailto:' email address /// The VAPID public key as a base64 encoded string diff --git a/WebPush/Model/WebPushException.cs b/WebPush/Model/WebPushException.cs index 86b977d..29562f4 100644 --- a/WebPush/Model/WebPushException.cs +++ b/WebPush/Model/WebPushException.cs @@ -5,7 +5,7 @@ namespace WebPush.Model; -public class WebPushException : Exception +public sealed class WebPushException : Exception { public WebPushException(string message, PushSubscription pushSubscription, HttpResponseMessage responseMessage) : base(message) { diff --git a/WebPush/Model/WebPushOptions.cs b/WebPush/Model/WebPushOptions.cs index 3b9cbda..a5bcbdd 100644 --- a/WebPush/Model/WebPushOptions.cs +++ b/WebPush/Model/WebPushOptions.cs @@ -2,7 +2,7 @@ namespace WebPush; -public class WebPushOptions +public sealed class WebPushOptions { public VapidDetails? VapidDetails { get; set; } public const int DefaultTtl = 2419200; // default is 4 weeks diff --git a/WebPush/Util/EnumHelper.cs b/WebPush/Util/EnumHelper.cs index 50daf0e..eb914b1 100644 --- a/WebPush/Util/EnumHelper.cs +++ b/WebPush/Util/EnumHelper.cs @@ -4,7 +4,7 @@ namespace WebPush.Util; -public static partial class EnumHelper +internal static partial class EnumHelper { public static string ToKebabCaseLower(this T val) where T : Enum diff --git a/WebPush/VapidHelper.cs b/WebPush/VapidHelper.cs index 10b77ac..5eb8c74 100644 --- a/WebPush/VapidHelper.cs +++ b/WebPush/VapidHelper.cs @@ -18,6 +18,19 @@ public static VapidDetails GenerateVapidKeys() return new VapidDetails("", keys.GetEncodedPublicKey(), keys.GetEncodedPrivateKey()); } + /// + /// This method takes the required VAPID parameters and returns the required + /// header to be added to a Web Push Protocol Request. + /// + /// This must be the origin of the push service. + /// The VAPID details + /// The content encoding (defaults to Aes128gcm) + /// A dictionary of header key/value pairs. + public static Dictionary GetVapidHeaders(string audience, VapidDetails vapid, ContentEncoding contentEncoding = ContentEncoding.Aes128gcm) + { + return GetVapidHeaders(audience, vapid.Subject, vapid.PublicKey, vapid.PrivateKey, vapid.Expiration, contentEncoding); + } + /// /// This method takes the required VAPID parameters and returns the required /// header to be added to a Web Push Protocol Request. @@ -27,6 +40,7 @@ public static VapidDetails GenerateVapidKeys() /// The VAPID public key as a base64 encoded string /// The VAPID private key as a base64 encoded string /// The expiration of the VAPID JWT. + /// The content encoding (defaults to Aes128gcm) /// A dictionary of header key/value pairs. public static Dictionary GetVapidHeaders(string audience, string subject, string publicKey, string privateKey, DateTime? expiration = null, ContentEncoding contentEncoding = ContentEncoding.Aes128gcm) { diff --git a/WebPush/WebPushClient.cs b/WebPush/WebPushClient.cs index 651c51e..1cae1bc 100644 --- a/WebPush/WebPushClient.cs +++ b/WebPush/WebPushClient.cs @@ -105,7 +105,7 @@ public HttpRequestMessage GenerateRequestDetails(PushSubscription subscription, string.IsNullOrEmpty(subscription.P256DH))) { throw new ArgumentException( - @"To send a message with a payload, the subscription must have 'auth' and 'p256dh' keys.", nameof(subscription)); + @"Unable to send a message with payload to this subscription since it doesn't have the required encryption key", nameof(subscription)); } if (options is not null) @@ -145,12 +145,6 @@ public HttpRequestMessage GenerateRequestDetails(PushSubscription subscription, var contentEncoding = options?.ContentEncoding ?? WebPushOptions.DefaultContentEncoding; if (!string.IsNullOrEmpty(payload)) { - if (string.IsNullOrEmpty(subscription.P256DH) || string.IsNullOrEmpty(subscription.Auth)) - { - throw new ArgumentException( - @"Unable to send a message with payload to this subscription since it doesn't have the required encryption key", nameof(subscription)); - } - var encryptedPayload = EncryptPayload(subscription, payload); request.Content = new ByteArrayContent(encryptedPayload.Payload); @@ -174,7 +168,7 @@ public HttpRequestMessage GenerateRequestDetails(PushSubscription subscription, { var uri = new Uri(subscription.Endpoint); var audience = uri.Scheme + @"://" + uri.Host; - var vapidHeaders = VapidHelper.GetVapidHeaders(audience, vapidDetails.Subject, vapidDetails.PublicKey, vapidDetails.PrivateKey, vapidDetails.Expiration, contentEncoding); + var vapidHeaders = VapidHelper.GetVapidHeaders(audience, vapidDetails, contentEncoding); request.Headers.Add(@"Authorization", vapidHeaders["Authorization"]); if (contentEncoding == ContentEncoding.Aesgcm) { diff --git a/WebPush/packages.lock.json b/WebPush/packages.lock.json index b6bf21c..62fe135 100644 --- a/WebPush/packages.lock.json +++ b/WebPush/packages.lock.json @@ -2,12 +2,6 @@ "version": 1, "dependencies": { "net10.0": { - "Meziantou.Analyzer": { - "type": "Direct", - "requested": "[3.0.58, )", - "resolved": "3.0.58", - "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g==" - }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", "requested": "[10.0.7, )", @@ -68,12 +62,6 @@ } }, "net8.0": { - "Meziantou.Analyzer": { - "type": "Direct", - "requested": "[3.0.58, )", - "resolved": "3.0.58", - "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g==" - }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", "requested": "[10.0.7, )", @@ -140,12 +128,6 @@ } }, "net9.0": { - "Meziantou.Analyzer": { - "type": "Direct", - "requested": "[3.0.58, )", - "resolved": "3.0.58", - "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g==" - }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", "requested": "[10.0.7, )", From ab6a8edd323b3287b961f0543e7f33fef29c8a84 Mon Sep 17 00:00:00 2001 From: Robert Bill <147130488+RobSlgm@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:45:08 +0200 Subject: [PATCH 2/3] Extend test coverage --- WebPush.Test/WebPushClientTest.cs | 63 ++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/WebPush.Test/WebPushClientTest.cs b/WebPush.Test/WebPushClientTest.cs index 52fb7f7..b271a6a 100644 --- a/WebPush.Test/WebPushClientTest.cs +++ b/WebPush.Test/WebPushClientTest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -45,20 +46,26 @@ public void TestBogusEndpoint() [TestMethod] - public void TestMissingAuth() + [DataRow(TestPublicKey, "")] + [DataRow("", TestPrivateKey)] + [DataRow("", "")] + public void TestMissingAuthWithPayload(string publicKey, string privateKey) { - var subscription = new PushSubscription(TestFcmEndpoint, TestPublicKey, string.Empty); + var subscription = new PushSubscription(TestFcmEndpoint, publicKey, privateKey); Assert.ThrowsExactly(() => client.GenerateRequestDetails(subscription, @"test payload")); } [TestMethod] - public void TestMissingP256DH() + [DataRow(TestPublicKey, "")] + [DataRow("", TestPrivateKey)] + [DataRow("", "")] + public void TestMissingAuthWithoutPayload(string publicKey, string privateKey) { - var subscription = new PushSubscription(TestFcmEndpoint, string.Empty, TestPrivateKey); - Assert.ThrowsExactly(() => client.GenerateRequestDetails(subscription, @"test payload")); + var subscription = new PushSubscription(TestFcmEndpoint, publicKey, privateKey); + var message = client.GenerateRequestDetails(subscription, null); + Assert.IsNotNull(message); } - [TestMethod] public void TestSetTopic() { @@ -68,13 +75,29 @@ public void TestSetTopic() } [TestMethod] - public void TestSetTopicFailures() + [DataRow("failing topic #3")] + [DataRow("")] + [DataRow("a123456789012345678901234567890toolong")] + public void TestSetTopicFailures(string topic) + { + var subscription = new PushSubscription(TestGcmEndpoint, TestPublicKey, TestPrivateKey); + Assert.ThrowsExactly(() => client.GenerateRequestDetails(subscription, @"test payload", new WebPushOptions { Topic = topic, })); + } + + [TestMethod] + public void TestExtraHeaders() { var subscription = new PushSubscription(TestGcmEndpoint, TestPublicKey, TestPrivateKey); - Assert.ThrowsExactly(() => client.GenerateRequestDetails(subscription, @"test payload", new WebPushOptions { Topic = "failing topic #3" })); + Dictionary extraHeaders = []; + extraHeaders["DEBUG-VERBOSE"] = true; + var message = client.GenerateRequestDetails(subscription, @"test payload", new WebPushOptions { ExtraHeaders = extraHeaders, }); + var checkHeader = message.Headers.GetValues(@"DEBUG-VERBOSE").First(); + Assert.IsNotNull(checkHeader); + Assert.AreEqual(@"True", checkHeader); } + [TestMethod] public void TestSetUrgency() { @@ -84,15 +107,29 @@ public void TestSetUrgency() } [TestMethod] - public void TestSetContentEncoding() + [DataRow(ContentEncoding.Aes128gcm, "aes128gcm")] + [DataRow(ContentEncoding.Aesgcm, "aesgcm")] + public void TestSetContentEncoding(ContentEncoding encoding, string encodingHeaderValue) { var subscription = new PushSubscription(TestGcmEndpoint, TestPublicKey, TestPrivateKey); - var messageAes128gcm = client.GenerateRequestDetails(subscription, @"test payload", new WebPushOptions { ContentEncoding = ContentEncoding.Aes128gcm }); - Assert.AreEqual(@"aes128gcm", messageAes128gcm.Content.Headers.ContentEncoding.First()); - var messageAesgcm = client.GenerateRequestDetails(subscription, @"test payload", new WebPushOptions { ContentEncoding = ContentEncoding.Aesgcm }); - Assert.AreEqual(@"aesgcm", messageAesgcm.Content.Headers.ContentEncoding.First()); + var message = client.GenerateRequestDetails(subscription, @"test payload", new WebPushOptions { ContentEncoding = encoding, }); + Assert.AreEqual(encodingHeaderValue, message.Content.Headers.ContentEncoding.First()); } + [TestMethod] + [DataRow(ContentEncoding.Aes128gcm, "aes128gcm")] + [DataRow(ContentEncoding.Aesgcm, "aesgcm")] + public void TestSetContentEncodingWithVapid(ContentEncoding encoding, string encodingHeaderValue) + { + client.SetVapidDetails(TestSubject, TestPublicKey, TestPrivateKey); + var subscription = new PushSubscription(TestGcmEndpoint, TestPublicKey, TestPrivateKey); + var message = client.GenerateRequestDetails(subscription, @"test payload", new WebPushOptions { ContentEncoding = encoding, }); + Assert.AreEqual(encodingHeaderValue, message.Content.Headers.ContentEncoding.First()); + // var authorizationHeader = message.Headers.GetValues(@"Authorization").First(); + // Assert.StartsWith(@"vapid ", authorizationHeader); + } + + [TestMethod] public void TestSetVapidDetails() From 34d35fbe34e95dcb21939879d00bc1a6feade1d7 Mon Sep 17 00:00:00 2001 From: Robert Bill <147130488+RobSlgm@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:48:17 +0200 Subject: [PATCH 3/3] Fix package lock --- WebPush/packages.lock.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/WebPush/packages.lock.json b/WebPush/packages.lock.json index 62fe135..b6bf21c 100644 --- a/WebPush/packages.lock.json +++ b/WebPush/packages.lock.json @@ -2,6 +2,12 @@ "version": 1, "dependencies": { "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.58, )", + "resolved": "3.0.58", + "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g==" + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", "requested": "[10.0.7, )", @@ -62,6 +68,12 @@ } }, "net8.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.58, )", + "resolved": "3.0.58", + "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g==" + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", "requested": "[10.0.7, )", @@ -128,6 +140,12 @@ } }, "net9.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.58, )", + "resolved": "3.0.58", + "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g==" + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", "requested": "[10.0.7, )",