Merge hands/mill-votes-cut-5: the vote page, the device-cookie voter key and cookieless CSRF
Held by concept-mill-votes:verdict:cut-5.s2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
68214ced30
7 changed files with 961 additions and 0 deletions
|
|
@ -24,4 +24,8 @@ public sealed class ConceptVoteOptions
|
|||
// https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator
|
||||
// refuses anything else outside Development and Testing, because "*" would allow every origin.
|
||||
public string[] AllowedOrigins { get; init; } = [];
|
||||
|
||||
// The Mill page the vote page links back to; the concept's key is appended as the anchor. Production is the
|
||||
// Aetheria site's page, so a deploy overrides nothing unless the page moves (ConceptVotes__MillPageUrl).
|
||||
public string MillPageUrl { get; init; } = "https://aetheria.gamecult.org/Game-Design/Concept-Mill";
|
||||
}
|
||||
|
|
|
|||
29
src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs
Normal file
29
src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Bifrost.Web.Features.ConceptVotes;
|
||||
|
||||
// The one place that decides whether a state-changing request to the vote page came from the site itself. The anonymous
|
||||
// vote flow carries no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead
|
||||
// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser
|
||||
// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header
|
||||
// are all refused. Header values are compared, never echoed.
|
||||
public static class CastRequestGuard
|
||||
{
|
||||
public static bool Allows(HttpRequest request, BifrostHostOptions host)
|
||||
{
|
||||
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var origin = request.Headers.Origin;
|
||||
if (request.Headers.ContainsKey("Origin"))
|
||||
{
|
||||
return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var fetchSite = request.Headers["Sec-Fetch-Site"];
|
||||
return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
55
src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs
Normal file
55
src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace Bifrost.Web.Features.ConceptVotes;
|
||||
|
||||
// The only reader and writer of the bifrost_voter cookie. It supplies a device id and nothing else: VoterIdentity.For
|
||||
// decides the voter key and class. The cookie holds a protected random id, never a count, class or account.
|
||||
public sealed class VoterKeyResolver(IDataProtectionProvider dataProtection)
|
||||
{
|
||||
public const string CookieName = "bifrost_voter";
|
||||
public const string CookiePath = "/mill";
|
||||
public static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(365);
|
||||
|
||||
private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1";
|
||||
|
||||
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
|
||||
|
||||
// The device id, or null when there is no cookie or it does not unprotect under this key ring and purpose
|
||||
// (tampered, truncated, a bare id, or minted elsewhere). Nothing about a refused value is kept or reported.
|
||||
public string? TryRead(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Cookies.TryGetValue(CookieName, out var token) || string.IsNullOrEmpty(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return protector.Unprotect(token);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sets a fresh device cookie on this response and returns its device id. Called only from a cast.
|
||||
public string Mint(HttpContext context)
|
||||
{
|
||||
var deviceId = Guid.NewGuid().ToString("N");
|
||||
context.Response.Cookies.Append(
|
||||
CookieName,
|
||||
protector.Protect(deviceId),
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = CookiePath,
|
||||
MaxAge = CookieLifetime,
|
||||
IsEssential = true
|
||||
});
|
||||
return deviceId;
|
||||
}
|
||||
}
|
||||
56
src/Bifrost.Web/Pages/Mill/Vote.cshtml
Normal file
56
src/Bifrost.Web/Pages/Mill/Vote.cshtml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
@page "/mill/{collection}/{key}"
|
||||
@model Bifrost.Web.Pages.Mill.VoteModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Subject.Title;
|
||||
var stance = Model.Subject.YourStance;
|
||||
}
|
||||
|
||||
<section class="panel">
|
||||
<a class="back-link" href="@Model.MillPageLink">Back to the Concept Mill</a>
|
||||
<h1>@Model.Subject.Title</h1>
|
||||
|
||||
<dl class="mill-counts">
|
||||
<dt>Everyone</dt>
|
||||
<dd>@Model.Subject.Tally.Up up, @Model.Subject.Tally.Down down</dd>
|
||||
<dt>Members</dt>
|
||||
<dd>@Model.Subject.Tally.Members.Up up, @Model.Subject.Tally.Members.Down down</dd>
|
||||
</dl>
|
||||
|
||||
<p>
|
||||
@if (stance is null)
|
||||
{
|
||||
<text>You have not voted on this concept.</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Your vote: @(stance == Bifrost.Web.Domain.ConceptStance.Up ? "up" : "down").</text>
|
||||
}
|
||||
</p>
|
||||
|
||||
<div class="mill-actions">
|
||||
<form method="post" asp-page-handler="Up" asp-antiforgery="false" hx-boost="false">
|
||||
<button class="button" type="submit">Up</button>
|
||||
</form>
|
||||
<form method="post" asp-page-handler="Down" asp-antiforgery="false" hx-boost="false">
|
||||
<button class="button" type="submit">Down</button>
|
||||
</form>
|
||||
@if (stance is not null)
|
||||
{
|
||||
<form method="post" asp-page-handler="Retract" asp-antiforgery="false" hx-boost="false">
|
||||
<button class="button ghost" type="submit">Retract</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Model.SignedIn)
|
||||
{
|
||||
<p class="muted">Your vote is kept against your Bifrost account. No cookie is set.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">
|
||||
Voting saves one small cookie on this device. Its only purpose is to keep your one vote yours so you can
|
||||
change or retract it; it lasts a year and carries nothing else.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
132
src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs
Normal file
132
src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.ConceptVotes;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Pages.Mill;
|
||||
|
||||
// The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the
|
||||
// voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads.
|
||||
[IgnoreAntiforgeryToken]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public sealed class VoteModel(
|
||||
ICurrentBifrostActorAccessor actorAccessor,
|
||||
ConceptAppraisalService appraisals,
|
||||
VoterKeyResolver deviceCookie,
|
||||
IOptions<ConceptVoteOptions> options,
|
||||
IOptions<BifrostHostOptions> host) : PageModel
|
||||
{
|
||||
public ConceptSubjectView Subject { get; private set; } = null!;
|
||||
|
||||
public bool SignedIn { get; private set; }
|
||||
|
||||
public string MillPageLink => options.Value.MillPageUrl + "#" + Uri.EscapeDataString(Subject.Key);
|
||||
|
||||
// The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler,
|
||||
// any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own
|
||||
// origin (CastRequestGuard); no antiforgery cookie or token exists on this flow.
|
||||
public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
|
||||
{
|
||||
var method = context.HttpContext.Request.Method;
|
||||
if (context.HandlerMethod is null)
|
||||
{
|
||||
context.Result = new StatusCodeResult(
|
||||
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsPost(method)
|
||||
? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status405MethodNotAllowed);
|
||||
return;
|
||||
}
|
||||
|
||||
// The handler the request names must be the handler selected: GET ?handler=Up has no OnGetUp to run.
|
||||
var named = context.HttpContext.Request.Query["handler"].ToString();
|
||||
if (string.IsNullOrEmpty(named) != string.IsNullOrEmpty(context.HandlerMethod.Name))
|
||||
{
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status400BadRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value))
|
||||
{
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string collection, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var voter = ResolveVoter(actor, mint: false);
|
||||
SignedIn = voter?.UserAccountId is not null;
|
||||
return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound();
|
||||
}
|
||||
|
||||
public Task<IActionResult> OnPostUpAsync(string collection, string key, CancellationToken cancellationToken) =>
|
||||
CastAsync(collection, key, ConceptStance.Up, cancellationToken);
|
||||
|
||||
public Task<IActionResult> OnPostDownAsync(string collection, string key, CancellationToken cancellationToken) =>
|
||||
CastAsync(collection, key, ConceptStance.Down, cancellationToken);
|
||||
|
||||
public async Task<IActionResult> OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var voter = ResolveVoter(actor, mint: false);
|
||||
await appraisals.RetractAsync(collection, key, voter, cancellationToken);
|
||||
return RedirectToPage(new { collection, key });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> CastAsync(
|
||||
string collection,
|
||||
string key,
|
||||
ConceptStance stance,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var voter = ResolveVoter(actor, mint: true);
|
||||
var result = await appraisals.CastAsync(
|
||||
collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken);
|
||||
return result is CastResult.UnknownSubject or CastResult.RetiredSubject
|
||||
? NotFound()
|
||||
: RedirectToPage(new { collection, key });
|
||||
}
|
||||
|
||||
// Accounts key to the account and never touch the cookie. An anonymous visitor keys to a device cookie that
|
||||
// unprotects to a well-formed id; a cast without one mints it, and no other path does.
|
||||
private VoterIdentity? ResolveVoter(CurrentBifrostActor actor, bool mint)
|
||||
{
|
||||
var voter = VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext));
|
||||
if (voter is null && mint)
|
||||
{
|
||||
voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext));
|
||||
}
|
||||
|
||||
return voter;
|
||||
}
|
||||
|
||||
private async Task<bool> LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken)
|
||||
{
|
||||
var view = await appraisals.GetSubjectAsync(collection, key, voter, cancellationToken);
|
||||
if (view is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Subject = view;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,7 @@ builder.Services.AddRazorPages(options =>
|
|||
{
|
||||
options.Conventions.AuthorizePage("/Membership/Status");
|
||||
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
|
||||
options.Conventions.AllowAnonymousToFolder("/Mill");
|
||||
});
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
|
|
@ -117,6 +118,7 @@ builder.Services.AddScoped<PatronageService>();
|
|||
builder.Services.AddScoped<StripeWebhookService>();
|
||||
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
|
||||
builder.Services.AddScoped<ConceptAppraisalService>();
|
||||
builder.Services.AddScoped<VoterKeyResolver>();
|
||||
builder.Services.AddScoped<ConceptSubjectRegistry>();
|
||||
builder.Services.AddScoped<ReadinessService>();
|
||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
|
|
|
|||
683
tests/Bifrost.Web.Tests/ConceptVotePageTests.cs
Normal file
683
tests/Bifrost.Web.Tests/ConceptVotePageTests.cs
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed partial class ConceptVotePageTests : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private const string CookieName = "bifrost_voter";
|
||||
private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1";
|
||||
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
|
||||
public ConceptVotePageTests(TestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_visitor_can_cast_change_and_retract()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
using var cast = await visitor.Post(collection, "alpha", "Up");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode);
|
||||
var setCookie = SetCookieHeader(cast, CookieName);
|
||||
Assert.NotNull(setCookie);
|
||||
var attributes = setCookie!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray();
|
||||
Assert.Contains("httponly", attributes);
|
||||
Assert.Contains("secure", attributes);
|
||||
Assert.Contains("samesite=lax", attributes);
|
||||
Assert.Contains("path=/mill", attributes);
|
||||
Assert.Contains("max-age=31536000", attributes);
|
||||
|
||||
using (var page = await visitor.Get(collection, "alpha"))
|
||||
{
|
||||
var (everyone, members) = Counts(await page.Content.ReadAsStringAsync());
|
||||
Assert.Equal("1 up, 0 down", everyone);
|
||||
Assert.Equal("0 up, 0 down", members);
|
||||
}
|
||||
|
||||
var firstRow = Assert.Single(await Rows(collection, "alpha"));
|
||||
Assert.Equal(ConceptStance.Up, firstRow.Stance);
|
||||
Assert.Equal(VoterClass.Anonymous, firstRow.VoterClass);
|
||||
|
||||
// The cookie from the first cast is the voter: casting again changes the row and mints nothing.
|
||||
using var change = await visitor.Post(collection, "alpha", "Down");
|
||||
Assert.Equal(HttpStatusCode.Redirect, change.StatusCode);
|
||||
Assert.Null(SetCookieHeader(change, CookieName));
|
||||
var changed = Assert.Single(await Rows(collection, "alpha"));
|
||||
Assert.Equal(ConceptStance.Down, changed.Stance);
|
||||
Assert.Equal(firstRow.VoterKey, changed.VoterKey);
|
||||
|
||||
using var retract = await visitor.Post(collection, "alpha", "Retract");
|
||||
Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode);
|
||||
Assert.Null(SetCookieHeader(retract, CookieName));
|
||||
Assert.Empty(await Rows(collection, "alpha"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_never_sets_the_device_cookie()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
using (var first = await visitor.Get(collection, "alpha"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Null(SetCookieHeader(first, CookieName));
|
||||
}
|
||||
|
||||
using (var retract = await visitor.Post(collection, "alpha", "Retract"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode);
|
||||
Assert.Null(SetCookieHeader(retract, CookieName));
|
||||
}
|
||||
|
||||
Assert.False(visitor.Jar.ContainsKey(CookieName));
|
||||
Assert.Empty(await Rows(collection, "alpha"));
|
||||
|
||||
using var cast = await visitor.Post(collection, "alpha", "Up");
|
||||
Assert.NotNull(SetCookieHeader(cast, CookieName));
|
||||
using var later = await visitor.Get(collection, "alpha");
|
||||
Assert.Null(SetCookieHeader(later, CookieName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signed_in_vote_is_keyed_to_the_account()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: false);
|
||||
|
||||
using var cast = await visitor.Post(collection, "alpha", "Up");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode);
|
||||
Assert.Null(SetCookieHeader(cast, CookieName));
|
||||
var row = Assert.Single(await Rows(collection, "alpha"));
|
||||
Assert.StartsWith("account:", row.VoterKey, StringComparison.Ordinal);
|
||||
Assert.Equal(VoterClass.Member, row.VoterClass);
|
||||
Assert.NotNull(row.UserAccountId);
|
||||
|
||||
using var page = await visitor.Get(collection, "alpha");
|
||||
var (everyone, members) = Counts(await page.Content.ReadAsStringAsync());
|
||||
Assert.Equal("1 up, 0 down", everyone);
|
||||
Assert.Equal("1 up, 0 down", members);
|
||||
Assert.Null(SetCookieHeader(page, CookieName));
|
||||
}
|
||||
|
||||
private const string SiteOrigin = "https://localhost";
|
||||
|
||||
[Fact]
|
||||
public async Task Cast_from_the_sites_own_origin_is_accepted_and_writes_one_row()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: SiteOrigin);
|
||||
Assert.Equal(HttpStatusCode.Redirect, byOrigin.StatusCode);
|
||||
Assert.Single(await Rows(collection, "alpha"));
|
||||
|
||||
using var byFetchSite = await visitor.Post(collection, "alpha", "Down", origin: null, fetchSite: "same-origin");
|
||||
Assert.Equal(HttpStatusCode.Redirect, byFetchSite.StatusCode);
|
||||
Assert.Equal(ConceptStance.Down, Assert.Single(await Rows(collection, "alpha")).Stance);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://localhost.evil.example")]
|
||||
[InlineData("https://evil-localhost")]
|
||||
[InlineData("https://sub.localhost")]
|
||||
[InlineData("https://LOCALHOST")]
|
||||
[InlineData("HTTPS://localhost")]
|
||||
[InlineData("https://localhost:8443")]
|
||||
[InlineData("http://localhost")]
|
||||
[InlineData("null")]
|
||||
[InlineData("https://localhost/")]
|
||||
[InlineData("https://evil.example")]
|
||||
public async Task Cast_from_any_other_origin_is_refused_even_when_fetch_site_says_same_origin(string origin)
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
foreach (var handler in new[] { "Up", "Down", "Retract" })
|
||||
{
|
||||
using var response = await visitor.Post(collection, "alpha", handler, origin: origin, fetchSite: "same-origin");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Null(SetCookieHeader(response, CookieName));
|
||||
Assert.False(response.Headers.Contains("Set-Cookie"));
|
||||
Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
Assert.Empty(await Rows(collection, "alpha"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cast_with_the_origin_header_repeated_is_refused_even_when_every_value_is_the_sites()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up")
|
||||
{
|
||||
Content = new FormUrlEncodedContent([])
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Origin", [SiteOrigin, SiteOrigin]);
|
||||
|
||||
using var response = await visitor.Send(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Empty(await Rows(collection, "alpha"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("same-origin", true)]
|
||||
[InlineData("same-site", false)]
|
||||
[InlineData("cross-site", false)]
|
||||
[InlineData("none", false)]
|
||||
[InlineData("Same-Origin", false)]
|
||||
[InlineData("same-origin, cross-site", false)]
|
||||
[InlineData(null, false)]
|
||||
public async Task Cast_without_an_origin_header_needs_fetch_site_same_origin(string? fetchSite, bool accepted)
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
using var response = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: fetchSite);
|
||||
|
||||
Assert.Equal(accepted ? HttpStatusCode.Redirect : HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(accepted ? 1 : 0, (await Rows(collection, "alpha")).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refusal_never_echoes_the_origin_or_fetch_site_values()
|
||||
{
|
||||
const string canary = "https://CANARY-origin-5e1c.example";
|
||||
var logs = new CapturingLoggerProvider();
|
||||
var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true, factory);
|
||||
|
||||
using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: canary);
|
||||
using var byFetchSite = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: "CANARY-site-5e1c");
|
||||
|
||||
foreach (var response in new[] { byOrigin, byFetchSite })
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
var seen = await response.Content.ReadAsStringAsync()
|
||||
+ string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value)));
|
||||
Assert.DoesNotContain("CANARY", seen, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(logs.Lines, line => line.Contains("CANARY", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_page_view_sets_no_cookie_at_all_and_a_cast_sets_only_the_device_cookie()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
using (var view = await visitor.Get(collection, "alpha"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, view.StatusCode);
|
||||
Assert.False(view.Headers.Contains("Set-Cookie"));
|
||||
var html = await view.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("__RequestVerificationToken", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
using var cast = await visitor.Post(collection, "alpha", "Up");
|
||||
var cookies = cast.Headers.GetValues("Set-Cookie").ToArray();
|
||||
Assert.Single(cookies);
|
||||
Assert.StartsWith(CookieName + "=", cookies[0], StringComparison.Ordinal);
|
||||
|
||||
using var later = await visitor.Get(collection, "alpha");
|
||||
Assert.False(later.Headers.Contains("Set-Cookie"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_request_that_selects_no_handler_is_a_client_error_and_never_a_500()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
using var noHandler = await visitor.Post(collection, "alpha", handler: null);
|
||||
using var bogus = await visitor.Post(collection, "alpha", "CANARYQ9");
|
||||
using var put = await visitor.Post(collection, "alpha", "Up", method: HttpMethod.Put);
|
||||
using var delete = await visitor.Post(collection, "alpha", null, method: HttpMethod.Delete);
|
||||
using var getBogus = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=CANARYQ9"));
|
||||
using var getUp = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=Up"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, noHandler.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bogus.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, put.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.MethodNotAllowed, delete.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, getBogus.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, getUp.StatusCode);
|
||||
foreach (var response in new[] { noHandler, bogus, put, delete, getBogus, getUp })
|
||||
{
|
||||
var status = (int)response.StatusCode;
|
||||
Assert.InRange(status, 400, 499);
|
||||
Assert.DoesNotContain("CANARYQ9", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
Assert.False(response.Headers.Contains("Set-Cookie"));
|
||||
}
|
||||
|
||||
Assert.Empty(await Rows(collection, "alpha"));
|
||||
}
|
||||
|
||||
public enum Forgery
|
||||
{
|
||||
OneCharacterChanged,
|
||||
BareDeviceId,
|
||||
SameKeyRingOtherPurpose,
|
||||
OtherKeyRingSamePurpose
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Forgery.OneCharacterChanged)]
|
||||
[InlineData(Forgery.BareDeviceId)]
|
||||
[InlineData(Forgery.SameKeyRingOtherPurpose)]
|
||||
[InlineData(Forgery.OtherKeyRingSamePurpose)]
|
||||
public async Task Tampered_or_foreign_cookie_is_ignored_and_a_new_one_minted(Forgery kind)
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var victim = NewVisitor(anonymous: true);
|
||||
using (await victim.Post(collection, "alpha", "Up"))
|
||||
{
|
||||
}
|
||||
|
||||
var genuine = victim.Jar[CookieName];
|
||||
var victimRow = Assert.Single(await Rows(collection, "alpha"));
|
||||
var deviceId = victimRow.VoterKey["device:".Length..];
|
||||
var forged = Forge(kind, genuine, deviceId);
|
||||
Assert.NotEqual(genuine, forged);
|
||||
if (kind is not Forgery.BareDeviceId)
|
||||
{
|
||||
Assert.Equal(genuine.Length, forged.Length);
|
||||
Assert.Matches("^[A-Za-z0-9_-]+$", forged);
|
||||
}
|
||||
|
||||
var attacker = NewVisitor(anonymous: true);
|
||||
attacker.Jar[CookieName] = forged;
|
||||
using var cast = await attacker.Post(collection, "alpha", "Down");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode);
|
||||
Assert.NotNull(SetCookieHeader(cast, CookieName));
|
||||
Assert.NotEqual(forged, attacker.Jar[CookieName]);
|
||||
Assert.NotEqual(genuine, attacker.Jar[CookieName]);
|
||||
var rows = await Rows(collection, "alpha");
|
||||
Assert.Equal(2, rows.Count);
|
||||
var victimNow = Assert.Single(rows, x => x.VoterKey == victimRow.VoterKey);
|
||||
Assert.Equal(ConceptStance.Up, victimNow.Stance);
|
||||
var attackerRow = Assert.Single(rows, x => x.VoterKey != victimRow.VoterKey);
|
||||
Assert.Equal(ConceptStance.Down, attackerRow.Stance);
|
||||
Assert.Equal(VoterClass.Anonymous, attackerRow.VoterClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forged_cookie_value_is_echoed_by_no_response_and_no_log_line()
|
||||
{
|
||||
const string canary = "CANARY-forged-voter-cookie-7f3a";
|
||||
var logs = new CapturingLoggerProvider();
|
||||
var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true, factory);
|
||||
visitor.Jar[CookieName] = canary;
|
||||
|
||||
using var page = await visitor.Get(collection, "alpha");
|
||||
using var cast = await visitor.Post(collection, "alpha", "Up");
|
||||
|
||||
foreach (var response in new[] { page, cast })
|
||||
{
|
||||
Assert.DoesNotContain(canary, await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
canary,
|
||||
string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.NotEmpty(logs.Lines);
|
||||
Assert.DoesNotContain(logs.Lines, line => line.Contains(canary, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_or_retired_key_is_404()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
await AddSubject(collection, "gone");
|
||||
await Retire(collection, "gone");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
foreach (var (c, k) in new[] { (collection, "gone"), (collection, "never-registered"), (NewCollection(), "alpha") })
|
||||
{
|
||||
using var get = await visitor.Get(c, k);
|
||||
Assert.Equal(HttpStatusCode.NotFound, get.StatusCode);
|
||||
foreach (var handler in new[] { "Up", "Down", "Retract" })
|
||||
{
|
||||
using var post = await visitor.Post(c, k, handler);
|
||||
Assert.Equal(HttpStatusCode.NotFound, post.StatusCode);
|
||||
Assert.Null(SetCookieHeader(post, CookieName));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(await Rows(collection, "gone"));
|
||||
Assert.Empty(await Rows(collection, "alpha"));
|
||||
Assert.False(visitor.Jar.ContainsKey(CookieName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Page_shows_counts_by_class_and_no_voter_identity()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var memberId = Guid.NewGuid();
|
||||
var accountId = Guid.NewGuid();
|
||||
await AddRows(collection, "alpha",
|
||||
Row("device:canary-device-id-4b1d", VoterClass.Anonymous, ConceptStance.Up, null, "canarytag-aaaa"),
|
||||
Row("device:canary-device-id-5c2e", VoterClass.Anonymous, ConceptStance.Down, null, "canarytag-bbbb"),
|
||||
Row("account:" + memberId.ToString("N"), VoterClass.Member, ConceptStance.Up, memberId, "canarytag-cccc"),
|
||||
Row("account:" + accountId.ToString("N"), VoterClass.Account, ConceptStance.Down, accountId, "canarytag-dddd"));
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
using (await visitor.Post(collection, "alpha", "Up"))
|
||||
{
|
||||
}
|
||||
|
||||
using var page = await visitor.Get(collection, "alpha");
|
||||
var html = await page.Content.ReadAsStringAsync();
|
||||
|
||||
var (everyone, members) = Counts(html);
|
||||
Assert.Equal("3 up, 2 down", everyone);
|
||||
Assert.Equal("1 up, 0 down", members);
|
||||
Assert.Contains("Your vote: up.", html, StringComparison.Ordinal);
|
||||
|
||||
var visitorRow = Assert.Single(await Rows(collection, "alpha"), x => x.UserAccountId is null && !x.VoterKey.Contains("canary"));
|
||||
var forbidden = new List<string>
|
||||
{
|
||||
"device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey,
|
||||
visitor.Jar[CookieName],
|
||||
memberId.ToString("N"), memberId.ToString("D"), accountId.ToString("N"), accountId.ToString("D")
|
||||
};
|
||||
foreach (var value in forbidden)
|
||||
{
|
||||
Assert.DoesNotContain(value, html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
Assert.True(page.Headers.CacheControl is { NoStore: true } or { NoCache: true });
|
||||
|
||||
var signedIn = NewVisitor(anonymous: false);
|
||||
using var signedInPage = await signedIn.Get(collection, "alpha");
|
||||
var signedInHtml = await signedInPage.Content.ReadAsStringAsync();
|
||||
using var signedInCast = await signedIn.Post(collection, "alpha", "Down");
|
||||
var accountRow = Assert.Single(
|
||||
await Rows(collection, "alpha"),
|
||||
x => x.UserAccountId is { } id && id != memberId && id != accountId);
|
||||
using var afterCast = await signedIn.Get(collection, "alpha");
|
||||
var afterHtml = await afterCast.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Your vote: down.", afterHtml, StringComparison.Ordinal);
|
||||
foreach (var text in new[] { signedInHtml, afterHtml })
|
||||
{
|
||||
Assert.DoesNotContain("account:", text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("N"), text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("D"), text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cookie_notice_is_shown_to_anonymous_visitors_and_the_page_links_back_to_the_mill()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
|
||||
using var anonymousPage = await NewVisitor(anonymous: true).Get(collection, "alpha");
|
||||
var html = await anonymousPage.Content.ReadAsStringAsync();
|
||||
using var signedInPage = await NewVisitor(anonymous: false).Get(collection, "alpha");
|
||||
var signedInHtml = await signedInPage.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Contains("only purpose is to keep your one vote yours", html, StringComparison.Ordinal);
|
||||
Assert.Contains("lasts a year", html, StringComparison.Ordinal);
|
||||
Assert.Contains("carries nothing else", html, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("only purpose is to keep your one vote yours", signedInHtml, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"href=\"https://aetheria.gamecult.org/Game-Design/Concept-Mill#alpha\"",
|
||||
html,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Page_loads_no_script_and_posts_with_plain_forms()
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
|
||||
using var page = await NewVisitor(anonymous: true).Get(collection, "alpha");
|
||||
var html = await page.Content.ReadAsStringAsync();
|
||||
var main = html[html.IndexOf("<main", StringComparison.Ordinal)..html.IndexOf("</main>", StringComparison.Ordinal)];
|
||||
|
||||
Assert.DoesNotContain("<script", main, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("hx-post", main, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("hx-get", main, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("method=\"post\"", main, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private string Forge(Forgery kind, string genuine, string deviceId) => kind switch
|
||||
{
|
||||
Forgery.OneCharacterChanged => genuine[..(genuine.Length / 2)]
|
||||
+ (genuine[genuine.Length / 2] == 'A' ? 'B' : 'A')
|
||||
+ genuine[(genuine.Length / 2 + 1)..],
|
||||
Forgery.BareDeviceId => deviceId,
|
||||
Forgery.SameKeyRingOtherPurpose => _factory.Services.GetRequiredService<IDataProtectionProvider>()
|
||||
.CreateProtector(Purpose + ".other").Protect(deviceId),
|
||||
Forgery.OtherKeyRingSamePurpose => new EphemeralDataProtectionProvider()
|
||||
.CreateProtector(Purpose).Protect(deviceId),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
private static string NewCollection() => "c" + Guid.NewGuid().ToString("N");
|
||||
|
||||
private Visitor NewVisitor(bool anonymous, WebApplicationFactory<Program>? factory = null) =>
|
||||
new((factory ?? _factory).CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
HandleCookies = false,
|
||||
BaseAddress = new Uri("https://localhost")
|
||||
}), anonymous);
|
||||
|
||||
private static string? SetCookieHeader(HttpResponseMessage response, string name) =>
|
||||
response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? values.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal))
|
||||
: null;
|
||||
|
||||
private static (string Everyone, string Members) Counts(string html)
|
||||
{
|
||||
var cells = DefinitionCell().Matches(html).Select(m => m.Groups[1].Value.Trim()).ToArray();
|
||||
Assert.Equal(2, cells.Length);
|
||||
return (cells[0], cells[1]);
|
||||
}
|
||||
|
||||
[GeneratedRegex("<dd>(.*?)</dd>", RegexOptions.Singleline)]
|
||||
private static partial Regex DefinitionCell();
|
||||
|
||||
private async Task AddSubject(string collection, string key)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
db.ConceptSubjects.Add(new ConceptSubject
|
||||
{
|
||||
Collection = collection,
|
||||
Key = key,
|
||||
Title = key,
|
||||
RegisteredAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task Retire(string collection, string key)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key);
|
||||
subject.RetiredAtUtc = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static ConceptAppraisal Row(string key, VoterClass voterClass, ConceptStance stance, Guid? accountId, string tag) =>
|
||||
new()
|
||||
{
|
||||
VoterKey = key,
|
||||
VoterClass = voterClass,
|
||||
Stance = stance,
|
||||
UserAccountId = accountId,
|
||||
NetworkTag = tag,
|
||||
CastAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private async Task AddRows(string collection, string key, params ConceptAppraisal[] rows)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
row.SubjectId = subject.Id;
|
||||
db.ConceptAppraisals.Add(row);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// A fresh DbContext per read: what a later request would see, not what this test's tracker remembers.
|
||||
private async Task<List<ConceptAppraisal>> Rows(string collection, string key)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
return await db.ConceptAppraisals.AsNoTracking()
|
||||
.Where(x => x.Subject!.Collection == collection && x.Subject.Key == key)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private sealed class Visitor(HttpClient client, bool anonymous)
|
||||
{
|
||||
public Dictionary<string, string> Jar { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<HttpResponseMessage> Get(string collection, string key) =>
|
||||
Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}"));
|
||||
|
||||
// A cookieless POST as a browser form would send it: Origin the site's own by default, null to omit it.
|
||||
public Task<HttpResponseMessage> Post(
|
||||
string collection,
|
||||
string key,
|
||||
string? handler,
|
||||
string? origin = SiteOrigin,
|
||||
string? fetchSite = null,
|
||||
HttpMethod? method = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(
|
||||
method ?? HttpMethod.Post,
|
||||
$"/mill/{collection}/{key}" + (handler is null ? string.Empty : "?handler=" + handler))
|
||||
{
|
||||
Content = new FormUrlEncodedContent([])
|
||||
};
|
||||
if (origin is not null)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Origin", origin);
|
||||
}
|
||||
|
||||
if (fetchSite is not null)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", fetchSite);
|
||||
}
|
||||
|
||||
return Send(request);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> Send(HttpRequestMessage request)
|
||||
{
|
||||
if (anonymous)
|
||||
{
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
}
|
||||
|
||||
if (Jar.Count > 0)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"Cookie",
|
||||
string.Join("; ", Jar.Select(x => x.Key + "=" + x.Value)));
|
||||
}
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
if (response.Headers.TryGetValues("Set-Cookie", out var values))
|
||||
{
|
||||
foreach (var header in values)
|
||||
{
|
||||
var pair = header.Split(';')[0];
|
||||
var split = pair.IndexOf('=');
|
||||
Jar[pair[..split]] = pair[(split + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly List<string> _lines = [];
|
||||
|
||||
public IReadOnlyList<string> Lines
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lines)
|
||||
{
|
||||
return _lines.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new Capture(this);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class Capture(CapturingLoggerProvider owner) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (owner._lines)
|
||||
{
|
||||
owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue