103 lines
4.0 KiB
C#
103 lines
4.0 KiB
C#
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;
|
|
}
|
|
}
|