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); } [CppCliFact] public async Task ExistingCppCliAuthenticatesAndChatsThroughManagedServer() { await using var fixture = new ServerFixture(); await using var receiver = await fixture.ConnectAsync(); User self = await receiver.LoginAsync("Managed"); var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!) { WorkingDirectory = fixture.Directory, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Cpp", "--text", "native interoperability", "--wait-ms", "10000" }) start.ArgumentList.Add(argument); using var process = Process.Start(start)!; Task output = process.StandardOutput.ReadToEndAsync(); Task error = process.StandardError.ReadToEndAsync(); try { await process.WaitForExitAsync(receiver.Timeout.Token); string log = await output + await error; Assert.True(process.ExitCode == 0, log); TextMessage text = (await receiver.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage; Assert.Equal("native interoperability", text.Body); Assert.NotEqual(self.Id, text.SenderId); Assert.Contains("native interoperability", log); } finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } } } private sealed class CppCliFactAttribute : FactAttribute { public CppCliFactAttribute() { if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI."; } } 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 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 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 TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token); public async Task ReadUntilAsync(Func predicate) { while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current; throw new IOException("Connection ended before the expected message."); } public async Task 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(); } } }