241 lines
9.5 KiB
C#
241 lines
9.5 KiB
C#
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 WatchModeSupportsBoundedRuntimeAndDeliberateRecoveryExercisesOnly()
|
|
{
|
|
TestClientParseResult reset = TestClientOptionParser.Parse(
|
|
["watch", "--run-seconds", "30", "--exercise-reset", "--script", "--json"]);
|
|
Assert.True(reset.Succeeded, reset.Error);
|
|
TestClientOptions resetOptions = Assert.IsType<TestClientOptions>(reset.Options);
|
|
Assert.Equal(TestClientMode.Watch, resetOptions.Mode);
|
|
Assert.Equal(TimeSpan.FromSeconds(30), resetOptions.RunDuration);
|
|
Assert.True(resetOptions.ExerciseReset);
|
|
|
|
TestClientParseResult reconnect = TestClientOptionParser.Parse(
|
|
["watch", "--exercise-reconnect", "--script"]);
|
|
Assert.True(reconnect.Succeeded, reconnect.Error);
|
|
Assert.True(Assert.IsType<TestClientOptions>(reconnect.Options).ExerciseReconnect);
|
|
|
|
Assert.False(TestClientOptionParser.Parse(["browse", "--exercise-reconnect"]).Succeeded);
|
|
Assert.False(TestClientOptionParser.Parse(["browse", "--exercise-reset"]).Succeeded);
|
|
Assert.False(TestClientOptionParser.Parse(
|
|
["watch", "--exercise-reconnect", "--exercise-reset"]).Succeeded);
|
|
Assert.False(TestClientOptionParser.Parse(["join", "--run-seconds", "30"]).Succeeded);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
}
|