Concept vote rate limits: cast and mint budgets per account or network prefix
One FixedWindowBudget (injectable clock) owned by ConceptVoteRateLimits; the vote page 429s with Retry-After before any row or cookie. Prefix lengths and budgets are validated in every environment. Pins the empty Origin branch of CastRequestGuard. Unverified; not yet built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
68214ced30
commit
fbf6e43955
13 changed files with 794 additions and 69 deletions
|
|
@ -20,6 +20,20 @@ public sealed class ConceptVoteOptions
|
|||
// default weighed in the note, not a standard. Tune on observed abuse.
|
||||
public int IPv6PrefixLength { get; init; } = 56;
|
||||
|
||||
// Vote budgets (Features/ConceptVotes/ConceptVoteRateLimits). The research note
|
||||
// (docs/research/anonymous-vote-stuffing-prior-art.md section 3) gives no numbers, so these are first values to tune on
|
||||
// observed abuse. Cast: one visitor reading through the Mill can vote on a few dozen concepts in a sitting, so 30 per
|
||||
// 10 minutes per account or network prefix does not trouble a person and caps a script at 180 an hour.
|
||||
public int CastPermits { get; init; } = 30;
|
||||
|
||||
public TimeSpan CastWindow { get; init; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
// Mint: a new device cookie is a new voter identity, the cheapest stuffing lever, and a household has few devices,
|
||||
// so 5 new devices per hour per network prefix.
|
||||
public int MintPermits { get; init; } = 5;
|
||||
|
||||
public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1);
|
||||
|
||||
// The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
using System.Net;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.ConceptVotes;
|
||||
|
||||
// The one owner of the vote budgets. Cast is spent by every cast and retract; mint is spent additionally by a cast that
|
||||
// is about to mint a device cookie. A denial returns how long to wait and writes nothing. The partition is the account
|
||||
// when the voter is signed in, otherwise the network prefix (NetworkPrefix.Of) of the address the forwarded-headers
|
||||
// middleware left on the connection. No header and no client input ever becomes a key.
|
||||
public sealed class ConceptVoteRateLimits(IOptions<ConceptVoteOptions> options, TimeProvider clock)
|
||||
{
|
||||
private readonly ConceptVoteOptions opts = options.Value;
|
||||
private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, clock);
|
||||
private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, clock);
|
||||
|
||||
public TimeSpan? DenyCast(IPAddress? client, VoterIdentity? known) =>
|
||||
cast.TryAcquire(known?.UserAccountId is { } account ? "account:" + account.ToString("N") : NetKey(client), out var wait)
|
||||
? null
|
||||
: wait;
|
||||
|
||||
public TimeSpan? DenyMint(IPAddress? client) =>
|
||||
mint.TryAcquire(NetKey(client), out var wait) ? null : wait;
|
||||
|
||||
private string NetKey(IPAddress? client) =>
|
||||
"net:" + (client is null ? "unknown" : NetworkPrefix.Of(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength));
|
||||
}
|
||||
78
src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs
Normal file
78
src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, read against an injected
|
||||
// clock. Keys are opaque and are never stored beyond their window: an entry whose window has ended is dropped by the
|
||||
// next sweep, so the table is bounded by the distinct keys seen in one window. System.Threading.RateLimiting is not
|
||||
// used because its limiters time themselves on Stopwatch and timers, which a test cannot move.
|
||||
public sealed class FixedWindowBudget
|
||||
{
|
||||
private readonly int permits;
|
||||
private readonly TimeSpan window;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Dictionary<string, Entry> entries = new(StringComparer.Ordinal);
|
||||
private readonly object gate = new();
|
||||
private DateTimeOffset nextSweep = DateTimeOffset.MinValue;
|
||||
|
||||
public FixedWindowBudget(int permits, TimeSpan window, TimeProvider clock)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero);
|
||||
this.permits = permits;
|
||||
this.window = window;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
// How many keys are held now: bounded by the distinct keys seen in one window, not by every key ever seen.
|
||||
public int TrackedKeys
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return entries.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Takes one permit for the key. When none is left, returns false and how long until its window ends.
|
||||
public bool TryAcquire(string key, out TimeSpan retryAfter)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
lock (gate)
|
||||
{
|
||||
if (now >= nextSweep)
|
||||
{
|
||||
foreach (var expired in entries.Where(x => x.Value.Start + window <= now).Select(x => x.Key).ToList())
|
||||
{
|
||||
entries.Remove(expired);
|
||||
}
|
||||
|
||||
nextSweep = now + window;
|
||||
}
|
||||
|
||||
if (!entries.TryGetValue(key, out var entry) || entry.Start + window <= now)
|
||||
{
|
||||
entries[key] = new Entry(now);
|
||||
retryAfter = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.Count < permits)
|
||||
{
|
||||
entry.Count++;
|
||||
retryAfter = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
retryAfter = entry.Start + window - now;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Entry(DateTimeOffset start)
|
||||
{
|
||||
public DateTimeOffset Start { get; } = start;
|
||||
|
||||
public int Count { get; set; } = 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,12 +16,35 @@ public sealed class StartupConfigurationValidator(
|
|||
{
|
||||
public void Validate()
|
||||
{
|
||||
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
|
||||
var failures = new List<string>();
|
||||
|
||||
// Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
|
||||
var conceptVotes = conceptVoteOptions.Value;
|
||||
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
|
||||
{
|
||||
return;
|
||||
failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}.");
|
||||
}
|
||||
|
||||
var failures = new List<string>();
|
||||
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits))
|
||||
{
|
||||
failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}.");
|
||||
}
|
||||
|
||||
if (conceptVotes.CastPermits < 1 || conceptVotes.CastWindow <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("ConceptVotes:CastPermits must be at least 1 and ConceptVotes:CastWindow must be positive.");
|
||||
}
|
||||
|
||||
if (conceptVotes.MintPermits < 1 || conceptVotes.MintWindow <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive.");
|
||||
}
|
||||
|
||||
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
|
||||
{
|
||||
Refuse(failures);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gitHubOAuthOptions.Value.IsConfigured)
|
||||
{
|
||||
|
|
@ -73,7 +96,6 @@ public sealed class StartupConfigurationValidator(
|
|||
failures.Add("DataProtection:KeysPath must be configured for production.");
|
||||
}
|
||||
|
||||
var conceptVotes = conceptVoteOptions.Value;
|
||||
if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) ||
|
||||
conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength)
|
||||
{
|
||||
|
|
@ -81,21 +103,16 @@ public sealed class StartupConfigurationValidator(
|
|||
$"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production.");
|
||||
}
|
||||
|
||||
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
|
||||
{
|
||||
failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}.");
|
||||
}
|
||||
|
||||
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits))
|
||||
{
|
||||
failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}.");
|
||||
}
|
||||
|
||||
if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin))
|
||||
{
|
||||
failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash.");
|
||||
}
|
||||
|
||||
Refuse(failures);
|
||||
}
|
||||
|
||||
private static void Refuse(List<string> failures)
|
||||
{
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public sealed class VoteModel(
|
|||
ICurrentBifrostActorAccessor actorAccessor,
|
||||
ConceptAppraisalService appraisals,
|
||||
VoterKeyResolver deviceCookie,
|
||||
ConceptVoteRateLimits limits,
|
||||
IOptions<ConceptVoteOptions> options,
|
||||
IOptions<BifrostHostOptions> host) : PageModel
|
||||
{
|
||||
|
|
@ -61,7 +62,7 @@ public sealed class VoteModel(
|
|||
public async Task<IActionResult> OnGetAsync(string collection, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var voter = ResolveVoter(actor, mint: false);
|
||||
var voter = ResolveVoter(actor);
|
||||
SignedIn = voter?.UserAccountId is not null;
|
||||
return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound();
|
||||
}
|
||||
|
|
@ -75,12 +76,17 @@ public sealed class VoteModel(
|
|||
public async Task<IActionResult> OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var voter = ResolveVoter(actor);
|
||||
if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter)) is { } throttled)
|
||||
{
|
||||
return throttled;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
|
@ -92,12 +98,28 @@ public sealed class VoteModel(
|
|||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var client = HttpContext.Connection.RemoteIpAddress;
|
||||
var voter = ResolveVoter(actor);
|
||||
if (Throttled(limits.DenyCast(client, voter)) is { } throttled)
|
||||
{
|
||||
return throttled;
|
||||
}
|
||||
|
||||
if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var voter = ResolveVoter(actor, mint: true);
|
||||
if (voter is null)
|
||||
{
|
||||
if (Throttled(limits.DenyMint(client)) is { } mintThrottled)
|
||||
{
|
||||
return mintThrottled;
|
||||
}
|
||||
|
||||
voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext));
|
||||
}
|
||||
|
||||
var result = await appraisals.CastAsync(
|
||||
collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken);
|
||||
return result is CastResult.UnknownSubject or CastResult.RetiredSubject
|
||||
|
|
@ -106,16 +128,20 @@ public sealed class VoteModel(
|
|||
}
|
||||
|
||||
// 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)
|
||||
// unprotects to a well-formed id; a cast without one mints it (CastAsync, after the mint budget), and no other path does.
|
||||
private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) =>
|
||||
VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext));
|
||||
|
||||
// A denied budget is a 429 that says when to retry; it writes no row and sets no cookie.
|
||||
private IActionResult? Throttled(TimeSpan? retryAfter)
|
||||
{
|
||||
var voter = VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext));
|
||||
if (voter is null && mint)
|
||||
if (retryAfter is not { } wait)
|
||||
{
|
||||
voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext));
|
||||
return null;
|
||||
}
|
||||
|
||||
return voter;
|
||||
Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return new StatusCodeResult(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
private async Task<bool> LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ builder.Services.AddScoped<StripeWebhookService>();
|
|||
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
|
||||
builder.Services.AddScoped<ConceptAppraisalService>();
|
||||
builder.Services.AddScoped<VoterKeyResolver>();
|
||||
builder.Services.AddSingleton<ConceptVoteRateLimits>();
|
||||
builder.Services.AddScoped<ConceptSubjectRegistry>();
|
||||
builder.Services.AddScoped<ReadinessService>();
|
||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
|
|
|
|||
|
|
@ -183,6 +183,22 @@ public sealed partial class ConceptVotePageTests : IClassFixture<TestWebApplicat
|
|||
Assert.Empty(await Rows(collection, "alpha"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("same-origin")]
|
||||
public async Task Cast_with_an_empty_origin_header_is_refused_whatever_fetch_site_says(string? fetchSite)
|
||||
{
|
||||
var collection = NewCollection();
|
||||
await AddSubject(collection, "alpha");
|
||||
var visitor = NewVisitor(anonymous: true);
|
||||
|
||||
using var response = await visitor.Post(collection, "alpha", "Up", origin: string.Empty, fetchSite: fetchSite);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.False(response.Headers.Contains("Set-Cookie"));
|
||||
Assert.Empty(await Rows(collection, "alpha"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("same-origin", true)]
|
||||
[InlineData("same-site", false)]
|
||||
|
|
@ -638,46 +654,4 @@ public sealed partial class ConceptVotePageTests : IClassFixture<TestWebApplicat
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
392
tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs
Normal file
392
tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
using System.Net;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// The vote budgets through the full pipeline: a loopback peer (the proxy) forwards the client address in
|
||||
// X-Forwarded-For, the clock is the test's own, and Cast is 3 per 10 minutes, Mint 2 per hour.
|
||||
public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLimitTests.LimitedFactory>
|
||||
{
|
||||
private const string Site = "https://localhost";
|
||||
private const string CookieName = "bifrost_voter";
|
||||
|
||||
private readonly LimitedFactory _factory;
|
||||
|
||||
public ConceptVoteRateLimitTests(LimitedFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Burst_from_one_prefix_gets_429_while_another_prefix_is_unaffected()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
var device = new Client(_factory, "203.0.113.10");
|
||||
foreach (var handler in new[] { "Up", "Down", "Up" })
|
||||
{
|
||||
using var ok = await device.Post(collection, handler);
|
||||
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
|
||||
}
|
||||
|
||||
using var denied = await device.Post(collection, "Down");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
|
||||
|
||||
var other = new Client(_factory, "203.0.113.11");
|
||||
using var unaffected = await other.Post(collection, "Up");
|
||||
Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retract_spends_the_cast_budget()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
var device = new Client(_factory, "203.0.113.12");
|
||||
await device.Post(collection, "Up");
|
||||
await device.Post(collection, "Retract");
|
||||
await device.Post(collection, "Retract");
|
||||
|
||||
using var denied = await device.Post(collection, "Retract");
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Addresses_in_one_ipv6_prefix_share_a_bucket_and_another_prefix_does_not()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
var first = new Client(_factory, "2001:db8:1:100::1");
|
||||
var sameBlock = new Client(_factory, "2001:db8:1:1ff::2");
|
||||
await first.Post(collection, "Up");
|
||||
await first.Post(collection, "Down");
|
||||
using var third = await sameBlock.Post(collection, "Up");
|
||||
Assert.Equal(HttpStatusCode.Redirect, third.StatusCode);
|
||||
|
||||
using var denied = await sameBlock.Post(collection, "Down");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
|
||||
|
||||
var elsewhere = new Client(_factory, "2001:db8:1:200::1");
|
||||
using var unaffected = await elsewhere.Post(collection, "Up");
|
||||
Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_window_ends_exactly_at_its_length_and_retry_after_counts_down_to_it()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
var device = new Client(_factory, "203.0.113.13");
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await device.Post(collection, "Up");
|
||||
}
|
||||
|
||||
using (var first = await device.Post(collection, "Up"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, first.StatusCode);
|
||||
Assert.Equal("600", RetryAfter(first));
|
||||
}
|
||||
|
||||
_factory.Clock.Advance(TimeSpan.FromSeconds(100));
|
||||
using (var later = await device.Post(collection, "Up"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, later.StatusCode);
|
||||
Assert.Equal("500", RetryAfter(later));
|
||||
}
|
||||
|
||||
_factory.Clock.Advance(TimeSpan.FromSeconds(499));
|
||||
using (var last = await device.Post(collection, "Up"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode);
|
||||
Assert.Equal("1", RetryAfter(last));
|
||||
}
|
||||
|
||||
_factory.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
using var reopened = await device.Post(collection, "Up");
|
||||
Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forged_forwarded_header_from_non_loopback_peer_does_not_move_the_bucket()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
var device = new Client(_factory, "198.51.100.9") { Peer = "198.51.100.9" };
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var ok = await device.Post(collection, "Up", forwardedFor: $"192.0.2.{i + 1}");
|
||||
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
|
||||
}
|
||||
|
||||
using var denied = await device.Post(collection, "Up", forwardedFor: "192.0.2.200");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signed_in_accounts_are_limited_per_account()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
var alice = new Client(_factory, "203.0.113.20") { Account = ("7001", "alice-limits") };
|
||||
var aliceElsewhere = new Client(_factory, "203.0.113.21") { Account = ("7001", "alice-limits") };
|
||||
var bob = new Client(_factory, "203.0.113.20") { Account = ("7002", "bob-limits") };
|
||||
var visitor = new Client(_factory, "203.0.113.20");
|
||||
|
||||
await alice.Post(collection, "Up");
|
||||
await alice.Post(collection, "Down");
|
||||
await aliceElsewhere.Post(collection, "Up");
|
||||
using var denied = await aliceElsewhere.Post(collection, "Down");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
|
||||
|
||||
var aliceAgain = new Client(_factory, "203.0.113.22") { Account = ("7001", "alice-limits") };
|
||||
using var sameAccountNewPrefix = await aliceAgain.Post(collection, "Down");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, sameAccountNewPrefix.StatusCode);
|
||||
|
||||
using var bobOk = await bob.Post(collection, "Up");
|
||||
Assert.Equal(HttpStatusCode.Redirect, bobOk.StatusCode);
|
||||
using var visitorOk = await visitor.Post(collection, "Up");
|
||||
Assert.Equal(HttpStatusCode.Redirect, visitorOk.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mint_budget_is_spent_only_by_minting_casts()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
const string prefix = "203.0.113.30";
|
||||
var first = new Client(_factory, prefix);
|
||||
using (var mint = await first.Post(collection, "Up"))
|
||||
{
|
||||
Assert.NotNull(SetCookie(mint));
|
||||
}
|
||||
|
||||
// Recasts by the existing device spend cast permits only; they would exhaust a budget of 2 if they spent mint.
|
||||
using (var recast = await first.Post(collection, "Down"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Redirect, recast.StatusCode);
|
||||
Assert.Null(SetCookie(recast));
|
||||
}
|
||||
|
||||
using (var recastAgain = await first.Post(collection, "Up"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Redirect, recastAgain.StatusCode);
|
||||
}
|
||||
|
||||
_factory.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
var second = new Client(_factory, prefix);
|
||||
using (var secondMint = await second.Post(collection, "Up"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Redirect, secondMint.StatusCode);
|
||||
Assert.NotNull(SetCookie(secondMint));
|
||||
}
|
||||
|
||||
var rowsBefore = (await Rows(collection)).Count;
|
||||
var third = new Client(_factory, prefix);
|
||||
using var denied = await third.Post(collection, "Up");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
|
||||
Assert.Null(SetCookie(denied));
|
||||
Assert.Equal(rowsBefore, (await Rows(collection)).Count);
|
||||
|
||||
// An hour after the first mint the budget is whole again.
|
||||
_factory.Clock.Advance(TimeSpan.FromMinutes(50));
|
||||
using var reopened = await third.Post(collection, "Up");
|
||||
Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_cast_under_the_limit_succeeds()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
var visitor = new Client(_factory, "203.0.113.40");
|
||||
|
||||
using var response = await visitor.Post(collection, "Up");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
var row = Assert.Single(await Rows(collection));
|
||||
Assert.Equal(VoterClass.Anonymous, row.VoterClass);
|
||||
Assert.Equal(ConceptStance.Up, row.Stance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejection_writes_nothing_says_when_to_retry_and_echoes_no_address()
|
||||
{
|
||||
var logs = new CapturingLoggerProvider();
|
||||
var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
|
||||
var collection = await NewSubject();
|
||||
var visitor = new Client(factory, "203.0.113.77");
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await visitor.Post(collection, i == 1 ? "Down" : "Up");
|
||||
}
|
||||
|
||||
var before = Assert.Single(await Rows(collection));
|
||||
using var denied = await visitor.Post(collection, "Down");
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
|
||||
Assert.NotNull(RetryAfter(denied));
|
||||
Assert.Null(SetCookie(denied));
|
||||
var after = Assert.Single(await Rows(collection));
|
||||
Assert.Equal(before.Stance, after.Stance);
|
||||
var seen = await denied.Content.ReadAsStringAsync()
|
||||
+ string.Join("\n", denied.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value)));
|
||||
Assert.DoesNotContain("203.0.113", seen);
|
||||
Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_are_not_under_the_vote_budgets()
|
||||
{
|
||||
var collection = await NewSubject();
|
||||
var visitor = new Client(_factory, "203.0.113.50");
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
await visitor.Post(collection, "Up");
|
||||
}
|
||||
|
||||
using var page = await visitor.Get($"/mill/{collection}/alpha");
|
||||
using var export = await visitor.Get($"/public/concepts/{collection}/tallies");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, page.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, export.StatusCode);
|
||||
}
|
||||
|
||||
private static string? RetryAfter(HttpResponseMessage response) =>
|
||||
response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null;
|
||||
|
||||
private static string? SetCookie(HttpResponseMessage response) =>
|
||||
response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal))
|
||||
: null;
|
||||
|
||||
private async Task<string> NewSubject()
|
||||
{
|
||||
var collection = "c" + Guid.NewGuid().ToString("N");
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
db.ConceptSubjects.Add(new ConceptSubject
|
||||
{
|
||||
Collection = collection,
|
||||
Key = "alpha",
|
||||
Title = "alpha",
|
||||
RegisteredAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return collection;
|
||||
}
|
||||
|
||||
private async Task<List<ConceptAppraisal>> Rows(string collection)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
return await db.ConceptAppraisals.AsNoTracking()
|
||||
.Where(x => x.Subject!.Collection == collection)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// One browser: a client address that reaches the app through the proxy, a cookie jar, optionally an account.
|
||||
private sealed class Client(WebApplicationFactory<Program> factory, string address)
|
||||
{
|
||||
private readonly HttpClient _http = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
HandleCookies = false,
|
||||
BaseAddress = new Uri(Site)
|
||||
});
|
||||
|
||||
private string? _cookie;
|
||||
|
||||
public string Peer { get; init; } = "127.0.0.1";
|
||||
|
||||
public (string Id, string Login)? Account { get; init; }
|
||||
|
||||
public Task<HttpResponseMessage> Post(string collection, string handler, string? forwardedFor = null) =>
|
||||
Send(
|
||||
new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler={handler}")
|
||||
{
|
||||
Content = new FormUrlEncodedContent([])
|
||||
},
|
||||
forwardedFor);
|
||||
|
||||
public Task<HttpResponseMessage> Get(string path) => Send(new HttpRequestMessage(HttpMethod.Get, path), null);
|
||||
|
||||
private async Task<HttpResponseMessage> Send(HttpRequestMessage request, string? forwardedFor)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Origin", Site);
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor ?? address);
|
||||
request.Headers.TryAddWithoutValidation("X-Test-Peer", Peer);
|
||||
if (Account is { } account)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Test-GitHub-Id", account.Id);
|
||||
request.Headers.TryAddWithoutValidation("X-Test-GitHub-Login", account.Login);
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Test-Anonymous", "true");
|
||||
}
|
||||
|
||||
if (_cookie is not null)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Cookie", _cookie);
|
||||
}
|
||||
|
||||
var response = await _http.SendAsync(request);
|
||||
if (SetCookie(response) is { } set)
|
||||
{
|
||||
_cookie = set.Split(';')[0];
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LimitedFactory : TestWebApplicationFactory
|
||||
{
|
||||
public TestClock Clock { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConceptVotes:CastPermits"] = "3",
|
||||
["ConceptVotes:CastWindow"] = "00:10:00",
|
||||
["ConceptVotes:MintPermits"] = "2",
|
||||
["ConceptVotes:MintWindow"] = "01:00:00",
|
||||
["ConceptVotes:NetworkTagSecret"] = "rate-limit-test-tag-secret-0123456789"
|
||||
}));
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<TimeProvider>();
|
||||
services.AddSingleton<TimeProvider>(Clock);
|
||||
services.AddTransient<IStartupFilter, PeerFilter>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TestClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
}
|
||||
|
||||
// The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline.
|
||||
private sealed class PeerFilter : IStartupFilter
|
||||
{
|
||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
|
||||
{
|
||||
app.Use((context, nextMiddleware) =>
|
||||
{
|
||||
context.Connection.RemoteIpAddress =
|
||||
IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "127.0.0.1");
|
||||
return nextMiddleware(context);
|
||||
});
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
}
|
||||
93
tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs
Normal file
93
tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using Bifrost.Web.Features.Shared;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class FixedWindowBudgetTests
|
||||
{
|
||||
private static readonly TimeSpan Window = TimeSpan.FromSeconds(60);
|
||||
|
||||
[Fact]
|
||||
public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(2, Window, clock);
|
||||
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
Assert.False(budget.TryAcquire("a", out var wait));
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(50), wait);
|
||||
Assert.True(budget.TryAcquire("b", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, clock);
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
|
||||
clock.Advance(Window - TimeSpan.FromTicks(1));
|
||||
Assert.False(budget.TryAcquire("a", out var wait));
|
||||
Assert.Equal(TimeSpan.FromTicks(1), wait);
|
||||
|
||||
clock.Advance(TimeSpan.FromTicks(1));
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
Assert.False(budget.TryAcquire("a", out wait));
|
||||
Assert.Equal(Window, wait);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_denied_attempt_does_not_extend_the_window()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, clock);
|
||||
budget.TryAcquire("a", out _);
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
Assert.False(budget.TryAcquire("a", out _));
|
||||
}
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, clock);
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
budget.TryAcquire("k" + i, out _);
|
||||
}
|
||||
|
||||
Assert.Equal(100, budget.TrackedKeys);
|
||||
clock.Advance(Window);
|
||||
budget.TryAcquire("fresh", out _);
|
||||
|
||||
Assert.Equal(1, budget.TrackedKeys);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 60)]
|
||||
[InlineData(-1, 60)]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(1, -5)]
|
||||
public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds)
|
||||
{
|
||||
Assert.ThrowsAny<ArgumentException>(
|
||||
() => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), new Clock()));
|
||||
}
|
||||
|
||||
private sealed class Clock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
}
|
||||
}
|
||||
|
|
@ -185,6 +185,50 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Development")]
|
||||
[InlineData("Testing")]
|
||||
[InlineData("Production")]
|
||||
public void Every_environment_refuses_prefix_lengths_outside_the_address_family(string environment)
|
||||
{
|
||||
var v4 = Assert.Throws<InvalidOperationException>(
|
||||
() => Validate(keysPath: "/keys", environment: environment, ipv4Length: 33));
|
||||
Assert.Contains("ConceptVotes:IPv4PrefixLength", v4.Message);
|
||||
Assert.DoesNotContain("IPv6PrefixLength", v4.Message);
|
||||
|
||||
var v6 = Assert.Throws<InvalidOperationException>(
|
||||
() => Validate(keysPath: "/keys", environment: environment, ipv6Length: 129));
|
||||
Assert.Contains("ConceptVotes:IPv6PrefixLength", v6.Message);
|
||||
Assert.DoesNotContain("IPv4PrefixLength", v6.Message);
|
||||
|
||||
Validate(keysPath: "/keys", environment: environment, ipv4Length: 0, ipv6Length: 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Development", 0, 1, 5, 1, "ConceptVotes:CastPermits")]
|
||||
[InlineData("Testing", 30, 0, 5, 1, "ConceptVotes:CastPermits")]
|
||||
[InlineData("Production", 30, 1, 0, 1, "ConceptVotes:MintPermits")]
|
||||
[InlineData("Production", 30, 1, 5, 0, "ConceptVotes:MintPermits")]
|
||||
public void Every_environment_refuses_a_vote_budget_that_cannot_be_enforced(
|
||||
string environment,
|
||||
int castPermits,
|
||||
int castMinutes,
|
||||
int mintPermits,
|
||||
int mintMinutes,
|
||||
string key)
|
||||
{
|
||||
var refusal = Assert.Throws<InvalidOperationException>(() => Validate(
|
||||
keysPath: "/keys",
|
||||
environment: environment,
|
||||
castPermits: castPermits,
|
||||
castWindow: TimeSpan.FromMinutes(castMinutes),
|
||||
mintPermits: mintPermits,
|
||||
mintWindow: TimeSpan.FromMinutes(mintMinutes)));
|
||||
|
||||
Assert.Contains(key, refusal.Message);
|
||||
Validate(keysPath: "/keys", environment: environment);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("*")]
|
||||
[InlineData("")]
|
||||
|
|
@ -267,7 +311,11 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
string? tagSecret = ValidTagSecret,
|
||||
int ipv4Length = 32,
|
||||
int ipv6Length = 56,
|
||||
string[]? allowedOrigins = null)
|
||||
string[]? allowedOrigins = null,
|
||||
int castPermits = 30,
|
||||
TimeSpan? castWindow = null,
|
||||
int mintPermits = 5,
|
||||
TimeSpan? mintWindow = null)
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
|
|
@ -295,7 +343,11 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
NetworkTagSecret = tagSecret ?? string.Empty,
|
||||
IPv4PrefixLength = ipv4Length,
|
||||
IPv6PrefixLength = ipv6Length,
|
||||
AllowedOrigins = allowedOrigins ?? []
|
||||
AllowedOrigins = allowedOrigins ?? [],
|
||||
CastPermits = castPermits,
|
||||
CastWindow = castWindow ?? TimeSpan.FromMinutes(10),
|
||||
MintPermits = mintPermits,
|
||||
MintWindow = mintWindow ?? TimeSpan.FromHours(1)
|
||||
})).Validate();
|
||||
}
|
||||
|
||||
|
|
|
|||
45
tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs
Normal file
45
tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
public 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,11 +21,14 @@ public sealed class TestAuthHandler(
|
|||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
// A test may sign in as another account by naming its GitHub id and login.
|
||||
var gitHubId = Request.Headers["X-Test-GitHub-Id"].FirstOrDefault() ?? "12345";
|
||||
var login = Request.Headers["X-Test-GitHub-Login"].FirstOrDefault() ?? "test-admin";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "12345"),
|
||||
new Claim(ClaimTypes.Name, "test-admin"),
|
||||
new Claim(BifrostClaimTypes.GitHubLogin, "test-admin"),
|
||||
new Claim(ClaimTypes.NameIdentifier, gitHubId),
|
||||
new Claim(ClaimTypes.Name, login),
|
||||
new Claim(BifrostClaimTypes.GitHubLogin, login),
|
||||
new Claim(BifrostClaimTypes.DisplayName, "Test Admin")
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
["GitHubApp:PrivateKeyPem"] = "test-private-key",
|
||||
["GitHubApp:AppId"] = "1",
|
||||
["Bridge:LocalBridgeToken"] = "test-bridge-token",
|
||||
["ConceptVotes:CastPermits"] = "100000",
|
||||
["ConceptVotes:MintPermits"] = "100000",
|
||||
["Host:PublicBaseUrl"] = "https://localhost",
|
||||
["Host:ExpectedHost"] = "localhost",
|
||||
["Host:RequireStrictHostValidation"] = "false",
|
||||
|
|
|
|||
Loading…
Reference in a new issue