Retire legacy implementations and flatten managed layout
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

This commit is contained in:
2026-09-21 00:11:32 +02:00
parent dd811a0bb8
commit 08e6c5930a
422 changed files with 252 additions and 38242 deletions
+57
View File
@@ -0,0 +1,57 @@
using Microsoft.Data.Sqlite;
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"); }
}
[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); }
}
}
+136
View File
@@ -0,0 +1,136 @@
using VoiceCat.Server.Data;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
using static VoiceCat.Tests.ChannelManagementTests;
using static VoiceCat.Tests.MediaRelayTests;
namespace VoiceCat.Tests;
public class AdministrationTests
{
[Fact]
public async Task AccountAdministrationIsPermissionGatedAndPersistsPasswordChanges()
{
await using var fixture = new ServerFixture();
await using var guest = await fixture.ConnectAsync();
User user = await guest.LoginAsync("Guest");
await using var admin = await AdminAsync(fixture);
Assert.False(await ResultAsync(guest, new() { ListAccounts = new() }));
Assert.False(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "secret" } }));
Assert.False(await ResultAsync(guest, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { IsAdmin = true } } }));
Assert.True(await ResultAsync(admin, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { CanAdminAccounts = true, CanCreateTempChannel = true } } }));
Assert.True(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "first" } }));
Assert.False(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "first" } }));
Assert.False(await ResultAsync(guest, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { IsAdmin = true } } }));
Assert.False(await ResultAsync(guest, new() { CreateChannel = new() { Channel = new() { Name = "Permanent" } } }));
Assert.True(await ResultAsync(guest, new() { CreateChannel = new() { Channel = new() { Name = "Temporary", Type = ChannelType.ChannelTemporary,
Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20 } } } }));
Assert.True(await ResultAsync(guest, new() { ResetPassword = new() { Username = "new", NewPassword = "second" } }));
Assert.False(await ResultAsync(guest, new() { ResetPassword = new() { Username = "missing", NewPassword = "second" } }));
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
{
Assert.Null(await store.AuthenticateAsync("new", "first"));
Assert.NotNull(await store.AuthenticateAsync("new", "second"));
}
guest.Send(new() { RequestId = 50, ListAccounts = new() });
Envelope list = await guest.ReadUntilAsync(e => e.ListAccountsResult is not null);
Assert.Equal(50UL, list.RequestId);
Assert.Equal(2, list.ListAccountsResult.Accounts.Count);
var entry = list.ListAccountsResult.Accounts.Single(a => a.Username == "new");
Assert.False(entry.IsAdmin);
Assert.True(entry.CreatedAtUnixMs > 1_000_000_000_000);
Assert.True(entry.LastLoginUnixMs > 1_000_000_000_000);
Assert.True(await ResultAsync(guest, new() { DeleteAccount = new() { Username = "new" } }));
Assert.False(await ResultAsync(guest, new() { DeleteAccount = new() { Username = "new" } }));
using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
Assert.Null(await reopened.AuthenticateAsync("new", "second"));
}
[Fact]
public async Task AccountBanPersistsByUsernameAndBlocksNewAuthentication()
{
await using var fixture = new ServerFixture();
await using var admin = await AdminAsync(fixture);
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
await store.CreateAccountAsync("Member", "password");
await using var member = await fixture.ConnectAsync();
member.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
await member.ReadUntilAsync(e => e.ServerHello is not null);
member.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = "password" } } });
AuthResult auth = (await member.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
Assert.True(auth.Ok);
Assert.True(await ResultAsync(admin, new() { Ban = new() { UserId = auth.Self.Id, Reason = "account banned" } }));
Assert.NotNull((await member.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect);
Assert.True(store.IsBanned("username", "Member"));
Assert.False(store.IsBanned("ip", "127.0.0.1"));
await using var retry = await fixture.ConnectAsync();
retry.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
await retry.ReadUntilAsync(e => e.ServerHello is not null);
retry.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = "password" } } });
Assert.False((await retry.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
Assert.False(await ResultAsync(admin, new() { Kick = new() { UserId = uint.MaxValue } }));
}
[Fact]
public async Task ServerMuteDeafenAndMoveImmediatelyChangeEncryptedMediaRouting()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
await using var admin = await AdminAsync(fixture);
uint a = alice.Client.Authentication!.Self.Id, b = bob.Client.Authentication!.Self.Id;
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
Assert.False(await ResultAsync(bob.Client, new() { ServerMute = new() { UserId = a, Muted = true } }));
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = a, Muted = true } }));
await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); await bob.AssertNoVoiceAsync();
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = a } }));
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = b, Deafened = true } }));
await alice.SendAsync(alice.Seal(stream.Ssrc, [2])); await bob.AssertNoVoiceAsync();
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = b } }));
await alice.SendAsync(alice.Seal(stream.Ssrc, [3])); Assert.Equal(new byte[] { 3 }, (await bob.ReceiveVoiceAsync()).Payload);
Assert.True(await ResultAsync(admin, new() { MoveUser = new() { UserId = a, ChannelId = 2 } }));
await alice.SendAsync(alice.Seal(stream.Ssrc, [4])); await bob.AssertNoVoiceAsync();
Assert.True(await ResultAsync(admin, new() { MoveUser = new() { UserId = a, ChannelId = 1 } }));
await alice.SendAsync(alice.Seal(stream.Ssrc, [5])); await bob.AssertNoVoiceAsync();
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
await alice.SendAsync(alice.Seal(replacement.Ssrc, [6])); Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task KickAndGuestBanDisconnectWithOneDepartureAndRetireMedia(bool ban)
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var observer = await VoicePeer.ConnectAsync(fixture, "Observer");
await using var admin = await AdminAsync(fixture);
uint id = alice.Client.Authentication!.Self.Id;
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
Envelope request = ban ? new() { Ban = new() { UserId = id, Reason = "removed", ExpiresUnixMs = (ulong)DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds() } }
: new() { Kick = new() { UserId = id, Reason = "removed" } };
Assert.True(await ResultAsync(admin, request));
Assert.Equal("removed", (await alice.Client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason);
var left = (await observer.Client.ReadUntilAsync(e => e.UserEvent?.LeftId == id)).UserEvent;
Assert.Equal("removed", left.Reason);
observer.Client.Send(new() { Ping = new() { Nonce = 99 } });
while (true)
{
Envelope message = await observer.Client.ReadUntilAsync(_ => true);
Assert.False(message.UserEvent?.LeftId == id);
if (message.Pong?.Nonce == 99) break;
}
await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); await observer.AssertNoVoiceAsync();
await using var reconnect = await fixture.ConnectAsync();
if (ban)
{
reconnect.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
Assert.NotNull((await reconnect.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect);
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
Assert.True(store.IsBanned("ip", "127.0.0.1"));
store.Ban("username", "expired", "", 1);
Assert.False(store.IsBanned("username", "expired"));
}
else await reconnect.LoginAsync("Alice");
}
}
+203
View File
@@ -0,0 +1,203 @@
using VoiceCat.Audio;
using VoiceCat.Codec;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class AudioEngineTests
{
[Theory]
[InlineData(-1000)]
[InlineData(1000)]
public void AdaptivePcmBufferAbsorbsIndependentClockDrift(int partsPerMillion)
{
var buffer = new AdaptivePcmBuffer(1, 40); short[] input = new short[962], output = new short[960];
input.AsSpan().Fill(1234); Assert.True(buffer.TryWrite(input.AsSpan(0, 960)));
Assert.True(buffer.TryWrite(input.AsSpan(0, 960)));
double produced = 0;
for (int cycle = 0; cycle < 10_000; cycle++)
{
produced += 960 * (1 + partsPerMillion / 1_000_000.0);
int frames = (int)produced; produced -= frames;
Assert.True(buffer.TryWrite(input.AsSpan(0, frames)));
Assert.Equal(output.Length, buffer.Read(output));
}
Assert.InRange(buffer.CountFrames, 480, 3840);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) { buffer.TryWrite(input.AsSpan(0, 960)); buffer.Read(output); }
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes()
{
var sent = new List<(uint Timestamp, VoiceFrameFlags Flags)>();
using var engine = new AudioEngine((_, timestamp, _, flags) => { sent.Add((timestamp, flags)); return true; }, false)
{ InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
engine.AddLocalStream(Stream()); short[] tone = Tone();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
engine.ProcessCycle();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
Assert.Equal(2, sent.Count); Assert.True((sent[0].Flags & VoiceFrameFlags.Marker) != 0); Assert.Equal(VoiceFrameFlags.None, sent[1].Flags & VoiceFrameFlags.Marker);
for (int i = 0; i < 10; i++) engine.ProcessCycle();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
}
[Theory]
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
{
using var stream = new ReceiveStream(2, Stream(frameMilliseconds));
Assert.Equal(frameMilliseconds * 48, stream.TargetDepthSamples);
}
[Fact]
public void JitterTargetAdaptsInSampleTimeAndRemainsCapped()
{
var clock = new ManualAudioClock(); StreamInfo info = Stream(); info.Audio.Fec = false;
using var stream = new ReceiveStream(2, info, clock); using var encoder = new OpusEncoder(new() { Bitrate = 32000 });
byte[] packet = new byte[1275]; int length = encoder.Encode(Tone(), packet);
for (uint i = 0; i < 40; i++)
{
clock.Advance(i % 2 == 0 ? 5 : 35);
stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
}
int[] output = new int[1920]; stream.Mix(output, false, null);
Assert.InRange(stream.TargetDepthSamples, 960, 5760);
}
[Fact]
public void DredUsesTimestampOffsetForConsecutiveMissingShortFrames()
{
using var probe = new OpusEncoder();
if (!probe.SupportsDeepRedundancy) return;
StreamInfo info = Stream(10, dred: true); using var stream = new ReceiveStream(2, info);
using var encoder = new OpusEncoder(new() { FrameDurationMilliseconds = 10, DeepRedundancy = true, ExpectedPacketLossPercent = 30, Bitrate = 64000 });
byte[] packet = new byte[1275]; short[] tone = new short[480]; int[] output = new int[1920];
for (uint i = 0; i < 50; i++)
{
CodecTests.FillTone(tone, 480, 1, 48000, (int)i); int length = encoder.Encode(tone, packet);
if (i is not (25 or 26)) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 480), packet.AsSpan(0, length));
if (i % 2 == 1) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
}
Assert.True(stream.DredFrames >= 2);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void LostFramesUseDredThenFecBeforeBoundedPlc(bool useDred)
{
using var receive = new ReceiveStream(2, Stream(dred: useDred));
using var encoder = new OpusEncoder(new() { DeepRedundancy = useDred, ForwardErrorCorrection = true, ExpectedPacketLossPercent = 30, Complexity = 10, Bitrate = 64000 });
byte[] packet = new byte[1275]; short[] tone = Tone(); int[] output = new int[1920];
for (uint i = 0; i < 40; i++)
{
CodecTests.FillTone(tone, 960, 1, 48000, (int)i);
int size = encoder.Encode(tone, packet);
if (i != 25 && i != 30 && i != 35) receive.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, size));
output.AsSpan().Clear(); receive.Mix(output, false, null);
}
if (useDred) Assert.True(receive.DredFrames > 0); else Assert.True(receive.FecFrames > 0);
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); receive.Mix(output, false, null); }
Assert.True(receive.ConcealedFrames > 0); Assert.All(output, sample => Assert.Equal(0, sample));
}
[Fact]
public void PcmRingDropsWholeFramesWhenFullAndPreservesOrderAcrossWraps()
{
var ring = new PcmRing(8); short[] output = new short[8];
for (int i = 0; i < 100; i++)
{
Assert.True(ring.TryWrite([1, 2, 3, 4, 5, 6])); Assert.False(ring.TryWrite([7, 8, 9]));
Assert.Equal(4, ring.Read(output.AsSpan(0, 4))); Assert.Equal(new short[] { 1, 2, 3, 4 }, output[..4]);
Assert.True(ring.TryWrite([7, 8])); Assert.Equal(4, ring.Read(output)); Assert.Equal(new short[] { 5, 6, 7, 8 }, output[..4]); Assert.Equal(0, ring.Count);
}
}
internal static StreamInfo Stream(int frame = 20, bool stereo = false, bool dred = false) => new()
{
StreamId = 1, Ssrc = 42, Kind = StreamKind.StreamMic,
Audio = new() { SampleRate = 48000, BitrateBps = 32000, FrameMs = (uint)frame, Complexity = 5,
Mode = stereo ? ChannelMode.ModeStereo : ChannelMode.ModeMono, Fec = true, ExpectedPacketLoss = 20, Dred = dred }
};
private static short[] Tone(int channels = 1)
{
var pcm = new short[960 * channels];
for (int i = 0; i < 960; i++) for (int c = 0; c < channels; c++) pcm[i * channels + c] = (short)(Math.Sin(i * 2 * Math.PI * (c == 0 ? 440 : 660) / 48000) * 8000);
return pcm;
}
[Theory]
[InlineData(5, false)] [InlineData(10, false)] [InlineData(20, false)] [InlineData(40, false)] [InlineData(60, false)] [InlineData(20, true)]
public void ReframedEncodedPcmIsDecodedAndMixedForMonoAndStereo(int frame, bool stereo)
{
StreamInfo stream = Stream(frame, stereo);
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.AlwaysOn; send.AddLocalStream(stream, stereo ? 2 : 1);
long energy = 0; int sinkChannels = 0;
receive.MixedPcm += pcm => { foreach (short sample in pcm) energy += Math.Abs((int)sample); };
receive.StreamPcm += (_, _, _, channels) => sinkChannels = channels;
short[] tone = Tone(stereo ? 2 : 1);
for (int i = 0; i < 30; i++) { Assert.True(send.FeedPcm(1, tone, stereo ? 2 : 1)); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.True(energy > 100000); Assert.Equal(stereo ? 2 : 1, sinkChannels);
receive.SetRemotePlayback(2, 1, 1, true, false); energy = 0;
for (int i = 0; i < 5; i++) { send.FeedPcm(1, tone, stereo ? 2 : 1); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.Equal(0, energy);
}
[Fact]
public void AudioCyclesAllocateZeroBytesWithEncodeDecodeStereoNoiseReductionAndMixing()
{
StreamInfo stream = Stream(20, true);
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
receive.SetRemotePlayback(2, 1, 0.8f, false, true);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.AlwaysOn; send.InputNoiseReduction = true; send.AddLocalStream(stream, 2);
short[] tone = Tone(2);
for (int i = 0; i < 30; i++) { send.FeedPcm(1, tone, 2); send.ProcessCycle(); receive.ProcessCycle(); }
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) { send.FeedPcm(1, tone, 2); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void JitterBacklogIsBoundedAndPlcEventuallyBecomesSilence()
{
var info = Stream(); using var stream = new ReceiveStream(2, info); using var encoder = new OpusEncoder(new() { Bitrate = 32000 });
byte[] packet = new byte[1275]; int length = encoder.Encode(Tone(), packet); int[] output = new int[1920];
for (uint i = 0; i < 64; i++) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
stream.Mix(output, false, null); Assert.InRange(stream.Depth, 0, 6);
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
Assert.All(output, value => Assert.Equal(0, value)); Assert.InRange(stream.ConcealedFrames, 1, 10);
}
[Fact]
public void PttResumeAndCaptureChannelChangesKeepTimestampProgressAndAudio()
{
var info = Stream(60, true); using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { info } }], 1, 1);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.PushToTalk; send.PushToTalk = true; send.AddLocalStream(info);
short[] mono = Tone(), stereo = Tone(2); long energy = 0;
receive.MixedPcm += pcm => { foreach (short value in pcm) energy += Math.Abs((int)value); };
for (int i = 0; i < 15; i++) { send.FeedPcm(1, mono, 1); send.ProcessCycle(); receive.ProcessCycle(); }
send.PushToTalk = false;
for (int i = 0; i < 16; i++) { send.FeedPcm(1, mono, 1); send.ProcessCycle(); receive.ProcessCycle(); }
send.SetCaptureChannels(1, 2); send.PushToTalk = true; energy = 0;
for (int i = 0; i < 15; i++) { send.FeedPcm(1, stereo, 2); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.True(energy > 100000);
}
private sealed class ManualAudioClock : TimeProvider
{
private long milliseconds;
public override long TimestampFrequency => 1000;
public override long GetTimestamp() => milliseconds;
internal void Advance(int value) => milliseconds += value;
}
}
@@ -0,0 +1,102 @@
using VoiceCat.Server.Data;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public class ChannelManagementTests
{
internal static async Task<Client> AdminAsync(ServerFixture fixture)
{
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
await store.CreateAccountAsync("Admin", "secret", true);
Client 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 = "Admin", Password = "secret" } } });
Assert.True((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
await client.ReadUntilAsync(e => e.ServerState is not null);
return client;
}
private static Channel Room(string name = "Protected") => new()
{
Name = name, MaxUsers = 1,
Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20, Complexity = 5, Fec = true }
};
internal static async Task<bool> ResultAsync(Client client, Envelope request)
{
request.RequestId = 42;
client.Send(request);
Envelope result = await client.ReadUntilAsync(e => e.GenericResult is not null);
Assert.Equal(42UL, result.RequestId);
return result.GenericResult.Ok;
}
[Fact]
public async Task ProtectedChannelCrudEnforcesPasswordCapacityAndMovesMembersToLobby()
{
await using var fixture = new ServerFixture();
await using var guest = await fixture.ConnectAsync();
User user = await guest.LoginAsync("Guest");
await using var admin = await AdminAsync(fixture);
Assert.False(await ResultAsync(guest, new() { CreateChannel = new() { Channel = Room() } }));
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room(), Password = "pāssword" } }));
Channel room = (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Created)).ChannelEvent.Channel;
Assert.True(room.PasswordProtected);
foreach (string password in new[] { "", "wrong", "pāssword" })
{
guest.Send(new() { RequestId = 7, JoinChannel = new() { ChannelId = room.Id, Password = password } });
Envelope result = await guest.ReadUntilAsync(e => e.JoinChannelResult is not null);
Assert.Equal(7UL, result.RequestId);
Assert.Equal(password == "pāssword", result.JoinChannelResult.Ok);
}
admin.Send(new() { JoinChannel = new() { ChannelId = room.Id, Password = "pāssword" } });
Assert.False((await admin.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
room.Name = "Renamed";
Assert.False(await ResultAsync(guest, new() { EditChannel = new() { Channel = room } }));
Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = room } }));
Assert.Equal("Renamed", (await guest.ReadUntilAsync(e => e.ChannelEvent is not null)).ChannelEvent.Channel.Name);
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
{
Assert.Equal("Renamed", store.LoadChannels().Single(c => c.Id == room.Id).Name);
Assert.True(store.CheckChannelPassword(room.Id, "pāssword"));
Assert.False(store.CheckChannelPassword(room.Id, "wrong"));
}
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = room.Id } }));
Assert.Equal(room.Id, (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Deleted)).ChannelEvent.DeletedId);
guest.Send(new() { Subscribe = new() });
var snapshot = (await guest.ReadUntilAsync(e => e.ServerState is not null)).ServerState;
Assert.Equal(1U, snapshot.Users.Single(u => u.Id == user.Id).ChannelId);
Assert.DoesNotContain(snapshot.Channels, c => c.Id == room.Id);
using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
Assert.DoesNotContain(reopened.LoadChannels(), c => c.Id == room.Id);
}
[Fact]
public async Task InvalidChangesCannotCorruptChannelTreeOrLobby()
{
await using var fixture = new ServerFixture();
await using var admin = await AdminAsync(fixture);
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Parent") } }));
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
Channel parent = store.LoadChannels().Single(c => c.Name == "Parent");
Channel child = Room("Child"); child.ParentId = parent.Id;
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = child } }));
child = store.LoadChannels().Single(c => c.Name == "Child");
parent.ParentId = child.Id;
Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = parent } }));
Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = 1 } }));
Channel lobby = store.LoadChannels().Single(c => c.Id == 1);
Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = lobby, Password = "lockout" } }));
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Child") } }));
var invalid = Room("Invalid"); invalid.Audio.SampleRate = 123;
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = invalid } }));
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() }));
Assert.Equal(4, store.LoadChannels().Count);
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = child.Id } }));
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
}
}
+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 scripts/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", "rnnoise.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 @@
{"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 @@
{"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]}
+9
View File
@@ -0,0 +1,9 @@
{
"envelope": "00000020082a521c08011204746578741a0b746573742d636c69656e742205302e302e31",
"media": [
{"sequence": 0, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "", "packet": "01010000cafebabe0000000000000000000003c032faa61a66270f8b198f47e32e32ca84"},
{"sequence": 1, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60616263", "packet": "01010000cafebabe0000000000000001000003c0695d7eda350fbe7d25787424bf19191d00e02d53daa4ea625d23af3335f38115f30cce2997de88a40961c10f8ace84e1f5cf7740bd5e62025c022a75532a11465f9322f9867fcf6a35396f86fdca1959d8512ae564c3f09eb1e8e224cd6bdef556a073c12aa45bdae5e77e1f2827b1f3e549f15c"},
{"sequence": 65535, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe000000000000ffff000003c096bac906a2d141b97834d57095a62f947529d13f6a74a866"},
{"sequence": 65536, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe0000000000010000000003c005ecf39e7f89b45accd35e9b5c9b45bde30713a28b8f3183"}
]
}
+181
View File
@@ -0,0 +1,181 @@
using System.Buffers;
using System.IO.Pipelines;
using Google.Protobuf;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class FramingTests
{
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(65536)]
[InlineData(ControlFraming.MaxPayloadLength)]
public void PayloadRoundTrips(int size)
{
byte[] payload = Enumerable.Range(0, size).Select(i => (byte)i).ToArray();
var output = new ArrayBufferWriter<byte>();
ControlFraming.WriteFrame(output, payload);
var input = new ReadOnlySequence<byte>(output.WrittenMemory);
Assert.True(ControlFraming.TryReadFrame(ref input, out var actual));
Assert.Equal(payload, actual.ToArray());
Assert.True(input.IsEmpty);
}
[Fact]
public void IncompleteFramesDoNotConsumeInput()
{
byte[] frame = [0, 0, 0, 3, 1, 2, 3];
for (int size = 0; size < frame.Length; size++)
{
var input = new ReadOnlySequence<byte>(frame.AsMemory(0, size));
Assert.False(ControlFraming.TryReadFrame(ref input, out _));
Assert.Equal(size, input.Length);
}
}
[Fact]
public void SegmentsAndBatchedFramesAreHandled()
{
byte[] bytes = [0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0];
var first = new Segment(bytes.AsMemory(0, 1));
var last = first;
for (int i = 1; i < bytes.Length; i++) last = last.Append(bytes.AsMemory(i, 1));
var input = new ReadOnlySequence<byte>(first, 0, last, last.Memory.Length);
Assert.True(ControlFraming.TryReadFrame(ref input, out var payload));
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
Assert.True(ControlFraming.TryReadFrame(ref input, out payload));
Assert.True(payload.IsEmpty);
Assert.True(input.IsEmpty);
}
[Fact]
public void OversizedLengthsAreRejectedImmediately()
{
var input = new ReadOnlySequence<byte>(new byte[] { 1, 0, 0, 1 });
Assert.Throws<InvalidDataException>(() => ControlFraming.TryReadFrame(ref input, out _));
Assert.Throws<ArgumentOutOfRangeException>(() => ControlFraming.WriteFrame(new ArrayBufferWriter<byte>(), new byte[ControlFraming.MaxPayloadLength + 1]));
}
[Fact]
public async Task EnvelopesRoundTripThroughPipe()
{
var expected = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
expected.ClientHello.Features.Add("text");
var pipe = new Pipe();
ControlFraming.WriteEnvelope(pipe.Writer, expected);
ControlFraming.WriteEnvelope(pipe.Writer, new());
await pipe.Writer.CompleteAsync();
var actual = new List<Envelope>();
await foreach (var envelope in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) actual.Add(envelope);
Assert.Equal(new[] { expected, new Envelope() }, actual);
await pipe.Reader.CompleteAsync();
}
[Theory]
[InlineData(new byte[] { 0 })]
[InlineData(new byte[] { 0, 0, 0, 2, 1 })]
public async Task TruncatedEndOfStreamIsRejected(byte[] bytes)
{
var pipe = new Pipe();
pipe.Writer.Write(bytes);
await pipe.Writer.CompleteAsync();
await Assert.ThrowsAsync<InvalidDataException>(async () =>
{
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
});
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task InvalidProtobufIsRejected()
{
var pipe = new Pipe();
ControlFraming.WriteFrame(pipe.Writer, new byte[] { 0xff });
await pipe.Writer.CompleteAsync();
await Assert.ThrowsAsync<InvalidProtocolBufferException>(async () =>
{
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
});
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task ReadCanBeCanceled()
{
var pipe = new Pipe();
using var cancellation = new CancellationTokenSource();
await using var enumerator = ControlFraming.ReadEnvelopesAsync(pipe.Reader, cancellation.Token).GetAsyncEnumerator();
var pending = enumerator.MoveNextAsync().AsTask();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
await pipe.Writer.CompleteAsync();
await pipe.Reader.CompleteAsync();
}
[Fact]
public void UnknownFieldsSurviveParsing()
{
byte[] bytes = [8, 42, 0xa0, 6, 7];
Assert.Equal(bytes, Envelope.Parser.ParseFrom(bytes).ToByteArray());
}
[Fact]
public async Task FragmentedLargeEnvelopeMakesProgressUnderBackpressure()
{
var envelope = new Envelope { ClientHello = new() { ClientName = new string('a', 200000) } };
var framed = new ArrayBufferWriter<byte>();
ControlFraming.WriteEnvelope(framed, envelope);
var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 32, resumeWriterThreshold: 16));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
async Task Produce()
{
for (int offset = 0; offset < framed.WrittenCount; offset += 7)
await pipe.Writer.WriteAsync(framed.WrittenMemory.Slice(offset, Math.Min(7, framed.WrittenCount - offset)), timeout.Token);
await pipe.Writer.CompleteAsync();
}
var producer = Produce();
var actual = new List<Envelope>();
await foreach (var item in ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token)) actual.Add(item);
await producer;
Assert.Equal(new[] { envelope }, actual);
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task StoppingEnumerationLeavesFollowingFramesAvailable()
{
var pipe = new Pipe();
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 1 });
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 2 });
await pipe.Writer.FlushAsync();
await using (var first = ControlFraming.ReadEnvelopesAsync(pipe.Reader).GetAsyncEnumerator())
{
Assert.True(await first.MoveNextAsync());
Assert.Equal(1UL, first.Current.RequestId);
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using (var second = ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token).GetAsyncEnumerator())
{
Assert.True(await second.MoveNextAsync());
Assert.Equal(2UL, second.Current.RequestId);
}
await pipe.Writer.CompleteAsync();
await pipe.Reader.CompleteAsync();
}
private sealed class Segment : ReadOnlySequenceSegment<byte>
{
public Segment(ReadOnlyMemory<byte> memory) => Memory = memory;
public Segment Append(ReadOnlyMemory<byte> memory)
{
var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
Next = segment;
return segment;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Buffers;
using System.Text.Json;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class GoldenTests
{
[Fact]
public void EnvelopeMatchesCanonicalWireVector()
{
using var fixture = Load();
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
var envelope = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
envelope.ClientHello.Features.Add("text");
var output = new ArrayBufferWriter<byte>();
ControlFraming.WriteEnvelope(output, envelope);
Assert.Equal(expected, output.WrittenSpan.ToArray());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MediaPacketsMatchCanonicalWireVectors(bool managed)
{
using var fixture = Load();
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
{
byte[] key = Convert.FromHexString(vector.GetProperty("key").GetString()!);
byte[] plaintext = Convert.FromHexString(vector.GetProperty("plaintext").GetString()!);
byte[] expected = Convert.FromHexString(vector.GetProperty("packet").GetString()!);
ulong sequence = vector.GetProperty("sequence").GetUInt64();
using var sender = new MediaEncryptor(key, managed, sequence);
using var receiver = new MediaDecryptor(key, managed);
var header = new VoiceFrameHeader(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
byte[] actual = new byte[expected.Length];
sender.Encrypt(header, plaintext, actual);
Assert.Equal(expected, actual);
byte[] decoded = new byte[plaintext.Length];
Assert.True(receiver.TryDecrypt(expected, decoded, out var parsed, out int written));
Assert.Equal(sequence, parsed.Sequence);
Assert.Equal(plaintext.Length, written);
Assert.Equal(plaintext, decoded);
}
}
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "wire.json")));
}
+68
View File
@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Formats.Asn1;
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public class IdentityTests
{
[Fact]
public void CredentialsSurviveRestartAndBindIdentityIntoCertificate()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-credentials-" + Guid.NewGuid());
try
{
string identityFingerprint, certificateFingerprint;
using (var credentials = ServerCredentials.LoadOrCreate(directory, "Server, with punctuation"))
{
identityFingerprint = credentials.Identity.Fingerprint;
certificateFingerprint = credentials.CertificateFingerprint;
using var tls = credentials.CreateTlsSession();
Assert.False(tls.IsReady);
byte[] identity = File.ReadAllBytes(Path.Combine(directory, "identity.key"));
Assert.Equal(96, identity.Length);
Assert.Equal(identity[..32], identity[64..]);
using var certificate = X509Certificate2.CreateFromPem(File.ReadAllText(Path.Combine(directory, "server.crt")));
var san = new AsnReader(certificate.Extensions["2.5.29.17"]!.RawData, AsnEncodingRules.DER).ReadSequence();
Assert.Equal("urn:voicecat:identity:ed25519:" + Convert.ToHexString(credentials.Identity.PublicKey).ToLowerInvariant(),
san.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)));
Assert.False(san.HasData);
}
using var restored = ServerCredentials.LoadOrCreate(directory, "ignored after creation");
Assert.Equal(identityFingerprint, restored.Identity.Fingerprint);
Assert.Equal(certificateFingerprint, restored.CertificateFingerprint);
File.Delete(Path.Combine(directory, "server.key"));
Assert.Throws<InvalidDataException>(() => ServerCredentials.LoadOrCreate(directory, "unchanged"));
using var stillPresent = ServerIdentity.Load(Path.Combine(directory, "identity.key"));
Assert.Equal(identityFingerprint, stillPresent.Fingerprint);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void TofuRequiresExplicitPinAndPreservesFileFormat()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "pins.txt");
string fingerprint = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
try
{
var store = new TofuStore(path);
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
Assert.False(File.Exists(path));
store.Pin("localhost", 9987, fingerprint);
Assert.Equal($"localhost:9987 {fingerprint.ToLowerInvariant()}\n", File.ReadAllText(path));
store = new(path);
Assert.Equal(TofuStatus.Matched, store.Check("localhost", 9987, fingerprint));
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, new string('0', 64)));
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, fingerprint));
store.Remove("localhost", 9987);
Assert.Equal(TofuStatus.FirstConnect, new TofuStore(path).Check("localhost", 9987, fingerprint));
File.WriteAllText(path, "localhost:9987 " + new string('g', 64));
Assert.Throws<InvalidDataException>(() => new TofuStore(path));
}
finally { Directory.Delete(directory, true); }
}
}
+45
View File
@@ -0,0 +1,45 @@
using System.Diagnostics;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public class ManagedCliTests
{
[Theory]
[InlineData(1u)]
[InlineData(2u)]
public async Task TwoManagedCliProcessesExchangeTextAndDecodedVoice(uint channel)
{
await using var fixture = new ServerFixture();
string root = FindRoot();
string cli = Path.Combine(root, "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
Assert.True(File.Exists(cli), $"Managed CLI was not built at {cli}.");
using Process alice = Start(cli, fixture, "Alice", "Alice says hello", "Bob says hello", channel);
using Process bob = Start(cli, fixture, "Bob", "Bob says hello", "Alice says hello", channel);
Task<string> aliceOut = alice.StandardOutput.ReadToEndAsync(); Task<string> aliceError = alice.StandardError.ReadToEndAsync();
Task<string> bobOut = bob.StandardOutput.ReadToEndAsync(); Task<string> bobError = bob.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
await Task.WhenAll(alice.WaitForExitAsync(timeout.Token), bob.WaitForExitAsync(timeout.Token));
string aout = await aliceOut, bout = await bobOut;
Assert.True(alice.ExitCode == 0, await aliceError + Environment.NewLine + aout);
Assert.True(bob.ExitCode == 0, await bobError + Environment.NewLine + bout);
Assert.Contains("Bob says hello", aout); Assert.Contains("Alice says hello", bout);
Assert.Contains("\"type\":\"complete\"", aout); Assert.Contains("\"type\":\"complete\"", bout);
Assert.DoesNotContain("\"voiceEnergy\":0", aout); Assert.DoesNotContain("\"voiceEnergy\":0", bout);
}
private static Process Start(string cli, ServerFixture fixture, string name, string send, string expect, uint channel)
{
var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
foreach (string argument in new[] { cli, "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nickname", name,
"--pins", Path.Combine(fixture.Directory, name + ".cli.pins"), "--trust-first", "--channel", channel.ToString(), "--voice", "--expect-voice", "--send-text", send,
"--expect-text", expect, "--start-delay-ms", "1500", "--timeout-seconds", "20" }) start.ArgumentList.Add(argument);
return Process.Start(start) ?? throw new InvalidOperationException("Could not start managed CLI.");
}
private static string FindRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx"))) directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
}
}
+114
View File
@@ -0,0 +1,114 @@
using VoiceCat.Core;
using VoiceCat.Crypto;
using VoiceCat.Server.Data;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
using static VoiceCat.Tests.MediaRelayTests;
namespace VoiceCat.Tests;
public class ManagedClientTests
{
private static VoiceCatClient NewClient(ServerFixture fixture, string name) => new(name, "test", Path.Combine(fixture.Directory, name + ".pins"));
private static Task Connect(VoiceCatClient client, ServerFixture fixture) => client.ConnectAsync("127.0.0.1", (ushort)fixture.Server.EndPoint.Port, (_, _) => ValueTask.FromResult(true));
private static async Task<Envelope> Event(VoiceCatClient client, Func<Envelope, bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await foreach (Envelope message in client.ReadEventsAsync(timeout.Token)) if (predicate(message)) return message;
throw new IOException("Expected client event was not received.");
}
[Fact]
public async Task ManagedClientsAuthenticateChatAndCorrelateConcurrentRequests()
{
await using var fixture = new ServerFixture();
await using var alice = NewClient(fixture, "Alice"); await using var bob = NewClient(fixture, "Bob");
await Connect(alice, fixture); await Connect(bob, fixture);
Assert.True((await alice.AuthenticateGuestAsync("Alice")).Ok); Assert.True((await bob.AuthenticateGuestAsync("Bob")).Ok);
await Event(bob, e => e.ServerState is not null); await Event(alice, e => e.UserEvent?.User?.Nickname == "Bob");
Assert.Equal(2, alice.Users.Count);
var copy = alice.Users[0]; copy.Nickname = "Mutated"; Assert.DoesNotContain(alice.Users, u => u.Nickname == "Mutated");
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, Body = "Managed conversation", ClientMsgId = "a1" } });
Assert.Equal("Managed conversation", (await Event(bob, e => e.TextMessage is not null)).TextMessage.Body);
var requests = Enumerable.Range(1, 20).Select(async i =>
{
Envelope response = await alice.RequestAsync(new() { Ping = new() { Nonce = (ulong)i } });
Assert.Equal((ulong)i, response.Pong.Nonce); return response.RequestId;
});
Assert.Equal(20, (await Task.WhenAll(requests)).Distinct().Count());
await Assert.ThrowsAsync<InvalidOperationException>(() => Connect(alice, fixture));
Assert.Equal(ClientConnectionState.Connected, alice.State);
await alice.DisconnectAsync();
await Event(bob, e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
await Connect(alice, fixture); Assert.True((await alice.AuthenticateGuestAsync("Returned")).Ok);
}
[Fact]
public async Task TofuRequiresApprovalPinsAcceptedCertificateAndRejectsChanges()
{
await using var first = new ServerFixture(); await using var second = new ServerFixture();
await using var client = NewClient(first, "Tofu");
await Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(() => client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port));
await client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port, (challenge, _) =>
{ Assert.Equal(TofuStatus.FirstConnect, challenge.Status); return ValueTask.FromResult(true); });
await client.DisconnectAsync();
await client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port); await client.DisconnectAsync();
// Pin the other server's certificate to this endpoint, simulating a changed server certificate.
using var credentials = ServerCredentials.LoadOrCreate(second.Directory, "VoiceCat Server");
new TofuStore(Path.Combine(first.Directory, "Other.pins")).Pin("127.0.0.1", (ushort)first.Server.EndPoint.Port, credentials.CertificateFingerprint);
await using var changed = new VoiceCatClient(tofuStorePath: Path.Combine(first.Directory, "Other.pins"));
await Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(() => changed.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port,
(challenge, _) => { Assert.Equal(TofuStatus.Mismatch, challenge.Status); return ValueTask.FromResult(false); }));
}
[Fact]
public async Task ManagedClientSendsAndReceivesAuthenticatedEncodedVoice()
{
await using var fixture = new ServerFixture();
await using var managed = NewClient(fixture, "Managed"); await Connect(managed, fixture); await managed.AuthenticateGuestAsync("Managed");
Assert.True((await managed.SubscribeVoiceAsync()).Ok);
await using var peer = await VoicePeer.ConnectAsync(fixture, "Peer");
var remote = await peer.AnnounceAsync(StreamKind.StreamMic);
var local = (await managed.RequestAsync(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } })).StreamAnnounceResult;
Assert.True(local.Ok);
var received = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
managed.VoiceReceived += (header, payload) => { Assert.Equal(remote.Ssrc, header.Ssrc); received.TrySetResult(payload.ToArray()); };
await peer.SendAsync(peer.Seal(remote.Ssrc, [1, 2, 3]));
Assert.Equal(new byte[] { 1, 2, 3 }, await received.Task.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.True(managed.TrySendEncodedVoice(local.Ssrc, 960, [4, 5, 6]));
Assert.Equal(new byte[] { 4, 5, 6 }, (await peer.ReceiveVoiceAsync()).Payload);
}
[Fact]
public async Task ManagedAdministrationHelpersRoundTripTypedResults()
{
await using var fixture = new ServerFixture();
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
await store.CreateAccountAsync("Admin", "secret", true);
await using var admin = NewClient(fixture, "ManagedAdmin"); await Connect(admin, fixture);
Assert.True((await admin.AuthenticateUserAsync("Admin", "secret")).Ok);
await Event(admin, envelope => envelope.ServerState is not null);
Assert.True(admin.Permissions.IsAdmin);
await using var member = NewClient(fixture, "ManagedMember"); await Connect(member, fixture);
AuthResult memberAuth = await member.AuthenticateGuestAsync("ManagedMember"); Assert.True(memberAuth.Ok);
await Event(member, envelope => envelope.ServerState is not null);
await Event(admin, envelope => envelope.UserEvent?.User?.Id == memberAuth.Self.Id);
Assert.True((await admin.CreateAccountAsync("managed-ui", "first")).Ok);
Assert.Contains(await admin.ListAccountsAsync(), account => account.Username == "managed-ui");
Assert.True((await admin.ResetPasswordAsync("managed-ui", "second")).Ok);
var room = new Voicecat.V1.Channel { Name = "Managed UI room", Audio = new()
{ SampleRate = 48000, BitrateBps = 64000, FrameMs = 20, Complexity = 10, Fec = true } };
Assert.True((await admin.CreateChannelAsync(room, "protected")).Ok);
Envelope created = await Event(admin, envelope => envelope.ChannelEvent?.Channel?.Name == room.Name);
Channel edited = created.ChannelEvent.Channel.Clone(); edited.Topic = "Edited from managed UI";
Assert.True((await admin.EditChannelAsync(edited)).Ok);
Assert.True((await admin.SetPermissionsAsync(memberAuth.Self.Id, new() { CanCreateTempChannel = true })).Ok);
Assert.True((await admin.SetServerMuteAsync(memberAuth.Self.Id, true, true)).Ok);
Assert.True((await admin.SetServerMuteAsync(memberAuth.Self.Id, false, false)).Ok);
Assert.True((await admin.MoveUserAsync(memberAuth.Self.Id, created.ChannelEvent.Channel.Id)).Ok);
Assert.True((await admin.KickUserAsync(memberAuth.Self.Id, "managed helper test")).Ok);
Assert.True((await admin.DeleteChannelAsync(created.ChannelEvent.Channel.Id)).Ok);
Assert.True((await admin.DeleteAccountAsync("managed-ui")).Ok);
}
}
@@ -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);
}
}
+110
View File
@@ -0,0 +1,110 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
using Xunit.Abstractions;
namespace VoiceCat.Tests;
public sealed class MediaFanoutTests(ITestOutputHelper output)
{
[Fact]
public async Task UdpRelayDeliversFiftyPacketsPerSecondToFiftySubscribers()
{
await using var relay = new MediaRelay(new(IPAddress.Loopback, 0));
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
Socket[] sockets = Enumerable.Range(0, 51).Select(_ => new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)).ToArray();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try
{
foreach (Socket socket in sockets)
{
socket.ReceiveBufferSize = 1024 * 1024;
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
}
MediaRoute[] routes = sockets.Select(socket => new MediaRoute(
new(new byte[16], new(new(key), new(key))) { Endpoint = ((IPEndPoint)socket.LocalEndPoint!).Serialize() },
1, true, false, false, [42])).ToArray();
relay.Publish(routes);
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
Task[] receivers = sockets.Skip(1).Select(async socket =>
{
using var decryptor = new MediaDecryptor(key);
byte[] packet = new byte[4000];
byte[] decoded = new byte[4000];
for (ulong sequence = 0; sequence < 50; sequence++)
{
int length = await socket.ReceiveAsync(packet, SocketFlags.None, timeout.Token);
Assert.True(decryptor.TryDecrypt(packet.AsSpan(0, length), decoded, out var header, out int bytes));
Assert.Equal(sequence, header.Sequence);
Assert.Equal(payload, decoded[..bytes]);
}
}).ToArray();
using var encryptor = new MediaEncryptor(key);
byte[] outgoing = new byte[VoiceFrameHeader.Size + payload.Length + 16];
var elapsed = Stopwatch.StartNew();
for (uint index = 0; index < 50; index++)
{
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, index * 960), payload, outgoing);
await sockets[0].SendToAsync(outgoing, SocketFlags.None, relay.EndPoint, timeout.Token);
await Task.Delay(20, timeout.Token);
}
await Task.WhenAll(receivers);
output.WriteLine($"Delivered all 2,500 recipient packets in {elapsed.Elapsed.TotalMilliseconds:F1} ms at a paced 50 pps input.");
}
finally { foreach (Socket socket in sockets) socket.Dispose(); }
}
[PlatformCipherFact]
public void FiftySubscriberFanoutAllocatesNoManagedMemoryAndPreservesPayload()
{
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
MediaRoute[] routes = Enumerable.Range(0, 51).Select(i => new MediaRoute(
new(new byte[16], new(new(key), new(key))) { Endpoint = new IPEndPoint(IPAddress.Loopback, 10000 + i).Serialize() },
1, true, false, false, [42])).ToArray();
using var sender = new MediaEncryptor(key);
using var receiver = new MediaDecryptor(key);
using var fanout = new MediaFanout();
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
byte[] decoded = new byte[payload.Length];
ReadOnlyMemory<byte> last = default;
try
{
for (int i = 0; i < 100; i++) Cycle();
long before = GC.GetAllocatedBytesForCurrentThread();
long started = Stopwatch.GetTimestamp();
for (int i = 0; i < 1000; i++) Cycle();
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.True(receiver.TryDecrypt(last.Span, decoded, out var header, out int length));
Assert.Equal(payload.Length, length);
Assert.Equal(payload, decoded);
Assert.Equal(42U, header.Ssrc);
Assert.Equal(1099UL, header.Sequence);
output.WriteLine($"50,000 recipient seals in {elapsed.TotalMilliseconds:F1} ms; {allocated} managed bytes. Transport scheduling is excluded.");
}
finally { foreach (MediaRoute route in routes) route.Peer.Dispose(); }
void Cycle()
{
sender.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), payload, packet);
if (!fanout.TryStart(packet, routes[0], routes)) throw new InvalidOperationException("Valid packet rejected.");
int recipients = 0;
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
}
}
private sealed class PlatformCipherFactAttribute : FactAttribute
{
public PlatformCipherFactAttribute()
{
if (!ChaCha20Poly1305.IsSupported) Skip = "The allocation guarantee requires platform ChaCha20-Poly1305; fallback conformance is tested separately.";
}
}
}
+220
View File
@@ -0,0 +1,220 @@
using VoiceCat.Transport;
using System.Net;
using System.Net.Sockets;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public sealed class MediaRelayTests
{
[Fact]
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
alice.Client.Send(new() { Disconnect = new() });
await bob.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left && e.UserEvent.LeftId == alice.Client.Authentication!.Self.Id);
await alice.SendAsync(alice.Seal(stream.Ssrc, [1]));
await bob.AssertNoVoiceAsync();
}
[Fact]
public async Task AnnounceRequiresSubscriptionAndUsesAuthoritativeMusicSettings()
{
await using var fixture = new ServerFixture();
await using var client = await fixture.ConnectAsync();
await client.LoginAsync("Alice");
client.Send(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } });
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
client.Send(new() { SubscribeVoice = new() });
await client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null);
client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
await client.ReadUntilAsync(e => e.JoinChannelResult is not null);
client.Send(new() { RequestId = 21, StreamAnnounce = new() { Kind = StreamKind.StreamScreenAudio, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 64000 } } });
var announced = await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null);
Assert.Equal(21UL, announced.RequestId);
Assert.True(announced.StreamAnnounceResult.Ok);
Assert.Equal(64000U, announced.StreamAnnounceResult.EffectiveAudio.BitrateBps);
Assert.Equal(48000U, announced.StreamAnnounceResult.EffectiveAudio.SampleRate);
Assert.Equal(ChannelMode.ModeStereo, announced.StreamAnnounceResult.EffectiveAudio.Mode);
client.Send(new() { StreamAnnounce = new() { Kind = (StreamKind)99 } });
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
}
[Fact]
public async Task EncryptedOpusIsResealedWithRecipientCountersAcrossMultipleStreamsAndSenders()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
await using var carol = await VoicePeer.ConnectAsync(fixture, "Carol");
StreamAnnounceResult mic = await alice.AnnounceAsync(StreamKind.StreamMic);
StreamAnnounceResult screen = await alice.AnnounceAsync(StreamKind.StreamScreenAudio);
StreamAnnounceResult other = await carol.AnnounceAsync(StreamKind.StreamMic);
Assert.NotEqual(mic.StreamId, screen.StreamId);
Assert.NotEqual(mic.Ssrc, screen.Ssrc);
Assert.Equal(48000U, screen.EffectiveAudio.SampleRate);
Assert.Equal(24000U, screen.EffectiveAudio.BitrateBps);
using var encoder = new Codec.OpusEncoder(new());
short[] samples = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * 0.1))).ToArray();
byte[] payload = new byte[4000];
int length = encoder.Encode(samples, payload);
payload = payload[..length];
foreach (var (sender, stream) in new[] { (alice, mic), (carol, other), (alice, screen) })
{
byte[] packet = sender.Seal(stream.Ssrc, payload, 960, VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent);
await sender.SendAsync(packet);
var received = await bob.ReceiveVoiceAsync();
Assert.Equal(payload, received.Payload);
Assert.Equal(stream.Ssrc, received.Header.Ssrc);
Assert.Equal(960U, received.Header.Timestamp);
Assert.Equal(VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent, received.Header.Flags);
}
Assert.Equal(2UL, bob.LastSequence);
}
[Fact]
public async Task ReplayForgeryAndSpoofedStreamsAreDroppedWithoutBreakingValidMedia()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
byte[] packet = alice.Seal(stream.Ssrc, [1, 2, 3]);
await alice.SendAsync(packet);
Assert.Equal(new byte[] { 1, 2, 3 }, (await bob.ReceiveVoiceAsync()).Payload);
await alice.SendAsync(packet);
byte[] forged = alice.Seal(stream.Ssrc, [4]);
forged[^1] ^= 1;
await alice.SendAsync(forged);
await alice.SendAsync(alice.Seal(stream.Ssrc + 1000, [5]));
await alice.SendAsync([1]);
await alice.SendAsync(alice.Seal(stream.Ssrc, [6]));
Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
Assert.Equal(1UL, bob.LastSequence);
}
[Fact]
public async Task SubscriptionChannelMovementAndStreamStopIsolateMedia()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var mic = await alice.AnnounceAsync(StreamKind.StreamMic);
await bob.SubscribeAsync(false);
await alice.SendAsync(alice.Seal(mic.Ssrc, [1]));
await bob.AssertNoVoiceAsync();
await bob.SubscribeAsync(true);
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
Assert.True((await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
await alice.SendAsync(alice.Seal(mic.Ssrc, [2]));
await bob.AssertNoVoiceAsync();
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 1 } });
await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null);
alice.Client.Send(new() { StreamStop = new() { StreamId = mic.StreamId } });
await alice.Client.ReadUntilAsync(e => e.UserEvent?.User?.Id == alice.Client.Authentication!.Self.Id && e.UserEvent.User.Streams.Count == 0);
await alice.SendAsync(alice.Seal(mic.Ssrc, [3]));
await bob.AssertNoVoiceAsync();
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
await alice.SendAsync(alice.Seal(replacement.Ssrc, [4]));
Assert.Equal(new byte[] { 4 }, (await bob.ReceiveVoiceAsync()).Payload);
}
[Fact]
public async Task BadTokensCannotBindAndExistingBindingCannotBeStolen()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
using var rogue = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
rogue.Bind(new IPEndPoint(IPAddress.Loopback, 0));
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
alice.Client.Authentication!.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await rogue.SendToAsync(keepalive, SocketFlags.None, fixture.Server.MediaEndPoint);
using var timeout = new CancellationTokenSource(200);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await rogue.ReceiveAsync(new byte[100], SocketFlags.None, timeout.Token));
await alice.SendAsync(keepalive);
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
}
internal sealed class VoicePeer : IAsyncDisposable
{
public Client Client { get; }
private readonly Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
private readonly IPEndPoint endpoint;
private readonly MediaSessionCrypto crypto;
public ulong LastSequence { get; private set; }
private VoicePeer(Client client, IPEndPoint endpoint, MediaSessionCrypto crypto)
{
Client = client; this.endpoint = endpoint; this.crypto = crypto;
udp.Bind(new IPEndPoint(IPAddress.Loopback, 0));
}
public static async Task<VoicePeer> ConnectAsync(ServerFixture fixture, string nickname)
{
Client client = await fixture.ConnectAsync();
await client.LoginAsync(nickname);
var peer = new VoicePeer(client, fixture.Server.MediaEndPoint, await client.TakeMediaCryptoAsync());
client.Send(new() { UdpBinding = new() { UdpToken = client.Authentication!.UdpToken } });
Assert.True((await client.ReadUntilAsync(e => e.UdpBinding is not null)).UdpBinding.Ack);
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
client.Authentication.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
await peer.SendAsync(binding);
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await peer.SendAsync(keepalive);
Assert.Equal(keepalive, await peer.ReceivePacketAsync());
await peer.SubscribeAsync(true);
return peer;
}
public async Task SubscribeAsync(bool subscribed)
{
Client.Send(subscribed ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() });
var result = (await Client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null)).VoiceSubscriptionResult;
Assert.True(result.Ok); Assert.Equal(subscribed, result.Subscribed);
}
public async Task<StreamAnnounceResult> AnnounceAsync(StreamKind kind)
{
Client.Send(new() { StreamAnnounce = new() { Kind = kind, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 900000 } } });
var result = (await Client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult;
Assert.True(result.Ok, result.Error);
return result;
}
public byte[] Seal(uint ssrc, byte[] payload, uint timestamp = 0, VoiceFrameFlags flags = 0)
{
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
crypto.Encryptor.Encrypt(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload, packet);
return packet;
}
public async Task SendAsync(byte[] packet) => await udp.SendToAsync(packet, SocketFlags.None, endpoint, Client.Timeout.Token);
public async Task<byte[]> ReceivePacketAsync()
{
byte[] buffer = new byte[65535];
int size = await udp.ReceiveAsync(buffer, SocketFlags.None, Client.Timeout.Token);
return buffer[..size];
}
public async Task<(VoiceFrameHeader Header, byte[] Payload)> ReceiveVoiceAsync()
{
byte[] packet = await ReceivePacketAsync();
byte[] plain = new byte[65535];
Assert.True(crypto.Decryptor.TryDecrypt(packet, plain, out var header, out int length));
LastSequence = header.Sequence;
return (header, plain[..length]);
}
public async Task AssertNoVoiceAsync()
{
using var timeout = new CancellationTokenSource(200);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await udp.ReceiveAsync(new byte[65535], SocketFlags.None, timeout.Token));
}
public async ValueTask DisposeAsync() { udp.Dispose(); crypto.Dispose(); await Client.DisposeAsync(); }
}
}
+166
View File
@@ -0,0 +1,166 @@
using System.Buffers.Binary;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class MediaTests
{
private static readonly byte[] Key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
private static readonly VoiceFrameHeader Header = new(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BothBackendsProduceIdenticalPackets(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, !managed);
byte[] plaintext = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray();
byte[] packet = Seal(sender, plaintext);
byte[] output = new byte[plaintext.Length];
Assert.True(receiver.TryDecrypt(packet, output, out var header, out int written));
Assert.Equal(Header, header);
Assert.Equal(plaintext.Length, written);
Assert.Equal(plaintext, output);
Assert.False(receiver.TryDecrypt(packet, output, out _, out written));
Assert.Equal(0, written);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ForgedCounterDoesNotPoisonReplayWindow(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, managed);
byte[] output = new byte[8];
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
byte[] packet = Seal(sender, new byte[8]);
byte[] forged = (byte[])packet.Clone();
BinaryPrimitives.WriteUInt64BigEndian(forged.AsSpan(8), ulong.MaxValue);
Array.Fill(output, (byte)0xaa);
Assert.False(receiver.TryDecrypt(forged, output, out var header, out int written));
Assert.Equal(default, header);
Assert.Equal(0, written);
Assert.All(output, value => Assert.Equal(0, value));
Assert.True(receiver.TryDecrypt(packet, output, out _, out _));
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void TamperingEveryPacketRegionFailsAuthentication(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
byte[] packet = Seal(sender, new byte[80]);
for (int i = 0; i < packet.Length; i++)
{
using var receiver = new MediaDecryptor(Key, managed);
byte[] tampered = (byte[])packet.Clone();
tampered[i] ^= 0x80;
Assert.False(receiver.TryDecrypt(tampered, new byte[80], out _, out _));
Assert.True(receiver.TryDecrypt(packet, new byte[80], out _, out _));
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReplayWindowAcceptsReorderingAndRejectsOldPackets(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, managed);
var packets = Enumerable.Range(0, 130).Select(_ => Seal(sender, new byte[1])).ToArray();
byte[] output = new byte[1];
Assert.True(receiver.TryDecrypt(packets[64], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[0], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[1], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[1], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[63], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[129], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[64], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[128], output, out _, out _));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CounterCrossesOldSixteenBitBoundary(bool managed)
{
using var sender = new MediaEncryptor(Key, managed, 65534);
using var receiver = new MediaDecryptor(Key, managed);
for (ulong sequence = 65534; sequence < 65540; sequence++)
{
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[1]), new byte[1], out var header, out _));
Assert.Equal(sequence, header.Sequence);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void InterleavedRelayUsesRecipientCounter(bool managed)
{
byte[] otherKey = Enumerable.Repeat((byte)42, 32).ToArray();
using var a = new MediaEncryptor(Key, managed);
using var b = new MediaEncryptor(otherKey, managed);
using var receiveA = new MediaDecryptor(Key, managed);
using var receiveB = new MediaDecryptor(otherKey, managed);
using var relay = new MediaEncryptor(Key, managed);
using var listener = new MediaDecryptor(Key, managed);
byte[] plaintext = [1, 2, 3];
byte[] decoded = new byte[3];
for (int i = 0; i < 16; i++)
{
var sender = i % 2 == 0 ? a : b;
var receiver = i % 2 == 0 ? receiveA : receiveB;
Assert.True(receiver.TryDecrypt(Seal(sender, plaintext), decoded, out var header, out _));
byte[] packet = new byte[39];
relay.Encrypt(header, decoded, packet);
Assert.True(listener.TryDecrypt(packet, decoded, out var relayedHeader, out _));
Assert.Equal((ulong)i, relayedHeader.Sequence);
Assert.Equal(plaintext, decoded);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EmptyPayloadAndLargeCountersWork(bool managed)
{
using var sender = new MediaEncryptor(Key, managed, ulong.MaxValue - 1);
using var receiver = new MediaDecryptor(Key, managed);
var packet = Seal(sender, []);
Assert.True(receiver.TryDecrypt(packet, [], out var header, out int written));
Assert.Equal(ulong.MaxValue - 1, header.Sequence);
Assert.Equal(0, written);
Assert.Throws<InvalidOperationException>(() => Seal(sender, []));
}
[Fact]
public void InvalidArgumentsAndDisposedInstancesAreRejected()
{
Assert.Throws<ArgumentException>(() => new MediaEncryptor(new byte[31]));
using var sender = new MediaEncryptor(Key);
using var receiver = new MediaDecryptor(Key);
Assert.Throws<ArgumentOutOfRangeException>(() => sender.Encrypt(Header, new byte[1], new byte[36]));
byte[] packet = Seal(sender, new byte[8]);
Assert.True(receiver.TryDecrypt(packet, new byte[8], out var header, out _));
Assert.Equal(0UL, header.Sequence);
Assert.False(receiver.TryDecrypt(new byte[35], [], out _, out _));
Assert.Throws<ArgumentOutOfRangeException>(() => receiver.TryDecrypt(packet, [], out _, out _));
sender.Dispose();
receiver.Dispose();
Assert.Throws<ObjectDisposedException>(() => Seal(sender, []));
Assert.Throws<ObjectDisposedException>(() => receiver.TryDecrypt(packet, new byte[8], out _, out _));
}
private static byte[] Seal(MediaEncryptor sender, byte[] plaintext)
{
byte[] packet = new byte[VoiceFrameHeader.Size + plaintext.Length + MediaEncryptor.TagSize];
Assert.Equal(packet.Length, sender.Encrypt(Header, plaintext, packet));
return packet;
}
}
+42
View File
@@ -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", "argon2id.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,162 @@
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;
}
}
@@ -0,0 +1,50 @@
using System.Diagnostics;
namespace VoiceCat.Tests;
public class PublishServerScriptTests
{
[Fact]
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
{
string script = Path.Combine(FindRoot(), "scripts", "publish-server.ps1");
string allTargets = await RunWhatIf(script);
Assert.Contains("win-x64", allTargets);
Assert.Contains("linux-x64", allTargets);
string linuxOnly = await RunWhatIf(script, "-Runtime", "linux-x64");
Assert.Contains("linux-x64", linuxOnly);
Assert.DoesNotContain("win-x64", linuxOnly);
}
private static async Task<string> RunWhatIf(string script, params string[] arguments)
{
string powerShell = OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh";
var start = new ProcessStartInfo(powerShell)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (string argument in new[] { "-NoProfile", "-NonInteractive", "-File", script, "-WhatIf" }.Concat(arguments))
start.ArgumentList.Add(argument);
using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start PowerShell.");
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
string output = await stdout + await stderr;
Assert.True(process.ExitCode == 0, output);
return output;
}
private static string FindRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx")))
directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
}
}
+129
View File
@@ -0,0 +1,129 @@
using VoiceCat.Protocol;
using System.Net.Sockets;
using VoiceCat.Server;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
using static VoiceCat.Tests.MediaRelayTests;
namespace VoiceCat.Tests;
public sealed class ReaperTests
{
private static readonly VoiceServerOptions Options = new()
{
IdleTimeout = TimeSpan.FromSeconds(10), ReaperInterval = TimeSpan.FromMilliseconds(20)
};
[Fact]
public async Task SilentPeerIsReapedWhileTcpActivityKeepsObserverAlive()
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
await using var alice = await fixture.ConnectAsync();
await alice.LoginAsync("Alice");
await using var bob = await fixture.ConnectAsync();
User self = await bob.LoginAsync("Bob");
clock.Advance(9);
alice.Send(new() { Ping = new() { Nonce = 99 } });
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 99);
clock.Advance(2);
Assert.Equal(self.Id, (await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
Assert.Equal("Receive idle timeout.", (await bob.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason);
alice.Send(new() { Subscribe = new() });
int additionalDepartures = 0;
var snapshot = await alice.ReadUntilAsync(e =>
{
if (e.UserEvent?.Kind == UserEvent.Types.Kind.Left) additionalDepartures++;
return e.ServerState is not null;
});
Assert.Equal(0, additionalDepartures);
Assert.DoesNotContain(snapshot.ServerState.Users, user => user.Id == self.Id);
alice.Send(new() { Ping = new() { Nonce = 100 } });
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 100);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ValidUdpActivityKeepsTcpIdleClientAlive(bool voice)
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
uint ssrc = voice ? (await alice.AnnounceAsync(StreamKind.StreamMic)).Ssrc : 0;
clock.Advance(9);
if (voice)
{
await alice.SendAsync(alice.Seal(ssrc, [1, 2, 3]));
await bob.ReceiveVoiceAsync();
}
else
{
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await alice.SendAsync(keepalive);
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
}
clock.Advance(2);
Assert.Equal(bob.Client.Authentication!.Self.Id,
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
alice.Client.Send(new() { Ping = new() { Nonce = 42 } });
await alice.Client.ReadUntilAsync(e => e.Pong?.Nonce == 42);
}
[Fact]
public async Task InvalidVoiceCannotKeepSilentSessionAlive()
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var stream = await bob.AnnounceAsync(StreamKind.StreamMic);
clock.Advance(9);
byte[] forged = bob.Seal(stream.Ssrc, [1]);
forged[^1] ^= 1;
await bob.SendAsync(forged);
alice.Client.Send(new() { Ping = new() { Nonce = 1 } });
await alice.Client.ReadUntilAsync(e => e.Pong is not null);
clock.Advance(2);
Assert.Equal(bob.Client.Authentication!.Self.Id,
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
}
[Fact]
public async Task ShutdownAwaitsActiveVoiceAndUnfinishedHandshake()
{
await using var fixture = new ServerFixture(options: Options);
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
await alice.SendAsync(alice.Seal(stream.Ssrc, [1, 2]));
await bob.ReceiveVoiceAsync();
using var unfinished = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await unfinished.ConnectAsync(fixture.Server.EndPoint);
await fixture.Server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10));
await fixture.Server.DisposeAsync();
}
[Fact]
public async Task ReaperCanBeDisabled()
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(options: Options with { IdleTimeout = TimeSpan.Zero, ReaperInterval = TimeSpan.Zero }, timeProvider: clock);
await using var client = await fixture.ConnectAsync();
await client.LoginAsync("Alice");
clock.Advance(1000);
await Task.Delay(100);
client.Send(new() { Ping = new() { Nonce = 1 } });
await client.ReadUntilAsync(e => e.Pong is not null);
}
private sealed class ManualClock : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => Volatile.Read(ref timestamp);
public void Advance(int seconds) => Interlocked.Add(ref timestamp, seconds * TimeSpan.TicksPerSecond);
}
}
@@ -0,0 +1,77 @@
using VoiceCat.Core;
namespace VoiceCat.Tests;
public sealed class ServerProfileTests
{
[Fact]
public void ProfilesRoundTripWithoutPasswordMaterial()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "servers.json");
try
{
var guest = ServerProfile.Create(" voice.example ", 8384, ServerAuthentication.Guest, nickname: " Cat ");
var account = ServerProfile.Create("secure.example", 9443, ServerAuthentication.Account, username: " talon ");
var store = new ServerProfileStore(path);
store.Save([guest, account]);
Assert.Equal([guest, account], store.Load());
string json = File.ReadAllText(path);
Assert.Contains("\"authentication\": \"Account\"", json);
Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void MissingCorruptAndInvalidProfilesDoNotBreakStartup()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "servers.json");
try
{
var store = new ServerProfileStore(path);
Assert.Empty(store.Load());
Directory.CreateDirectory(directory);
File.WriteAllText(path, "not json");
Assert.Empty(store.Load());
File.WriteAllText(path, "[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"host\":\"\",\"port\":0,\"authentication\":\"Guest\"}]");
Assert.Empty(store.Load());
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void AccountProfilesRequireAUsername()
{
Assert.Throws<ArgumentException>(() => ServerProfile.Create("voice.example", 8384, ServerAuthentication.Account));
}
[Fact]
public void SwiftProfilesImportAndAreBackedUpOnManagedSave()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "servers.json");
Guid accountId = Guid.NewGuid();
try
{
Directory.CreateDirectory(directory);
File.WriteAllText(path, $$"""
[{"id":"{{accountId:D}}","host":"voice.example","port":8384,"authMode":"password","savedUsername":"talon","nickname":null,"keychainTag":"voicecat.server.legacy"}]
""");
var store = new ServerProfileStore(path);
ServerProfile profile = Assert.Single(store.Load());
Assert.Equal(ServerAuthentication.Account, profile.Authentication);
Assert.Equal("talon", profile.Username);
Assert.Equal("voicecat.server.legacy", profile.LegacyKeychainTag);
store.Save([profile]);
Assert.True(File.Exists(path + ".swift-backup.json"));
string managed = File.ReadAllText(path);
Assert.Contains("\"authentication\": \"Account\"", managed);
Assert.DoesNotContain("keychainTag", managed);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
}
+161
View File
@@ -0,0 +1,161 @@
using VoiceCat.Transport;
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);
}
internal 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, VoiceServerOptions? options = null, TimeProvider? timeProvider = null)
{
System.IO.Directory.CreateDirectory(Directory);
Server = new(Directory, new(IPAddress.Loopback, 0), options ?? new() { AllowGuests = guests }, timeProvider);
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);
}
}
internal 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 AuthResult? Authentication { get; private set; }
public Task<MediaSessionCrypto> TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token);
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;
Authentication = auth;
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();
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class TlsTests
{
[Fact]
public void ManagedTlsHandshakeExportsMatchingDirectionalKeys()
{
var (pem, key, fingerprint) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(value => value == fingerprint);
Assert.Throws<InvalidOperationException>(() => client.CreateMediaEncryptor());
Handshake(client, server);
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
Assert.Equal(server.ExportMediaKey(0), client.ExportMediaKey(0));
Assert.Equal(server.ExportMediaKey(1), client.ExportMediaKey(1));
Assert.NotEqual(client.ExportMediaKey(0), client.ExportMediaKey(1));
client.WritePlaintext("hello"u8);
Pump(client, server);
byte[] output = new byte[5];
Assert.Equal(5, server.ReadPlaintext(output));
Assert.Equal("hello"u8.ToArray(), output);
using var encryptor = server.CreateMediaEncryptor();
using var decryptor = client.CreateMediaDecryptor();
byte[] packet = new byte[41];
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), "hello"u8, packet);
Assert.True(decryptor.TryDecrypt(packet, output, out _, out _));
Assert.Equal("hello"u8.ToArray(), output);
}
[Fact]
public void CertificateRejectionPreventsApplicationDataAndMediaKeys()
{
var (pem, key, _) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(_ => false);
Assert.ThrowsAny<IOException>(() => Handshake(client, server));
Assert.False(client.IsReady);
Assert.Throws<InvalidOperationException>(() => client.CreateMediaDecryptor());
Assert.Throws<InvalidOperationException>(() => client.WritePlaintext("secret"u8));
}
[Fact]
public void CloseNotifyEndsSessionAndAbruptEofIsRejected()
{
var (pem, key, fingerprint) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(value => value == fingerprint);
Handshake(client, server);
client.Close();
Pump(client, server);
Assert.False(client.IsReady);
Assert.False(server.IsReady);
server.CompleteInput();
using var incomplete = TlsSession.CreateClient(_ => true);
Assert.ThrowsAny<IOException>(() => incomplete.CompleteInput());
}
[Fact]
public void TlsTwelveCannotNegotiateWithManagedServer()
{
var (pem, key, _) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
var legacy = new Org.BouncyCastle.Tls.TlsClientProtocol();
legacy.Connect(new LegacyPeer());
byte[] hello = new byte[legacy.GetAvailableOutputBytes()];
legacy.ReadOutput(hello, 0, hello.Length);
Assert.ThrowsAny<IOException>(() => server.ReceiveCiphertext(hello));
Assert.False(server.IsReady);
Assert.Throws<InvalidOperationException>(() => server.CreateMediaEncryptor());
}
private sealed class LegacyPeer() : Org.BouncyCastle.Tls.DefaultTlsClient(new Org.BouncyCastle.Tls.Crypto.Impl.BC.BcTlsCrypto())
{
protected override Org.BouncyCastle.Tls.ProtocolVersion[] GetSupportedVersions() => [Org.BouncyCastle.Tls.ProtocolVersion.TLSv12];
public override Org.BouncyCastle.Tls.TlsAuthentication GetAuthentication() => throw new InvalidOperationException("TLS 1.2 must be rejected before authentication.");
}
internal static (string Certificate, string Key, string Fingerprint) Credentials()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest("CN=VoiceCat TLS test", key, HashAlgorithmName.SHA256);
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
return (certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem(), Convert.ToHexString(SHA256.HashData(certificate.RawData)));
}
internal static void Handshake(TlsSession client, TlsSession server)
{
for (int i = 0; i < 100 && (!client.IsReady || !server.IsReady); i++)
{
Pump(client, server);
Pump(server, client);
}
Assert.True(client.IsReady);
Assert.True(server.IsReady);
}
private static void Pump(TlsSession sender, TlsSession receiver)
{
byte[] buffer = new byte[17];
while (sender.PendingCiphertextBytes > 0)
{
int count = sender.DrainCiphertext(buffer);
receiver.ReceiveCiphertext(buffer.AsSpan(0, count));
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public class TofuTlsTests
{
[Fact]
public void PinCreatesMissingParentDirectory()
{
string root = Path.Combine(Path.GetTempPath(), "voicecat-tofu-parent-" + Guid.NewGuid());
string path = Path.Combine(root, "nested", "pins.txt");
try
{
var store = new TofuStore(path);
store.Pin("localhost", 8384, new string('a', 64));
Assert.True(File.Exists(path));
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 8384, new string('A', 64)));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public void RealHandshakesRequireAcceptanceAndRejectChangedCertificatesAfterRestart()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tofu-tls-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "pins.txt");
try
{
using var credentials = ServerCredentials.LoadOrCreate(Path.Combine(directory, "server"), "server");
var store = new TofuStore(path);
using (var server = credentials.CreateTlsSession())
using (var rejected = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
return false;
}))
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(rejected, server));
Assert.False(File.Exists(path));
using (var server = credentials.CreateTlsSession())
using (var accepted = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
store.Pin("localhost", 9987, fingerprint);
return true;
}))
TlsTests.Handshake(accepted, server);
store = new(path);
using (var server = credentials.CreateTlsSession())
using (var returning = TlsSession.CreateClient(fingerprint => store.Check("localhost", 9987, fingerprint) == TofuStatus.Matched))
TlsTests.Handshake(returning, server);
using var rotated = ServerCredentials.LoadOrCreate(Path.Combine(directory, "rotated"), "server");
using (var server = rotated.CreateTlsSession())
using (var mismatch = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, fingerprint));
return false;
}))
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(mismatch, server));
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, credentials.CertificateFingerprint));
}
finally { Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../clients/windows/VoiceCat.Windows/VoiceCat.Windows.csproj" />
<ProjectReference Include="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
<ProjectReference Include="../../src/VoiceCat.Cli/VoiceCat.Cli.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<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>
</Project>
+19
View File
@@ -0,0 +1,19 @@
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class VoiceHeaderTests
{
[Fact]
public void HeaderUsesBigEndianFieldsAndPreservesUnknownValues()
{
var header = new VoiceFrameHeader((MediaFrameType)255, (VoiceFrameFlags)128, 0x1234, 0x56789abc, 0x0123456789abcdef, 0xfedcba98);
byte[] bytes = new byte[20];
header.Write(bytes);
Assert.Equal("FF80123456789ABC0123456789ABCDEFFEDCBA98", Convert.ToHexString(bytes));
Assert.True(VoiceFrameHeader.TryRead(bytes, out var parsed));
Assert.Equal(header, parsed);
Assert.False(VoiceFrameHeader.TryRead(bytes.AsSpan(0, 19), out _));
Assert.Throws<ArgumentOutOfRangeException>(() => header.Write(new byte[19]));
}
}
@@ -0,0 +1,58 @@
using VoiceCat.Windows;
using VoiceCat.Server.Data;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
using Client = VoiceCat.Windows.VoiceCatClient;
namespace VoiceCat.Tests;
public class WindowsManagedClientTests
{
private static async Task Until(Client client, Func<bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
do { client.PumpEvents(); if (predicate()) return; await Task.Delay(10, timeout.Token); } while (true);
}
private static async Task Login(Client client, ServerFixture fixture, bool admin = false)
{
client.EventReceived += e => { if (e.Type == VcEventType.ServerIdentity) client.ConfirmServerIdentity(true); };
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", (ushort)fixture.Server.EndPoint.Port));
if (admin) client.AuthenticateUser("Admin", "secret"); else client.AuthenticateGuest("Guest");
await Until(client, () => client.ListUsers().Count > 0);
}
[Fact]
public async Task ShippedWindowsFacadeChatsExchangesPcmAndKeepsCaptureIdsAcrossChannelMoves()
{
await using var fixture = new ServerFixture();
using (var accounts = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) await accounts.CreateAccountAsync("Admin", "secret", true);
using var alice = new Client("Alice", "test", tofuStorePath: Path.Combine(fixture.Directory, "alice.pins"));
using var bob = new Client("Bob", "test", tofuStorePath: Path.Combine(fixture.Directory, "bob.pins"));
await Login(alice, fixture, true); await Login(bob, fixture);
Assert.True(alice.GetPermissions().IsAdmin);
string? body = null; bob.EventReceived += e => { if (e.Type == VcEventType.TextMessage) body = e.Text; };
Assert.Equal(VcResult.Ok, alice.SendText(VcTextScope.Channel, 1, "Managed Windows chat"));
await Until(bob, () => body is not null); Assert.Equal("Managed Windows chat", body);
Assert.Equal(VcResult.Ok, alice.JoinVoice()); Assert.Equal(VcResult.Ok, bob.JoinVoice());
alice.SetInputMode(VcInputMode.AlwaysOn); bob.SetInputMode(VcInputMode.AlwaysOn);
var a = alice.StartStreamExternalFeed(VcStreamKind.Mic, "Mic"); var b = bob.StartStreamExternalFeed(VcStreamKind.Mic, "Mic");
Assert.Equal(VcResult.Ok, a.Result); Assert.Equal(VcResult.Ok, b.Result);
await Until(bob, () => bob.ManagedClient.Users.Any(u => u.Streams.Count > 0 && u.Id != bob.ManagedClient.Authentication!.Self.Id));
long aliceEnergy = 0, bobEnergy = 0;
alice.ManagedClient.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref aliceEnergy, sum); };
bob.ManagedClient.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref bobEnergy, sum); };
short[] tone = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * Math.PI * 880 / 48000))).ToArray();
for (int i = 0; i < 40; i++) { alice.StreamFeedPcm(a.StreamId, tone, 960, 1); bob.StreamFeedPcm(b.StreamId, tone, 960, 1); alice.PumpEvents(); bob.PumpEvents(); await Task.Delay(20); }
Assert.True(Interlocked.Read(ref aliceEnergy) > 100000); Assert.True(Interlocked.Read(ref bobEnergy) > 100000);
uint oldId = alice.ManagedClient.LocalStreams.Single().StreamId;
await alice.ManagedClient.RequestAsync(new() { CreateChannel = new() { Channel = new() { Name = "Stereo", ParentId = 1, Audio = AudioEngineTests.Stream(20, true).Audio } } });
await Until(alice, () => alice.ListChannels().Any(c => c.Name == "Stereo"));
uint channel = alice.ListChannels().Single(c => c.Name == "Stereo").Id;
Assert.Equal(VcResult.Ok, alice.JoinChannel(channel));
await Until(alice, () => alice.ManagedClient.LocalStreams.Any(s => s.StreamId != oldId));
Assert.Equal(a.StreamId, Assert.Single(alice.ListUserStreams(alice.ManagedClient.Authentication!.Self.Id)).StreamId);
Assert.True(alice.GetStreamAudioConfig(alice.ManagedClient.Authentication!.Self.Id, a.StreamId).Config!.Stereo);
Assert.Equal(VcResult.Ok, alice.StreamFeedPcm(a.StreamId, tone, 960, 1));
Assert.Equal(VcResult.Ok, alice.StopStream(a.StreamId));
Assert.Empty(alice.ManagedClient.LocalStreams);
}
}
+200
View File
@@ -0,0 +1,200 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.14.1, )",
"resolved": "17.14.1",
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
"dependencies": {
"Microsoft.CodeCoverage": "17.14.1",
"Microsoft.TestPlatform.TestHost": "17.14.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.1, )",
"resolved": "3.1.1",
"contentHash": "gNu2zhnuwjq5vQlU4S7yK/lfaKZDLmtcu+vTjnhfTlMAUYn+Hmgu8IIX0UCwWepYkk+Szx03DHx1bDnc9Fd+9w=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"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",
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
"Newtonsoft.Json": "13.0.3"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"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",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.windows": {
"type": "Project",
"dependencies": {
"VoiceCat.Core": "[1.0.0, )"
}
},
"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, )"
}
}
}
}
}