2026-09-16 16:48:06 +02:00
using VoiceCat.Transport ;
2026-09-15 22:53:54 +02:00
using System.Net ;
using System.Diagnostics ;
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
{
[CppCliVoiceTheory]
[InlineData(1)]
[InlineData(2)]
public async Task TwoCppCliProcessesJoinChatAndExchangeVoice ( int channel )
{
await using var fixture = new ServerFixture ();
await using var observer = await fixture . ConnectAsync ();
await observer . LoginAsync ( "Observer" );
observer . Send ( new () { JoinChannel = new () { ChannelId = checked (( uint ) channel ) } });
Assert . True (( await observer . ReadUntilAsync ( e => e . JoinChannelResult is not null )). JoinChannelResult . Ok );
using var timeout = new CancellationTokenSource ( TimeSpan . FromSeconds ( 30 ));
await Task . WhenAll ( RunAsync ( "Cli Alice" ), RunAsync ( "Cli Bob" ));
var first = ( await observer . ReadUntilAsync ( e => e . TextMessage is not null )). TextMessage ;
var second = ( await observer . ReadUntilAsync ( e => e . TextMessage is not null )). TextMessage ;
Assert . Equal ( "CLI voice checkpoint" , first . Body );
Assert . Equal ( first . Body , second . Body );
Assert . NotEqual ( first . SenderId , second . SenderId );
async Task RunAsync ( string nickname )
{
var start = new 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 ( System . Globalization . CultureInfo . InvariantCulture ),
"--nick" , nickname , "--channel" , channel . ToString ( System . Globalization . CultureInfo . InvariantCulture ), "--text" , "CLI voice checkpoint" , "--test-tone-ms" , "4000" })
start . ArgumentList . Add ( argument );
using var process = Process . Start ( start )!;
Task < string > stdout = process . StandardOutput . ReadToEndAsync ();
Task < string > stderr = process . StandardError . ReadToEndAsync ();
try
{
await process . WaitForExitAsync ( timeout . Token );
string log = await stdout + await stderr ;
Assert . True ( process . ExitCode == 0 , log );
Assert . Contains ( "[test-tone] received=" , log );
}
finally { if (! process . HasExited ) { process . Kill ( true ); await process . WaitForExitAsync (); } }
}
}
private sealed class CppCliVoiceTheoryAttribute : TheoryAttribute
{
public CppCliVoiceTheoryAttribute ()
{
if ( string . IsNullOrEmpty ( Environment . GetEnvironmentVariable ( "VOICECAT_VCCLI" ))) Skip = "Set VOICECAT_VCCLI to the existing native CLI." ;
}
}
[VoiceOracleTheory]
[InlineData(1)]
[InlineData(2)]
public async Task ExistingCppClientsExchangeBidirectionalVoiceThroughManagedServer ( int channel )
{
await using var fixture = new ServerFixture ();
var start = new ProcessStartInfo ( Environment . GetEnvironmentVariable ( "VOICECAT_VOICE_ORACLE" )!)
{
WorkingDirectory = fixture . Directory , UseShellExecute = false , CreateNoWindow = true ,
RedirectStandardOutput = true , RedirectStandardError = true
};
start . ArgumentList . Add ( fixture . Server . EndPoint . Port . ToString ( System . Globalization . CultureInfo . InvariantCulture ));
start . ArgumentList . Add ( channel . ToString ( System . Globalization . CultureInfo . InvariantCulture ));
using var process = Process . Start ( start )!;
Task < string > output = process . StandardOutput . ReadToEndAsync ();
Task < string > error = process . StandardError . ReadToEndAsync ();
using var timeout = new CancellationTokenSource ( TimeSpan . FromSeconds ( 45 ));
try
{
await process . WaitForExitAsync ( timeout . Token );
Assert . True ( process . ExitCode == 0 , await output + await error );
}
finally { if (! process . HasExited ) { process . Kill ( true ); await process . WaitForExitAsync (); } }
}
private sealed class VoiceOracleTheoryAttribute : TheoryAttribute
{
public VoiceOracleTheoryAttribute ()
{
if ( string . IsNullOrEmpty ( Environment . GetEnvironmentVariable ( "VOICECAT_VOICE_ORACLE" ))) Skip = "Set VOICECAT_VOICE_ORACLE to the native voice conformance executable." ;
}
}
[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 ( 21 UL , announced . RequestId );
Assert . True ( announced . StreamAnnounceResult . Ok );
Assert . Equal ( 64000 U , announced . StreamAnnounceResult . EffectiveAudio . BitrateBps );
Assert . Equal ( 48000 U , 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 ( 48000 U , screen . EffectiveAudio . SampleRate );
Assert . Equal ( 24000 U , 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 ( 960 U , received . Header . Timestamp );
Assert . Equal ( VoiceFrameFlags . Marker | VoiceFrameFlags . FecPresent , received . Header . Flags );
}
Assert . Equal ( 2 UL , 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 ( 1 UL , 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 (); }
}
}