feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s
quality-gate / quality (push) Successful in 1m1s
Closes #10
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class ConnectionTicketValidatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void AuthorizedTicketIsAcceptedExactlyOnce()
|
||||
{
|
||||
ManualConnectionTicketClock clock = new();
|
||||
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||
JoinAttemptId attempt = NewAttempt();
|
||||
string ticket = Ticket('A');
|
||||
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Accepted,
|
||||
validator.Consume(attempt, ticket));
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.AlreadyConsumed,
|
||||
validator.Consume(attempt, ticket));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlteredCrossAttemptExpiredAndRevokedTicketsAreRejected()
|
||||
{
|
||||
ManualConnectionTicketClock clock = new();
|
||||
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||
JoinAttemptId first = NewAttempt();
|
||||
JoinAttemptId second = NewAttempt();
|
||||
Assert.True(validator.TryAuthorize(first, Ticket('A'), Now.AddSeconds(20)));
|
||||
Assert.True(validator.TryAuthorize(second, Ticket('B'), Now.AddSeconds(40)));
|
||||
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Rejected,
|
||||
validator.Consume(first, Ticket('B')));
|
||||
clock.Advance(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Expired,
|
||||
validator.Consume(first, Ticket('A')));
|
||||
Assert.True(validator.Revoke(second));
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Revoked,
|
||||
validator.Consume(second, Ticket('B')));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentConsumptionHasOneWinner()
|
||||
{
|
||||
ManualConnectionTicketClock clock = new();
|
||||
using ConnectionTicketValidator validator = new(1_024, clock);
|
||||
JoinAttemptId attempt = NewAttempt();
|
||||
string ticket = Ticket('C');
|
||||
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
|
||||
using ManualResetEventSlim start = new(false);
|
||||
Task<ConnectionTicketConsumptionResult> left = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return validator.Consume(attempt, ticket);
|
||||
});
|
||||
Task<ConnectionTicketConsumptionResult> right = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return validator.Consume(attempt, ticket);
|
||||
});
|
||||
|
||||
start.Set();
|
||||
ConnectionTicketConsumptionResult[] results = await Task.WhenAll(left, right);
|
||||
|
||||
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.Accepted);
|
||||
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.AlreadyConsumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorIsBoundedDisposableAndRedacted()
|
||||
{
|
||||
ManualConnectionTicketClock clock = new();
|
||||
ConnectionTicketValidator validator = new(1, clock);
|
||||
Assert.True(validator.TryAuthorize(NewAttempt(), Ticket('A'), Now.AddSeconds(20)));
|
||||
Assert.False(validator.TryAuthorize(NewAttempt(), Ticket('B'), Now.AddSeconds(20)));
|
||||
Assert.DoesNotContain(Ticket('A'), validator.ToString(), StringComparison.Ordinal);
|
||||
|
||||
validator.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(() => validator.Revoke(NewAttempt()));
|
||||
Assert.Throws<ObjectDisposedException>(() => validator.Consume(default, string.Empty));
|
||||
}
|
||||
|
||||
private static JoinAttemptId NewAttempt() => new(Guid.NewGuid());
|
||||
private static string Ticket(char value) => new(value, 43);
|
||||
|
||||
private sealed class ManualConnectionTicketClock : IConnectionTicketClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; set; } = Now;
|
||||
|
||||
public void Advance(TimeSpan duration) => UtcNow += duration;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user