diff --git a/src/Bifrost.Web/Features/Membership/MergeProofs.cs b/src/Bifrost.Web/Features/Membership/MergeProofs.cs new file mode 100644 index 0000000..264103e --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/MergeProofs.cs @@ -0,0 +1,72 @@ +using Bifrost.Web.Features.Heimdall; + +namespace Bifrost.Web.Features.Membership; + +public enum MergeSide +{ + /// The account signed in here. + Self, + + /// The second account, proved by its own fresh sign-in. + Other +} + +public enum PutOutcome +{ + Stored, + AmbiguousLogins, + TooManyProofs +} + +/// What a page may know of one side: no bearer, no Heimdall account id, no login names. +public sealed record MergeSideView( + Guid UserAccountId, IReadOnlyList ProvedProviders, IReadOnlyList 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 Folded) : PeekResult; + + /// A side holds no live proof. + public sealed record Expired : PeekResult; + + /// Logins whose provider has no live proof, by provider name only. + public sealed record LoginsUnproved(IReadOnlyList Providers) : PeekResult; +} + +public abstract record UndoPeekResult +{ + private UndoPeekResult() + { + } + + public sealed record Ready(IReadOnlyList Proofs) : UndoPeekResult; + + public sealed record Expired : UndoPeekResult; + + public sealed record LoginsUnproved(IReadOnlyList Providers) : UndoPeekResult; +} + +/// Holder of live bearers between a merge-proof sign-in's completion and the page's call into the saga. See the cut spec. +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 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 movedIdentityIds) => throw new NotImplementedException(); + + public void Clear(Guid actorId) => throw new NotImplementedException(); +} diff --git a/tests/Bifrost.Web.Tests/MergeProofsTests.cs b/tests/Bifrost.Web.Tests/MergeProofsTests.cs new file mode 100644 index 0000000..fa85e8d --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergeProofsTests.cs @@ -0,0 +1,198 @@ +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +/// The in-memory holder of merge proofs: per actor, expiring, never consuming, bearer-free in every view. +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(_proofs.Peek(_actor, MergeSide.Self)); + var second = Assert.IsType(_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(_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(_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(_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(_proofs.Peek(_actor, MergeSide.Self)); + + Assert.Equal(PutOutcome.Stored, self); + Assert.Equal(["discord"], unproved.Providers); + + PutOther("discord", discord, patreon); + var ready = Assert.IsType(_proofs.Peek(_actor, MergeSide.Self)); + Assert.Equal(["discord", "patreon"], ready.Folded.Select(x => x.Provider).Order().ToArray()); + var replacedAt = Assert.IsType(_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(_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(_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(_proofs.PeekUndo(_actor, [discord.Id, patreon.Id])); + PutSelf("patreon", discord, patreon, twitch); + var ready = Assert.IsType(_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(_proofs.PeekUndo(_actor, [discord.Id, Guid.NewGuid().ToString()])); + Assert.IsType(_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); + } +}