Add managed codec DSP and initial control server

This commit is contained in:
2026-09-15 22:51:33 +02:00
parent 2df79cdd4c
commit 4067bab7c2
52 changed files with 2503 additions and 20 deletions
@@ -0,0 +1,101 @@
using Microsoft.Data.Sqlite;
using System.Diagnostics;
using VoiceCat.Server.Data;
namespace VoiceCat.Tests;
public sealed class AccountStoreTests
{
[Fact]
public void UnsupportedSchemaIsRejectedWithoutCreatingAccountTables()
{
string path = Path.Combine(Path.GetTempPath(), "voicecat-future-" + Guid.NewGuid().ToString("N") + ".db");
try
{
SQLitePCL.Batteries_V2.Init();
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE server_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL); INSERT INTO server_meta VALUES ('schema_version','99');";
command.ExecuteNonQuery();
Assert.Throws<InvalidDataException>(() => new AccountStore(path));
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE name='accounts'";
Assert.Equal(0L, command.ExecuteScalar());
command.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
Assert.Equal("99", command.ExecuteScalar());
}
finally { File.Delete(path); File.Delete(path + "-wal"); File.Delete(path + "-shm"); }
}
[NativeDatabaseFact]
public async Task ExistingCppDatabaseAndManagedAccountsWorkInBothImplementations()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-import-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "voicecat.db");
try
{
await RunOracleAsync("create", path);
using (var store = new AccountStore(path))
{
Account account = Assert.IsType<Account>(await store.AuthenticateAsync("legacy", "legacy password"));
Assert.True(account.IsAdmin);
var channel = Assert.Single(store.LoadChannels());
Assert.Equal("Preserved native topic", channel.Topic);
Assert.Equal(7U, channel.MaxUsers);
Assert.Equal(32000U, channel.Audio.BitrateBps);
await store.CreateAccountAsync("managed", "managed password", true);
}
await RunOracleAsync("verify", path);
}
finally { Directory.Delete(directory, true); }
}
private static async Task RunOracleAsync(string mode, string path)
{
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE")!) { UseShellExecute = false, CreateNoWindow = true };
start.ArgumentList.Add(mode);
start.ArgumentList.Add(path);
using var process = Process.Start(start)!;
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try { await process.WaitForExitAsync(timeout.Token); Assert.Equal(0, process.ExitCode); }
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class NativeDatabaseFactAttribute : FactAttribute
{
public NativeDatabaseFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE"))) Skip = "Set VOICECAT_DATABASE_ORACLE to the native database oracle.";
}
}
[Fact]
public async Task AccountsSurviveRestartAndFailedAuthDoesNotChangeLastLogin()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-db-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "voicecat.db");
try
{
Account account;
using (var store = new AccountStore(path)) account = await store.CreateAccountAsync("admin'", "secret", true);
using (var store = new AccountStore(path))
{
Assert.Null(await store.AuthenticateAsync("admin'", "wrong"));
Assert.Null(await store.AuthenticateAsync("missing", "secret"));
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT last_login FROM accounts WHERE id=$id";
command.Parameters.AddWithValue("$id", account.Id);
Assert.Equal(0L, command.ExecuteScalar());
Account authenticated = Assert.IsType<Account>(await store.AuthenticateAsync("admin'", "secret"));
Assert.Equal(account.Id, authenticated.Id);
Assert.True(authenticated.IsAdmin);
Assert.True(authenticated.LastLogin > 0);
}
}
finally { Directory.Delete(directory, true); }
}
}
+123
View File
@@ -0,0 +1,123 @@
using VoiceCat.Codec;
namespace VoiceCat.Tests;
public sealed class CodecTests
{
public static IEnumerable<object[]> Formats()
{
foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 })
foreach (int channels in new[] { 1, 2 })
foreach (int duration in new[] { 10, 20, 40, 60 })
yield return [rate, channels, duration];
}
[Theory]
[MemberData(nameof(Formats))]
public void RoundTripAndLossConcealment(int sampleRate, int channels, int duration)
{
var options = new OpusOptions { SampleRate = sampleRate, Channels = channels, FrameDurationMilliseconds = duration, Bitrate = 64000 };
using var encoder = new OpusEncoder(options);
using var decoder = new OpusDecoder(sampleRate, channels);
short[] input = new short[options.SamplesPerChannel * channels];
short[] output = new short[input.Length];
byte[] packet = new byte[4000];
for (int frame = 0; frame < 12; frame++)
{
FillTone(input, options.SamplesPerChannel, channels, sampleRate, frame);
int bytes = encoder.Encode(input, packet);
Assert.InRange(bytes, 1, packet.Length);
Assert.Equal(options.SamplesPerChannel, decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
}
double rms = Rms(output);
Assert.InRange(rms, 2000, 12000);
Assert.Equal(options.SamplesPerChannel, decoder.Decode([], output, options.SamplesPerChannel));
Assert.True(Rms(output) > 100);
}
[Fact]
public void RejectsInvalidStorageAndOptionsBeforeNativeCalls()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { Channels = 3 }));
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { FrameDurationMilliseconds = 30 }));
using var encoder = new OpusEncoder();
using var decoder = new OpusDecoder();
Assert.Throws<ArgumentException>(() => encoder.Encode(new short[959], new byte[4000]));
Assert.Throws<ArgumentException>(() => decoder.Decode([], new short[959], 960));
encoder.Dispose();
Assert.Throws<ObjectDisposedException>(() => encoder.Encode(new short[960], new byte[4000]));
}
[Fact]
public void DredIsExplicitlySupportedOrRejected()
{
using var probe = new OpusEncoder();
Assert.Contains("libopus", OpusEncoder.Version);
if (!probe.SupportsDeepRedundancy)
{
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { DeepRedundancy = true }));
Assert.Throws<NotSupportedException>(() => new OpusDeepRedundancy());
return;
}
VerifyDredRecovery(new() { DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
}
[Theory]
[MemberData(nameof(Formats))]
public void DredRecoversDroppedFrames(int sampleRate, int channels, int duration)
{
using var probe = new OpusEncoder();
Assert.True(probe.SupportsDeepRedundancy, "Build native bindings with dotnet/build-native.ps1 for DRED recovery tests.");
if (sampleRate < 16000)
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { SampleRate = sampleRate, DeepRedundancy = true }));
VerifyDredRecovery(new() { SampleRate = sampleRate, Channels = channels,
FrameDurationMilliseconds = duration, DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
}
private static void VerifyDredRecovery(OpusOptions options)
{
// The pinned encoder cannot emit DRED at 8/12 kHz; packets can still be decoded at those rates.
var encoderOptions = options with { SampleRate = Math.Max(16000, options.SampleRate) };
using var encoder = new OpusEncoder(encoderOptions);
using var decoder = new OpusDecoder(options.SampleRate, options.Channels);
using var recovery = new OpusDeepRedundancy();
short[] input = new short[encoderOptions.SamplesPerChannel * options.Channels];
short[] output = new short[options.SamplesPerChannel * options.Channels];
byte[] packet = new byte[4000];
bool missing = false;
int recovered = 0;
for (int frame = 0; frame < 40; frame++)
{
FillTone(input, encoderOptions.SamplesPerChannel, options.Channels, encoderOptions.SampleRate, frame);
int bytes = encoder.Encode(input, packet);
if (missing)
{
Assert.True(recovery.TryRecover(decoder, packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
Assert.True(Rms(output) > 10);
recovered++;
missing = false;
}
if (frame > 20 && frame % 5 == 0)
{
missing = true;
continue;
}
decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel);
}
Assert.Equal(3, recovered);
}
internal static void FillTone(Span<short> pcm, int samples, int channels, int rate, int frame)
{
for (int i = 0; i < samples; i++)
for (int channel = 0; channel < channels; channel++)
pcm[i * channels + channel] = (short)(8000 * Math.Sin(2 * Math.PI * (440 + 220 * channel) * (frame * samples + i) / rate));
}
internal static double Rms(ReadOnlySpan<short> pcm)
{
double sum = 0;
foreach (short value in pcm) sum += (double)value * value;
return Math.Sqrt(sum / pcm.Length);
}
}
+70
View File
@@ -0,0 +1,70 @@
using VoiceCat.Dsp;
using System.Text.Json;
namespace VoiceCat.Tests;
public sealed class DspTests
{
[Fact]
public void SuppressesNoiseAndPreservesUnsupportedSampleRates()
{
using var processor = new RnnoiseProcessor();
short[] pcm = new short[960];
uint random = 0x12345678;
double inputEnergy = 0, outputEnergy = 0;
for (int frame = 0; frame < 200; frame++)
{
FillNoise(pcm, ref random);
if (frame >= 60) foreach (short value in pcm) inputEnergy += (double)value * value;
processor.Process(pcm);
if (frame >= 60) foreach (short value in pcm) outputEnergy += (double)value * value;
}
Assert.True(Math.Sqrt(outputEnergy / inputEnergy) < 0.2);
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-noise.json")));
short[] expected = fixture.RootElement.GetProperty("samples").EnumerateArray().Select(value => value.GetInt16()).ToArray();
Assert.Equal(pcm.Length, expected.Length);
for (int i = 0; i < pcm.Length; i++) Assert.InRange(Math.Abs(pcm[i] - expected[i]), 0, 1);
FillNoise(pcm, ref random);
short[] original = (short[])pcm.Clone();
processor.Process(pcm, 16000);
Assert.Equal(original, pcm);
Assert.Throws<ArgumentException>(() => processor.Process(new short[481]));
processor.Dispose();
Assert.Throws<ObjectDisposedException>(() => processor.Process(pcm));
}
[Fact]
public void VadStartsClosedAndUsesMonotonicHangTime()
{
var clock = new ManualTimeProvider();
var processor = new EnergyVadProcessor(0.02f, TimeSpan.FromMilliseconds(300), clock);
Assert.False(processor.Process(new short[480]));
Assert.True(processor.Process(new short[] { 32767 }));
clock.Advance(299);
Assert.True(processor.Process([]));
clock.Advance(1);
Assert.False(processor.Process(new short[480]));
processor.Threshold = 0.5f;
Assert.False(processor.Process(new short[] { 1000 }));
Assert.Throws<ArgumentOutOfRangeException>(() => processor.Threshold = float.NaN);
}
internal static void FillNoise(Span<short> pcm, ref uint random)
{
for (int i = 0; i < pcm.Length; i++)
{
random ^= random << 13;
random ^= random >> 17;
random ^= random << 5;
pcm[i] = (short)((int)(random % 6001) - 3000);
}
}
private sealed class ManualTimeProvider : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => 1000;
public override long GetTimestamp() => timestamp;
public void Advance(int milliseconds) => timestamp += milliseconds;
}
}
@@ -0,0 +1 @@
{"samples":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}
@@ -0,0 +1 @@
{"hashes":[{"passwordBase64":"dm9pY2VjYXQgdGVzdA","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE"},{"passwordBase64":"Y2Fmw6k","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$lEpmh4tmC0xaD5DhMboQo/3Hw7JqT3VThdqq0n1pImc"},{"passwordBase64":"YQBi","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$XZZGeWLqPMYfYmkPOuDe9dOMu0w7kVG9WS8/Dl6sVI0"}]}
@@ -0,0 +1,34 @@
using VoiceCat.Codec;
using VoiceCat.Dsp;
namespace VoiceCat.Tests;
public sealed class MediaAllocationTests
{
[Fact]
public void SteadyStateCodecAndDspDoNotAllocateManagedMemory()
{
using var encoder = new OpusEncoder();
using var decoder = new OpusDecoder();
using var denoiser = new RnnoiseProcessor();
var vad = new EnergyVadProcessor();
short[] pcm = new short[960];
short[] decoded = new short[960];
byte[] packet = new byte[4000];
CodecTests.FillTone(pcm, 960, 1, 48000, 0);
for (int i = 0; i < 100; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
private static void Cycle(OpusEncoder encoder, OpusDecoder decoder, RnnoiseProcessor denoiser,
EnergyVadProcessor vad, short[] pcm, short[] decoded, byte[] packet)
{
int bytes = encoder.Encode(pcm, packet);
decoder.Decode(packet.AsSpan(0, bytes), decoded, 960);
denoiser.Process(decoded);
vad.Process(decoded);
}
}
@@ -0,0 +1,42 @@
using System.Text;
using System.Text.Json;
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public sealed class PasswordTests
{
[Fact]
public void VerifiesLibsodiumHashesWithoutPasswordNormalization()
{
var hasher = new PasswordHasher();
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-passwords.json")));
foreach (var item in fixture.RootElement.GetProperty("hashes").EnumerateArray())
{
string encodedPassword = item.GetProperty("passwordBase64").GetString()!;
string password = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPassword.PadRight((encodedPassword.Length + 3) / 4 * 4, '=')));
string hash = item.GetProperty("hash").GetString()!;
Assert.True(hasher.Verify(password, hash));
Assert.False(hasher.Verify(password + "!", hash));
}
}
[Fact]
public void FreshHashesUseRandomSaltAndNativePhcFormat()
{
var hasher = new PasswordHasher();
string first = hasher.Hash("hello");
string second = hasher.Hash("hello");
Assert.NotEqual(first, second);
Assert.StartsWith("$argon2id$v=19$m=65536,t=2,p=1$", first);
Assert.True(hasher.Verify("hello", first));
Assert.False(hasher.Verify("wrong", first));
}
[Theory]
[InlineData("$argon2id$v=19$m=999999999,t=2,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=19$m=65536,t=99999,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=16$m=65536,t=2,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=19$m=65536,t=2,p=1$!!!$!!!")]
public void MalformedOrExcessiveHashesFailClosed(string hash) => Assert.False(new PasswordHasher().Verify("hello", hash));
}
+194
View File
@@ -0,0 +1,194 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using VoiceCat.Crypto;
using VoiceCat.Server;
using VoiceCat.Server.Transport;
using Voicecat.V1;
namespace VoiceCat.Tests;
public sealed class ServerTests
{
[Fact]
public async Task ControlFramesCanSpanMultipleTlsRecordsAndPingEchoesCorrelation()
{
await using var fixture = new ServerFixture();
await using var client = await fixture.ConnectAsync();
client.Send(new() { ClientHello = new() { ProtoVersion = 2, ClientName = new string('x', 48000) } });
await client.ReadUntilAsync(e => e.ServerHello is not null);
client.Send(new() { RequestId = 45, Ping = new() { Nonce = 123456 } });
Envelope pong = await client.ReadUntilAsync(e => e.Pong is not null);
Assert.Equal(45UL, pong.RequestId);
Assert.Equal(123456UL, pong.Pong.Nonce);
}
[Fact]
public async Task GuestsChatJoinChannelsAndDisconnectOverTls()
{
await using var fixture = new ServerFixture();
await using var alice = await fixture.ConnectAsync();
User a = await alice.LoginAsync("Alice");
await using var bob = await fixture.ConnectAsync();
User b = await bob.LoginAsync("Bob");
Assert.NotEqual(a.Id, b.Id);
Envelope joined = await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Joined);
Assert.Equal(b.Id, joined.UserEvent.User.Id);
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, SenderId = b.Id, Body = "hello", ClientMsgId = "one" } });
TextMessage text = (await bob.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
Assert.Equal("hello", text.Body);
Assert.Equal(a.Id, text.SenderId);
Assert.True(text.SentAtUnixMs > 0);
Assert.True((await alice.ReadUntilAsync(e => e.TextMessageAck is not null)).TextMessageAck.Ok);
bob.Send(new() { RequestId = 10, JoinChannel = new() { ChannelId = 2 } });
Envelope moved = await bob.ReadUntilAsync(e => e.JoinChannelResult is not null);
Assert.Equal(10UL, moved.RequestId);
Assert.True(moved.JoinChannelResult.Ok);
Assert.Equal(128000U, moved.JoinChannelResult.Audio.BitrateBps);
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 2, Body = "unauthorized", ClientMsgId = "two" } });
Assert.False((await alice.ReadUntilAsync(e => e.TextMessageAck is not null)).TextMessageAck.Ok);
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, Body = "isolated" } });
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextPrivate, TargetId = b.Id, Body = "private" } });
Assert.Equal("private", (await bob.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage.Body);
bob.Send(new() { Disconnect = new() });
Envelope left = await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
Assert.Equal(b.Id, left.UserEvent.LeftId);
alice.Send(new() { RequestId = 11, Subscribe = new() });
ServerStateSnapshot snapshot = (await alice.ReadUntilAsync(e => e.ServerState is not null)).ServerState;
Assert.Equal(a.Id, Assert.Single(snapshot.Users).Id);
}
[Fact]
public async Task PasswordAuthenticationCanRetryAndGuestAccessCanBeDisabled()
{
await using var fixture = new ServerFixture(false);
using (var accounts = new VoiceCat.Server.Data.AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
await accounts.CreateAccountAsync("Admin", "secret", true);
await using var client = await fixture.ConnectAsync();
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
ServerHello hello = (await client.ReadUntilAsync(e => e.ServerHello is not null)).ServerHello;
Assert.Equal(["password"], hello.AuthMethods);
client.Send(new() { AuthRequest = new() { Guest = new() { Nickname = "Guest" } } });
Assert.False((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "wrong" } } });
Assert.False((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
client.Send(new() { RequestId = 3, AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
Envelope authenticated = await client.ReadUntilAsync(e => e.AuthResult is not null);
Assert.True(authenticated.AuthResult.Ok);
Assert.Equal(3UL, authenticated.RequestId);
Assert.True(authenticated.AuthResult.Permissions.IsAdmin);
Assert.False(authenticated.AuthResult.Self.IsGuest);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task InvalidVersionAndUnauthenticatedTextAreDisconnected(bool invalidVersion)
{
await using var fixture = new ServerFixture();
await using var client = await fixture.ConnectAsync();
client.Send(invalidVersion ? new() { ClientHello = new() { ProtoVersion = 1 } } : new() { TextMessage = new() { Body = "pre-auth" } });
Assert.NotEqual(0U, (await client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Code);
}
[CppCliFact]
public async Task ExistingCppCliAuthenticatesAndChatsThroughManagedServer()
{
await using var fixture = new ServerFixture();
await using var receiver = await fixture.ConnectAsync();
User self = await receiver.LoginAsync("Managed");
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true
};
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Cpp", "--text", "native interoperability", "--wait-ms", "10000" })
start.ArgumentList.Add(argument);
using var process = Process.Start(start)!;
Task<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> error = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(receiver.Timeout.Token);
string log = await output + await error;
Assert.True(process.ExitCode == 0, log);
TextMessage text = (await receiver.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
Assert.Equal("native interoperability", text.Body);
Assert.NotEqual(self.Id, text.SenderId);
Assert.Contains("native interoperability", log);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class CppCliFactAttribute : FactAttribute
{
public CppCliFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
}
}
private sealed class ServerFixture : IAsyncDisposable
{
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
public VoiceServer Server { get; }
private readonly string fingerprint;
public ServerFixture(bool guests = true)
{
System.IO.Directory.CreateDirectory(Directory);
Server = new(Directory, new(IPAddress.Loopback, 0), guests);
using var credentials = ServerCredentials.LoadOrCreate(Directory, "VoiceCat Server");
fingerprint = credentials.CertificateFingerprint;
}
public async Task<Client> ConnectAsync()
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(Server.EndPoint);
return new(new(socket, TlsSession.CreateClient(value => value == fingerprint), CancellationToken.None));
}
public async ValueTask DisposeAsync()
{
await Server.DisposeAsync();
System.IO.Directory.Delete(Directory, true);
}
}
private sealed class Client : IAsyncDisposable
{
public CancellationTokenSource Timeout { get; } = new(TimeSpan.FromSeconds(30));
private readonly TlsControlConnection connection;
private readonly IAsyncEnumerator<Envelope> messages;
public Client(TlsControlConnection connection)
{
this.connection = connection;
messages = connection.ReadAsync(Timeout.Token).GetAsyncEnumerator();
}
public void Send(Envelope envelope) => Assert.True(connection.TrySend(envelope));
public async Task<Envelope> ReadUntilAsync(Func<Envelope, bool> predicate)
{
while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current;
throw new IOException("Connection ended before the expected message.");
}
public async Task<User> LoginAsync(string nickname)
{
Send(new() { RequestId = 1, ClientHello = new() { ProtoVersion = 2, ClientName = "Managed test" } });
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
Assert.True(auth.Ok, auth.Error);
ServerStateSnapshot state = (await ReadUntilAsync(e => e.ServerState is not null)).ServerState;
Assert.Equal(2, state.Channels.Count);
Assert.Contains(state.Users, user => user.Id == auth.Self.Id);
return auth.Self;
}
public async ValueTask DisposeAsync()
{
await messages.DisposeAsync();
await connection.DisposeAsync();
Timeout.Dispose();
}
}
}
@@ -9,6 +9,9 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />
<ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<ProjectReference Include="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<ProjectReference Include="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<ProjectReference Include="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<ProjectReference Include="../../src/VoiceCat.Server/VoiceCat.Server.csproj" />
<Using Include="Xunit" />
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
@@ -44,6 +44,14 @@
"resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
@@ -63,6 +71,41 @@
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"SourceGear.sqlite3": {
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
"dependencies": {
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
"SourceGear.sqlite3": "3.50.4.2"
}
},
"SQLitePCLRaw.config.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
"dependencies": {
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
@@ -103,6 +146,9 @@
"xunit.extensibility.core": "[2.9.3]"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
@@ -110,11 +156,23 @@
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
},
"voicecat.server": {
"type": "Project",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "[10.0.5, )",
"SQLitePCLRaw.bundle_e_sqlite3": "[3.0.2, )",
"SourceGear.sqlite3": "[3.50.4.2, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
}
}
}