Files
voice-cat/dotnet/tests/VoiceCat.Tests/ServerTests.cs
T

162 lines
8.5 KiB
C#
Raw Normal View History

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();
}
}
}