322 lines
14 KiB
C#
322 lines
14 KiB
C#
using FinalFactory.Rendezvous.Contracts;
|
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
|
using FinalFactory.Rendezvous.Server.State;
|
|
|
|
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
|
|
|
public sealed class JoinAttemptServiceTests
|
|
{
|
|
[Fact]
|
|
public void CreateIsIdempotentAndScopesDistinctRoleCredentials()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key");
|
|
|
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> first = fixture.Service.Create(
|
|
fixture.ClientSubject,
|
|
request);
|
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> replay = fixture.Service.Create(
|
|
fixture.ClientSubject,
|
|
request);
|
|
|
|
Assert.True(first.Succeeded);
|
|
Assert.True(replay.Succeeded);
|
|
Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId);
|
|
Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle);
|
|
Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability);
|
|
Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability));
|
|
Assert.InRange(
|
|
first.Value.ClientPunchCapability.Length,
|
|
1,
|
|
ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
|
|
|
HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost(
|
|
registration.ListingId,
|
|
ContractLimits.ContractVersion,
|
|
registration.LeaseToken,
|
|
10,
|
|
null).Value!.Items);
|
|
Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability);
|
|
Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void SameIdempotencyKeyWithDifferentRequestConflicts()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
|
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key");
|
|
Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded);
|
|
|
|
request.ListingId = other.ListingId;
|
|
JoinAttemptServiceResult<CreateJoinAttemptResponse> conflict = fixture.Service.Create(
|
|
fixture.ClientSubject,
|
|
request);
|
|
|
|
Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreationRejectsStaleIncompatibleAndCrossTenantListings()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
|
|
Assert.Equal(
|
|
RendezvousErrorCode.NotFound,
|
|
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
|
|
|
|
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
|
CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId);
|
|
incompatible.ProtocolVersion = 8;
|
|
Assert.Equal(
|
|
RendezvousErrorCode.IncompatibleProtocol,
|
|
fixture.Service.Create(fixture.ClientSubject, incompatible).Error);
|
|
|
|
CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId);
|
|
otherTenant.GameId = new("other-game");
|
|
Assert.Equal(
|
|
RendezvousErrorCode.NotFound,
|
|
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
fixture.Create(registration.ListingId);
|
|
fixture.Create(registration.ListingId);
|
|
fixture.Create(registration.ListingId);
|
|
|
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> first = fixture.Service.BrowseForHost(
|
|
registration.ListingId,
|
|
ContractLimits.ContractVersion,
|
|
registration.LeaseToken,
|
|
1,
|
|
null);
|
|
Assert.True(first.Succeeded);
|
|
Assert.Single(first.Value!.Items);
|
|
Assert.NotNull(first.Value.NextCursor);
|
|
|
|
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> second = fixture.Service.BrowseForHost(
|
|
registration.ListingId,
|
|
ContractLimits.ContractVersion,
|
|
registration.LeaseToken,
|
|
1,
|
|
first.Value.NextCursor);
|
|
Assert.True(second.Succeeded);
|
|
Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId);
|
|
|
|
Assert.Equal(
|
|
RendezvousErrorCode.NotFound,
|
|
fixture.Service.BrowseForHost(
|
|
registration.ListingId,
|
|
ContractLimits.ContractVersion,
|
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
1,
|
|
null).Error);
|
|
Assert.Equal(
|
|
RendezvousErrorCode.InvalidRequest,
|
|
fixture.Service.BrowseForHost(
|
|
registration.ListingId,
|
|
ContractLimits.ContractVersion,
|
|
registration.LeaseToken,
|
|
1,
|
|
first.Value.NextCursor + "x").Error);
|
|
|
|
(RegisterSessionResponse other, _) = fixture.CreateHost();
|
|
Assert.Equal(
|
|
RendezvousErrorCode.InvalidRequest,
|
|
fixture.Service.BrowseForHost(
|
|
other.ListingId,
|
|
ContractLimits.ContractVersion,
|
|
other.LeaseToken,
|
|
1,
|
|
first.Value.NextCursor).Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
|
|
|
Assert.Equal(
|
|
RendezvousErrorCode.NotFound,
|
|
fixture.Service.Cancel(
|
|
created.AttemptId,
|
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error);
|
|
Assert.True(fixture.Service.Cancel(
|
|
created.AttemptId,
|
|
created.ClientPunchCapability).Succeeded);
|
|
Assert.True(fixture.Service.Cancel(
|
|
created.AttemptId,
|
|
created.ClientPunchCapability).Succeeded);
|
|
HostJoinAttempt cancelled = Assert.Single(fixture.Service.BrowseForHost(
|
|
registration.ListingId,
|
|
ContractLimits.ContractVersion,
|
|
registration.LeaseToken,
|
|
10,
|
|
null).Value!.Items);
|
|
Assert.Equal(created.AttemptId, cancelled.AttemptId);
|
|
Assert.True(cancelled.IsCancelled);
|
|
}
|
|
|
|
[Fact]
|
|
public void CancellationAfterIntroductionRevokesTicketIssuanceAndConsumption()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
|
JoinAttemptServiceResult<ConnectionTicketGrant> issued =
|
|
fixture.Service.IssueConnectionTicket(introduction.Attempt);
|
|
Assert.True(issued.Succeeded);
|
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
|
issued.Value!.Ticket,
|
|
out SecretFingerprint ticketFingerprint));
|
|
|
|
Assert.True(fixture.Service.Cancel(
|
|
created.AttemptId,
|
|
created.ClientPunchCapability).Succeeded);
|
|
|
|
StoredJoinAttempt cancelled = fixture.GetAttempt(registration, created.AttemptId);
|
|
Assert.True(cancelled.IsCancelled);
|
|
Assert.Equal(
|
|
RendezvousErrorCode.Conflict,
|
|
fixture.Service.IssueConnectionTicket(cancelled).Error);
|
|
Assert.Equal(
|
|
StoreResultCode.Conflict,
|
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
|
created.AttemptId,
|
|
ticketFingerprint)).Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
|
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
|
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
|
second.ClientPunchCapability,
|
|
out SecretFingerprint secondClientFingerprint));
|
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
|
first.ClientPunchCapability,
|
|
out SecretFingerprint firstClientFingerprint));
|
|
|
|
Assert.Equal(
|
|
StoreResultCode.NotFound,
|
|
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
|
firstStored.MediationHandle,
|
|
AttemptPeerRole.Client,
|
|
secondClientFingerprint,
|
|
new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000),
|
|
null)).Code);
|
|
Assert.Equal(
|
|
StoreResultCode.NotFound,
|
|
fixture.Sessions.Store.BindAttemptEndpoint(new(
|
|
firstStored.MediationHandle,
|
|
AttemptPeerRole.Host,
|
|
firstClientFingerprint,
|
|
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
|
|
null)).Code);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
|
JoinAttemptServiceResult<ConnectionTicketGrant> issued = fixture.Service.IssueConnectionTicket(
|
|
introduction.Attempt);
|
|
Assert.True(issued.Succeeded);
|
|
Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket));
|
|
Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket);
|
|
Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal);
|
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
|
issued.Value.Ticket,
|
|
out SecretFingerprint ticketFingerprint));
|
|
ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint);
|
|
using ManualResetEventSlim start = new(false);
|
|
|
|
Task<StoreResult<bool>> left = Task.Run(() =>
|
|
{
|
|
start.Wait();
|
|
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
|
});
|
|
Task<StoreResult<bool>> right = Task.Run(() =>
|
|
{
|
|
start.Wait();
|
|
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
|
|
});
|
|
start.Set();
|
|
StoreResult<bool>[] results = await Task.WhenAll(left, right);
|
|
|
|
Assert.Single(results, static result => result.Succeeded);
|
|
Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected);
|
|
}
|
|
|
|
[Fact]
|
|
public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry()
|
|
{
|
|
EphemeralStoreOptions options = new()
|
|
{
|
|
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
|
|
};
|
|
using JoinAttemptFixture fixture = new(options);
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
|
|
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
|
|
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
|
|
StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId);
|
|
|
|
Assert.Equal(
|
|
StoreResultCode.Conflict,
|
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
|
first.AttemptId,
|
|
firstStored.ConnectionTicketFingerprint)).Code);
|
|
Assert.Equal(
|
|
StoreResultCode.NotFound,
|
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
|
second.AttemptId,
|
|
firstStored.ConnectionTicketFingerprint)).Code);
|
|
|
|
fixture.Introduce(registration, first);
|
|
fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime);
|
|
Assert.Equal(
|
|
StoreResultCode.Expired,
|
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
|
first.AttemptId,
|
|
firstStored.ConnectionTicketFingerprint)).Code);
|
|
Assert.False(secondStored.ConnectionTicketConsumed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt()
|
|
{
|
|
using JoinAttemptFixture fixture = new();
|
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
|
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15));
|
|
|
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
|
ConnectionTicketGrant ticket = Assert.IsType<ConnectionTicketGrant>(
|
|
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
|
|
|
Assert.Equal(created.ExpiresAt, ticket.ExpiresAt);
|
|
Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow);
|
|
Assert.DoesNotContain(
|
|
introduction.Attempt.CapabilityDerivationSalt,
|
|
introduction.Attempt.ToString(),
|
|
StringComparison.Ordinal);
|
|
}
|
|
}
|