using System.Text.Json; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Tests.Provisioning; namespace FinalFactory.Rendezvous.Tests.Sessions; public sealed class SessionLeaseServiceTests { [Fact] public void RegistrationReturnsOpaqueCredentialsButRemainsHiddenUntilPresence() { using SessionLeaseFixture fixture = new(); RegisterSessionResponse response = fixture.Register(); Assert.Equal(30, response.LeaseRenewAfterSeconds); Assert.Equal(10, response.HostPresenceRefreshAfterSeconds); Assert.Equal(43, response.LeaseToken.Length); Assert.Equal(43, response.HostPresenceCapability.Length); Assert.Empty(fixture.Browse()); string json = JsonSerializer.Serialize(response, ContractJson.Options); Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("fingerprint", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("store", json, StringComparison.OrdinalIgnoreCase); } [Fact] public void ExactRegistrationRetryReproducesIdsAndCapabilitiesWithoutRetainingPlaintext() { using SessionLeaseFixture fixture = new(); RegisterSessionRequest request = fixture.Request("same-key"); RegisterSessionResponse first = fixture.Register(request); RegisterSessionRequest reordered = fixture.Request("same-key"); reordered.Metadata = new Dictionary(StringComparer.Ordinal) { ["map"] = "europa", ["mode"] = "co-op", }; RegisterSessionResponse duplicate = fixture.Register(reordered); RegisterSessionRequest changedRequest = fixture.Request("same-key"); changedRequest.DisplayName = "Changed"; SessionServiceResult changed = fixture.Service.Register( fixture.Principal, changedRequest); Assert.Equal(first.ListingId, duplicate.ListingId); Assert.Equal(first.LeaseId, duplicate.LeaseId); Assert.Equal(first.LeaseToken, duplicate.LeaseToken); Assert.Equal(first.HostPresenceHandle, duplicate.HostPresenceHandle); Assert.Equal(first.HostPresenceCapability, duplicate.HostPresenceCapability); Assert.Equal(RendezvousErrorCode.Conflict, changed.Error); } [Fact] public void ReRegistrationAfterIdempotencyExpiryRotatesIdsAndCapabilities() { EphemeralStoreOptions options = new() { LeaseLifetime = TimeSpan.FromSeconds(5), JoinAttemptLifetime = TimeSpan.FromSeconds(5), ConnectionTicketLifetime = TimeSpan.FromSeconds(5), IdempotencyLifetime = TimeSpan.FromSeconds(6), }; using SessionLeaseFixture fixture = new(options); RegisterSessionRequest request = fixture.Request("reused-after-expiry"); RegisterSessionResponse first = fixture.Register(request); fixture.Clock.Advance(options.IdempotencyLifetime); RegisterSessionResponse second = fixture.Register(request); Assert.NotEqual(first.ListingId, second.ListingId); Assert.NotEqual(first.LeaseId, second.LeaseId); Assert.NotEqual(first.LeaseToken, second.LeaseToken); Assert.NotEqual(first.HostPresenceCapability, second.HostPresenceCapability); } [Fact] public void PresenceTransitionsAwaitingToListedToStaleAndBackWithoutChangingIdentity() { using SessionLeaseFixture fixture = new(); RegisterSessionResponse registration = fixture.Register(); Assert.Empty(fixture.Browse()); Assert.True(fixture.BindPresence(registration).Succeeded); Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId); fixture.Clock.Advance(fixture.StoreOptions.PresenceLifetime); Assert.Empty(fixture.Browse()); Assert.True(fixture.BindPresence(registration).Succeeded); Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId); } [Fact] public void RenewUpdateAndDeleteMaintainCanonicalIdentityAndAdvisoryCapacity() { using SessionLeaseFixture fixture = new(); RegisterSessionResponse registration = fixture.Register(); fixture.Clock.Advance(TimeSpan.FromSeconds(1)); SessionServiceResult renewed = fixture.Service.Renew( fixture.Principal, registration.ListingId, new() { LeaseToken = registration.LeaseToken }); SessionServiceResult updated = fixture.Service.Update( fixture.Principal, registration.ListingId, new() { LeaseToken = registration.LeaseToken, BuildVersion = "1.4.3", DisplayName = "Europa Updated", Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 }, Metadata = new Dictionary(StringComparer.Ordinal) { ["mode"] = "co-op", ["map"] = "europa", }, DedicatedFallback = new() { AddressFamily = AddressFamilyKind.Ipv4, Address = "203.0.113.92", Port = 9_062, }, }); StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!; Assert.True(renewed.Succeeded); Assert.Equal(fixture.Clock.UtcNow.Add(fixture.StoreOptions.LeaseLifetime), renewed.Value!.ExpiresAt); Assert.True(updated.Succeeded); Assert.Equal(registration.ListingId, stored.Definition.ListingId); Assert.Equal(fixture.Scope, stored.Definition.Scope); Assert.Equal(8, stored.Definition.CurrentPlayers); Assert.Equal(8, stored.Definition.MaximumPlayers); Assert.Equal("203.0.113.92", stored.Definition.DedicatedFallback!.Address); Assert.True(fixture.Service.Delete( fixture.Principal, registration.ListingId, new() { LeaseToken = registration.LeaseToken }).Succeeded); Assert.True(fixture.Service.Delete( fixture.Principal, registration.ListingId, new() { LeaseToken = registration.LeaseToken }).Succeeded); Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code); } [Fact] public void DisabledFallbackPolicyRejectsRegistrationAndUpdateEndpoints() { GamePolicyOptions policy = ProvisioningTestData.CreatePolicy(); policy.FallbackPolicy = FallbackPolicyMode.Disabled; using SessionLeaseFixture fixture = new(policyOptions: policy); RegisterSessionRequest registrationRequest = fixture.Request(); registrationRequest.DedicatedFallback = new() { AddressFamily = AddressFamilyKind.Ipv4, Address = "203.0.113.94", Port = 9_064, }; Assert.Equal( RendezvousErrorCode.Forbidden, fixture.Service.Register(fixture.Principal, registrationRequest).Error); RegisterSessionResponse registration = fixture.Register(); Assert.Equal( RendezvousErrorCode.Forbidden, fixture.Service.Update( fixture.Principal, registration.ListingId, new UpdateSessionRequest { LeaseToken = registration.LeaseToken, BuildVersion = "1.4.3", DisplayName = "Europa Updated", Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 }, Metadata = new Dictionary(StringComparer.Ordinal) { ["mode"] = "co-op", ["map"] = "europa", }, DedicatedFallback = registrationRequest.DedicatedFallback, }).Error); } [Fact] public void AnotherPublisherCannotRenewUpdateOrDeleteListing() { using SessionLeaseFixture fixture = new(); RegisterSessionResponse registration = fixture.Register(); DedicatedPublisherPrincipal other = fixture.Publisher("publisher-2"); Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew( other, registration.ListingId, new() { LeaseToken = registration.LeaseToken }).Error); Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Update( other, registration.ListingId, new() { LeaseToken = registration.LeaseToken, BuildVersion = "1.4.3", DisplayName = "Hijacked", Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 2 }, Metadata = new Dictionary { ["mode"] = "co-op" }, }).Error); Assert.True(fixture.Service.Delete( other, registration.ListingId, new() { LeaseToken = registration.LeaseToken }).Succeeded); Assert.True(fixture.Store.GetListing(registration.ListingId, false).Succeeded); } [Fact] public async Task ConcurrentRenewDeleteCannotResurrectListing() { using SessionLeaseFixture fixture = new(); RegisterSessionResponse registration = fixture.Register(); using ManualResetEventSlim start = new(false); Task> renew = Task.Run(() => { start.Wait(); return fixture.Service.Renew( fixture.Principal, registration.ListingId, new() { LeaseToken = registration.LeaseToken }); }); Task> delete = Task.Run(() => { start.Wait(); return fixture.Service.Delete( fixture.Principal, registration.ListingId, new() { LeaseToken = registration.LeaseToken }); }); start.Set(); await Task.WhenAll(renew, delete); SessionServiceResult renewResult = await renew; SessionServiceResult deleteResult = await delete; Assert.True(deleteResult.Succeeded); Assert.Contains(renewResult.Error, new[] { RendezvousErrorCode.None, RendezvousErrorCode.NotFound, RendezvousErrorCode.Conflict, }); Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code); } [Fact] public void AbandonedRegistrationExpiresAndFreesBoundedCapacity() { EphemeralStoreOptions options = new() { MaxListings = 1, LeaseLifetime = TimeSpan.FromSeconds(5), }; using SessionLeaseFixture fixture = new(options); fixture.Register(fixture.Request("first")); Assert.Equal(RendezvousErrorCode.CapacityExceeded, fixture.Service.Register( fixture.Principal, fixture.Request("second")).Error); fixture.Clock.Advance(options.LeaseLifetime); Assert.True(fixture.Service.Register( fixture.Principal, fixture.Request("second")).Succeeded); } [Fact] public void LeaseExpiryRemovesMutationAndPresencePaths() { using SessionLeaseFixture fixture = new(); RegisterSessionResponse registration = fixture.Register(); fixture.BindPresence(registration); fixture.Clock.Advance(fixture.StoreOptions.LeaseLifetime); Assert.Empty(fixture.Browse()); Assert.Equal(RendezvousErrorCode.NotFound, fixture.Service.Renew( fixture.Principal, registration.ListingId, new() { LeaseToken = registration.LeaseToken }).Error); Assert.Equal(StoreResultCode.NotFound, fixture.BindPresence(registration).Code); } [Fact] public void LossOfAtomicStateFailsLeaseMutationClosed() { using SessionLeaseFixture fixture = new(); RegisterSessionResponse registration = fixture.Register(); fixture.Store.MarkUnavailable(); Assert.Equal(RendezvousErrorCode.ServiceUnavailable, fixture.Service.Renew( fixture.Principal, registration.ListingId, new() { LeaseToken = registration.LeaseToken }).Error); } [Fact] public void InvalidPolicyBoundInputsReturnStableTypedErrors() { using SessionLeaseFixture fixture = new(); RegisterSessionRequest capacity = fixture.Request("bad-capacity"); capacity.Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 1 }; Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( fixture.Principal, capacity).Error); RegisterSessionRequest protocol = fixture.Request("bad-protocol"); protocol.ProtocolVersion = 8; Assert.Equal(RendezvousErrorCode.IncompatibleProtocol, fixture.Service.Register( fixture.Principal, protocol).Error); RegisterSessionRequest region = fixture.Request("bad-region"); region.RegionId = new("us-east"); Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( fixture.Principal, region).Error); RegisterSessionRequest visibility = fixture.Request("bad-visibility"); visibility.Visibility = (ListingVisibility)99; Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( fixture.Principal, visibility).Error); RegisterSessionRequest metadata = fixture.Request("bad-metadata"); metadata.Metadata = new Dictionary { ["unknown"] = "value" }; Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( fixture.Principal, metadata).Error); RegisterSessionRequest build = fixture.Request("bad-build"); build.BuildVersion = " "; Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Service.Register( fixture.Principal, build).Error); } }