diff --git a/Blazor.Cookies.sln b/Blazor.Cookies.sln index 112d102..cbbcb8d 100644 --- a/Blazor.Cookies.sln +++ b/Blazor.Cookies.sln @@ -25,9 +25,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\Tests.yml = .github\workflows\Tests.yml EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitzArt.Blazor.Cookies.Client", "src\BitzArt.Blazor.Cookies.Client\BitzArt.Blazor.Cookies.Client.csproj", "{5E61195E-5AB8-469E-B848-CDB0228F3984}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BitzArt.Blazor.Cookies.Client", "src\BitzArt.Blazor.Cookies.Client\BitzArt.Blazor.Cookies.Client.csproj", "{5E61195E-5AB8-469E-B848-CDB0228F3984}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitzArt.Blazor.Cookies.Server", "src\BitzArt.Blazor.Cookies.Server\BitzArt.Blazor.Cookies.Server.csproj", "{9DDC9769-C0C6-452C-97E1-A11991976106}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BitzArt.Blazor.Cookies.Server", "src\BitzArt.Blazor.Cookies.Server\BitzArt.Blazor.Cookies.Server.csproj", "{9DDC9769-C0C6-452C-97E1-A11991976106}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitzArt.Blazor.Cookies.Server.Tests", "tests\BitzArt.Blazor.Cookies.Server.Tests\BitzArt.Blazor.Cookies.Server.Tests.csproj", "{117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -59,6 +61,10 @@ Global {9DDC9769-C0C6-452C-97E1-A11991976106}.Debug|Any CPU.Build.0 = Debug|Any CPU {9DDC9769-C0C6-452C-97E1-A11991976106}.Release|Any CPU.ActiveCfg = Release|Any CPU {9DDC9769-C0C6-452C-97E1-A11991976106}.Release|Any CPU.Build.0 = Release|Any CPU + {117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -71,6 +77,7 @@ Global {890E8E99-1FCA-4A48-8189-92FF199211D8} = {53C2EA4F-8EA8-41FE-A091-D85B0314B1F7} {5E61195E-5AB8-469E-B848-CDB0228F3984} = {F0AC2847-ADDF-4D66-B1FA-2D6B34F206CF} {9DDC9769-C0C6-452C-97E1-A11991976106} = {F0AC2847-ADDF-4D66-B1FA-2D6B34F206CF} + {117F8E5A-B3AB-4C0C-824C-656F0DD2AB15} = {FE7AAF4D-63E3-41CA-8E4F-D16CC839D8DA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {43738754-874B-41F7-8B6C-087023E2CD94} diff --git a/src/BitzArt.Blazor.Cookies.Server/BitzArt.Blazor.Cookies.Server.csproj b/src/BitzArt.Blazor.Cookies.Server/BitzArt.Blazor.Cookies.Server.csproj index 4fd35f9..c380389 100644 --- a/src/BitzArt.Blazor.Cookies.Server/BitzArt.Blazor.Cookies.Server.csproj +++ b/src/BitzArt.Blazor.Cookies.Server/BitzArt.Blazor.Cookies.Server.csproj @@ -27,4 +27,10 @@ + + + <_Parameter1>BitzArt.Blazor.Cookies.Server.Tests + + + diff --git a/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs b/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs index 21b8a18..4119967 100644 --- a/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs +++ b/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs @@ -1,51 +1,65 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.AspNetCore.Http; namespace BitzArt.Blazor.Cookies; internal class HttpContextCookieService : ICookieService { private readonly HttpContext _httpContext; - private readonly List _cache; + private readonly Dictionary _cache; public HttpContextCookieService(IHttpContextAccessor httpContextAccessor) { _httpContext = httpContextAccessor.HttpContext!; _cache = _httpContext.Request.Cookies - .Select(x => new Cookie(x.Key, x.Value)).ToList(); + .Select(x => new Cookie(x.Key, x.Value)).ToDictionary(cookie => cookie.Key); } public Task> GetAllAsync() { - return Task.FromResult(_cache.ToList().AsEnumerable()); + return Task.FromResult(_cache.Select(x => x.Value).ToList().AsEnumerable()); } public Task GetAsync(string key) { - return Task.FromResult(_cache.FirstOrDefault(x => x.Key == key)); + if (_cache.TryGetValue(key, out var cookie)) return Task.FromResult(cookie); + + return Task.FromResult(null); } public Task RemoveAsync(string key, CancellationToken cancellationToken = default) { - var cookie = _cache.FirstOrDefault(x => x.Key == key); - if (cookie is null) return Task.CompletedTask; + if (!_cache.TryGetValue(key, out _)) return Task.CompletedTask; - _cache.Remove(cookie); + _cache.Remove(key); _httpContext.Response.Cookies.Delete(key); return Task.CompletedTask; } public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default) + => SetAsync(new Cookie(key, value, expiration), cancellationToken); + + public async Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default) { - _cache.Add(new Cookie(key, value, expiration)); - _httpContext.Response.Cookies.Append(key, value, new CookieOptions + var alreadyExists = _cache.TryGetValue(cookie.Key, out var existingCookie); + + if (alreadyExists) + { + // If the cookie already exists and the value has not changed, + // we don't need to update it. + if (existingCookie == cookie) return; + + // If the cookie already exists and the new value has changed, + // we remove the old one before adding the new one. + await RemoveAsync(cookie.Key, cancellationToken); + } + + _cache.Add(cookie.Key, cookie); + _httpContext.Response.Cookies.Append(cookie.Key, cookie.Value, new CookieOptions { - Expires = expiration, + Expires = cookie.Expiration, Path = "/", }); - return Task.CompletedTask; } - - public Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default) - => SetAsync(cookie.Key, cookie.Value, cookie.Expiration, cancellationToken); } diff --git a/src/BitzArt.Blazor.Cookies/Model/Cookie.cs b/src/BitzArt.Blazor.Cookies/Model/Cookie.cs index cf39168..8093bfc 100644 --- a/src/BitzArt.Blazor.Cookies/Model/Cookie.cs +++ b/src/BitzArt.Blazor.Cookies/Model/Cookie.cs @@ -1,15 +1,3 @@ namespace BitzArt.Blazor.Cookies; -public class Cookie -{ - public string Key { get; set; } - public string Value { get; set; } - public DateTimeOffset? Expiration { get; set; } - - public Cookie(string key, string value, DateTimeOffset? expiration = null) - { - Key = key; - Value = value; - Expiration = expiration; - } -} +public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null) { } diff --git a/src/BitzArt.Blazor.Cookies/Services/BrowserCookieService.cs b/src/BitzArt.Blazor.Cookies/Services/BrowserCookieService.cs index f0e2942..20254d5 100644 --- a/src/BitzArt.Blazor.Cookies/Services/BrowserCookieService.cs +++ b/src/BitzArt.Blazor.Cookies/Services/BrowserCookieService.cs @@ -7,7 +7,7 @@ internal class BrowserCookieService(IJSRuntime js) : ICookieService public async Task> GetAllAsync() { var raw = await js.InvokeAsync("eval", "document.cookie"); - if (string.IsNullOrWhiteSpace(raw)) return Enumerable.Empty(); + if (string.IsNullOrWhiteSpace(raw)) return []; return raw.Split("; ").Select(x => { diff --git a/tests/BitzArt.Blazor.Cookies.Server.Tests/BitzArt.Blazor.Cookies.Server.Tests.csproj b/tests/BitzArt.Blazor.Cookies.Server.Tests/BitzArt.Blazor.Cookies.Server.Tests.csproj new file mode 100644 index 0000000..939cf93 --- /dev/null +++ b/tests/BitzArt.Blazor.Cookies.Server.Tests/BitzArt.Blazor.Cookies.Server.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs b/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs new file mode 100644 index 0000000..362b63d --- /dev/null +++ b/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Http; + +namespace BitzArt.Blazor.Cookies.Server.Tests; + +public class HttpContextCookieServiceTests +{ + [Fact] + public async Task SetCookie_WhenProperCookie_ShouldSetCookie() + { + // Arrange + (var httpContext, _, var service) = CreateTestServices(); + + // Act + await service.SetAsync("key", "value", null); + + // Assert + Assert.Single(httpContext.Response.Headers); + Assert.Single(httpContext.Response.Headers["Set-Cookie"]); + Assert.Contains("key=value", httpContext.Response.Headers["Set-Cookie"].First()); + } + + [Fact] + public async Task RemoveCookie_AfterSetCookie_ShouldRemoveCookie() + { + // Arrange + (var httpContext, _, var service) = CreateTestServices(); + + await service.SetAsync("key", "value", null); + + // Act + await service.RemoveAsync("key"); + + // Assert + Assert.Single(httpContext.Response.Headers); + Assert.Single(httpContext.Response.Headers["Set-Cookie"]); + Assert.Contains("key=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=", httpContext.Response.Headers["Set-Cookie"].First()); + } + + [Fact] + public async Task SetCookie_WhenDuplicate_ShouldOnlySetCookieOnce() + { + // Arrange + (var httpContext, _, var service) = CreateTestServices(); + + // Act + await service.SetAsync("key", "value", null); + await service.SetAsync("key", "value", null); + + // Assert + Assert.Single(httpContext.Response.Headers); + } + + private static TestServices CreateTestServices() + { + var httpContext = new DefaultHttpContext(); + var accessor = new TestHttpContextAccessor(httpContext); + + var cookieService = new HttpContextCookieService(accessor); + + return new TestServices(httpContext, accessor, cookieService); + } + + private record TestServices(HttpContext HttpContext, IHttpContextAccessor HttpContextAccessor, ICookieService CookieService); + + private class TestHttpContextAccessor(HttpContext httpContext) : IHttpContextAccessor + { + private HttpContext? _httpContext = httpContext; + + public HttpContext? HttpContext + { + get => _httpContext; + set => _httpContext = value; + } + } +} \ No newline at end of file