Skip to content

Commit 1d89584

Browse files
authored
Handle duplicate cookie attachment to http response (#11)
1 parent e68cfa4 commit 1d89584

File tree

7 files changed

+148
-31
lines changed

7 files changed

+148
-31
lines changed

Blazor.Cookies.sln

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
2525
.github\workflows\Tests.yml = .github\workflows\Tests.yml
2626
EndProjectSection
2727
EndProject
28-
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}"
28+
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}"
2929
EndProject
30-
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}"
30+
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}"
31+
EndProject
32+
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}"
3133
EndProject
3234
Global
3335
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -59,6 +61,10 @@ Global
5961
{9DDC9769-C0C6-452C-97E1-A11991976106}.Debug|Any CPU.Build.0 = Debug|Any CPU
6062
{9DDC9769-C0C6-452C-97E1-A11991976106}.Release|Any CPU.ActiveCfg = Release|Any CPU
6163
{9DDC9769-C0C6-452C-97E1-A11991976106}.Release|Any CPU.Build.0 = Release|Any CPU
64+
{117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
65+
{117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}.Debug|Any CPU.Build.0 = Debug|Any CPU
66+
{117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}.Release|Any CPU.ActiveCfg = Release|Any CPU
67+
{117F8E5A-B3AB-4C0C-824C-656F0DD2AB15}.Release|Any CPU.Build.0 = Release|Any CPU
6268
EndGlobalSection
6369
GlobalSection(SolutionProperties) = preSolution
6470
HideSolutionNode = FALSE
@@ -71,6 +77,7 @@ Global
7177
{890E8E99-1FCA-4A48-8189-92FF199211D8} = {53C2EA4F-8EA8-41FE-A091-D85B0314B1F7}
7278
{5E61195E-5AB8-469E-B848-CDB0228F3984} = {F0AC2847-ADDF-4D66-B1FA-2D6B34F206CF}
7379
{9DDC9769-C0C6-452C-97E1-A11991976106} = {F0AC2847-ADDF-4D66-B1FA-2D6B34F206CF}
80+
{117F8E5A-B3AB-4C0C-824C-656F0DD2AB15} = {FE7AAF4D-63E3-41CA-8E4F-D16CC839D8DA}
7481
EndGlobalSection
7582
GlobalSection(ExtensibilityGlobals) = postSolution
7683
SolutionGuid = {43738754-874B-41F7-8B6C-087023E2CD94}

src/BitzArt.Blazor.Cookies.Server/BitzArt.Blazor.Cookies.Server.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@
2727
<ProjectReference Include="..\BitzArt.Blazor.Cookies\BitzArt.Blazor.Cookies.csproj" />
2828
</ItemGroup>
2929

30+
<ItemGroup>
31+
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
32+
<_Parameter1>BitzArt.Blazor.Cookies.Server.Tests</_Parameter1>
33+
</AssemblyAttribute>
34+
</ItemGroup>
35+
3036
</Project>
Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,65 @@
1-
using Microsoft.AspNetCore.Http;
1+
using Microsoft.AspNetCore.DataProtection.KeyManagement;
2+
using Microsoft.AspNetCore.Http;
23

34
namespace BitzArt.Blazor.Cookies;
45

56
internal class HttpContextCookieService : ICookieService
67
{
78
private readonly HttpContext _httpContext;
8-
private readonly List<Cookie> _cache;
9+
private readonly Dictionary<string, Cookie> _cache;
910

1011
public HttpContextCookieService(IHttpContextAccessor httpContextAccessor)
1112
{
1213
_httpContext = httpContextAccessor.HttpContext!;
1314
_cache = _httpContext.Request.Cookies
14-
.Select(x => new Cookie(x.Key, x.Value)).ToList();
15+
.Select(x => new Cookie(x.Key, x.Value)).ToDictionary(cookie => cookie.Key);
1516
}
1617

1718
public Task<IEnumerable<Cookie>> GetAllAsync()
1819
{
19-
return Task.FromResult(_cache.ToList().AsEnumerable());
20+
return Task.FromResult(_cache.Select(x => x.Value).ToList().AsEnumerable());
2021
}
2122

2223
public Task<Cookie?> GetAsync(string key)
2324
{
24-
return Task.FromResult(_cache.FirstOrDefault(x => x.Key == key));
25+
if (_cache.TryGetValue(key, out var cookie)) return Task.FromResult<Cookie?>(cookie);
26+
27+
return Task.FromResult<Cookie?>(null);
2528
}
2629

2730
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
2831
{
29-
var cookie = _cache.FirstOrDefault(x => x.Key == key);
30-
if (cookie is null) return Task.CompletedTask;
32+
if (!_cache.TryGetValue(key, out _)) return Task.CompletedTask;
3133

32-
_cache.Remove(cookie);
34+
_cache.Remove(key);
3335
_httpContext.Response.Cookies.Delete(key);
3436

3537
return Task.CompletedTask;
3638
}
3739

3840
public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default)
41+
=> SetAsync(new Cookie(key, value, expiration), cancellationToken);
42+
43+
public async Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default)
3944
{
40-
_cache.Add(new Cookie(key, value, expiration));
41-
_httpContext.Response.Cookies.Append(key, value, new CookieOptions
45+
var alreadyExists = _cache.TryGetValue(cookie.Key, out var existingCookie);
46+
47+
if (alreadyExists)
48+
{
49+
// If the cookie already exists and the value has not changed,
50+
// we don't need to update it.
51+
if (existingCookie == cookie) return;
52+
53+
// If the cookie already exists and the new value has changed,
54+
// we remove the old one before adding the new one.
55+
await RemoveAsync(cookie.Key, cancellationToken);
56+
}
57+
58+
_cache.Add(cookie.Key, cookie);
59+
_httpContext.Response.Cookies.Append(cookie.Key, cookie.Value, new CookieOptions
4260
{
43-
Expires = expiration,
61+
Expires = cookie.Expiration,
4462
Path = "/",
4563
});
46-
return Task.CompletedTask;
4764
}
48-
49-
public Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default)
50-
=> SetAsync(cookie.Key, cookie.Value, cookie.Expiration, cancellationToken);
5165
}
Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,3 @@
11
namespace BitzArt.Blazor.Cookies;
22

3-
public class Cookie
4-
{
5-
public string Key { get; set; }
6-
public string Value { get; set; }
7-
public DateTimeOffset? Expiration { get; set; }
8-
9-
public Cookie(string key, string value, DateTimeOffset? expiration = null)
10-
{
11-
Key = key;
12-
Value = value;
13-
Expiration = expiration;
14-
}
15-
}
3+
public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null) { }

src/BitzArt.Blazor.Cookies/Services/BrowserCookieService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ internal class BrowserCookieService(IJSRuntime js) : ICookieService
77
public async Task<IEnumerable<Cookie>> GetAllAsync()
88
{
99
var raw = await js.InvokeAsync<string>("eval", "document.cookie");
10-
if (string.IsNullOrWhiteSpace(raw)) return Enumerable.Empty<Cookie>();
10+
if (string.IsNullOrWhiteSpace(raw)) return [];
1111

1212
return raw.Split("; ").Select(x =>
1313
{
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
8+
<IsPackable>false</IsPackable>
9+
<IsTestProject>true</IsTestProject>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="coverlet.collector" Version="6.0.0" />
14+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
15+
<PackageReference Include="xunit" Version="2.5.3" />
16+
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<ProjectReference Include="..\..\src\BitzArt.Blazor.Cookies.Server\BitzArt.Blazor.Cookies.Server.csproj" />
21+
</ItemGroup>
22+
23+
<ItemGroup>
24+
<Using Include="Xunit" />
25+
</ItemGroup>
26+
27+
</Project>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using Microsoft.AspNetCore.Http;
2+
3+
namespace BitzArt.Blazor.Cookies.Server.Tests;
4+
5+
public class HttpContextCookieServiceTests
6+
{
7+
[Fact]
8+
public async Task SetCookie_WhenProperCookie_ShouldSetCookie()
9+
{
10+
// Arrange
11+
(var httpContext, _, var service) = CreateTestServices();
12+
13+
// Act
14+
await service.SetAsync("key", "value", null);
15+
16+
// Assert
17+
Assert.Single(httpContext.Response.Headers);
18+
Assert.Single(httpContext.Response.Headers["Set-Cookie"]);
19+
Assert.Contains("key=value", httpContext.Response.Headers["Set-Cookie"].First());
20+
}
21+
22+
[Fact]
23+
public async Task RemoveCookie_AfterSetCookie_ShouldRemoveCookie()
24+
{
25+
// Arrange
26+
(var httpContext, _, var service) = CreateTestServices();
27+
28+
await service.SetAsync("key", "value", null);
29+
30+
// Act
31+
await service.RemoveAsync("key");
32+
33+
// Assert
34+
Assert.Single(httpContext.Response.Headers);
35+
Assert.Single(httpContext.Response.Headers["Set-Cookie"]);
36+
Assert.Contains("key=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=", httpContext.Response.Headers["Set-Cookie"].First());
37+
}
38+
39+
[Fact]
40+
public async Task SetCookie_WhenDuplicate_ShouldOnlySetCookieOnce()
41+
{
42+
// Arrange
43+
(var httpContext, _, var service) = CreateTestServices();
44+
45+
// Act
46+
await service.SetAsync("key", "value", null);
47+
await service.SetAsync("key", "value", null);
48+
49+
// Assert
50+
Assert.Single(httpContext.Response.Headers);
51+
}
52+
53+
private static TestServices CreateTestServices()
54+
{
55+
var httpContext = new DefaultHttpContext();
56+
var accessor = new TestHttpContextAccessor(httpContext);
57+
58+
var cookieService = new HttpContextCookieService(accessor);
59+
60+
return new TestServices(httpContext, accessor, cookieService);
61+
}
62+
63+
private record TestServices(HttpContext HttpContext, IHttpContextAccessor HttpContextAccessor, ICookieService CookieService);
64+
65+
private class TestHttpContextAccessor(HttpContext httpContext) : IHttpContextAccessor
66+
{
67+
private HttpContext? _httpContext = httpContext;
68+
69+
public HttpContext? HttpContext
70+
{
71+
get => _httpContext;
72+
set => _httpContext = value;
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)