feat(client): standardize connection outcomes (#13)
quality-gate / quality (push) Successful in 59s
quality-gate / quality (push) Successful in 59s
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
@@ -103,6 +104,139 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
Assert.True(cancelledAttempt.IsCancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutcomeReportingIsCapabilityAuthenticatedAndIdempotentOverHttp()
|
||||
{
|
||||
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistration(),
|
||||
host.PublisherCredential));
|
||||
Assert.True(host.Capabilities.TryFingerprint(
|
||||
session.HostPresenceCapability,
|
||||
out SecretFingerprint presenceFingerprint));
|
||||
Assert.True(host.Store.BindHostPresence(new(
|
||||
session.HostPresenceHandle,
|
||||
presenceFingerprint,
|
||||
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
|
||||
null)).Succeeded);
|
||||
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
|
||||
"v1/join-attempts",
|
||||
new CreateJoinAttemptRequest
|
||||
{
|
||||
IdempotencyKey = "outcome-report-1",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ListingId = session.ListingId,
|
||||
ProtocolVersion = 7,
|
||||
},
|
||||
ContractJson.Options);
|
||||
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
|
||||
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
|
||||
ReportConnectionOutcomeRequest report = new()
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.PunchTimedOut,
|
||||
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||
};
|
||||
|
||||
ReportConnectionOutcomeResponse first = await SendOutcomeAsync(
|
||||
host.HttpClient,
|
||||
created,
|
||||
report);
|
||||
ReportConnectionOutcomeResponse duplicate = await SendOutcomeAsync(
|
||||
host.HttpClient,
|
||||
created,
|
||||
report);
|
||||
|
||||
Assert.True(first.Accepted);
|
||||
Assert.False(first.IsDuplicate);
|
||||
Assert.True(duplicate.Accepted);
|
||||
Assert.True(duplicate.IsDuplicate);
|
||||
Assert.Equal(
|
||||
1,
|
||||
host.OutcomeMetrics.GetCount(
|
||||
ConnectionOutcomeKind.PunchTimedOut,
|
||||
ConnectionElapsedBucket.FiveToFifteenSeconds));
|
||||
|
||||
using HttpRequestMessage conflictRequest = OutcomeRequest(
|
||||
created,
|
||||
new ReportConnectionOutcomeRequest
|
||||
{
|
||||
Outcome = ConnectionOutcomeKind.Connected,
|
||||
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
|
||||
});
|
||||
using HttpResponseMessage conflict = await host.HttpClient.SendAsync(conflictRequest);
|
||||
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
|
||||
|
||||
using HttpRequestMessage unauthorizedRequest = OutcomeRequest(created, report);
|
||||
unauthorizedRequest.Headers.Remove("X-Rendezvous-Client-Punch-Capability");
|
||||
unauthorizedRequest.Headers.Add(
|
||||
"X-Rendezvous-Client-Punch-Capability",
|
||||
new string('X', ContractLimits.DerivedCredentialCharacters));
|
||||
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedRequest);
|
||||
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7u, HttpStatusCode.Gone, RendezvousErrorCode.StaleHost)]
|
||||
[InlineData(8u, HttpStatusCode.Conflict, RendezvousErrorCode.IncompatibleProtocol)]
|
||||
public async Task JoinCreationPreservesTypedTerminalErrorsOverHttp(
|
||||
uint protocolVersion,
|
||||
HttpStatusCode expectedStatus,
|
||||
RendezvousErrorCode expectedError)
|
||||
{
|
||||
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistration(),
|
||||
host.PublisherCredential));
|
||||
|
||||
using HttpResponseMessage response = await host.HttpClient.PostAsJsonAsync(
|
||||
"v1/join-attempts",
|
||||
new CreateJoinAttemptRequest
|
||||
{
|
||||
IdempotencyKey = $"typed-http-error-{protocolVersion}",
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ListingId = session.ListingId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
},
|
||||
ContractJson.Options);
|
||||
|
||||
Assert.Equal(expectedStatus, response.StatusCode);
|
||||
ApiError error = Assert.IsType<ApiError>(
|
||||
await response.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
|
||||
Assert.Equal(expectedError, error.Code);
|
||||
}
|
||||
|
||||
private static async Task<ReportConnectionOutcomeResponse> SendOutcomeAsync(
|
||||
HttpClient client,
|
||||
CreateJoinAttemptResponse attempt,
|
||||
ReportConnectionOutcomeRequest report)
|
||||
{
|
||||
using HttpRequestMessage request = OutcomeRequest(attempt, report);
|
||||
using HttpResponseMessage response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return Assert.IsType<ReportConnectionOutcomeResponse>(
|
||||
await response.Content.ReadFromJsonAsync<ReportConnectionOutcomeResponse>(ContractJson.Options));
|
||||
}
|
||||
|
||||
private static HttpRequestMessage OutcomeRequest(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
ReportConnectionOutcomeRequest report)
|
||||
{
|
||||
HttpRequestMessage request = new(
|
||||
HttpMethod.Post,
|
||||
$"v1/join-attempts/{attempt.AttemptId}/outcome")
|
||||
{
|
||||
Content = JsonContent.Create(report, options: ContractJson.Options),
|
||||
};
|
||||
request.Headers.Add(
|
||||
"X-Rendezvous-Client-Punch-Capability",
|
||||
attempt.ClientPunchCapability);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
@@ -132,18 +266,21 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
HttpClient httpClient,
|
||||
InMemoryEphemeralRendezvousStore store,
|
||||
EphemeralCapabilityIssuer capabilities,
|
||||
ConnectionOutcomeMetrics outcomeMetrics,
|
||||
string publisherCredential)
|
||||
{
|
||||
_application = application;
|
||||
HttpClient = httpClient;
|
||||
Store = store;
|
||||
Capabilities = capabilities;
|
||||
OutcomeMetrics = outcomeMetrics;
|
||||
PublisherCredential = publisherCredential;
|
||||
}
|
||||
|
||||
internal HttpClient HttpClient { get; }
|
||||
internal InMemoryEphemeralRendezvousStore Store { get; }
|
||||
internal EphemeralCapabilityIssuer Capabilities { get; }
|
||||
internal ConnectionOutcomeMetrics OutcomeMetrics { get; }
|
||||
internal string PublisherCredential { get; }
|
||||
|
||||
internal static async Task<JoinHttpTestHost> StartAsync()
|
||||
@@ -181,6 +318,9 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||
builder.Services.AddSingleton<JoinAttemptService>();
|
||||
ConnectionOutcomeMetrics outcomeMetrics = new();
|
||||
builder.Services.AddSingleton(outcomeMetrics);
|
||||
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
@@ -193,6 +333,7 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
new HttpClient { BaseAddress = new Uri(address) },
|
||||
store,
|
||||
capabilities,
|
||||
outcomeMetrics,
|
||||
credential);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
|
||||
@@ -64,7 +66,7 @@ public sealed class JoinAttemptServiceTests
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.NotFound,
|
||||
RendezvousErrorCode.StaleHost,
|
||||
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
|
||||
|
||||
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||
@@ -81,6 +83,41 @@ public sealed class JoinAttemptServiceTests
|
||||
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListingProtocolMismatchRemainsDistinctWhenTheRequestedProtocolIsAllowed()
|
||||
{
|
||||
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
|
||||
policy.ProtocolVersions.Add(8);
|
||||
using JoinAttemptFixture fixture = new(joinPolicy: policy);
|
||||
(RegisterSessionResponse active, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptRequest request = fixture.Request(active.ListingId);
|
||||
request.ProtocolVersion = 8;
|
||||
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.IncompatibleProtocol,
|
||||
fixture.Service.Create(fixture.ClientSubject, request).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IssuedAttemptCarriesTheHostsDedicatedFallbackCandidate()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
RegisterSessionRequest registrationRequest = fixture.Sessions.Request();
|
||||
registrationRequest.DedicatedFallback = new()
|
||||
{
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
Address = "203.0.113.91",
|
||||
Port = 9_061,
|
||||
};
|
||||
RegisterSessionResponse registration = fixture.Sessions.Register(registrationRequest);
|
||||
Assert.True(fixture.Sessions.BindPresence(registration).Succeeded);
|
||||
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||
|
||||
Assert.Equal("203.0.113.91", created.DedicatedFallback!.Address);
|
||||
Assert.Equal(9_061, created.DedicatedFallback.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
|
||||
{
|
||||
|
||||
@@ -12,11 +12,15 @@ internal sealed class JoinAttemptFixture : IDisposable
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
|
||||
public JoinAttemptFixture(
|
||||
EphemeralStoreOptions? options = null,
|
||||
GamePolicyOptions? joinPolicy = null)
|
||||
{
|
||||
Sessions = new(options);
|
||||
Cursors = new();
|
||||
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
|
||||
GamePolicyRegistry policies = GamePolicyRegistry.Create([
|
||||
joinPolicy ?? ProvisioningTestData.CreatePolicy(),
|
||||
]);
|
||||
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
|
||||
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user