feat(tooling): add standalone rendezvous test client (#25)
quality-gate / quality (push) Failing after 1m3s
quality-gate / quality (push) Failing after 1m3s
This commit is contained in:
@@ -161,10 +161,17 @@ public sealed class RendezvousJoinClientTests
|
||||
|
||||
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
|
||||
CreateRequest("cancelled-before-send"),
|
||||
new NetworkEndpoint
|
||||
{
|
||||
AddressFamily = AddressFamilyKind.Ipv4,
|
||||
Address = "203.0.113.90",
|
||||
Port = 7777,
|
||||
},
|
||||
cancellationToken: cancellation.Token);
|
||||
|
||||
Assert.Empty(handler.Requests);
|
||||
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
|
||||
Assert.True(result.Outcome.HasDedicatedFallback);
|
||||
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using FinalFactory.Rendezvous.TestClient;
|
||||
using LiteNetLib;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.TestClient;
|
||||
|
||||
public sealed class DirectEchoProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HostReleasesPendingNonceWhenPeerDisconnectsBeforeAcknowledgement()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
|
||||
EventBasedNetListener hostEvents = new();
|
||||
EventBasedNetListener clientEvents = new();
|
||||
hostEvents.ConnectionRequestEvent += request => request.Accept();
|
||||
NetPeer? clientPeer = null;
|
||||
clientEvents.PeerConnectedEvent += peer => clientPeer = peer;
|
||||
NetManager hostManager = new(hostEvents);
|
||||
NetManager clientManager = new(clientEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(hostManager.Start(0));
|
||||
Assert.True(clientManager.Start(0));
|
||||
clientManager.Connect(
|
||||
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||
"echo-test");
|
||||
await PumpUntilAsync(
|
||||
() => clientPeer is not null,
|
||||
hostManager,
|
||||
clientManager,
|
||||
clientManager,
|
||||
timeout.Token);
|
||||
using DirectEchoProtocol host = new(hostEvents, host: true);
|
||||
clientPeer!.Send(
|
||||
Encoding.ASCII.GetBytes("rv1-ping:00112233445566778899aabbccddeeff"),
|
||||
DeliveryMethod.ReliableOrdered);
|
||||
await PumpUntilAsync(
|
||||
() => host.PendingHostExchangeCount == 1,
|
||||
hostManager,
|
||||
clientManager,
|
||||
clientManager,
|
||||
timeout.Token);
|
||||
|
||||
clientPeer.Disconnect();
|
||||
await PumpUntilAsync(
|
||||
() => host.PendingHostExchangeCount == 0,
|
||||
hostManager,
|
||||
clientManager,
|
||||
clientManager,
|
||||
timeout.Token);
|
||||
|
||||
Assert.Equal(0, host.PendingHostExchangeCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
clientManager.Stop();
|
||||
hostManager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostVerifiesOverlappingPeersAgainstTheirOwnNonces()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
|
||||
EventBasedNetListener hostEvents = new();
|
||||
EventBasedNetListener firstEvents = new();
|
||||
EventBasedNetListener secondEvents = new();
|
||||
hostEvents.ConnectionRequestEvent += request => request.Accept();
|
||||
NetPeer? firstPeer = null;
|
||||
NetPeer? secondPeer = null;
|
||||
firstEvents.PeerConnectedEvent += peer => firstPeer = peer;
|
||||
secondEvents.PeerConnectedEvent += peer => secondPeer = peer;
|
||||
NetManager hostManager = new(hostEvents);
|
||||
NetManager firstManager = new(firstEvents);
|
||||
NetManager secondManager = new(secondEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(hostManager.Start(0));
|
||||
Assert.True(firstManager.Start(0));
|
||||
Assert.True(secondManager.Start(0));
|
||||
IPEndPoint hostEndpoint = new(IPAddress.Loopback, hostManager.LocalPort);
|
||||
firstManager.Connect(hostEndpoint, "echo-test");
|
||||
secondManager.Connect(hostEndpoint, "echo-test");
|
||||
await PumpUntilAsync(
|
||||
() => firstPeer is not null && secondPeer is not null,
|
||||
hostManager,
|
||||
firstManager,
|
||||
secondManager,
|
||||
timeout.Token);
|
||||
|
||||
using DirectEchoProtocol host = new(hostEvents, host: true);
|
||||
using DirectEchoProtocol first = new(firstEvents, host: false);
|
||||
using DirectEchoProtocol second = new(secondEvents, host: false);
|
||||
int hostCompletions = 0;
|
||||
host.ExchangeCompleted += _ => hostCompletions++;
|
||||
first.BeginJoin(firstPeer!);
|
||||
second.BeginJoin(secondPeer!);
|
||||
|
||||
await PumpUntilAsync(
|
||||
() => first.Completion.IsCompleted
|
||||
&& second.Completion.IsCompleted
|
||||
&& hostCompletions == 2,
|
||||
hostManager,
|
||||
firstManager,
|
||||
secondManager,
|
||||
timeout.Token);
|
||||
|
||||
Assert.Equal(2, hostCompletions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
firstManager.Stop();
|
||||
secondManager.Stop();
|
||||
hostManager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PumpUntilAsync(
|
||||
Func<bool> predicate,
|
||||
NetManager host,
|
||||
NetManager first,
|
||||
NetManager second,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (!predicate())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
host.PollEvents();
|
||||
first.PollEvents();
|
||||
second.PollEvents();
|
||||
await Task.Delay(2, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.TestClient;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.TestClient;
|
||||
|
||||
public sealed class TestClientCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostOptionsParseBoundedPublicConfigurationWithoutAcceptingASecretArgument()
|
||||
{
|
||||
TestClientParseResult parsed = TestClientOptionParser.Parse(
|
||||
[
|
||||
"host",
|
||||
"--service", "https://rendezvous.example/base",
|
||||
"--mediator", "127.0.0.1:9050",
|
||||
"--game", "space-game",
|
||||
"--environment", "production",
|
||||
"--region", "eu-central",
|
||||
"--protocol", "7",
|
||||
"--metadata", "mode=online-coop",
|
||||
"--fallback", "203.0.113.50:7777",
|
||||
"--publisher-credential-env", "TEST_PUBLISHER_CREDENTIAL",
|
||||
"--script",
|
||||
"--json",
|
||||
"--exit-after-echo",
|
||||
]);
|
||||
|
||||
Assert.True(parsed.Succeeded, parsed.Error);
|
||||
TestClientOptions options = Assert.IsType<TestClientOptions>(parsed.Options);
|
||||
Assert.Equal(TestClientMode.Host, options.Mode);
|
||||
Assert.Equal(new Uri("https://rendezvous.example/base/"), options.ServiceUri);
|
||||
Assert.Equal(7u, options.ProtocolVersion);
|
||||
Assert.Equal("online-coop", options.Metadata["mode"]);
|
||||
Assert.Equal("203.0.113.50", options.DedicatedFallback?.Address);
|
||||
Assert.Equal(7777, options.DedicatedFallback?.Port);
|
||||
Assert.Equal("TEST_PUBLISHER_CREDENTIAL", options.PublisherCredentialEnvironmentVariable);
|
||||
Assert.True(options.Script);
|
||||
Assert.True(options.Json);
|
||||
Assert.True(options.ExitAfterEcho);
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), options.RunDuration);
|
||||
|
||||
TestClientParseResult secret = TestClientOptionParser.Parse(
|
||||
["host", "--publisher-credential", "secret-canary"]);
|
||||
Assert.False(secret.Succeeded);
|
||||
Assert.Contains("Unknown option", secret.Error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExitCodesRemainStable()
|
||||
{
|
||||
Assert.Equal(0, (int)TestClientExitCode.Success);
|
||||
Assert.Equal(2, (int)TestClientExitCode.Usage);
|
||||
Assert.Equal(3, (int)TestClientExitCode.Configuration);
|
||||
Assert.Equal(10, (int)TestClientExitCode.ServiceFailure);
|
||||
Assert.Equal(11, (int)TestClientExitCode.NoCompatibleSession);
|
||||
Assert.Equal(12, (int)TestClientExitCode.TraversalFailed);
|
||||
Assert.Equal(13, (int)TestClientExitCode.DirectTrafficFailed);
|
||||
Assert.Equal(130, (int)TestClientExitCode.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
HostServiceFailureBudget authority = new();
|
||||
Assert.True(authority.ShouldStop(
|
||||
RendezvousErrorCode.NotFound,
|
||||
now.AddMinutes(1),
|
||||
now));
|
||||
|
||||
HostServiceFailureBudget transient = new();
|
||||
Assert.False(transient.ShouldStop(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
now.AddMinutes(1),
|
||||
now));
|
||||
Assert.False(transient.ShouldStop(
|
||||
RendezvousErrorCode.RateLimited,
|
||||
now.AddMinutes(1),
|
||||
now));
|
||||
Assert.True(transient.ShouldStop(
|
||||
RendezvousErrorCode.InternalError,
|
||||
now.AddMinutes(1),
|
||||
now));
|
||||
|
||||
transient.Reset();
|
||||
Assert.True(transient.ShouldStop(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
now,
|
||||
now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://user:password@rendezvous.example/")]
|
||||
[InlineData("file:///tmp/rendezvous")]
|
||||
[InlineData("https://rendezvous.example/?token=secret")]
|
||||
public void ServiceUrlRejectsCredentialAndNonHttpShapes(string url)
|
||||
{
|
||||
TestClientParseResult parsed = TestClientOptionParser.Parse(["browse", "--service", url]);
|
||||
|
||||
Assert.False(parsed.Succeeded);
|
||||
Assert.Contains("service URL", parsed.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplicationRoutesParsedOptionsThroughTheInjectableUiFlow()
|
||||
{
|
||||
FakeCommandRunner runner = new(TestClientExitCode.NoCompatibleSession);
|
||||
TestClientApplication application = new(runner);
|
||||
StringWriter output = new();
|
||||
StringWriter error = new();
|
||||
|
||||
int exitCode = await application.RunAsync(
|
||||
["browse", "--script", "--json"],
|
||||
new StringReader(string.Empty),
|
||||
output,
|
||||
error,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal((int)TestClientExitCode.NoCompatibleSession, exitCode);
|
||||
Assert.NotNull(runner.Options);
|
||||
Assert.Equal(TestClientMode.Browse, runner.Options.Mode);
|
||||
Assert.True(runner.Options.Script);
|
||||
using JsonDocument item = JsonDocument.Parse(output.ToString());
|
||||
Assert.Equal(1, item.RootElement.GetProperty("version").GetInt32());
|
||||
Assert.Equal("fake.completed", item.RootElement.GetProperty("event").GetString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidArgumentsFailBeforeTheRunnerAndDoNotEchoTheValue()
|
||||
{
|
||||
FakeCommandRunner runner = new(TestClientExitCode.Success);
|
||||
TestClientApplication application = new(runner);
|
||||
StringWriter output = new();
|
||||
StringWriter error = new();
|
||||
|
||||
int exitCode = await application.RunAsync(
|
||||
["host", "--publisher-credential", "secret-canary"],
|
||||
new StringReader(string.Empty),
|
||||
output,
|
||||
error,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal((int)TestClientExitCode.Usage, exitCode);
|
||||
Assert.Null(runner.Options);
|
||||
Assert.DoesNotContain("secret-canary", error.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("host", "--json", "--unknown", "value", "cli.usage", 2)]
|
||||
[InlineData("host", "--json", "--help", "", "cli.help", 0)]
|
||||
public async Task JsonModeKeepsHelpAndUsageFailuresMachineReadable(
|
||||
string mode,
|
||||
string json,
|
||||
string option,
|
||||
string value,
|
||||
string expectedEvent,
|
||||
int expectedExit)
|
||||
{
|
||||
FakeCommandRunner runner = new(TestClientExitCode.Success);
|
||||
TestClientApplication application = new(runner);
|
||||
StringWriter output = new();
|
||||
StringWriter error = new();
|
||||
string[] args = string.IsNullOrEmpty(value)
|
||||
? [mode, json, option]
|
||||
: [mode, json, option, value];
|
||||
|
||||
int exitCode = await application.RunAsync(
|
||||
args,
|
||||
new StringReader(string.Empty),
|
||||
output,
|
||||
error,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(expectedExit, exitCode);
|
||||
string jsonLine = expectedExit == 0 ? output.ToString() : error.ToString();
|
||||
using JsonDocument item = JsonDocument.Parse(jsonLine);
|
||||
Assert.Equal(expectedEvent, item.RootElement.GetProperty("event").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HumanOutputNeutralizesControlCharactersFromPublicListingText()
|
||||
{
|
||||
StringWriter output = new();
|
||||
TestClientOutput sink = new(output, new StringWriter(), json: false);
|
||||
|
||||
sink.Write(
|
||||
"browse.session",
|
||||
"available",
|
||||
displayName: "host\nforged-line\u001b[31m outcome=connected\u2028next\u2029line\u202eright");
|
||||
|
||||
string line = output.ToString();
|
||||
Assert.Equal(1, line.Count(static character => character == '\n'));
|
||||
Assert.DoesNotContain('\u001b', line);
|
||||
Assert.DoesNotContain('\u2028', line);
|
||||
Assert.DoesNotContain('\u2029', line);
|
||||
Assert.DoesNotContain('\u202e', line);
|
||||
Assert.Contains("name=\"host?forged-line?[31m outcome=connected?next?line?right\"", line, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class FakeCommandRunner(TestClientExitCode exitCode) : ITestClientCommandRunner
|
||||
{
|
||||
internal TestClientOptions? Options { get; private set; }
|
||||
|
||||
public Task<TestClientExitCode> RunAsync(
|
||||
TestClientOptions options,
|
||||
TestClientOutput output,
|
||||
TextReader input,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Options = options;
|
||||
output.Write("fake.completed", "complete", phase: "test");
|
||||
return Task.FromResult(exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.TestClient;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.TestClient;
|
||||
|
||||
public sealed class TestClientProcessIntegrationTests
|
||||
{
|
||||
#if DEBUG
|
||||
private const string BuildConfiguration = "Debug";
|
||||
#else
|
||||
private const string BuildConfiguration = "Release";
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public async Task ServerHostAndJoinProcessesExchangeAuthenticatedDirectTrafficWithoutLeakingSecrets()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30));
|
||||
string root = RepositoryRoot();
|
||||
string serverAssembly = Path.Combine(
|
||||
root,
|
||||
$"src/FinalFactory.Rendezvous.Server/bin/{BuildConfiguration}/net10.0/FinalFactory.Rendezvous.Server.dll");
|
||||
string clientAssembly = Path.Combine(
|
||||
root,
|
||||
$"src/FinalFactory.Rendezvous.TestClient/bin/{BuildConfiguration}/net8.0/FinalFactory.Rendezvous.TestClient.dll");
|
||||
Assert.True(File.Exists(serverAssembly), $"Missing server build output: {serverAssembly}");
|
||||
Assert.True(File.Exists(clientAssembly), $"Missing TestClient build output: {clientAssembly}");
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
byte[] signingKey = RandomNumberGenerator.GetBytes(32);
|
||||
string signingKeyText = Convert.ToBase64String(signingKey);
|
||||
string publisherCredential = IssuePublisherCredential(signingKey, now);
|
||||
CryptographicOperations.ZeroMemory(signingKey);
|
||||
Dictionary<string, string> serverEnvironment = ServerEnvironment(
|
||||
signingKeyText,
|
||||
now);
|
||||
|
||||
await using ProcessCapture server = Start(
|
||||
serverAssembly,
|
||||
[],
|
||||
serverEnvironment);
|
||||
string httpLine = await server.WaitForLineAsync("Now listening on: http://127.0.0.1:", timeout.Token);
|
||||
string serviceUrl = ParseServiceUrl(httpLine);
|
||||
string udpLine = await server.WaitForLineAsync("UDP mediator listening on 127.0.0.1:", timeout.Token);
|
||||
int udpPort = ParseTrailingPort(udpLine);
|
||||
await WaitForReadyAsync(serviceUrl, server, timeout.Token);
|
||||
|
||||
string[] emptyBrowseArguments =
|
||||
[
|
||||
"browse",
|
||||
"--service", serviceUrl,
|
||||
"--game", "space-game",
|
||||
"--environment", "integration",
|
||||
"--region", "local",
|
||||
"--protocol", "1",
|
||||
"--script",
|
||||
"--json",
|
||||
"--timeout-seconds", "15",
|
||||
];
|
||||
await using ProcessCapture emptyBrowse = Start(
|
||||
clientAssembly,
|
||||
emptyBrowseArguments,
|
||||
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
Assert.Equal(
|
||||
(int)TestClientExitCode.NoCompatibleSession,
|
||||
await emptyBrowse.WaitForExitAsync(timeout.Token));
|
||||
Assert.True(emptyBrowse.HasEvent("browse.completed", "complete"), emptyBrowse.DiagnosticText());
|
||||
Assert.Contains(
|
||||
emptyBrowse.JsonEvents(),
|
||||
item => item.GetProperty("event").GetString() == "browse.completed"
|
||||
&& item.GetProperty("count").GetInt32() == 0);
|
||||
|
||||
string[] missingJoinArguments =
|
||||
[
|
||||
"join",
|
||||
"--service", serviceUrl,
|
||||
"--mediator", $"127.0.0.1:{udpPort}",
|
||||
"--game", "space-game",
|
||||
"--environment", "integration",
|
||||
"--region", "local",
|
||||
"--protocol", "1",
|
||||
"--listing", Guid.NewGuid().ToString("D"),
|
||||
"--script",
|
||||
"--json",
|
||||
"--timeout-seconds", "15",
|
||||
];
|
||||
await using ProcessCapture missingJoin = Start(
|
||||
clientAssembly,
|
||||
missingJoinArguments,
|
||||
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
Assert.Equal((int)TestClientExitCode.ServiceFailure, await missingJoin.WaitForExitAsync(timeout.Token));
|
||||
Assert.True(missingJoin.HasEvent("join.selection", "failed"), missingJoin.DiagnosticText());
|
||||
|
||||
string[] boundedHostArguments =
|
||||
[
|
||||
"host",
|
||||
"--service", serviceUrl,
|
||||
"--mediator", $"127.0.0.1:{udpPort}",
|
||||
"--game", "space-game",
|
||||
"--environment", "integration",
|
||||
"--region", "local",
|
||||
"--protocol", "1",
|
||||
"--script",
|
||||
"--json",
|
||||
"--exit-after-echo",
|
||||
"--run-seconds", "1",
|
||||
"--timeout-seconds", "15",
|
||||
];
|
||||
await using ProcessCapture boundedHost = Start(
|
||||
clientAssembly,
|
||||
boundedHostArguments,
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["RENDEZVOUS_PUBLISHER_CREDENTIAL"] = publisherCredential,
|
||||
});
|
||||
Assert.Equal((int)TestClientExitCode.DirectTrafficFailed, await boundedHost.WaitForExitAsync(timeout.Token));
|
||||
Assert.True(boundedHost.HasEvent("host.direct-traffic", "timed-out"), boundedHost.DiagnosticText());
|
||||
Assert.True(boundedHost.HasEvent("host.deregistered", "complete"), boundedHost.DiagnosticText());
|
||||
|
||||
string[] hostArguments =
|
||||
[
|
||||
"host",
|
||||
"--service", serviceUrl,
|
||||
"--mediator", $"127.0.0.1:{udpPort}",
|
||||
"--game", "space-game",
|
||||
"--environment", "integration",
|
||||
"--region", "local",
|
||||
"--protocol", "1",
|
||||
"--script",
|
||||
"--json",
|
||||
"--exit-after-echo",
|
||||
"--timeout-seconds", "15",
|
||||
];
|
||||
await using ProcessCapture host = Start(
|
||||
clientAssembly,
|
||||
hostArguments,
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["RENDEZVOUS_PUBLISHER_CREDENTIAL"] = publisherCredential,
|
||||
});
|
||||
JsonElement hostRegistered = await host.WaitForEventAsync(
|
||||
"host.ready",
|
||||
timeout.Token);
|
||||
string listingId = Assert.IsType<string>(hostRegistered.GetProperty("listingId").GetString());
|
||||
|
||||
string[] browseArguments =
|
||||
[
|
||||
"browse",
|
||||
"--service", serviceUrl,
|
||||
"--game", "space-game",
|
||||
"--environment", "integration",
|
||||
"--region", "local",
|
||||
"--protocol", "1",
|
||||
"--script",
|
||||
"--json",
|
||||
"--timeout-seconds", "15",
|
||||
];
|
||||
await using ProcessCapture browse = Start(
|
||||
clientAssembly,
|
||||
browseArguments,
|
||||
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
Assert.Equal(0, await browse.WaitForExitAsync(timeout.Token));
|
||||
Assert.True(browse.HasEvent("browse.completed", "complete"), browse.DiagnosticText());
|
||||
Assert.True(
|
||||
browse.JsonEvents().Any(item =>
|
||||
item.GetProperty("event").GetString() == "browse.session"
|
||||
&& item.GetProperty("status").GetString() == "available"
|
||||
&& item.GetProperty("listingId").GetString() == listingId),
|
||||
browse.DiagnosticText());
|
||||
|
||||
string[] joinArguments =
|
||||
[
|
||||
"join",
|
||||
"--service", serviceUrl,
|
||||
"--mediator", $"127.0.0.1:{udpPort}",
|
||||
"--game", "space-game",
|
||||
"--environment", "integration",
|
||||
"--region", "local",
|
||||
"--protocol", "1",
|
||||
"--listing", listingId,
|
||||
"--script",
|
||||
"--json",
|
||||
"--timeout-seconds", "15",
|
||||
];
|
||||
await using ProcessCapture join = Start(
|
||||
clientAssembly,
|
||||
joinArguments,
|
||||
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
|
||||
Assert.Equal(0, await join.WaitForExitAsync(timeout.Token));
|
||||
Assert.Equal(0, await host.WaitForExitAsync(timeout.Token));
|
||||
Assert.True(join.HasEvent("join.connected", "connected"), join.DiagnosticText());
|
||||
Assert.True(join.HasEvent("join.punch", "started"), join.DiagnosticText());
|
||||
Assert.True(join.HasEvent("join.direct-connect", "started"), join.DiagnosticText());
|
||||
Assert.True(join.HasEvent("join.direct-traffic", "verified"), join.DiagnosticText());
|
||||
Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText());
|
||||
Assert.True(host.HasEvent("host.direct-traffic", "verified"), host.DiagnosticText());
|
||||
Assert.True(host.HasEvent("host.punch", "started"), host.DiagnosticText());
|
||||
Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText());
|
||||
Assert.True(host.HasEvent("host.deregistered", "complete"), host.DiagnosticText());
|
||||
Assert.Equal(host.AllLines().Count(), host.JsonEvents().Count);
|
||||
Assert.Equal(emptyBrowse.AllLines().Count(), emptyBrowse.JsonEvents().Count);
|
||||
Assert.Equal(missingJoin.AllLines().Count(), missingJoin.JsonEvents().Count);
|
||||
Assert.Equal(browse.AllLines().Count(), browse.JsonEvents().Count);
|
||||
Assert.Equal(join.AllLines().Count(), join.JsonEvents().Count);
|
||||
Assert.All(
|
||||
host.JsonEvents()
|
||||
.Concat(emptyBrowse.JsonEvents())
|
||||
.Concat(missingJoin.JsonEvents())
|
||||
.Concat(browse.JsonEvents())
|
||||
.Concat(join.JsonEvents()),
|
||||
AssertAllowlistedEventShape);
|
||||
|
||||
using HttpClient service = new() { BaseAddress = new Uri(serviceUrl) };
|
||||
using HttpResponseMessage removed = await service.GetAsync(
|
||||
$"v1/sessions/{listingId}?contractVersion=1&gameId=space-game&environmentId=integration&protocolVersion=1",
|
||||
timeout.Token);
|
||||
Assert.Equal(HttpStatusCode.NotFound, removed.StatusCode);
|
||||
|
||||
string captured = string.Join(
|
||||
'\n',
|
||||
server.AllLines()
|
||||
.Concat(boundedHost.AllLines())
|
||||
.Concat(emptyBrowse.AllLines())
|
||||
.Concat(missingJoin.AllLines())
|
||||
.Concat(host.AllLines())
|
||||
.Concat(browse.AllLines())
|
||||
.Concat(join.AllLines()));
|
||||
Assert.False(
|
||||
captured.Contains(publisherCredential, StringComparison.Ordinal),
|
||||
"Captured process output contained the publisher credential.");
|
||||
Assert.False(
|
||||
captured.Contains(signingKeyText, StringComparison.Ordinal),
|
||||
"Captured process output contained signing-key material.");
|
||||
}
|
||||
|
||||
private static string IssuePublisherCredential(byte[] signingKey, DateTimeOffset now)
|
||||
{
|
||||
const string secretReference = "env:RENDEZVOUS_INTEGRATION_SIGNING_KEY";
|
||||
ProvisioningOptions options = ProvisioningOptions(now, secretReference);
|
||||
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[secretReference] = signingKey,
|
||||
});
|
||||
using ProvisioningRuntime provisioning = ProvisioningRuntime.Create(options, secrets, now);
|
||||
return provisioning.Credentials.Issue(
|
||||
new DedicatedPublisherPrincipal(
|
||||
"test-client-process-host",
|
||||
now.AddMinutes(5),
|
||||
new GameId("space-game"),
|
||||
new EnvironmentId("integration"),
|
||||
[new RegionId("local")]),
|
||||
now);
|
||||
}
|
||||
|
||||
private static ProvisioningOptions ProvisioningOptions(
|
||||
DateTimeOffset now,
|
||||
string secretReference) => new()
|
||||
{
|
||||
Issuer = "rendezvous-process-test",
|
||||
Audience = "rendezvous-process-test-client",
|
||||
ClockSkewSeconds = 5,
|
||||
SigningKeys =
|
||||
[
|
||||
new SigningKeyOptions
|
||||
{
|
||||
KeyId = "process-test-key",
|
||||
SecretReference = secretReference,
|
||||
CredentialKinds = [PrincipalCredentialKind.DedicatedPublisher],
|
||||
GameId = "space-game",
|
||||
EnvironmentId = "integration",
|
||||
NotBefore = now.AddMinutes(-1),
|
||||
SignUntil = now.AddMinutes(10),
|
||||
VerifyUntil = now.AddMinutes(20),
|
||||
},
|
||||
],
|
||||
Games =
|
||||
[
|
||||
new GamePolicyOptions
|
||||
{
|
||||
GameId = "space-game",
|
||||
EnvironmentId = "integration",
|
||||
Enabled = true,
|
||||
ProtocolVersions = [1],
|
||||
Regions = ["local"],
|
||||
VisibilityModes = [ListingVisibility.Public],
|
||||
PublisherTrustModes = [PublisherTrustMode.ManagedDedicated],
|
||||
MetadataMaxBytes = 256,
|
||||
MetadataMaxKeys = 2,
|
||||
MaxListingsPerPrincipal = 4,
|
||||
MaxAnonymousListingsPerAddress = 1,
|
||||
MaxActiveJoinAttempts = 16,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> ServerEnvironment(
|
||||
string signingKey,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
Dictionary<string, string> values = new(StringComparer.Ordinal)
|
||||
{
|
||||
["ASPNETCORE_ENVIRONMENT"] = "Production",
|
||||
["ASPNETCORE_URLS"] = "http://127.0.0.1:0",
|
||||
["Rendezvous__Udp__ListenAddress"] = "127.0.0.1",
|
||||
["Rendezvous__Udp__Port"] = "0",
|
||||
["Rendezvous__Udp__PollIntervalMilliseconds"] = "1",
|
||||
["Rendezvous__Provisioning__Issuer"] = "rendezvous-process-test",
|
||||
["Rendezvous__Provisioning__Audience"] = "rendezvous-process-test-client",
|
||||
["Rendezvous__Provisioning__ClockSkewSeconds"] = "5",
|
||||
["Rendezvous__Provisioning__SigningKeys__0__KeyId"] = "process-test-key",
|
||||
["Rendezvous__Provisioning__SigningKeys__0__SecretReference"] =
|
||||
"env:RENDEZVOUS_INTEGRATION_SIGNING_KEY",
|
||||
["Rendezvous__Provisioning__SigningKeys__0__CredentialKinds__0"] = "DedicatedPublisher",
|
||||
["Rendezvous__Provisioning__SigningKeys__0__GameId"] = "space-game",
|
||||
["Rendezvous__Provisioning__SigningKeys__0__EnvironmentId"] = "integration",
|
||||
["Rendezvous__Provisioning__SigningKeys__0__NotBefore"] = now.AddMinutes(-1).ToString("O"),
|
||||
["Rendezvous__Provisioning__SigningKeys__0__SignUntil"] = now.AddMinutes(10).ToString("O"),
|
||||
["Rendezvous__Provisioning__SigningKeys__0__VerifyUntil"] = now.AddMinutes(20).ToString("O"),
|
||||
["Rendezvous__Provisioning__Games__0__GameId"] = "space-game",
|
||||
["Rendezvous__Provisioning__Games__0__EnvironmentId"] = "integration",
|
||||
["Rendezvous__Provisioning__Games__0__Enabled"] = "true",
|
||||
["Rendezvous__Provisioning__Games__0__ProtocolVersions__0"] = "1",
|
||||
["Rendezvous__Provisioning__Games__0__Regions__0"] = "local",
|
||||
["Rendezvous__Provisioning__Games__0__VisibilityModes__0"] = "Public",
|
||||
["Rendezvous__Provisioning__Games__0__PublisherTrustModes__0"] = "ManagedDedicated",
|
||||
["Rendezvous__Provisioning__Games__0__MetadataMaxBytes"] = "256",
|
||||
["Rendezvous__Provisioning__Games__0__MetadataMaxKeys"] = "2",
|
||||
["Rendezvous__Provisioning__Games__0__MaxListingsPerPrincipal"] = "4",
|
||||
["Rendezvous__Provisioning__Games__0__MaxAnonymousListingsPerAddress"] = "1",
|
||||
["Rendezvous__Provisioning__Games__0__MaxActiveJoinAttempts"] = "16",
|
||||
["RENDEZVOUS_INTEGRATION_SIGNING_KEY"] = signingKey,
|
||||
};
|
||||
return values;
|
||||
}
|
||||
|
||||
private static ProcessCapture Start(
|
||||
string assembly,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment)
|
||||
{
|
||||
ProcessStartInfo start = new()
|
||||
{
|
||||
FileName = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add(assembly);
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
foreach (KeyValuePair<string, string> item in environment)
|
||||
{
|
||||
start.Environment[item.Key] = item.Value;
|
||||
}
|
||||
return new ProcessCapture(start);
|
||||
}
|
||||
|
||||
private static async Task WaitForReadyAsync(
|
||||
string serviceUrl,
|
||||
ProcessCapture server,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using HttpClient client = new() { BaseAddress = new Uri(serviceUrl) };
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (server.HasExited)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException($"Server exited before readiness. {server.DiagnosticText()}");
|
||||
}
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await client.GetAsync(
|
||||
"health/ready",
|
||||
cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
}
|
||||
await Task.Delay(25, cancellationToken);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
private static string ParseServiceUrl(string line)
|
||||
{
|
||||
const string marker = "Now listening on: ";
|
||||
string value = line[(line.IndexOf(marker, StringComparison.Ordinal) + marker.Length)..].Trim();
|
||||
return new Uri(value).AbsoluteUri;
|
||||
}
|
||||
|
||||
private static int ParseTrailingPort(string line)
|
||||
{
|
||||
string value = line[(line.LastIndexOf(':') + 1)..].Trim();
|
||||
return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
directory = directory.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not locate the Rendezvous repository root.");
|
||||
}
|
||||
|
||||
private static void AssertAllowlistedEventShape(JsonElement item)
|
||||
{
|
||||
string[] forbidden = ["credential", "capability", "ticket", "token", "secret", "metadata"];
|
||||
foreach (JsonProperty property in item.EnumerateObject())
|
||||
{
|
||||
Assert.DoesNotContain(forbidden, forbiddenName =>
|
||||
property.Name.Contains(forbiddenName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProcessCapture : IAsyncDisposable
|
||||
{
|
||||
private readonly Process _process;
|
||||
private readonly ConcurrentQueue<string> _standardOutput = new();
|
||||
private readonly ConcurrentQueue<string> _standardError = new();
|
||||
|
||||
internal ProcessCapture(ProcessStartInfo start)
|
||||
{
|
||||
_process = new Process { StartInfo = start };
|
||||
_process.OutputDataReceived += (_, eventArgs) => Add(eventArgs.Data, _standardOutput);
|
||||
_process.ErrorDataReceived += (_, eventArgs) => Add(eventArgs.Data, _standardError);
|
||||
Assert.True(_process.Start(), $"Failed to start {start.FileName}.");
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
}
|
||||
|
||||
internal bool HasExited => _process.HasExited;
|
||||
|
||||
internal async Task<JsonElement> WaitForEventAsync(
|
||||
string eventName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
foreach (JsonElement item in JsonEvents())
|
||||
{
|
||||
if (item.TryGetProperty("event", out JsonElement eventProperty)
|
||||
&& eventProperty.GetString() == eventName)
|
||||
{
|
||||
return item;
|
||||
}
|
||||
}
|
||||
if (_process.HasExited)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"Process exited before event {eventName}. {DiagnosticText()}");
|
||||
}
|
||||
await Task.Delay(10, cancellationToken);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
throw new UnreachableException();
|
||||
}
|
||||
|
||||
internal async Task<string> WaitForLineAsync(
|
||||
string marker,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
string? line = AllLines().FirstOrDefault(item =>
|
||||
item.Contains(marker, StringComparison.Ordinal));
|
||||
if (line is not null)
|
||||
{
|
||||
return line;
|
||||
}
|
||||
if (_process.HasExited)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"Process exited before output marker {marker}. {DiagnosticText()}");
|
||||
}
|
||||
await Task.Delay(10, cancellationToken);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
throw new UnreachableException();
|
||||
}
|
||||
|
||||
internal bool HasEvent(string eventName, string status) => JsonEvents().Any(item =>
|
||||
item.TryGetProperty("event", out JsonElement eventProperty)
|
||||
&& eventProperty.GetString() == eventName
|
||||
&& item.TryGetProperty("status", out JsonElement statusProperty)
|
||||
&& statusProperty.GetString() == status);
|
||||
|
||||
internal List<JsonElement> JsonEvents()
|
||||
{
|
||||
List<JsonElement> items = [];
|
||||
foreach (string line in _standardOutput.Concat(_standardError))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
items.Add(document.RootElement.Clone());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
internal IEnumerable<string> AllLines() => _standardOutput.Concat(_standardError);
|
||||
|
||||
internal async Task<int> WaitForExitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _process.WaitForExitAsync(cancellationToken);
|
||||
return _process.ExitCode;
|
||||
}
|
||||
|
||||
internal string DiagnosticText()
|
||||
{
|
||||
string events = string.Join(
|
||||
',',
|
||||
JsonEvents().Select(static item =>
|
||||
$"{item.GetProperty("event").GetString()}:{item.GetProperty("status").GetString()}"));
|
||||
return $"stdoutLines={_standardOutput.Count}; stderrLines={_standardError.Count}; events=[{events}]";
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
bool cleanupTimedOut = false;
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
}
|
||||
using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5));
|
||||
await _process.WaitForExitAsync(cleanup.Token);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
cleanupTimedOut = !_process.HasExited;
|
||||
if (cleanupTimedOut)
|
||||
{
|
||||
try
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_process.Dispose();
|
||||
}
|
||||
if (cleanupTimedOut)
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException("Child process did not exit within the cleanup deadline.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(string? line, ConcurrentQueue<string> destination)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(line))
|
||||
{
|
||||
destination.Enqueue(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user