Test: MergeProofs holder (red; type is a skeleton)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8175f6f61e
commit
5fbb673c55
2 changed files with 270 additions and 0 deletions
72
src/Bifrost.Web/Features/Membership/MergeProofs.cs
Normal file
72
src/Bifrost.Web/Features/Membership/MergeProofs.cs
Normal 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();
|
||||
}
|
||||
198
tests/Bifrost.Web.Tests/MergeProofsTests.cs
Normal file
198
tests/Bifrost.Web.Tests/MergeProofsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue