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:
Meta Vens 2026-10-11 09:00:23 +02:00
commit fbf6e43955
13 changed files with 794 additions and 69 deletions

View file

@ -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));
}
}
}
}
}

View 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);
};
}
}

View 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;
}
}

View file

@ -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();
}

View 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));
}
}
}
}

View file

@ -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")
};

View file

@ -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",