Test: MergeProofs holder (red; type is a skeleton)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 09:56:49 +00:00
commit 5fbb673c55
2 changed files with 270 additions and 0 deletions

View file

@ -0,0 +1,72 @@
using Bifrost.Web.Features.Heimdall;
namespace Bifrost.Web.Features.Membership;
public enum MergeSide
{
/// <summary>The account signed in here.</summary>
Self,
/// <summary>The second account, proved by its own fresh sign-in.</summary>
Other
}
public enum PutOutcome
{
Stored,
AmbiguousLogins,
TooManyProofs
}
/// <summary>What a page may know of one side: no bearer, no Heimdall account id, no login names.</summary>
public sealed record MergeSideView(
Guid UserAccountId, IReadOnlyList<string> ProvedProviders, IReadOnlyList<string> LoginProviders, TimeSpan OldestProofAge, DateTimeOffset ExpiresAt);
public sealed record MergeProofsView(MergeSideView? Self, MergeSideView? Other);
public abstract record PeekResult
{
private PeekResult()
{
}
public sealed record Ready(MergeProof Survivor, IReadOnlyList<MergeProof> Folded) : PeekResult;
/// <summary>A side holds no live proof.</summary>
public sealed record Expired : PeekResult;
/// <summary>Logins whose provider has no live proof, by provider name only.</summary>
public sealed record LoginsUnproved(IReadOnlyList<string> Providers) : PeekResult;
}
public abstract record UndoPeekResult
{
private UndoPeekResult()
{
}
public sealed record Ready(IReadOnlyList<MergeProof> Proofs) : UndoPeekResult;
public sealed record Expired : UndoPeekResult;
public sealed record LoginsUnproved(IReadOnlyList<string> Providers) : UndoPeekResult;
}
/// <summary>Holder of live bearers between a merge-proof sign-in's completion and the page's call into the saga. See the cut spec.</summary>
public sealed class MergeProofs(TimeProvider timeProvider)
{
public static readonly TimeSpan ProofLifetime = TimeSpan.FromSeconds(270);
public const int MaxProofsPerSide = 8;
public PutOutcome Put(Guid actorId, MergeSide side, MergeProof proof, Guid userAccountId, IReadOnlyList<LinkedIdentitySummary> logins) =>
throw new NotImplementedException();
public MergeProofsView Snapshot(Guid actorId) => throw new NotImplementedException();
public PeekResult Peek(Guid actorId, MergeSide survivorSide) => throw new NotImplementedException();
public UndoPeekResult PeekUndo(Guid actorId, IReadOnlyCollection<string> movedIdentityIds) => throw new NotImplementedException();
public void Clear(Guid actorId) => throw new NotImplementedException();
}

View file

@ -0,0 +1,198 @@
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
/// <summary>The in-memory holder of merge proofs: per actor, expiring, never consuming, bearer-free in every view.</summary>
public sealed class MergeProofsTests
{
private const string Secret = "BEARER-CANARY";
private readonly TestClock _clock = new();
private readonly MergeProofs _proofs;
private readonly Guid _actor = Guid.NewGuid();
private readonly Guid _self = Guid.NewGuid();
private readonly Guid _other = Guid.NewGuid();
private readonly string _selfAccount = HeimdallShapes.NewAccountId();
private readonly string _otherAccount = HeimdallShapes.NewAccountId();
public MergeProofsTests() => _proofs = new MergeProofs(_clock);
private static LinkedIdentitySummary Login(string provider) => new(Guid.NewGuid().ToString(), provider, "Name of " + provider, null);
private MergeProof Proof(string account, string provider, string? token = null) => new(token ?? Secret + provider, account, provider);
private PutOutcome PutSelf(string provider, params LinkedIdentitySummary[] logins) =>
_proofs.Put(_actor, MergeSide.Self, Proof(_selfAccount, provider), _self, logins);
private PutOutcome PutOther(string provider, params LinkedIdentitySummary[] logins) =>
_proofs.Put(_actor, MergeSide.Other, Proof(_otherAccount, provider), _other, logins);
[Fact]
public void Peek_gives_one_actor_their_own_proofs_and_never_consumes()
{
var selfLogin = Login("discord");
var otherLogin = Login("patreon");
Assert.Equal(PutOutcome.Stored, PutSelf("discord", selfLogin));
Assert.Equal(PutOutcome.Stored, PutOther("patreon", otherLogin));
var first = Assert.IsType<PeekResult.Ready>(_proofs.Peek(_actor, MergeSide.Self));
var second = Assert.IsType<PeekResult.Ready>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal("discord", first.Survivor.Provider);
Assert.Equal("patreon", Assert.Single(first.Folded).Provider);
Assert.Equal(first, second);
Assert.IsType<PeekResult.Expired>(_proofs.Peek(Guid.NewGuid(), MergeSide.Self));
Assert.Null(_proofs.Snapshot(Guid.NewGuid()).Self);
Assert.Null(_proofs.Snapshot(Guid.NewGuid()).Other);
}
[Fact]
public void Peek_runs_both_ways_the_survivor_is_chosen_by_the_caller()
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"));
var ready = Assert.IsType<PeekResult.Ready>(_proofs.Peek(_actor, MergeSide.Other));
Assert.Equal(_otherAccount, ready.Survivor.HeimdallAccountId);
Assert.Equal(_selfAccount, Assert.Single(ready.Folded).HeimdallAccountId);
}
[Fact]
public void Clear_removes_both_sides_and_only_that_actors()
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"));
var stranger = Guid.NewGuid();
_proofs.Put(stranger, MergeSide.Self, Proof(_selfAccount, "discord"), _self, [Login("discord")]);
_proofs.Clear(_actor);
Assert.IsType<PeekResult.Expired>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(new MergeProofsView(null, null), _proofs.Snapshot(_actor));
Assert.NotNull(_proofs.Snapshot(stranger).Self);
}
[Theory]
[InlineData(269, true)]
[InlineData(270, false)]
[InlineData(271, false)]
public void A_proof_lives_270_seconds_from_its_redeem(int seconds, bool alive)
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"));
_clock.Advance(TimeSpan.FromSeconds(seconds));
Assert.Equal(alive, _proofs.Peek(_actor, MergeSide.Self) is PeekResult.Ready);
Assert.Equal(alive, _proofs.Snapshot(_actor).Self is not null);
}
[Fact]
public void One_lapsed_proof_costs_only_that_provider_and_a_newer_proof_replaces_the_older()
{
var discord = Login("discord");
var patreon = Login("patreon");
PutSelf("discord", Login("discord"));
PutOther("discord", discord, patreon);
_clock.Advance(TimeSpan.FromSeconds(200));
PutOther("patreon", discord, patreon);
_clock.Advance(TimeSpan.FromSeconds(100)); // the discord proof is 300 s old, the patreon one 100 s
var self = PutSelf("discord", Login("discord")); // keeps the self side alive
var unproved = Assert.IsType<PeekResult.LoginsUnproved>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(PutOutcome.Stored, self);
Assert.Equal(["discord"], unproved.Providers);
PutOther("discord", discord, patreon);
var ready = Assert.IsType<PeekResult.Ready>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(["discord", "patreon"], ready.Folded.Select(x => x.Provider).Order().ToArray());
var replacedAt = Assert.IsType<MergeSideView>(_proofs.Snapshot(_actor).Other);
Assert.Equal(["discord", "patreon"], replacedAt.ProvedProviders.Order().ToArray());
}
[Fact]
public void A_login_list_with_two_logins_of_one_provider_is_refused_and_stores_nothing()
{
var outcome = PutOther("discord", Login("discord"), Login("discord"));
Assert.Equal(PutOutcome.AmbiguousLogins, outcome);
Assert.Null(_proofs.Snapshot(_actor).Other);
}
[Fact]
public void A_folded_side_with_an_unproved_provider_names_providers_and_nothing_else()
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"), Login("twitch"), Login("google"));
var unproved = Assert.IsType<PeekResult.LoginsUnproved>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(["google", "twitch"], unproved.Providers.Order().ToArray());
Assert.DoesNotContain(Secret, unproved.ToString());
}
[Fact]
public void A_proof_for_another_account_on_a_side_replaces_the_side_and_the_old_proofs_are_not_mixed_in()
{
PutOther("patreon", Login("patreon"), Login("twitch"));
var someoneElse = Guid.NewGuid();
var elsewhere = HeimdallShapes.NewAccountId();
_proofs.Put(_actor, MergeSide.Other, Proof(elsewhere, "twitch"), someoneElse, [Login("twitch")]);
var view = Assert.IsType<MergeSideView>(_proofs.Snapshot(_actor).Other);
Assert.Equal(someoneElse, view.UserAccountId);
Assert.Equal(["twitch"], view.ProvedProviders);
}
[Fact]
public void A_side_holds_at_most_eight_proofs()
{
var providers = Enumerable.Range(0, MergeProofs.MaxProofsPerSide + 1).Select(x => "p" + x).ToArray();
var logins = providers.Select(Login).ToArray();
var outcomes = providers.Select(p => PutOther(p, logins)).ToArray();
Assert.All(outcomes.Take(MergeProofs.MaxProofsPerSide), x => Assert.Equal(PutOutcome.Stored, x));
Assert.Equal(PutOutcome.TooManyProofs, outcomes[^1]);
Assert.Equal(MergeProofs.MaxProofsPerSide, _proofs.Snapshot(_actor).Other!.ProvedProviders.Count);
}
[Fact]
public void Undo_needs_every_moved_login_proved_and_returns_only_the_selfs_proofs()
{
var discord = Login("discord");
var patreon = Login("patreon");
var twitch = Login("twitch");
PutSelf("discord", discord, patreon, twitch);
PutOther("twitch", Login("twitch"));
var missing = Assert.IsType<UndoPeekResult.LoginsUnproved>(_proofs.PeekUndo(_actor, [discord.Id, patreon.Id]));
PutSelf("patreon", discord, patreon, twitch);
var ready = Assert.IsType<UndoPeekResult.Ready>(_proofs.PeekUndo(_actor, [discord.Id, patreon.Id]));
Assert.Equal(["patreon"], missing.Providers);
Assert.Equal(["discord", "patreon"], ready.Proofs.Select(x => x.Provider).Order().ToArray());
Assert.All(ready.Proofs, x => Assert.Equal(_selfAccount, x.HeimdallAccountId));
Assert.IsType<UndoPeekResult.LoginsUnproved>(_proofs.PeekUndo(_actor, [discord.Id, Guid.NewGuid().ToString()]));
Assert.IsType<UndoPeekResult.Expired>(_proofs.PeekUndo(Guid.NewGuid(), [discord.Id]));
}
[Fact]
public void No_view_or_printed_result_holds_a_bearer()
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"));
var ready = _proofs.Peek(_actor, MergeSide.Self);
var printed = string.Join('\n', _proofs.Snapshot(_actor), ready, ready is PeekResult.Ready r ? r.Survivor : null, _proofs.PeekUndo(_actor, ["x"]));
Assert.DoesNotContain(Secret, printed);
Assert.DoesNotContain(_selfAccount, printed);
Assert.DoesNotContain(_otherAccount, printed);
}
}