using System.ComponentModel; using System.Diagnostics; using System.Net; using System.Net.Http.Json; using System.Text.Json; using System.Text.RegularExpressions; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.Registration; using Bifrost.Web.Features.Shared; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; namespace Bifrost.Web.Tests; public class RegistrationFormFactory : TestWebApplicationFactory { public ManualClock Time { get; } = new(); protected virtual string FallbackPermits => "100"; protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) { base.ConfigureWebHost(builder); builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary { ["Bifrost:Registration:Captcha:FallbackWaitSeconds"] = "30", ["Bifrost:Registration:Limits:FallbackPermits"] = FallbackPermits })); builder.ConfigureServices(services => { services.RemoveAll(); services.AddSingleton(Time); }); } } public sealed class RegistrationFallbackBudgetFactory : RegistrationFormFactory { protected override string FallbackPermits => "2"; } internal static class FormKit { public static string Hidden(string html, string name) { var match = Regex.Match(html, $" PageAsync(HttpClient client, string query = "") { using var response = await client.GetAsync("/auth/bifrost/register" + query); Assert.Equal(HttpStatusCode.OK, response.StatusCode); return await response.Content.ReadAsStringAsync(); } public const string SiteOrigin = "https://localhost"; // A browser on the site's own page sends its Origin; the guard refuses a post without one. public static Task PostAsync(HttpClient client, Dictionary fields, string? origin = SiteOrigin, string? peer = null) { var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = new FormUrlEncodedContent(fields) }; if (origin is not null) { request.Headers.Add("Origin", origin); } if (peer is not null) { request.Headers.Add("X-Test-Peer", peer); } return client.SendAsync(request); } public static Dictionary Fields(string html, string identity, bool consent = true, string? nonce = null, bool waited = false, string? returnUrl = null) { var fields = new Dictionary { ["identity"] = identity, ["displayName"] = "", ["challenge"] = Hidden(html, "challenge"), ["acceptedNoticeVersion"] = Hidden(html, "acceptedNoticeVersion"), ["returnUrl"] = returnUrl ?? Hidden(html, "returnUrl") }; if (consent) { fields["consent"] = "yes"; } if (nonce is not null) { fields["nonce"] = nonce; } if (waited) { fields["waited"] = "true"; } return fields; } /// A nonce that provably fails this page's challenge: its hash lacks the leading zero bits the difficulty needs. public static string Unsolved(string html) { var challenge = Hidden(html, "challenge"); var difficulty = int.Parse(Regex.Match(html, "data-difficulty=\"(\\d+)\"").Groups[1].Value); // Starts at a long marker value so a test can check the nonce is never echoed. for (var nonce = 7777777777L; ; nonce++) { var text = nonce.ToString(System.Globalization.CultureInfo.InvariantCulture); if (RegistrationChallengeService.LeadingZeroBits(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + text))) < difficulty) { return text; } } } public static string Solved(string html) => RegistrationKit.Solve(Hidden(html, "challenge"), int.Parse(Regex.Match(html, "data-difficulty=\"(\\d+)\"").Groups[1].Value)); } public sealed class RegistrationFormTests(RegistrationFormFactory factory) : IClassFixture { private static string NewIdentity() => $"form-{Guid.NewGuid():N}"; private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); private (int Accounts, int Receipts) Counts() { using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return (db.UserAccounts.Count(), db.ConsentReceipts.Count()); } private bool Exists(string identity) { using var scope = factory.Services.CreateScope(); return scope.ServiceProvider.GetRequiredService().UserAccounts.Any(x => x.NormalizedBifrostIdentity == identity); } private (bool Account, bool Receipt) Created(string identity) { using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var account = db.UserAccounts.SingleOrDefault(x => x.NormalizedBifrostIdentity == identity); return (account is not null, account is not null && db.ConsentReceipts.Any(x => x.UserAccountId == account.Id && x.NoticeVersion == TrainingNotice.Version)); } [Fact] public async Task The_page_shows_the_whole_notice_beside_an_unchecked_consent_and_touches_nothing_outside() { using var client = Client(); using var response = await client.GetAsync("/auth/bifrost/register"); var html = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.False(response.Headers.Contains("Set-Cookie")); foreach (var paragraph in TrainingNotice.Text.Split("\n\n")) { Assert.Contains(WebUtility.HtmlEncode(paragraph), html); } Assert.Contains($"version {TrainingNotice.Version}", html); Assert.Contains("", html); Assert.Matches("]*/>", html); Assert.DoesNotMatch("]*\\bchecked\\b", html); Assert.Contains("I agree to this notice, to speak in a recorded room", html); Assert.Contains("Withdrawing ends future speaking there; lines you have already spoken stay.", html); Assert.DoesNotMatch("]*\\brequired\\b", html); foreach (var id in new[] { "identity", "displayName", "consent" }) { Assert.Matches($"]*for=\"{id}\"", html); } Assert.Contains("
", html); Assert.Contains("", html); Assert.Contains("aria-live=\"polite\"", html); Assert.Equal(TrainingNotice.Version.ToString(), FormKit.Hidden(html, "acceptedNoticeVersion")); Assert.False(string.IsNullOrWhiteSpace(FormKit.Hidden(html, "challenge"))); Assert.Matches("wait \\d+ seconds", html); Assert.Matches("", html); Assert.DoesNotContain("a few seconds", html); Assert.DoesNotContain("nothing is lost", html); Assert.Contains("tick it again if you want to agree", html); Assert.DoesNotContain("().ConsentReceipts.Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity)); } [Fact] public async Task The_consent_box_with_a_stale_version_returns_the_page_with_an_error_and_nothing_created() { using var client = Client(); var html = await FormKit.PageAsync(client); var challenge = FormKit.Hidden(html, "challenge"); var identity = NewIdentity(); var before = Counts(); var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html)); fields["acceptedNoticeVersion"] = (TrainingNotice.Version + 1).ToString(); using var response = await FormKit.PostAsync(client, fields); var again = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Matches("role=\"alert\"", again); Assert.Matches("id=\"consent\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again); Assert.Contains("", again); Assert.DoesNotMatch("]*\\bchecked\\b", again); Assert.Contains($"value=\"{identity}\"", again); Assert.NotEqual(challenge, FormKit.Hidden(again, "challenge")); Assert.Equal(before, Counts()); } [Theory] [InlineData("abc")] [InlineData("02")] [InlineData("002")] [InlineData("+2")] [InlineData(" 2")] public async Task The_consent_box_with_a_version_that_is_not_the_canonical_decimal_is_refused_not_read_as_no_consent(string posted) { using var client = Client(); var html = await FormKit.PageAsync(client); var identity = NewIdentity(); var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html)); fields["acceptedNoticeVersion"] = posted; using var response = await FormKit.PostAsync(client, fields); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.False(Exists(identity)); } [Fact] public async Task A_wrong_nonce_fails_the_challenge_and_the_error_never_echoes_the_challenge_or_nonce() { using var client = Client(); var html = await FormKit.PageAsync(client); var challenge = FormKit.Hidden(html, "challenge"); var identity = NewIdentity(); var nonce = FormKit.Unsolved(html); using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce)); var again = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Contains("Registration challenge failed.", again); Assert.DoesNotContain(challenge, again); Assert.DoesNotContain(nonce, again); Assert.False(Exists(identity)); } [Fact] public async Task An_identity_with_markup_is_escaped_when_the_page_comes_back() { using var client = Client(); var html = await FormKit.PageAsync(client); const string hostile = "\">"; using var response = await FormKit.PostAsync(client, FormKit.Fields(html, hostile, consent: false)); var again = await response.Content.ReadAsStringAsync(); Assert.DoesNotContain("