148 lines
8.7 KiB
C#
148 lines
8.7 KiB
C#
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
|
||
|
|
{
|
||
|
|
[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;
|
||
|
|
}
|
||
|
|
}
|