Files
voice-cat/tests/VoiceCat.Tests/ProductionServerTests.cs
Talon 08e6c5930a
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
Retire legacy implementations and flatten managed layout
2026-09-21 00:11:32 +02:00

163 lines
9.7 KiB
C#

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<string> 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<string> 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<string, string> { ["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<ArgumentException>(() => ServerCommand.Parse(["--port", "65536"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--bind", "example.com"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--port"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--unencrypted", "true"], _ => null));
Assert.Throws<ArgumentOutOfRangeException>(() => 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<bool> 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<int> 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<string> 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;
}
}