feat: add presence-gated session leases (#7)
quality-gate / quality (push) Successful in 55s

Closes #7
This commit is contained in:
KyuubiYoru
2026-07-16 05:58:47 +02:00
parent 02ca502a76
commit 49564c7e7e
25 changed files with 2069 additions and 46 deletions
@@ -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();
}