413 lines
25 KiB
C#
413 lines
25 KiB
C#
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Bifrost.Web.Data;
|
|
using Bifrost.Web.Domain;
|
|
using Bifrost.Web.Features.Motions;
|
|
using Bifrost.Web.Features.Shared;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Bifrost.Web.Tests.Support;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Org.BouncyCastle.Crypto.Parameters;
|
|
using Org.BouncyCastle.Crypto.Signers;
|
|
using Org.BouncyCastle.Security;
|
|
|
|
namespace Bifrost.Web.Tests;
|
|
|
|
public sealed class MindIntakeTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private const string Salt = "00112233445566778899aabbccddeeff";
|
|
private const string Reason = "Distinctive-mind-reason-xyzzy.";
|
|
private static int _next = 5000;
|
|
|
|
private sealed record Key(Ed25519PrivateKeyParameters Private)
|
|
{
|
|
public string Public => Convert.ToBase64String(Private.GeneratePublicKey().GetEncoded());
|
|
public static Key New() => new(new Ed25519PrivateKeyParameters(new SecureRandom()));
|
|
}
|
|
|
|
private sealed class World
|
|
{
|
|
public required string Persona { get; init; }
|
|
public required Guid Account { get; init; }
|
|
public required Guid RevealMotion { get; init; }
|
|
public required Guid CommitMotion { get; init; }
|
|
public required Key Key { get; init; }
|
|
public required Guid KeyId { get; init; }
|
|
public required HttpClient Moderator { get; init; }
|
|
public required string MemberGitHubId { get; init; }
|
|
}
|
|
|
|
private async Task<World> WorldAsync(bool bindRevealToPersona = true)
|
|
{
|
|
using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); }
|
|
var n = Interlocked.Add(ref _next, 10);
|
|
var persona = $"mind{n}";
|
|
var now = DateTimeOffset.UtcNow;
|
|
Guid account, reveal, commit;
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
UserAccount Make(long gh, string name)
|
|
{
|
|
var u = new UserAccount { GitHubUserId = gh, GitHubLogin = name, NormalizedGitHubLogin = name.ToUpperInvariant(), DisplayName = name };
|
|
u.MemberProfile = new MemberProfile { UserAccountId = u.Id, Nickname = name, Headline = "seed", UpdatedAtUtc = now };
|
|
u.Membership = new Domain.Membership { UserAccountId = u.Id, Status = MembershipStatus.Active, CreatedAtUtc = now };
|
|
Bifrost.Web.Features.Shared.ApplicationBootstrapper.EnsureRole(u.Membership, MemberRole.StandardMember, null, now, "seed");
|
|
db.UserAccounts.Add(u);
|
|
return u;
|
|
}
|
|
|
|
var mind = Make(n + 1, persona);
|
|
Make(n + 2, $"member{n}");
|
|
Motion M(DateTimeOffset commitCloses, DateTimeOffset closes)
|
|
{
|
|
var m = new Motion
|
|
{
|
|
CreatedByUserAccountId = mind.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s",
|
|
Candidates = "Ana\nBo", OpensAtUtc = now.AddHours(-3), CommitClosesAtUtc = commitCloses, ClosesAtUtc = closes, CreatedAtUtc = now
|
|
};
|
|
db.Motions.Add(m);
|
|
return m;
|
|
}
|
|
|
|
var r = M(now.AddHours(-1), now.AddHours(1));
|
|
var c = M(now.AddHours(1), now.AddHours(2));
|
|
db.SealedBallots.Add(new SealedBallot
|
|
{
|
|
MotionId = r.Id, UserAccountId = mind.Id, CommittedAtUtc = now.AddHours(-2),
|
|
Commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, r.Id, 1, "Bo", Reason, Salt, bindRevealToPersona ? persona : ""), mind.Id)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
(account, reveal, commit) = (mind.Id, r.Id, c.Id);
|
|
}
|
|
|
|
var moderator = factory.CreateClient();
|
|
var key = Key.New();
|
|
var registered = await moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona, userAccountId = account, publicKey = key.Public });
|
|
Assert.True(registered.StatusCode == HttpStatusCode.OK, await registered.Content.ReadAsStringAsync());
|
|
var keyId = JsonDocument.Parse(await registered.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid();
|
|
return new World { Persona = persona, Account = account, RevealMotion = reveal, CommitMotion = commit, Key = key, KeyId = keyId, Moderator = moderator, MemberGitHubId = (n + 2).ToString() };
|
|
}
|
|
|
|
private static MindEnvelope Sign(Key key, string persona, string verb, Guid motion, string? nonce = null, string? commitment = null,
|
|
string? candidate = null, string? reason = null, string? salt = null, long? issuedAt = null)
|
|
{
|
|
var e = new MindEnvelope(persona, verb, motion, nonce ?? Guid.NewGuid().ToString("N"), issuedAt ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds(), commitment, 1, candidate, reason, salt, "");
|
|
var signer = new Ed25519Signer();
|
|
signer.Init(true, key.Private);
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(MindIntakeService.SigningText(e));
|
|
signer.BlockUpdate(bytes, 0, bytes.Length);
|
|
return e with { Signature = Convert.ToBase64String(signer.GenerateSignature()) };
|
|
}
|
|
|
|
private HttpClient Anonymous()
|
|
{
|
|
var c = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
|
c.DefaultRequestHeaders.Add("X-Test-Anonymous", "true"); // the intake needs no session; this proves it
|
|
return c;
|
|
}
|
|
|
|
private static Task<HttpResponseMessage> Post(HttpClient c, Guid route, MindEnvelope e) => c.PostAsJsonAsync($"/api/sealed-motions/{route}/mind", e);
|
|
|
|
private static async Task Refused(HttpResponseMessage r, HttpStatusCode expected)
|
|
{
|
|
var body = await r.Content.ReadAsStringAsync();
|
|
Assert.True(r.StatusCode == expected, $"{r.StatusCode}: {body}");
|
|
Assert.DoesNotContain(Reason, body);
|
|
Assert.DoesNotContain(Salt, body);
|
|
Assert.DoesNotContain("Bo", body.Replace("Bonce", ""));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_mind_commits_and_reveals_by_signature_alone_and_the_ballot_stays_sealed()
|
|
{
|
|
var w = await WorldAsync();
|
|
var anon = Anonymous();
|
|
var commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, w.CommitMotion, 1, "Ana", Reason, Salt, w.Persona), w.Account);
|
|
var commit = await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "commit", w.CommitMotion, commitment: commitment));
|
|
Assert.True(commit.StatusCode == HttpStatusCode.OK, await commit.Content.ReadAsStringAsync());
|
|
|
|
var reveal = await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt));
|
|
Assert.True(reveal.StatusCode == HttpStatusCode.OK, await reveal.Content.ReadAsStringAsync());
|
|
Assert.DoesNotContain(Reason, await reveal.Content.ReadAsStringAsync());
|
|
|
|
using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
Assert.Equal(commitment, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment);
|
|
Assert.Contains(db.AuditEvents, x => x.Action == "mindkey.registered");
|
|
var member = factory.CreateClient();
|
|
member.DefaultRequestHeaders.Add("X-Test-GitHub-Id", w.MemberGitHubId);
|
|
member.DefaultRequestHeaders.Add("X-Test-GitHub-Login", "member");
|
|
var state = await (await member.GetAsync($"/api/sealed-motions/{w.RevealMotion}")).Content.ReadAsStringAsync();
|
|
Assert.DoesNotContain(Reason, state);
|
|
Assert.DoesNotContain(Salt, state);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_refusal_is_typed_and_leaks_no_ballot()
|
|
{
|
|
var w = await WorldAsync();
|
|
var anon = Anonymous();
|
|
var other = Key.New();
|
|
MindEnvelope Reveal(Key k, string persona = "", string? nonce = null, string? reason = null) =>
|
|
Sign(k, persona == "" ? w.Persona : persona, "reveal", w.RevealMotion, nonce, candidate: "Bo", reason: reason ?? Reason, salt: Salt);
|
|
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(other)), HttpStatusCode.Unauthorized); // key not registered
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, "nobody")), HttpStatusCode.Unauthorized); // unknown persona
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Reason = "tampered" }), HttpStatusCode.Unauthorized); // signature covers every field
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Signature = "AAAA" }), HttpStatusCode.Unauthorized);
|
|
await Refused(await Post(anon, w.CommitMotion, Reveal(w.Key)), HttpStatusCode.BadRequest); // signed for another motion than the route
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, nonce: "short")), HttpStatusCode.BadRequest); // malformed nonce
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Verb = "tally" }), HttpStatusCode.BadRequest);
|
|
await Refused(await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "reveal", w.CommitMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Conflict); // wrong phase
|
|
|
|
// A second persona with its own key cannot speak as the first: persona id must match the signing key.
|
|
var rival = await WorldAsync();
|
|
await Refused(await Post(anon, w.RevealMotion, Sign(rival.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Unauthorized);
|
|
|
|
// Replay: the same signed envelope is accepted once, then refused.
|
|
var once = Reveal(w.Key);
|
|
Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, once)).StatusCode);
|
|
await Refused(await Post(anon, w.RevealMotion, once), HttpStatusCode.Conflict);
|
|
// A bad signature does not spend the nonce.
|
|
var fresh = Reveal(w.Key);
|
|
await Refused(await Post(anon, w.RevealMotion, fresh with { Reason = "x" }), HttpStatusCode.Unauthorized);
|
|
Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, fresh)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_commitment_not_bound_to_the_persona_cannot_be_revealed_through_the_intake()
|
|
{
|
|
var w = await WorldAsync(bindRevealToPersona: false);
|
|
await Refused(await Post(Anonymous(), w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_revoked_key_is_refused_and_a_commitment_made_before_revocation_stays()
|
|
{
|
|
var w = await WorldAsync();
|
|
var anon = Anonymous();
|
|
var commitment = new string('c', 64);
|
|
Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "commit", w.CommitMotion, commitment: commitment))).StatusCode);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await w.Moderator.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode);
|
|
await Refused(await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Unauthorized);
|
|
|
|
using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
Assert.Equal(commitment, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment);
|
|
Assert.Contains(db.AuditEvents, x => x.Action == "mindkey.revoked");
|
|
|
|
// Rotation: a new key registered for the same persona works.
|
|
var next = Key.New();
|
|
Assert.Equal(HttpStatusCode.OK, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona, userAccountId = w.Account, publicKey = next.Public })).StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, Sign(next, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt))).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Only_a_moderator_manages_keys_and_a_persona_stays_bound_to_one_account()
|
|
{
|
|
var w = await WorldAsync();
|
|
var member = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
|
member.DefaultRequestHeaders.Add("X-Test-GitHub-Id", w.MemberGitHubId);
|
|
member.DefaultRequestHeaders.Add("X-Test-GitHub-Login", "member");
|
|
var body = new { persona = w.Persona, userAccountId = w.Account, publicKey = Key.New().Public };
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await member.PostAsJsonAsync("/api/sealed-motions/mind-keys", body)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await member.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await member.GetAsync("/api/sealed-motions/mind-keys")).StatusCode);
|
|
Assert.NotEqual(HttpStatusCode.OK, (await Anonymous().PostAsJsonAsync("/api/sealed-motions/mind-keys", body)).StatusCode);
|
|
|
|
var elsewhere = new { persona = w.Persona, userAccountId = Guid.NewGuid(), publicKey = Key.New().Public };
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", elsewhere)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = "Bad Name", userAccountId = w.Account, publicKey = Key.New().Public })).StatusCode);
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona, userAccountId = w.Account, publicKey = "AAAA" })).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_signing_helper_and_the_server_agree_on_the_envelope_and_the_commitment()
|
|
{
|
|
var w = await WorldAsync();
|
|
var root = AppContext.BaseDirectory;
|
|
while (!Directory.Exists(Path.Combine(root, "tools"))) root = Path.GetDirectoryName(root)!;
|
|
var keyFile = Path.Combine(Path.GetTempPath(), $"sealed-{Guid.NewGuid():N}.key");
|
|
var seed = w.Key.Private.GetEncoded();
|
|
await File.WriteAllBytesAsync(keyFile, seed);
|
|
File.SetUnixFileMode(keyFile, UnixFileMode.UserRead);
|
|
try
|
|
{
|
|
string Run(params string[] a)
|
|
{
|
|
var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
|
|
psi.ArgumentList.Add(Path.Combine(root, "tools", "sealed-motion-sign.mjs"));
|
|
foreach (var x in a) psi.ArgumentList.Add(x);
|
|
using var p = Process.Start(psi)!;
|
|
var o = p.StandardOutput.ReadToEnd();
|
|
p.WaitForExit();
|
|
Assert.True(p.ExitCode == 0, p.StandardError.ReadToEnd());
|
|
return o.Trim();
|
|
}
|
|
|
|
Assert.Equal(w.Key.Public, Run("pubkey", "--key", keyFile));
|
|
var committed = JsonDocument.Parse(Run("commit", "--key", keyFile, "--persona", w.Persona, "--motion", w.CommitMotion.ToString(),
|
|
"--voter", w.Account.ToString(), "--candidate", "Ana", "--reason", Reason));
|
|
var post = await Anonymous().PostAsync($"/api/sealed-motions/{w.CommitMotion}/mind",
|
|
new StringContent(committed.RootElement.GetProperty("envelope").GetRawText(), System.Text.Encoding.UTF8, "application/json"));
|
|
Assert.True(post.StatusCode == HttpStatusCode.OK, await post.Content.ReadAsStringAsync());
|
|
|
|
var ballot = committed.RootElement.GetProperty("ballot");
|
|
var expected = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, w.CommitMotion, 1, "Ana", Reason, ballot.GetProperty("salt").GetString()!, w.Persona), w.Account);
|
|
using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
Assert.Equal(expected, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment);
|
|
}
|
|
finally { File.Delete(keyFile); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_refusal_before_a_verified_signature_costs_one_signature_check_and_reveals_nothing_about_state()
|
|
{
|
|
var w = await WorldAsync();
|
|
var anon = Anonymous();
|
|
long Checks() => Interlocked.Read(ref MindIntakeService.SignatureChecks);
|
|
MindEnvelope Reveal(Key k, string persona) => Sign(k, persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt);
|
|
|
|
var before = Checks();
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, "nobody")), HttpStatusCode.Unauthorized); // unknown persona
|
|
Assert.True(Checks() > before, "unknown persona skipped the verification");
|
|
|
|
before = Checks();
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, w.Persona) with { Signature = "not-base64!" }), HttpStatusCode.Unauthorized); // malformed signature
|
|
Assert.True(Checks() > before, "malformed signature skipped the verification");
|
|
|
|
// Route/motion mismatch and a stale clock are answered 400 only to a caller whose signature verified; an unsigned one gets the 401.
|
|
var wrongRoute = Reveal(w.Key, w.Persona);
|
|
await Refused(await Post(anon, w.CommitMotion, wrongRoute with { Signature = Convert.ToBase64String(new byte[64]) }), HttpStatusCode.Unauthorized);
|
|
await Refused(await Post(anon, w.CommitMotion, wrongRoute), HttpStatusCode.BadRequest);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await w.Moderator.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode);
|
|
before = Checks();
|
|
await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, w.Persona)), HttpStatusCode.Unauthorized); // revoked
|
|
Assert.True(Checks() > before, "revoked key skipped the verification");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stale_envelope_is_refused_and_old_nonces_are_pruned()
|
|
{
|
|
var w = await WorldAsync();
|
|
var anon = Anonymous();
|
|
var old = DateTimeOffset.UtcNow.AddHours(-1);
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
db.MindNonces.Add(new MindNonce { PersonaId = w.Persona, Nonce = "old-nonce-old-nonce-1234", SeenAtUtc = old });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await Refused(await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt,
|
|
issuedAt: DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds())), HttpStatusCode.BadRequest);
|
|
Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt))).StatusCode);
|
|
|
|
using var check = factory.Services.CreateScope().ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
Assert.DoesNotContain(check.MindNonces, x => x.Nonce == "old-nonce-old-nonce-1234");
|
|
Assert.Single(check.MindNonces.Where(x => x.PersonaId == w.Persona));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_endpoint_caps_the_body_and_rate_limits_per_source()
|
|
{
|
|
var w = await WorldAsync();
|
|
var big = new StringContent(new string('x', 10_000), System.Text.Encoding.UTF8, "application/json");
|
|
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await Anonymous().PostAsync($"/api/sealed-motions/{w.RevealMotion}/mind", big)).StatusCode);
|
|
|
|
using var limited = factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, c) => c.AddInMemoryCollection(new Dictionary<string, string?> { ["MindIntake:Permits"] = "3" })));
|
|
var client = limited.CreateClient();
|
|
client.DefaultRequestHeaders.Add("X-Test-Anonymous", "true");
|
|
var codes = new List<HttpStatusCode>();
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
codes.Add((await Post(client, w.RevealMotion, Sign(Key.New(), w.Persona, "reveal", w.RevealMotion))).StatusCode);
|
|
}
|
|
|
|
Assert.Equal([HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized, HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests], codes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task One_account_cannot_carry_two_personas_and_the_bindings_are_unique_in_the_model()
|
|
{
|
|
var w = await WorldAsync();
|
|
var second = await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona + "b", userAccountId = w.Account, publicKey = Key.New().Public });
|
|
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
|
|
|
|
using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
var type = db.Model.FindEntityType(typeof(MindPersona))!;
|
|
Assert.Contains(type.GetIndexes(), i => i.IsUnique && i.Properties.Single().Name == nameof(MindPersona.PersonaId));
|
|
Assert.Contains(type.GetIndexes(), i => i.IsUnique && i.Properties.Single().Name == nameof(MindPersona.UserAccountId));
|
|
Assert.Contains(db.Model.FindEntityType(typeof(MindNonce))!.GetIndexes(), i => i.IsUnique && i.Properties.Count == 2);
|
|
}
|
|
|
|
private sealed class ThrowOnNonce : SaveChangesInterceptor
|
|
{
|
|
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData e, InterceptionResult<int> r, CancellationToken ct = default) =>
|
|
e.Context!.ChangeTracker.Entries<MindNonce>().Any(x => x.State == EntityState.Added)
|
|
? throw new DbUpdateException("disk fault")
|
|
: ValueTask.FromResult(r);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_write_failure_that_is_not_a_nonce_collision_is_not_reported_as_a_replay()
|
|
{
|
|
var clock = TimeProvider.System;
|
|
await using var db = new BifrostDbContext(new DbContextOptionsBuilder<BifrostDbContext>()
|
|
.UseInMemoryDatabase($"intake-fault-{Guid.NewGuid():N}").AddInterceptors(new ThrowOnNonce()).Options);
|
|
var key = Key.New();
|
|
var user = new UserAccount { DisplayName = "m", GitHubLogin = "m", BifrostIdentity = "m", NormalizedBifrostIdentity = "M", NormalizedGitHubLogin = "M" };
|
|
user.Membership = new Domain.Membership { UserAccountId = user.Id, Status = MembershipStatus.Active };
|
|
db.UserAccounts.Add(user);
|
|
db.MindKeys.Add(new MindKey { PersonaId = "mindx", UserAccountId = user.Id, PublicKey = key.Public, RegisteredAtUtc = DateTimeOffset.UtcNow });
|
|
await db.SaveChangesAsync();
|
|
var audit = new AuditTrailService(db, clock);
|
|
var service = new MindIntakeService(db, new SealedBallotService(db, audit, clock), audit, clock);
|
|
var motion = Guid.NewGuid();
|
|
|
|
await Assert.ThrowsAsync<DbUpdateException>(() => service.HandleAsync(motion, Sign(key, "mindx", "commit", motion, commitment: new string('a', 64)), default));
|
|
}
|
|
|
|
private sealed class FailOnNonceDelete : SaveChangesInterceptor
|
|
{
|
|
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData e, InterceptionResult<int> r, CancellationToken ct = default) =>
|
|
e.Context!.ChangeTracker.Entries<MindNonce>().Any(x => x.State == EntityState.Deleted)
|
|
? throw new DbUpdateConcurrencyException("the other request deleted these rows first")
|
|
: ValueTask.FromResult(r);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_prune_that_loses_a_race_never_fails_a_valid_request()
|
|
{
|
|
var clock = TimeProvider.System;
|
|
await using var db = new BifrostDbContext(new DbContextOptionsBuilder<BifrostDbContext>()
|
|
.UseInMemoryDatabase($"intake-prune-{Guid.NewGuid():N}").AddInterceptors(new FailOnNonceDelete()).Options);
|
|
var key = Key.New();
|
|
var user = new UserAccount { DisplayName = "m", GitHubLogin = "m", BifrostIdentity = "m", NormalizedBifrostIdentity = "M", NormalizedGitHubLogin = "M" };
|
|
user.Membership = new Domain.Membership { UserAccountId = user.Id, Status = MembershipStatus.Active };
|
|
var now = DateTimeOffset.UtcNow;
|
|
var motion = new Motion
|
|
{
|
|
CreatedByUserAccountId = user.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s", Candidates = "Ana\nBo",
|
|
OpensAtUtc = now, CommitClosesAtUtc = now.AddHours(1), ClosesAtUtc = now.AddHours(2), CreatedAtUtc = now
|
|
};
|
|
db.UserAccounts.Add(user);
|
|
db.Motions.Add(motion);
|
|
db.MindKeys.Add(new MindKey { PersonaId = "mindx", UserAccountId = user.Id, PublicKey = key.Public, RegisteredAtUtc = now });
|
|
db.MindNonces.Add(new MindNonce { PersonaId = "mindx", Nonce = "expired-nonce-expired-1", SeenAtUtc = now.AddHours(-2) });
|
|
await db.SaveChangesAsync();
|
|
var audit = new AuditTrailService(db, clock);
|
|
var service = new MindIntakeService(db, new SealedBallotService(db, audit, clock), audit, clock);
|
|
|
|
var receipt = await service.HandleAsync(motion.Id, Sign(key, "mindx", "commit", motion.Id, commitment: new string('a', 64)), default);
|
|
|
|
Assert.Equal(new string('a', 64), receipt.Commitment);
|
|
Assert.Equal(1, await db.MindNonces.CountAsync(x => x.Nonce != "expired-nonce-expired-1")); // the nonce was still spent
|
|
}
|
|
}
|