-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Handle duplicate cookie attachment to http response (#11)
- Loading branch information
1 parent
e68cfa4
commit 1d89584
Showing
7 changed files
with
148 additions
and
31 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 29 additions & 15 deletions
44
src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Cookie> _cache; | ||
private readonly Dictionary<string, Cookie> _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<IEnumerable<Cookie>> GetAllAsync() | ||
{ | ||
return Task.FromResult(_cache.ToList().AsEnumerable()); | ||
return Task.FromResult(_cache.Select(x => x.Value).ToList().AsEnumerable()); | ||
} | ||
|
||
public Task<Cookie?> GetAsync(string key) | ||
{ | ||
return Task.FromResult(_cache.FirstOrDefault(x => x.Key == key)); | ||
if (_cache.TryGetValue(key, out var cookie)) return Task.FromResult<Cookie?>(cookie); | ||
|
||
return Task.FromResult<Cookie?>(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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) { } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
27 changes: 27 additions & 0 deletions
27
tests/BitzArt.Blazor.Cookies.Server.Tests/BitzArt.Blazor.Cookies.Server.Tests.csproj
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
<Project Sdk="Microsoft.NET.Sdk"> | ||
|
||
<PropertyGroup> | ||
<TargetFramework>net8.0</TargetFramework> | ||
<ImplicitUsings>enable</ImplicitUsings> | ||
<Nullable>enable</Nullable> | ||
|
||
<IsPackable>false</IsPackable> | ||
<IsTestProject>true</IsTestProject> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<PackageReference Include="coverlet.collector" Version="6.0.0" /> | ||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> | ||
<PackageReference Include="xunit" Version="2.5.3" /> | ||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<ProjectReference Include="..\..\src\BitzArt.Blazor.Cookies.Server\BitzArt.Blazor.Cookies.Server.csproj" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<Using Include="Xunit" /> | ||
</ItemGroup> | ||
|
||
</Project> |
75 changes: 75 additions & 0 deletions
75
tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} | ||
} |