From 7f0a6d65d621ab28a4b11a41a45c3791bd99978f Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Thu, 4 Apr 2024 16:55:20 +0200 Subject: [PATCH] :sparkles: adding matchers for HttpResponse cookies --- .../TestHttpResponseMatchers.cs | 135 ++++++++++++---- .../HttpResponseMatchers.cs | 153 ++---------------- .../HttpResponseMessageMatchers.cs | 47 +++--- .../NExpect.Matchers.AspNetCore.csproj | 9 ++ .../HttpResponseMessageMatchers.cs | 17 +- src/PeanutButter | 2 +- 6 files changed, 154 insertions(+), 209 deletions(-) diff --git a/src/NExpect.Matchers.AspNetCore.Tests/TestHttpResponseMatchers.cs b/src/NExpect.Matchers.AspNetCore.Tests/TestHttpResponseMatchers.cs index 9eb8f0da..f0b47b54 100644 --- a/src/NExpect.Matchers.AspNetCore.Tests/TestHttpResponseMatchers.cs +++ b/src/NExpect.Matchers.AspNetCore.Tests/TestHttpResponseMatchers.cs @@ -169,6 +169,64 @@ public void ShouldTestCookieSecureHttpOnlyDomain() // Assert } + // [Test] + // public void ShouldBeAbleToAssertCookieNeverExpires() + // { + // // Arrange + // var eternalKey = GetRandomString(); + // var eternalValue = GetRandomString(); + // var expiringKey = GetAnother(eternalKey); + // var expiringValue = GetRandomString(); + // var res = HttpResponseBuilder.Create() + // .WithCookie(eternalKey, eternalValue) + // .WithCookie( + // expiringKey, + // expiringValue, + // new CookieOptions() + // { + // Expires = DateTimeOffset.Now.AddHours(1) + // } + // ) + // .Build(); + // var ints = new[] { 1, 2, 3 }; + // Expect(ints) + // .To.Contain.Only(3).Items() + // .And + // .To.Contain.All + // // Act + // Assert.That( + // () => + // { + // Expect(res) + // .To.Have.Cookie(eternalKey) + // .With.Value(eternalValue) + // .Which.Does.Not.Expire(); + // }, + // Throws.Nothing + // ); + // Assert.That( + // () => + // { + // Expect(res) + // .To.Have.Cookie(eternalKey) + // .With.Value(eternalValue) + // .Which.Expires(); + // }, + // Throws.Exception.InstanceOf() + // ); + // Assert.That( + // () => + // { + // Expect(res) + // .To.Have.Cookie(expiringKey) + // .With.Value(expiringValue) + // .Which.Expires(); + // }, + // Throws.Nothing + // ); + // // Assert + // } + [TestFixture] public class Issues { @@ -235,39 +293,54 @@ public void ShouldCorrectlyParsePathAndSameSite() { // Arrange var res = HttpResponseBuilder.Create() - .WithHeader("Set-Cookie", "le_cookie=le_value; Path=/; SameSite=le_same_site; Secure; HttpOnly") + .WithHeader("Set-Cookie", "le_cookie=le_value; Path=/; SameSite=Lax; Secure; HttpOnly") .Build(); // Act - Assert.That(() => - { - Expect(res) - .To.Have.Cookie("le_cookie") - .With.Path("/"); - }, Throws.Nothing); - Assert.That(() => - { - Expect(res) - .To.Have.Cookie("le_cookie") - .With.Value("le_value"); - }, Throws.Nothing); - Assert.That(() => - { - Expect(res) - .To.Have.Cookie("le_cookie") - .With.SameSite("le_same_site"); - }, Throws.Nothing); - Assert.That(() => - { - Expect(res) - .To.Have.Cookie("le_cookie") - .Which.Is.HttpOnly(); - }, Throws.Nothing); - Assert.That(() => - { - Expect(res) - .To.Have.Cookie("le_cookie") - .Which.Is.Secure(); - }, Throws.Nothing); + Assert.That( + () => + { + Expect(res) + .To.Have.Cookie("le_cookie") + .With.Path("/"); + }, + Throws.Nothing + ); + Assert.That( + () => + { + Expect(res) + .To.Have.Cookie("le_cookie") + .With.Value("le_value"); + }, + Throws.Nothing + ); + Assert.That( + () => + { + Expect(res) + .To.Have.Cookie("le_cookie") + .With.SameSite(SameSiteMode.Lax); + }, + Throws.Nothing + ); + Assert.That( + () => + { + Expect(res) + .To.Have.Cookie("le_cookie") + .Which.Is.HttpOnly(); + }, + Throws.Nothing + ); + Assert.That( + () => + { + Expect(res) + .To.Have.Cookie("le_cookie") + .Which.Is.Secure(); + }, + Throws.Nothing + ); // Assert } } diff --git a/src/NExpect.Matchers.AspNetCore/HttpResponseMatchers.cs b/src/NExpect.Matchers.AspNetCore/HttpResponseMatchers.cs index c05f63d1..5b5e8ea0 100644 --- a/src/NExpect.Matchers.AspNetCore/HttpResponseMatchers.cs +++ b/src/NExpect.Matchers.AspNetCore/HttpResponseMatchers.cs @@ -1,13 +1,12 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Net; -using System.Web; -using Imported.PeanutButter.Utils; using Microsoft.AspNetCore.Http; using NExpect.Implementations; using NExpect.Interfaces; using NExpect.MatcherLogic; +using Imported.PeanutButter.TestUtils.AspNetCore; +using Imported.PeanutButter.Utils; namespace NExpect { @@ -65,20 +64,16 @@ Func customMessageGenerator Cookie resolvedCookie = null; have.AddMatcher(actual => { - var cookies = actual.Headers.Where( - h => h.Key == "Set-Cookie" - ).Select(h => h.Value - .Select(ParseCookieHeader) - .SelectMany(o => o) - ) - .SelectMany(o => o) - .ToArray(); - resolvedCookie = cookies.FirstOrDefault(c => c.Name.Equals(name)); - var passed = resolvedCookie != null; + var cookies = actual.Headers.ParseCookies(); + var encodedName = name; + resolvedCookie = cookies.FirstOrDefault(c => c.Name.Equals(encodedName)); + resolvedCookie?.SetMetadata("response", actual); + + var passed = resolvedCookie is not null; return new MatcherResult( passed, MessageHelpers.FinalMessageFor( - () => $"Expected {passed.AsNot()}to find set-cookie header for '{name}'", + () => $"Expected {passed.AsNot()}to find set-cookie header for '{encodedName}'", customMessageGenerator ) ); @@ -89,136 +84,6 @@ have as IExpectationContext ); } - private static IEnumerable ParseCookieHeader( - string header - ) - { - var headerParts = header.Split(',').Select(p => p.Trim()); - foreach (var cookiePart in headerParts) - { - var parts = cookiePart - .Split(';') - .Trim(); - yield return parts.Aggregate( - new Cookie(), - (acc, cur) => - { - var subs = cur.Split('='); - var key = subs[0].Trim(); - var value = string.Join("=", subs.Skip(1)); - if (string.IsNullOrWhiteSpace(acc.Name)) - { - acc.Name = HttpUtility.UrlDecode(key); - acc.Value = HttpUtility.UrlDecode(value); - } - else - { - if (CookieMutations.TryGetValue(key, out var modifier)) - { - modifier(acc, value); - } - } - - return acc; - } - ); - } - } - - private static readonly Dictionary> - CookieMutations = new Dictionary>( - StringComparer.InvariantCultureIgnoreCase - ) - { - ["Expires"] = SetCookieExpiration, - ["Max-Age"] = SetCookieMaxAge, - ["Domain"] = SetCookieDomain, - ["Secure"] = SetCookieSecure, - ["HttpOnly"] = SetCookieHttpOnly, - ["SameSite"] = SetCookieSameSite, - ["Path"] = SetCookiePath - }; - - private static void SetCookiePath( - Cookie cookie, - string value - ) - { - cookie.Path = value; - } - - private static void SetCookieSameSite( - Cookie cookie, - string value - ) - { - // Cookie object doesn't natively support the SameSite property, yet - cookie.SetMetadata("SameSite", value); - } - - private static void SetCookieHttpOnly( - Cookie cookie, - string value - ) - { - cookie.HttpOnly = true; - } - - private static void SetCookieSecure( - Cookie cookie, - string value - ) - { - cookie.Secure = true; - } - - private static void SetCookieDomain( - Cookie cookie, - string value - ) - { - cookie.Domain = value; - } - - private static void SetCookieMaxAge( - Cookie cookie, - string value - ) - { - if (!int.TryParse(value, out var seconds)) - { - throw new ArgumentException( - $"Unable to parse '{value}' as an integer value" - ); - } - - cookie.Expires = DateTime.Now.AddSeconds(seconds); - cookie.Expired = seconds < 1; - cookie.SetMetadata("MaxAge", seconds); - } - - private static void SetCookieExpiration( - Cookie cookie, - string value - ) - { - if (cookie.TryGetMetadata("MaxAge", out _)) - { - // Max-Age takes precedence over Expires - return; - } - - if (!DateTime.TryParse(value, out var expires)) - { - throw new ArgumentException( - $"Unable to parse '{value}' as a date-time value" - ); - } - - cookie.Expires = expires; - cookie.Expired = expires <= DateTime.Now; - } - private static string Name(this Cookie cookie) { return cookie?.Name ?? "unknown cookie"; diff --git a/src/NExpect.Matchers.AspNetCore/HttpResponseMessageMatchers.cs b/src/NExpect.Matchers.AspNetCore/HttpResponseMessageMatchers.cs index 42d94c28..60082359 100644 --- a/src/NExpect.Matchers.AspNetCore/HttpResponseMessageMatchers.cs +++ b/src/NExpect.Matchers.AspNetCore/HttpResponseMessageMatchers.cs @@ -3,11 +3,13 @@ using System.Linq; using System.Net; using System.Net.Http; +using Imported.PeanutButter.TestUtils.AspNetCore; using NExpect.Implementations; using NExpect.Interfaces; using NExpect.MatcherLogic; using static NExpect.Implementations.MessageHelpers; using Imported.PeanutButter.Utils; +using Microsoft.AspNetCore.Http; // ReSharper disable MemberCanBePrivate.Global @@ -233,7 +235,7 @@ Func customMessageGenerator /// public static IMore SameSite( this IWith with, - string expected + SameSiteMode expected ) { return with.SameSite( @@ -251,7 +253,7 @@ string expected /// public static IMore SameSite( this IWith with, - string expected, + SameSiteMode expected, string customMessage ) { @@ -270,31 +272,33 @@ string customMessage /// public static IMore SameSite( this IWith with, - string expected, + SameSiteMode expected, Func customMessageGenerator ) { return with.AddMatcher( actual => { - var hasSameSiteSet = actual.HasMetadata("SameSite"); - var passed = hasSameSiteSet; // for now - string sameSite = null; - if (hasSameSiteSet) + if (!actual.TryGetMetadata("response", out var owner)) { - sameSite = actual.GetMetadata("SameSite"); - passed = string.Equals( - sameSite, - expected, - StringComparison.OrdinalIgnoreCase + return new EnforcedMatcherResult( + false, + FinalMessageFor( + () => + "Unable to determine SameSite for cookie: start your assertion from the HttpResponse so that headers associated with the cookie can be interrogated.", + customMessageGenerator + ) ); } + var sameSite = owner.Headers.ReadSameSiteForCookie(actual.Name); + var passed = sameSite == expected; + return new MatcherResult( passed, FinalMessageFor( () => - $"Expected {passed.AsNot()}to find SameSite '{expected}' (received: '{(hasSameSiteSet ? "no SameSite set" : sameSite)}')", + $"Expected {passed.AsNot()}to find SameSite '{expected}' (received: '{sameSite}')", customMessageGenerator ) ); @@ -504,25 +508,22 @@ Func customMessageGenerator return more.AddMatcher( actual => { - var passed = false; - var hasMaxAge = actual.TryGetMetadata("MaxAge", out var maxAge); - if (hasMaxAge) - { - passed = maxAge == expectedAge; - } - + var maxAgeSeconds = MaxAgeSecondsFor(actual); + var passed = maxAgeSeconds == expectedAge; return new MatcherResult( passed, () => - hasMaxAge - ? $"Expected {actual.Name()} {passed.AsNot()}to have Max-Age '{expectedAge}' (found {maxAge})" - : $"Expected {actual.Name()} {passed.AsNot()}to have Max-Age set", + $"Expected {actual.Name()} {passed.AsNot()}to have Max-Age '{expectedAge}' (found {maxAgeSeconds})", customMessageGenerator ); } ); } + private static int MaxAgeSecondsFor(Cookie cookie) + { + return (int)Math.Round((cookie.Expires - DateTime.Now).TotalSeconds); + } private static Cookie ParseCookieHeader( string header diff --git a/src/NExpect.Matchers.AspNetCore/NExpect.Matchers.AspNetCore.csproj b/src/NExpect.Matchers.AspNetCore/NExpect.Matchers.AspNetCore.csproj index d91eb743..03a7d4dd 100644 --- a/src/NExpect.Matchers.AspNetCore/NExpect.Matchers.AspNetCore.csproj +++ b/src/NExpect.Matchers.AspNetCore/NExpect.Matchers.AspNetCore.csproj @@ -49,6 +49,15 @@ + + Imported\CookieNotFoundException.cs + + + Imported\HeaderDictionaryExtensions.cs + + + Imported\InvalidSameSiteValueException.cs + diff --git a/src/NExpect.Matchers.AspNetMvc/HttpResponseMessageMatchers.cs b/src/NExpect.Matchers.AspNetMvc/HttpResponseMessageMatchers.cs index 27d706ad..d705e185 100644 --- a/src/NExpect.Matchers.AspNetMvc/HttpResponseMessageMatchers.cs +++ b/src/NExpect.Matchers.AspNetMvc/HttpResponseMessageMatchers.cs @@ -337,24 +337,21 @@ Func customMessageGenerator { return more.AddMatcher(actual => { - var passed = false; - var hasMaxAge = actual.TryGetMetadata("MaxAge", out var maxAge); - if (hasMaxAge) - { - passed = maxAge == expectedAge; - } + var maxAgeSeconds = MaxAgeSecondsFor(actual); + var passed = maxAgeSeconds == expectedAge; return new MatcherResult( passed, - () => - hasMaxAge - ? $"Expected {actual.Name()} {passed.AsNot()}to have Max-Age '{expectedAge}' (found {maxAge})" - : $"Expected {actual.Name()} {passed.AsNot()}to have Max-Age set", + () => $"Expected {actual.Name()} {passed.AsNot()}to have Max-Age '{expectedAge}' (found {maxAgeSeconds})", customMessageGenerator ); }); } + private static int MaxAgeSecondsFor(Cookie cookie) + { + return (int)Math.Round((cookie.Expires - DateTime.Now).TotalSeconds); + } private static Cookie ParseCookieHeader( string header diff --git a/src/PeanutButter b/src/PeanutButter index 36650b37..8694f6f4 160000 --- a/src/PeanutButter +++ b/src/PeanutButter @@ -1 +1 @@ -Subproject commit 36650b37c74f4da153011147bb8c682053b29ff9 +Subproject commit 8694f6f4acda44a2d8f9511080f8261741b0cf23