199 lines
10 KiB
C#
199 lines
10 KiB
C#
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<string> output = process.StandardOutput.ReadToEndAsync();
|
|
Task<string> error = process.StandardError.ReadToEndAsync();
|
|
try
|
|
{
|
|
await process.WaitForExitAsync(receiver.Timeout.Token);
|
|
string log = await output + await error;
|
|
Assert.True(process.ExitCode == 0, log);
|
|
TextMessage text = (await receiver.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
|
Assert.Equal("native interoperability", text.Body);
|
|
Assert.NotEqual(self.Id, text.SenderId);
|
|
Assert.Contains("native interoperability", log);
|
|
}
|
|
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
|
}
|
|
|
|
private sealed class CppCliFactAttribute : FactAttribute
|
|
{
|
|
public CppCliFactAttribute()
|
|
{
|
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|