Skip to content

Commit

Permalink
Handle duplicate cookie attachment to http response (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
YuriyDurov authored Oct 24, 2024
1 parent e68cfa4 commit 1d89584
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 31 deletions.
11 changes: 9 additions & 2 deletions Blazor.Cookies.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,10 @@
<ProjectReference Include="..\BitzArt.Blazor.Cookies\BitzArt.Blazor.Cookies.csproj" />
</ItemGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>BitzArt.Blazor.Cookies.Server.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

</Project>
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);
}
14 changes: 1 addition & 13 deletions src/BitzArt.Blazor.Cookies/Model/Cookie.cs
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) { }
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ internal class BrowserCookieService(IJSRuntime js) : ICookieService
public async Task<IEnumerable<Cookie>> GetAllAsync()
{
var raw = await js.InvokeAsync<string>("eval", "document.cookie");
if (string.IsNullOrWhiteSpace(raw)) return Enumerable.Empty<Cookie>();
if (string.IsNullOrWhiteSpace(raw)) return [];

return raw.Split("; ").Select(x =>
{
Expand Down
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>
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;
}
}
}

0 comments on commit 1d89584

Please sign in to comment.