172 lines
11 KiB
C#
172 lines
11 KiB
C#
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
|
||
|
|
{
|
||
|
|
[NativeCliFact]
|
||
|
|
public async Task ExistingCppCliCreatesProtectedChannelsAndAdministersAccounts()
|
||
|
|
{
|
||
|
|
await using var fixture = new ServerFixture();
|
||
|
|
await using var admin = await AdminAsync(fixture);
|
||
|
|
var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||
|
|
{
|
||
|
|
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||
|
|
RedirectStandardOutput = true, RedirectStandardError = true
|
||
|
|
};
|
||
|
|
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--username", "Admin", "--password", "secret",
|
||
|
|
"--create-channel", "--new-channel-name", "Native room", "--new-channel-password", "protected", "--create-account", "native", "secret", "--list-accounts", "--wait-ms", "10000" })
|
||
|
|
start.ArgumentList.Add(argument);
|
||
|
|
using var process = System.Diagnostics.Process.Start(start)!;
|
||
|
|
Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await process.WaitForExitAsync(admin.Timeout.Token);
|
||
|
|
Assert.True(process.ExitCode == 0, await stdout + await stderr);
|
||
|
|
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||
|
|
var room = store.LoadChannels().Single(c => c.Name == "Native room");
|
||
|
|
Assert.True(store.CheckChannelPassword(room.Id, "protected"));
|
||
|
|
Assert.NotNull(await store.AuthenticateAsync("native", "secret"));
|
||
|
|
}
|
||
|
|
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class NativeCliFactAttribute : FactAttribute
|
||
|
|
{
|
||
|
|
public NativeCliFactAttribute()
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
[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");
|
||
|
|
}
|
||
|
|
}
|