feat: add presence-gated session leases (#7)
quality-gate / quality (push) Successful in 55s
quality-gate / quality (push) Successful in 55s
Closes #7
This commit is contained in:
@@ -4,6 +4,15 @@ namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||
|
||||
public sealed class ContractLimitTests
|
||||
{
|
||||
[Fact]
|
||||
public void RequiredPlayerFacingTextRejectsEmptyOrWhitespaceValues()
|
||||
{
|
||||
Assert.False(ContractValidation.IsBuildVersionValid(string.Empty));
|
||||
Assert.False(ContractValidation.IsBuildVersionValid(" "));
|
||||
Assert.False(ContractValidation.IsDisplayNameValid(string.Empty));
|
||||
Assert.False(ContractValidation.IsDisplayNameValid(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteAndCollectionLimitsAcceptTheBoundaryOnly()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class ContractSerializationTests
|
||||
public static TheoryData<string, Type> GoldenJsonVectors => new()
|
||||
{
|
||||
{ "register-session.json", typeof(RegisterSessionRequest) },
|
||||
{ "register-session-response.json", typeof(RegisterSessionResponse) },
|
||||
{ "browse-sessions.json", typeof(BrowseSessionsResponse) },
|
||||
{ "create-join-response.json", typeof(CreateJoinAttemptResponse) },
|
||||
{ "api-error.json", typeof(ApiError) },
|
||||
|
||||
@@ -64,5 +64,26 @@ public sealed class OpenApiCompatibilityTests
|
||||
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
JsonElement publisherBearer = root.GetProperty("components")
|
||||
.GetProperty("securitySchemes")
|
||||
.GetProperty("PublisherBearer");
|
||||
Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
|
||||
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString());
|
||||
(string Path, string Method)[] publisherOperations =
|
||||
[
|
||||
("/v1/sessions", "post"),
|
||||
("/v1/sessions/{listingId}", "put"),
|
||||
("/v1/sessions/{listingId}", "delete"),
|
||||
("/v1/sessions/{listingId}/renew", "post"),
|
||||
];
|
||||
foreach ((string operationPath, string method) in publisherOperations)
|
||||
{
|
||||
JsonElement security = root.GetProperty("paths")
|
||||
.GetProperty(operationPath)
|
||||
.GetProperty(method)
|
||||
.GetProperty("security");
|
||||
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using FinalFactory.Rendezvous.Tests.Sessions;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -7,6 +12,39 @@ namespace FinalFactory.Rendezvous.Tests.Server;
|
||||
|
||||
public sealed class UdpMediatorServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket()
|
||||
{
|
||||
using SessionLeaseFixture fixture = new();
|
||||
RegisterSessionResponse registration = fixture.Register();
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(new UdpMediatorOptions { ListenAddress = "127.0.0.1", Port = 0 }),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
fixture.Store,
|
||||
fixture.Capabilities);
|
||||
PresenceDatagram presence = new()
|
||||
{
|
||||
MessageType = UdpPresenceMessageType.HostPresence,
|
||||
MediationHandle = registration.HostPresenceHandle,
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
LocalAddress = "192.168.1.50",
|
||||
LocalPort = 40_000,
|
||||
Capability = registration.HostPresenceCapability,
|
||||
};
|
||||
IPEndPoint observedGameplaySocket = new(IPAddress.Parse("203.0.113.77"), 51_234);
|
||||
presence.Capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
Assert.Equal(
|
||||
UdpPresenceProcessingResult.HostPresenceRejected,
|
||||
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
||||
Assert.Empty(fixture.Browse());
|
||||
|
||||
presence.Capability = registration.HostPresenceCapability;
|
||||
Assert.Equal(
|
||||
UdpPresenceProcessingResult.HostPresenceAccepted,
|
||||
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
|
||||
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
|
||||
{
|
||||
@@ -16,9 +54,14 @@ public sealed class UdpMediatorServiceTests
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
};
|
||||
ManualRendezvousClock clock = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
|
||||
using EphemeralCapabilityIssuer capabilities = new();
|
||||
using UdpMediatorService service = new(
|
||||
Options.Create(options),
|
||||
NullLogger<UdpMediatorService>.Instance);
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
store,
|
||||
capabilities);
|
||||
|
||||
await service.StartAsync(timeout.Token);
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||
|
||||
public sealed class SessionHttpEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AuthenticatedHttpLifecycleReturnsStableContractsAndStatuses()
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
ProvisioningTestData.CreateSecrets("secret-1"),
|
||||
clock.UtcNow);
|
||||
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
|
||||
string publisherCredential = provisioning.Credentials.Issue(principal, clock.UtcNow);
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||
ContractJson.Configure(options.SerializerOptions));
|
||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
builder.Services.AddSingleton(provisioning);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
|
||||
builder.Services.AddSingleton<IWallClock>(clock);
|
||||
builder.Services.AddSingleton(capabilities);
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
await using WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
await app.StartAsync();
|
||||
IServer server = app.Services.GetRequiredService<IServer>();
|
||||
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
|
||||
using HttpClient client = new() { BaseAddress = new Uri(address) };
|
||||
RegisterSessionRequest registration = new()
|
||||
{
|
||||
IdempotencyKey = "http-register-1",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.4.2",
|
||||
DisplayName = "HTTP host",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
},
|
||||
};
|
||||
|
||||
HttpResponseMessage unauthenticated = await client.PostAsJsonAsync(
|
||||
"/v1/sessions",
|
||||
registration,
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthenticated.StatusCode);
|
||||
Assert.Equal("Bearer", Assert.Single(unauthenticated.Headers.WwwAuthenticate).Scheme);
|
||||
ApiError? authenticationError = await unauthenticated.Content.ReadFromJsonAsync<ApiError>(
|
||||
ContractJson.Options);
|
||||
Assert.Equal(RendezvousErrorCode.AuthenticationRequired, authenticationError!.Code);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
publisherCredential);
|
||||
string invalidJson = JsonSerializer.Serialize(registration, ContractJson.Options)
|
||||
.Replace("\"public\"", "\"futureVisibility\"", StringComparison.Ordinal);
|
||||
HttpResponseMessage invalid = await client.PostAsync(
|
||||
"/v1/sessions",
|
||||
new StringContent(invalidJson, Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||
ApiError? invalidError = await invalid.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options);
|
||||
Assert.Equal(RendezvousErrorCode.InvalidRequest, invalidError!.Code);
|
||||
|
||||
HttpResponseMessage created = await client.PostAsJsonAsync(
|
||||
"/v1/sessions",
|
||||
registration,
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
RegisterSessionResponse? session = await created.Content.ReadFromJsonAsync<RegisterSessionResponse>(
|
||||
ContractJson.Options);
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString);
|
||||
|
||||
HttpResponseMessage renewed = await client.PostAsJsonAsync(
|
||||
$"/v1/sessions/{session.ListingId}/renew",
|
||||
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.OK, renewed.StatusCode);
|
||||
Assert.NotNull(await renewed.Content.ReadFromJsonAsync<RenewLeaseResponse>(ContractJson.Options));
|
||||
|
||||
HttpResponseMessage updated = await client.PutAsJsonAsync(
|
||||
$"/v1/sessions/{session.ListingId}",
|
||||
new UpdateSessionRequest
|
||||
{
|
||||
LeaseToken = session.LeaseToken,
|
||||
BuildVersion = "1.4.3",
|
||||
DisplayName = "HTTP host updated",
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||
Metadata = new Dictionary<string, string> { ["mode"] = "co-op" },
|
||||
},
|
||||
ContractJson.Options);
|
||||
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
|
||||
|
||||
using HttpRequestMessage deleteRequest = new(
|
||||
HttpMethod.Delete,
|
||||
$"/v1/sessions/{session.ListingId}")
|
||||
{
|
||||
Content = JsonContent.Create(
|
||||
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
|
||||
options: ContractJson.Options),
|
||||
};
|
||||
HttpResponseMessage deleted = await client.SendAsync(deleteRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
Assert.Equal(StoreResultCode.NotFound, store.GetListing(session.ListingId, false).Code);
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["map"] = "europa",
|
||||
["mode"] = "co-op",
|
||||
};
|
||||
RegisterSessionResponse duplicate = fixture.Register(reordered);
|
||||
RegisterSessionRequest changedRequest = fixture.Request("same-key");
|
||||
changedRequest.DisplayName = "Changed";
|
||||
SessionServiceResult<RegisterSessionResponse> 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),
|
||||
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<RenewLeaseResponse> renewed = fixture.Service.Renew(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken });
|
||||
SessionServiceResult<bool> 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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
["map"] = "europa",
|
||||
},
|
||||
});
|
||||
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.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 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<string, string> { ["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<SessionServiceResult<RenewLeaseResponse>> renew = Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return fixture.Service.Renew(
|
||||
fixture.Principal,
|
||||
registration.ListingId,
|
||||
new() { LeaseToken = registration.LeaseToken });
|
||||
});
|
||||
Task<SessionServiceResult<bool>> 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<RenewLeaseResponse> renewResult = await renew;
|
||||
SessionServiceResult<bool> 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<string, string> { ["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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Sessions;
|
||||
|
||||
internal sealed class SessionLeaseFixture : IDisposable
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null)
|
||||
{
|
||||
StoreOptions = storeOptions ?? new EphemeralStoreOptions();
|
||||
Clock = new();
|
||||
Store = new(StoreOptions, Clock, Clock);
|
||||
Capabilities = new();
|
||||
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
|
||||
Service = new(
|
||||
new PublisherAuthorizationService(policies),
|
||||
Store,
|
||||
Capabilities,
|
||||
SessionLeaseTiming.From(StoreOptions),
|
||||
Clock);
|
||||
Principal = Publisher("publisher-1");
|
||||
}
|
||||
|
||||
public EphemeralStoreOptions StoreOptions { get; }
|
||||
public ManualRendezvousClock Clock { get; }
|
||||
public InMemoryEphemeralRendezvousStore Store { get; }
|
||||
public EphemeralCapabilityIssuer Capabilities { get; }
|
||||
public SessionLeaseService Service { get; }
|
||||
public DedicatedPublisherPrincipal Principal { get; }
|
||||
public TenantScope Scope { get; } = new(new("space-game"), new("production"));
|
||||
|
||||
public DedicatedPublisherPrincipal Publisher(string subject) => new(
|
||||
subject,
|
||||
Clock.UtcNow.AddMinutes(10),
|
||||
Scope.GameId,
|
||||
Scope.EnvironmentId,
|
||||
new HashSet<RegionId> { new("eu-central") });
|
||||
|
||||
public RegisterSessionRequest Request(string? idempotencyKey = null) => new()
|
||||
{
|
||||
IdempotencyKey = idempotencyKey ?? $"register-{Interlocked.Increment(ref _sequence)}",
|
||||
GameId = Scope.GameId,
|
||||
EnvironmentId = Scope.EnvironmentId,
|
||||
RegionId = new("eu-central"),
|
||||
ProtocolVersion = 7,
|
||||
BuildVersion = "1.4.2",
|
||||
DisplayName = "Europa Relay",
|
||||
Visibility = ListingVisibility.Public,
|
||||
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["mode"] = "co-op",
|
||||
["map"] = "europa",
|
||||
},
|
||||
};
|
||||
|
||||
public RegisterSessionResponse Register(
|
||||
RegisterSessionRequest? request = null,
|
||||
DedicatedPublisherPrincipal? principal = null)
|
||||
{
|
||||
SessionServiceResult<RegisterSessionResponse> result = Service.Register(
|
||||
principal ?? Principal,
|
||||
request ?? Request());
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.NotNull(result.Value);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
public StoreResult<StoredListing> BindPresence(RegisterSessionResponse registration)
|
||||
{
|
||||
Assert.True(Capabilities.TryFingerprint(
|
||||
registration.HostPresenceCapability,
|
||||
out SecretFingerprint fingerprint));
|
||||
return Store.BindHostPresence(new(
|
||||
registration.HostPresenceHandle,
|
||||
fingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.50", 40_000),
|
||||
new ObservedEndpoint(AddressFamilyKind.Ipv4, "192.168.1.50", 40_000)));
|
||||
}
|
||||
|
||||
public IReadOnlyList<StoredListing> Browse() => Store.BrowseVisibleListings(new(
|
||||
Scope,
|
||||
7,
|
||||
new RegionId("eu-central"))).Value!;
|
||||
|
||||
public void Dispose() => Capabilities.Dispose();
|
||||
}
|
||||
@@ -5,7 +5,10 @@ namespace FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
internal sealed class ManualRendezvousClock : IWallClock, IMonotonicClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; private set; } = new(2026, 7, 16, 0, 0, 0, TimeSpan.Zero);
|
||||
public ManualRendezvousClock(DateTimeOffset? utcNow = null) =>
|
||||
UtcNow = utcNow ?? new DateTimeOffset(2026, 7, 16, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DateTimeOffset UtcNow { get; private set; }
|
||||
public TimeSpan Elapsed { get; private set; }
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
@@ -58,6 +61,7 @@ internal sealed class EphemeralStateFixture
|
||||
LeaseFingerprint = Fingerprint($"lease-{sequence}"),
|
||||
HostPresenceHandle = NewHandle(),
|
||||
HostPresenceFingerprint = Fingerprint($"presence-{sequence}"),
|
||||
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,16 @@ namespace FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
public sealed class InMemoryEphemeralRendezvousStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void IdempotencyRetentionMustCoverResourceLifetimes()
|
||||
{
|
||||
ManualRendezvousClock clock = new();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new InMemoryEphemeralRendezvousStore(
|
||||
new EphemeralStoreOptions { IdempotencyLifetime = TimeSpan.FromSeconds(5) },
|
||||
clock,
|
||||
clock));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateRegistrationIsIdempotentButChangedRequestConflicts()
|
||||
{
|
||||
@@ -49,6 +59,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
||||
listing.Definition.ListingId,
|
||||
listing.Definition.LeaseId,
|
||||
listing.Definition.LeaseFingerprint,
|
||||
listing.Definition.OwnerSubject,
|
||||
listing.Version));
|
||||
Assert.Equal(new DateTimeOffset(2026, 7, 16, 0, 1, 1, TimeSpan.Zero), renewed.Value!.LeaseExpiresAt);
|
||||
fixture.Clock.MoveWall(TimeSpan.FromDays(-60));
|
||||
@@ -72,6 +83,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
||||
listing.Definition.ListingId,
|
||||
listing.Definition.LeaseId,
|
||||
listing.Definition.LeaseFingerprint,
|
||||
listing.Definition.OwnerSubject,
|
||||
listing.Version));
|
||||
});
|
||||
Task<StoreResult<bool>> delete = Task.Run(() =>
|
||||
@@ -80,7 +92,8 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
||||
return fixture.Store.DeleteListing(new(
|
||||
command.Listing.ListingId,
|
||||
command.Listing.LeaseId,
|
||||
command.Listing.LeaseFingerprint));
|
||||
command.Listing.LeaseFingerprint,
|
||||
command.Listing.OwnerSubject));
|
||||
});
|
||||
|
||||
start.Set();
|
||||
@@ -102,6 +115,7 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
|
||||
listing.Definition.ListingId,
|
||||
listing.Definition.LeaseId,
|
||||
listing.Definition.LeaseFingerprint,
|
||||
listing.Definition.OwnerSubject,
|
||||
listing.Version);
|
||||
|
||||
StoreResult<StoredListing> first = fixture.Store.RenewLease(command);
|
||||
|
||||
@@ -217,7 +217,9 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionResponse
|
||||
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
||||
PROP System.String HostPresenceCapability {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;}
|
||||
PROP System.Int32 HostPresenceRefreshAfterSeconds {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;}
|
||||
PROP System.Int32 LeaseRenewAfterSeconds {get;set;}
|
||||
PROP System.String LeaseToken {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode
|
||||
@@ -250,6 +252,7 @@ TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseResponse
|
||||
CTOR ()
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
||||
PROP System.Int32 RenewAfterSeconds {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
|
||||
CTOR ()
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","leaseId":"11112233-4455-6677-8899-aabbccddeeff","leaseToken":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","hostPresenceHandle":"22222233-4455-6677-8899-aabbccddeeff","hostPresenceCapability":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","expiresAt":"2026-07-16T12:01:00+00:00","leaseRenewAfterSeconds":30,"hostPresenceRefreshAfterSeconds":10}
|
||||
Reference in New Issue
Block a user