using VoiceCat.Transport; using System.Net; using System.Net.Sockets; using System.Text.Json; using VoiceCat.Crypto; using VoiceCat.Server; using VoiceCat.Server.Data; using VoiceCat.Server.Transport; using static VoiceCat.Tests.ServerTests; namespace VoiceCat.Tests; public class ProductionServerTests { [Fact] public async Task HealthCheckPerformsTlsHandshakeAndCanPinCertificate() { await using var fixture = new ServerFixture(); using var credentials = ServerCredentials.LoadOrCreate(fixture.Directory, "VoiceCat Server"); var output = new StringWriter(); var error = new StringWriter(); string endpoint = "127.0.0.1:" + fixture.Server.EndPoint.Port; Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint], output, error)); Assert.Contains("\"status\":\"healthy\"", output.ToString()); output.GetStringBuilder().Clear(); Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", credentials.CertificateFingerprint], output, error)); Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", new string('0', 64)], output, error)); Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", "127.0.0.1:1"], output, error)); } [PublishedServerFact] public async Task PublishedExecutableProvisionsAdminAndReportsFingerprintsWithoutPasswordOutput() { string directory = Path.Combine(Path.GetTempPath(), "voicecat-published-" + Guid.NewGuid().ToString("N")); async Task Run(params string[] arguments) { var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_PUBLISHED_SERVER")!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; start.Environment["VOICECAT_ADMIN_PASSWORD"] = "published test password"; foreach (string argument in arguments.Concat(new[] { "--data-dir", directory })) start.ArgumentList.Add(argument); using var process = System.Diagnostics.Process.Start(start)!; Task stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); try { await process.WaitForExitAsync(timeout.Token); string log = await stdout + await stderr; Assert.True(process.ExitCode == 0, log); Assert.DoesNotContain("published test password", log); return log; } finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } } } try { await Run("account", "add", "Operator", "--admin"); Assert.Contains("Operator", await Run("account", "list")); using (var store = new AccountStore(Path.Combine(directory, "voicecat.db"))) Assert.True((await store.AuthenticateAsync("Operator", "published test password"))!.IsAdmin); string fingerprint = (await Run("--print-fingerprint")).Trim(); Assert.Equal(64, fingerprint.Length); Assert.Equal(fingerprint, (await Run("--print-fingerprint")).Trim()); await Run("account", "reset", "Operator"); await Run("account", "delete", "Operator"); Assert.DoesNotContain("Operator", await Run("account", "list")); } finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); } } private sealed class PublishedServerFactAttribute : FactAttribute { public PublishedServerFactAttribute() { if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_PUBLISHED_SERVER"))) Skip = "Set VOICECAT_PUBLISHED_SERVER to the self-contained executable."; } } [Fact] public void ConfigurationHonorsEnvironmentAndCommandPrecedenceWithoutCreatingFiles() { var env = new Dictionary { ["VOICECAT_BIND_PORT"] = "9000", ["VOICECAT_ALLOW_GUESTS"] = "false", ["VOICECAT_SERVER_NAME"] = "Environment" }; var config = ServerCommand.Parse(["--port", "0", "--name", "Command", "--bind", "::1", "--idle-seconds", "0"], key => env.GetValueOrDefault(key)); Assert.Equal(0, config.Port); Assert.Equal("Command", config.Options.Name); Assert.False(config.Options.AllowGuests); Assert.Equal("::1", config.BindAddress); Assert.Equal(TimeSpan.Zero, config.Options.IdleTimeout); Assert.Equal(8384, ServerCommand.Parse([], _ => null).Port); Assert.Equal("0.0.0.0", ServerCommand.Parse([], _ => null).BindAddress); Assert.Throws(() => ServerCommand.Parse(["--port", "65536"], _ => null)); Assert.Throws(() => ServerCommand.Parse(["--bind", "example.com"], _ => null)); Assert.Throws(() => ServerCommand.Parse(["--port"], _ => null)); Assert.Throws(() => ServerCommand.Parse(["--unencrypted", "true"], _ => null)); Assert.Throws(() => ServerCommand.Parse(["--auth-burst", "0"], _ => null)); Assert.EndsWith("admin-data", ServerCommand.Parse(["account", "list", "--data-dir", "admin-data"], _ => null).Directory); } [Fact] public void AuthenticationLimitsAreSharedAcrossConnectionsAndAddressesWithBackoff() { var clock = new TestClock(); var limiter = new AuthenticationLimiter(new() { AuthenticationBurst = 5 }, clock); for (int i = 0; i < 3; i++) { Assert.True(limiter.TryAcquire("a", "user")); limiter.Record("a", "user", false); } Assert.False(limiter.TryAcquire("a", "other")); Assert.False(limiter.TryAcquire("b", "user")); clock.Advance(1); Assert.True(limiter.TryAcquire("b", "user")); limiter.Record("b", "user", false); clock.Advance(1); Assert.False(limiter.TryAcquire("c", "user")); clock.Advance(1); Assert.True(limiter.TryAcquire("c", "user")); limiter.Record("c", "user", true); Assert.False(limiter.TryAcquire("d", "user")); // Success does not restore spent tokens. clock.Advance(10); Assert.True(limiter.TryAcquire("d", "user")); } [Fact] public async Task PasswordThrottleSurvivesReconnectAndRefillsBeforeSuccessfulLogin() { var clock = new TestClock(); await using var fixture = new ServerFixture(options: new() { AuthenticationBurst = 1 }, timeProvider: clock); using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) await store.CreateAccountAsync("Member", "secret"); async Task Login(string password) { await using var client = await fixture.ConnectAsync(); client.Send(new() { ClientHello = new() { ProtoVersion = 2 } }); await client.ReadUntilAsync(e => e.ServerHello is not null); client.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = password } } }); return (await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok; } Assert.False(await Login("wrong")); Assert.False(await Login("secret")); clock.Advance(10); Assert.True(await Login("secret")); } [Fact] public async Task HostPublishesReadinessPreventsDuplicateInstancesAndShutsDownActiveTls() { string directory = Path.Combine(Path.GetTempPath(), "voicecat-host-" + Guid.NewGuid().ToString("N")); using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(20)); var output = new ReadyWriter(); var error = new StringWriter(); Task running = ServerCommand.RunAsync(["--data-dir", directory, "--bind", "127.0.0.1", "--port", "0"], output, error, stop.Token); try { using JsonDocument ready = JsonDocument.Parse(await output.Ready.Task.WaitAsync(stop.Token)); int port = ready.RootElement.GetProperty("port").GetInt32(); Assert.Equal(port, ready.RootElement.GetProperty("udp_port").GetInt32()); Assert.Equal(64, ready.RootElement.GetProperty("certificate_fingerprint").GetString()!.Length); Assert.Equal(1, await ServerCommand.RunAsync(["--data-dir", directory, "--port", "0"], new StringWriter(), new StringWriter())); var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port), stop.Token); string fingerprint = ready.RootElement.GetProperty("certificate_fingerprint").GetString()!; await using var client = new Client(new TlsControlConnection(socket, TlsSession.CreateClient(value => value == fingerprint), CancellationToken.None)); await client.LoginAsync("Host test"); stop.Cancel(); Assert.Equal(0, await running.WaitAsync(TimeSpan.FromSeconds(10))); Assert.Equal("", error.ToString()); } finally { stop.Cancel(); await running; Directory.Delete(directory, true); } } private sealed class ReadyWriter : StringWriter { internal TaskCompletionSource Ready = new(TaskCreationOptions.RunContinuationsAsynchronously); public override Task WriteLineAsync(string? value) { if (value?.Contains("\"ready\"", StringComparison.Ordinal) == true) Ready.TrySetResult(value); return base.WriteLineAsync(value); } } private sealed class TestClock : TimeProvider { private long timestamp; public override long TimestampFrequency => 1000; public override long GetTimestamp() => timestamp; internal void Advance(int seconds) => timestamp += seconds * 1000; } }