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 left = Task.Run(() => { start.Wait(); return validator.Consume(attempt, ticket); }); Task 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(() => validator.Revoke(NewAttempt())); Assert.Throws(() => 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; } }