feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s
quality-gate / quality (push) Successful in 1m1s
Closes #10
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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.Empty(fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
10,
|
||||
null).Value!.Items);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user