Files
Rendezvous/tests/FinalFactory.Rendezvous.Tests/Client/ConnectionTicketValidatorTests.cs
KyuubiYoru 1baa1055dc
quality-gate / quality (push) Successful in 1m1s
feat: implement scoped join attempts and tickets (#10)
Closes #10
2026-07-16 06:56:30 +02:00

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;
}
}