feat(client): add rendezvous traversal coordinators (#12)
quality-gate / quality (push) Successful in 56s
quality-gate / quality (push) Successful in 56s
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using LiteNetLib;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class RendezvousCoordinatorBehaviorTests
|
||||
{
|
||||
[Fact]
|
||||
public void NatIntroductionAloneDoesNotCompleteTheClientAttempt()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
using ClientHarness harness = new(clock);
|
||||
int completions = 0;
|
||||
harness.Coordinator.Completed += (_, _) => completions++;
|
||||
|
||||
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||
NatAddressType.External,
|
||||
harness.IntroductionToken);
|
||||
|
||||
Assert.Equal(RendezvousConnectionState.Connecting, harness.Coordinator.State);
|
||||
Assert.False(harness.Coordinator.IsCompleted);
|
||||
Assert.Equal(0, completions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientRejectsASyntacticallyValidIntroductionWithTheWrongTicket()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
using ClientHarness harness = new(clock);
|
||||
string forged = NatIntroductionTokenCodec.Encode(
|
||||
harness.AttemptId,
|
||||
Credential('F'));
|
||||
|
||||
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||
NatAddressType.External,
|
||||
forged);
|
||||
|
||||
Assert.Equal(RendezvousConnectionState.Punching, harness.Coordinator.State);
|
||||
Assert.False(harness.Coordinator.IsCompleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
using ClientHarness harness = new(clock);
|
||||
List<RendezvousConnectionState> completions = [];
|
||||
harness.Coordinator.Completed += (_, completion) => completions.Add(completion.State);
|
||||
|
||||
harness.Coordinator.Cancel();
|
||||
harness.Coordinator.Poll();
|
||||
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||
NatAddressType.External,
|
||||
harness.IntroductionToken);
|
||||
harness.Coordinator.Poll();
|
||||
|
||||
Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State);
|
||||
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExhaustedPunchBudgetTimesOutExactlyOnceUnderAFakeClock()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
|
||||
{
|
||||
MaximumPunchRequests = 1,
|
||||
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||
JitterRatio = 0,
|
||||
});
|
||||
int completions = 0;
|
||||
harness.Coordinator.Completed += (_, _) => completions++;
|
||||
|
||||
harness.Coordinator.Poll();
|
||||
clock.Advance(TimeSpan.FromMilliseconds(10));
|
||||
harness.Coordinator.Poll();
|
||||
clock.Advance(TimeSpan.FromMinutes(1));
|
||||
harness.Coordinator.Poll();
|
||||
|
||||
Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State);
|
||||
Assert.Equal(1, completions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManagerShutdownAndDisposalEachReleaseTheirTerminalPathOnce()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
using ClientHarness stopped = new(clock);
|
||||
int stoppedCompletions = 0;
|
||||
stopped.Coordinator.Completed += (_, _) => stoppedCompletions++;
|
||||
stopped.Manager.Stop();
|
||||
|
||||
stopped.Coordinator.Poll();
|
||||
stopped.Coordinator.Poll();
|
||||
|
||||
Assert.Equal(RendezvousConnectionState.ManagerStopped, stopped.Coordinator.State);
|
||||
Assert.Equal(1, stoppedCompletions);
|
||||
|
||||
using ClientHarness disposed = new(clock);
|
||||
int disposedCompletions = 0;
|
||||
disposed.Coordinator.Completed += (_, _) => disposedCompletions++;
|
||||
disposed.Coordinator.Dispose();
|
||||
disposed.Coordinator.Dispose();
|
||||
((INatPunchListener)disposed.PunchEvents).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||
NatAddressType.External,
|
||||
disposed.IntroductionToken);
|
||||
|
||||
Assert.Equal(RendezvousConnectionState.Disposed, disposed.Coordinator.State);
|
||||
Assert.Equal(1, disposedCompletions);
|
||||
Assert.Throws<ObjectDisposedException>(() => disposed.Coordinator.Poll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DirectConnectionRejectionProducesOneTerminalTransition()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
using ClientHarness client = new(clock);
|
||||
EventBasedNetListener rejectingEvents = new();
|
||||
rejectingEvents.ConnectionRequestEvent += request => request.Reject();
|
||||
NetManager rejectingHost = new(rejectingEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(rejectingHost.Start(0));
|
||||
int completions = 0;
|
||||
client.Coordinator.Completed += (_, _) => completions++;
|
||||
((INatPunchListener)client.PunchEvents).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, rejectingHost.LocalPort),
|
||||
NatAddressType.External,
|
||||
client.IntroductionToken);
|
||||
|
||||
DateTime deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (!client.Coordinator.IsCompleted && DateTime.UtcNow < deadline)
|
||||
{
|
||||
rejectingHost.PollEvents();
|
||||
client.Coordinator.Poll();
|
||||
await Task.Delay(2);
|
||||
}
|
||||
|
||||
Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State);
|
||||
Assert.Equal(1, completions);
|
||||
client.Coordinator.Poll();
|
||||
Assert.Equal(1, completions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rejectingHost.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsynchronizedLiteNetCallbacksAreRejectedAtConstruction()
|
||||
{
|
||||
RendezvousNetListener networkEvents = new();
|
||||
NetManager manager = networkEvents.CreateManager();
|
||||
manager.UnsyncedEvents = true;
|
||||
try
|
||||
{
|
||||
Assert.True(manager.Start(0));
|
||||
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
|
||||
manager,
|
||||
networkEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 9_050),
|
||||
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoordinatorRejectsAManagerCreatedByAnotherRoutingListener()
|
||||
{
|
||||
RendezvousNetListener managerEvents = new();
|
||||
NetManager manager = managerEvents.CreateManager();
|
||||
RendezvousNetListener mismatchedEvents = new();
|
||||
try
|
||||
{
|
||||
Assert.True(manager.Start(0));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
|
||||
manager,
|
||||
mismatchedEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 9_050),
|
||||
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishedSessionTimingRemainsValidDuringConcurrentRenewalReads()
|
||||
{
|
||||
DateTimeOffset firstExpiry = new(2030, 1, 1, 0, 1, 0, TimeSpan.Zero);
|
||||
DateTimeOffset secondExpiry = new(2030, 1, 1, 0, 2, 0, TimeSpan.Zero);
|
||||
PublishedSession session = CreateSession(firstExpiry);
|
||||
|
||||
Task writer = Task.Run(() =>
|
||||
{
|
||||
for (int index = 0; index < 10_000; index++)
|
||||
{
|
||||
session.ExpiresAt = index % 2 == 0 ? firstExpiry : secondExpiry;
|
||||
session.LeaseRenewAfterSeconds = index % 2 == 0 ? 10 : 20;
|
||||
}
|
||||
});
|
||||
Task reader = Task.Run(() =>
|
||||
{
|
||||
for (int index = 0; index < 10_000; index++)
|
||||
{
|
||||
DateTimeOffset expiry = session.ExpiresAt;
|
||||
int renewAfter = session.LeaseRenewAfterSeconds;
|
||||
Assert.True(expiry == firstExpiry || expiry == secondExpiry);
|
||||
Assert.True(renewAfter is 10 or 20 or 30);
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(writer, reader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostDoesNotMistakeAnIntroducedAttemptForCancellationWhenItLeavesPolling()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
RendezvousNetListener networkEvents = new();
|
||||
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
|
||||
NetManager manager = networkEvents.CreateManager();
|
||||
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000111"));
|
||||
HostJoinAttempt invitation = new()
|
||||
{
|
||||
AttemptId = attemptId,
|
||||
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000112")),
|
||||
HostPunchCapability = Credential('H'),
|
||||
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||
NatIntroductionTokenCodec.Encode(attemptId, Credential('T'))),
|
||||
ExpiresAt = clock.UtcNow + TimeSpan.FromSeconds(30),
|
||||
};
|
||||
MutableJoinClient joins = new([invitation]);
|
||||
using ConnectionTicketValidator tickets = new(16, clock);
|
||||
try
|
||||
{
|
||||
Assert.True(manager.Start(0));
|
||||
using RendezvousHostCoordinator host = new(
|
||||
manager,
|
||||
networkEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||
joins,
|
||||
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||
clock,
|
||||
tickets);
|
||||
int completions = 0;
|
||||
host.AttemptCompleted += (_, _) => completions++;
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
host.Poll();
|
||||
Assert.Equal(1, host.PendingAttemptCount);
|
||||
|
||||
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, 65_003),
|
||||
NatAddressType.External,
|
||||
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')));
|
||||
joins.Attempts = [];
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
host.Poll();
|
||||
|
||||
Assert.Equal(1, host.PendingAttemptCount);
|
||||
Assert.Equal(0, completions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostCancellationSnapshotRevokesAnAuthorizedTicketAndCompletesOnce()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
RendezvousNetListener networkEvents = new();
|
||||
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
|
||||
NetManager manager = networkEvents.CreateManager();
|
||||
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000131"));
|
||||
string ticket = NatIntroductionTokenCodec.Encode(attemptId, Credential('T'));
|
||||
HostJoinAttempt invitation = CreateHostAttempt(
|
||||
attemptId,
|
||||
new(Guid.Parse("00000000-0000-0000-0000-000000000132")),
|
||||
ticket,
|
||||
clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||
MutableJoinClient joins = new([invitation]);
|
||||
using ConnectionTicketValidator tickets = new(16, clock);
|
||||
try
|
||||
{
|
||||
Assert.True(manager.Start(0));
|
||||
using RendezvousHostCoordinator host = new(
|
||||
manager,
|
||||
networkEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||
joins,
|
||||
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||
clock,
|
||||
tickets);
|
||||
List<RendezvousConnectionState> completions = [];
|
||||
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
host.Poll();
|
||||
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, 65_003),
|
||||
NatAddressType.External,
|
||||
ticket);
|
||||
|
||||
invitation.IsCancelled = true;
|
||||
joins.Attempts = [invitation];
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
host.Poll();
|
||||
host.Poll();
|
||||
|
||||
Assert.Equal(0, host.PendingAttemptCount);
|
||||
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
|
||||
Assert.Equal(
|
||||
ConnectionTicketConsumptionResult.Revoked,
|
||||
tickets.Consume(attemptId, ticket));
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostAppliesOnlyTheLatestUnpolledSnapshot()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
RendezvousNetListener networkEvents = new();
|
||||
NetManager manager = networkEvents.CreateManager();
|
||||
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000141"));
|
||||
HostJoinAttempt invitation = CreateHostAttempt(
|
||||
attemptId,
|
||||
new(Guid.Parse("00000000-0000-0000-0000-000000000142")),
|
||||
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
|
||||
clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||
MutableJoinClient joins = new([invitation]);
|
||||
try
|
||||
{
|
||||
Assert.True(manager.Start(0));
|
||||
using RendezvousHostCoordinator host = new(
|
||||
manager,
|
||||
networkEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||
joins,
|
||||
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||
clock,
|
||||
null);
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
joins.Attempts = [];
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
|
||||
host.Poll();
|
||||
|
||||
Assert.Equal(0, host.PendingAttemptCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposingHostDuringRefreshDropsTheLateSnapshot()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
RendezvousNetListener networkEvents = new();
|
||||
NetManager manager = networkEvents.CreateManager();
|
||||
BlockingJoinClient joins = new();
|
||||
try
|
||||
{
|
||||
Assert.True(manager.Start(0));
|
||||
RendezvousHostCoordinator host = new(
|
||||
manager,
|
||||
networkEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||
joins,
|
||||
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||
clock,
|
||||
null);
|
||||
Task<RendezvousClientResult<int>> refresh = host.RefreshJoinAttemptsAsync();
|
||||
await joins.WaitUntilCalled;
|
||||
|
||||
host.Dispose();
|
||||
joins.Complete([]);
|
||||
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await refresh);
|
||||
Assert.Throws<ObjectDisposedException>(() => host.Poll());
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostBoundsAttemptExpiryChecksPerPoll()
|
||||
{
|
||||
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
RendezvousNetListener networkEvents = new();
|
||||
NetManager manager = networkEvents.CreateManager();
|
||||
JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000151"));
|
||||
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000152"));
|
||||
DateTimeOffset expiresAt = clock.UtcNow + TimeSpan.FromSeconds(1);
|
||||
MutableJoinClient joins = new([
|
||||
CreateHostAttempt(
|
||||
firstId,
|
||||
new(Guid.Parse("00000000-0000-0000-0000-000000000153")),
|
||||
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
|
||||
expiresAt),
|
||||
CreateHostAttempt(
|
||||
secondId,
|
||||
new(Guid.Parse("00000000-0000-0000-0000-000000000154")),
|
||||
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
|
||||
expiresAt),
|
||||
]);
|
||||
try
|
||||
{
|
||||
Assert.True(manager.Start(0));
|
||||
using RendezvousHostCoordinator host = new(
|
||||
manager,
|
||||
networkEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||
joins,
|
||||
new RendezvousCoordinatorOptions
|
||||
{
|
||||
MaximumAttemptChecksPerPoll = 1,
|
||||
JitterRatio = 0,
|
||||
},
|
||||
clock,
|
||||
null);
|
||||
List<RendezvousConnectionState> completions = [];
|
||||
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
host.Poll();
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
|
||||
host.Poll();
|
||||
|
||||
Assert.Equal(1, host.PendingAttemptCount);
|
||||
Assert.Equal([RendezvousConnectionState.TimedOut], completions);
|
||||
host.Poll();
|
||||
Assert.Equal(0, host.PendingAttemptCount);
|
||||
Assert.Equal(
|
||||
[RendezvousConnectionState.TimedOut, RendezvousConnectionState.TimedOut],
|
||||
completions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new()
|
||||
{
|
||||
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")),
|
||||
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000102")),
|
||||
ClientPunchCapability = Credential('C'),
|
||||
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||
NatIntroductionTokenCodec.Encode(
|
||||
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000101")),
|
||||
Credential('T'))),
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
|
||||
private static PublishedSession CreateSession(DateTimeOffset expiresAt) => new(new RegisterSessionResponse
|
||||
{
|
||||
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000121")),
|
||||
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000122")),
|
||||
LeaseToken = "lease-token",
|
||||
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000123")),
|
||||
HostPresenceCapability = Credential('P'),
|
||||
ExpiresAt = expiresAt,
|
||||
LeaseRenewAfterSeconds = 30,
|
||||
HostPresenceRefreshAfterSeconds = 10,
|
||||
});
|
||||
|
||||
private static HostJoinAttempt CreateHostAttempt(
|
||||
JoinAttemptId attemptId,
|
||||
MediationHandle mediationHandle,
|
||||
string connectionTicket,
|
||||
DateTimeOffset expiresAt) => new()
|
||||
{
|
||||
AttemptId = attemptId,
|
||||
MediationHandle = mediationHandle,
|
||||
HostPunchCapability = Credential('H'),
|
||||
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(connectionTicket),
|
||||
ExpiresAt = expiresAt,
|
||||
};
|
||||
|
||||
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
|
||||
|
||||
private sealed class ClientHarness : IDisposable
|
||||
{
|
||||
internal ClientHarness(
|
||||
ManualCoordinatorClock clock,
|
||||
RendezvousCoordinatorOptions? options = null)
|
||||
{
|
||||
NetworkEvents = new();
|
||||
PunchEvents = NetworkEvents.PunchEvents;
|
||||
Manager = NetworkEvents.CreateManager();
|
||||
Assert.True(Manager.Start(0));
|
||||
CreateJoinAttemptResponse attempt = CreateAttempt(clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||
AttemptId = attempt.AttemptId;
|
||||
IntroductionToken = NatIntroductionTokenCodec.Encode(
|
||||
attempt.AttemptId,
|
||||
Credential('T'));
|
||||
Coordinator = new(
|
||||
Manager,
|
||||
NetworkEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 65_001),
|
||||
attempt,
|
||||
options,
|
||||
clock);
|
||||
}
|
||||
|
||||
internal RendezvousNetListener NetworkEvents { get; }
|
||||
internal EventBasedNatPunchListener PunchEvents { get; }
|
||||
internal NetManager Manager { get; }
|
||||
internal RendezvousClientCoordinator Coordinator { get; }
|
||||
internal JoinAttemptId AttemptId { get; }
|
||||
internal string IntroductionToken { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Coordinator.Dispose();
|
||||
Manager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MutableJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
|
||||
{
|
||||
internal IReadOnlyList<HostJoinAttempt> Attempts { get; set; } = attempts;
|
||||
|
||||
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||
PublishedSession session,
|
||||
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||
PublishedSession session,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
RendezvousClientResult.Success(Attempts));
|
||||
}
|
||||
|
||||
private sealed class BlockingJoinClient : IRendezvousJoinClient
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _called = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource<IReadOnlyList<HostJoinAttempt>> _result = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
internal Task WaitUntilCalled => _called.Task;
|
||||
|
||||
internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) =>
|
||||
_result.SetResult(attempts);
|
||||
|
||||
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||
PublishedSession session,
|
||||
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||
PublishedSession session,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_called.SetResult(true);
|
||||
return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManualCoordinatorClock(DateTimeOffset now) :
|
||||
IRendezvousCoordinatorClock,
|
||||
IConnectionTicketClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; private set; } = now;
|
||||
|
||||
internal void Advance(TimeSpan amount) => UtcNow += amount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
using LiteNetLib;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class RendezvousCoordinatorIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(8));
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "sdk-direct-connect");
|
||||
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||
NatMediationProcessor processor = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService mediatorService = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
await mediatorService.StartAsync(timeout.Token);
|
||||
|
||||
RendezvousNetListener hostEvents = new();
|
||||
RendezvousNetListener clientEvents = new();
|
||||
bool gameplayConnectionRequestHandled = false;
|
||||
hostEvents.GameplayEvents.ConnectionRequestEvent += _ =>
|
||||
gameplayConnectionRequestHandled = true;
|
||||
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
|
||||
EventBasedNatPunchListener clientPunch = clientEvents.PunchEvents;
|
||||
NetManager hostManager = hostEvents.CreateManager();
|
||||
NetManager clientManager = clientEvents.CreateManager();
|
||||
string? introductionToken = null;
|
||||
string? hostIntroductionToken = null;
|
||||
clientPunch.NatIntroductionSuccess += (_, _, token) => introductionToken = token;
|
||||
hostPunch.NatIntroductionSuccess += (_, _, token) => hostIntroductionToken = token;
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(hostManager.Start(0));
|
||||
Assert.True(clientManager.Start(0));
|
||||
IPEndPoint mediator = Assert.IsType<IPEndPoint>(mediatorService.LocalEndpoint);
|
||||
FakeJoinClient joinClient = new([hostAttempt]);
|
||||
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
|
||||
using ConnectionTicketValidator tickets = new(1_024, clock);
|
||||
using RendezvousHostCoordinator host = new(
|
||||
hostManager,
|
||||
hostEvents,
|
||||
mediator,
|
||||
new PublishedSession(registration),
|
||||
joinClient,
|
||||
FastOptions(),
|
||||
clock,
|
||||
tickets);
|
||||
using RendezvousClientCoordinator client = new(
|
||||
clientManager,
|
||||
clientEvents,
|
||||
mediator,
|
||||
created,
|
||||
FastOptions(),
|
||||
clock);
|
||||
List<RendezvousHostAttemptCompletedEventArgs> hostCompletions = [];
|
||||
List<RendezvousConnectionCompletedEventArgs> clientCompletions = [];
|
||||
host.AttemptCompleted += (_, completion) => hostCompletions.Add(completion);
|
||||
client.Completed += (_, completion) => clientCompletions.Add(completion);
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync(timeout.Token)).IsSuccess);
|
||||
|
||||
while ((!client.IsCompleted || hostCompletions.Count == 0)
|
||||
&& !timeout.IsCancellationRequested)
|
||||
{
|
||||
host.Poll();
|
||||
client.Poll();
|
||||
await Task.Delay(2);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
client.State == RendezvousConnectionState.Connected,
|
||||
$"Client ended in {client.State}; host pending={host.PendingAttemptCount}; "
|
||||
+ $"host completions={hostCompletions.Count}; introduction={introductionToken is not null}; "
|
||||
+ $"host introduction={hostIntroductionToken is not null}; "
|
||||
+ $"client digest={NatIntroductionTokenCodec.MatchesDigest(introductionToken, created.ConnectionTicketDigest)}; "
|
||||
+ $"host digest={NatIntroductionTokenCodec.MatchesDigest(hostIntroductionToken, hostAttempt.ConnectionTicketDigest)}.");
|
||||
Assert.NotNull(client.ConnectedPeer);
|
||||
Assert.Equal(
|
||||
RendezvousConnectionState.Connected,
|
||||
Assert.Single(clientCompletions).State);
|
||||
RendezvousHostAttemptCompletedEventArgs hostCompletion = Assert.Single(hostCompletions);
|
||||
Assert.Equal(created.AttemptId, hostCompletion.AttemptId);
|
||||
Assert.Equal(RendezvousConnectionState.Connected, hostCompletion.State);
|
||||
Assert.NotNull(hostCompletion.Peer);
|
||||
Assert.False(gameplayConnectionRequestHandled);
|
||||
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||
introductionToken,
|
||||
out NatIntroductionToken? introduction));
|
||||
Assert.NotNull(introduction);
|
||||
|
||||
EventBasedNetListener replayEvents = new();
|
||||
bool replayConnected = false;
|
||||
replayEvents.PeerConnectedEvent += _ => replayConnected = true;
|
||||
NetManager replayManager = new(replayEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(replayManager.Start(0));
|
||||
replayManager.Connect(
|
||||
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||
DirectConnectionRequestCodec.Encode(
|
||||
created.AttemptId,
|
||||
introduction.ConnectionTicket));
|
||||
DateTime replayDeadline = DateTime.UtcNow.AddSeconds(1);
|
||||
while (DateTime.UtcNow < replayDeadline && !replayConnected)
|
||||
{
|
||||
host.Poll();
|
||||
replayManager.PollEvents();
|
||||
await Task.Delay(2, timeout.Token);
|
||||
}
|
||||
|
||||
Assert.False(replayConnected);
|
||||
Assert.False(gameplayConnectionRequestHandled);
|
||||
Assert.Single(hostCompletions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
replayManager.Stop();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
hostManager.Stop();
|
||||
clientManager.Stop();
|
||||
await mediatorService.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostDefersADirectRequestUntilTheMatchingNatIntroductionArrives()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "direct-before-nat");
|
||||
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||
ConnectionTicketGrant grant = Assert.IsType<ConnectionTicketGrant>(
|
||||
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
||||
RendezvousNetListener hostEvents = new();
|
||||
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
|
||||
EventBasedNetListener clientEvents = new();
|
||||
bool clientConnected = false;
|
||||
clientEvents.PeerConnectedEvent += _ => clientConnected = true;
|
||||
NetManager hostManager = hostEvents.CreateManager();
|
||||
NetManager clientManager = new(clientEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(hostManager.Start(0));
|
||||
Assert.True(clientManager.Start(0));
|
||||
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
|
||||
FakeJoinClient joins = new([hostAttempt]);
|
||||
using ConnectionTicketValidator tickets = new(16, clock);
|
||||
using RendezvousHostCoordinator host = new(
|
||||
hostManager,
|
||||
hostEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||
new PublishedSession(registration),
|
||||
joins,
|
||||
FastOptions(),
|
||||
clock,
|
||||
tickets);
|
||||
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
|
||||
host.AttemptCompleted += (_, completion) => completions.Add(completion);
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
host.Poll();
|
||||
bool observedDeferredRequest = false;
|
||||
hostEvents.RendezvousConnectionRequest += _ =>
|
||||
{
|
||||
observedDeferredRequest = host.DeferredRequestCount == 1;
|
||||
((INatPunchListener)hostPunch).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, clientManager.LocalPort),
|
||||
NatAddressType.External,
|
||||
grant.Ticket);
|
||||
};
|
||||
|
||||
clientManager.Connect(
|
||||
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||
DirectConnectionRequestCodec.Encode(created.AttemptId, grant.Ticket));
|
||||
DateTime connectedDeadline = DateTime.UtcNow.AddSeconds(1);
|
||||
while ((!clientConnected || completions.Count == 0)
|
||||
&& DateTime.UtcNow < connectedDeadline)
|
||||
{
|
||||
host.Poll();
|
||||
clientManager.PollEvents();
|
||||
await Task.Delay(2);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
clientConnected,
|
||||
$"Deferred observed={observedDeferredRequest}; deferred={host.DeferredRequestCount}; "
|
||||
+ $"pending={host.PendingAttemptCount}; completions={completions.Count}.");
|
||||
Assert.True(observedDeferredRequest);
|
||||
Assert.Equal(0, host.DeferredRequestCount);
|
||||
Assert.Equal(
|
||||
RendezvousConnectionState.Connected,
|
||||
Assert.Single(completions).State);
|
||||
}
|
||||
finally
|
||||
{
|
||||
hostManager.Stop();
|
||||
clientManager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static RendezvousCoordinatorOptions FastOptions() => new()
|
||||
{
|
||||
MaximumPunchRequests = 20,
|
||||
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(100),
|
||||
JitterRatio = 0,
|
||||
};
|
||||
|
||||
private sealed class FakeJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
|
||||
{
|
||||
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||
PublishedSession session,
|
||||
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||
PublishedSession session,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
RendezvousClientResult.Success(attempts));
|
||||
}
|
||||
|
||||
private sealed class FixedCoordinatorClock(DateTimeOffset now) :
|
||||
IRendezvousCoordinatorClock,
|
||||
IConnectionTicketClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; } = now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class RendezvousJoinClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task JoinIssuanceRetriesTheSameIdempotentPayloadAndCancellationUsesCapability()
|
||||
{
|
||||
CreateJoinAttemptResponse created = CreateAttempt();
|
||||
RecordingHandler handler = new(
|
||||
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
|
||||
JsonResponse(HttpStatusCode.Created, created),
|
||||
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RendezvousJoinClient client = new(
|
||||
http,
|
||||
new RendezvousClientOptions { JitterRatio = 0 },
|
||||
new ImmediateDelay());
|
||||
CreateJoinAttemptRequest request = new()
|
||||
{
|
||||
IdempotencyKey = "stable-join-key",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000201")),
|
||||
ProtocolVersion = 7,
|
||||
};
|
||||
|
||||
RendezvousClientResult<CreateJoinAttemptResponse> result = await client.CreateAsync(request);
|
||||
RendezvousClientResult<bool> cancelled = await client.CancelAsync(created);
|
||||
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
Assert.True(cancelled.IsSuccess, cancelled.Message);
|
||||
Assert.Equal(handler.Requests[0].Body, handler.Requests[1].Body);
|
||||
Assert.Contains("stable-join-key", handler.Requests[0].Body, StringComparison.Ordinal);
|
||||
RecordedRequest cancellation = handler.Requests[2];
|
||||
Assert.Equal(HttpMethod.Delete, cancellation.Method);
|
||||
Assert.Equal(
|
||||
created.ClientPunchCapability,
|
||||
cancellation.Headers["X-Rendezvous-Client-Punch-Capability"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostInvitationPollingFollowsCursorsWithTheLeaseToken()
|
||||
{
|
||||
HostJoinAttempt first = CreateHostAttempt("00000000-0000-0000-0000-000000000211");
|
||||
HostJoinAttempt second = CreateHostAttempt("00000000-0000-0000-0000-000000000212");
|
||||
RecordingHandler handler = new(
|
||||
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
|
||||
{
|
||||
Items = [first],
|
||||
NextCursor = "next page+cursor",
|
||||
}),
|
||||
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
|
||||
{
|
||||
Items = [second],
|
||||
}));
|
||||
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||
RendezvousJoinClient client = new(http);
|
||||
PublishedSession session = new(new RegisterSessionResponse
|
||||
{
|
||||
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000220")),
|
||||
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000221")),
|
||||
LeaseToken = "lease-secret",
|
||||
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000222")),
|
||||
HostPresenceCapability = Credential('P'),
|
||||
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
LeaseRenewAfterSeconds = 15,
|
||||
HostPresenceRefreshAfterSeconds = 10,
|
||||
});
|
||||
|
||||
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
|
||||
await client.BrowseAllForHostAsync(session);
|
||||
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
Assert.Equal([first.AttemptId, second.AttemptId], result.Value!.Select(item => item.AttemptId));
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.All(handler.Requests, request =>
|
||||
Assert.Equal("lease-secret", request.Headers["X-Rendezvous-Lease-Token"]));
|
||||
Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static CreateJoinAttemptResponse CreateAttempt() => new()
|
||||
{
|
||||
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")),
|
||||
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000203")),
|
||||
ClientPunchCapability = Credential('C'),
|
||||
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||
NatIntroductionTokenCodec.Encode(
|
||||
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000202")),
|
||||
Credential('T'))),
|
||||
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
private static HostJoinAttempt CreateHostAttempt(string id) => new()
|
||||
{
|
||||
AttemptId = new(Guid.Parse(id)),
|
||||
MediationHandle = new(Guid.NewGuid()),
|
||||
HostPunchCapability = Credential('H'),
|
||||
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||
NatIntroductionTokenCodec.Encode(new JoinAttemptId(Guid.Parse(id)), Credential('T'))),
|
||||
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
|
||||
|
||||
private static HttpResponseMessage JsonResponse<T>(HttpStatusCode status, T value) => new(status)
|
||||
{
|
||||
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)),
|
||||
};
|
||||
|
||||
private sealed class RecordingHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||
|
||||
internal List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<string, string> headers = request.Headers.ToDictionary(
|
||||
static item => item.Key,
|
||||
static item => string.Join(",", item.Value),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
Requests.Add(new(
|
||||
request.Method,
|
||||
request.RequestUri!,
|
||||
headers,
|
||||
request.Content is null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken)));
|
||||
return _responses.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RecordedRequest(
|
||||
HttpMethod Method,
|
||||
Uri Uri,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string Body);
|
||||
|
||||
private sealed class ImmediateDelay : IRendezvousDelay
|
||||
{
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user