Add managed codec DSP and initial control server
This commit is contained in:
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user