Retire legacy implementations and flatten managed layout
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-21 00:11:32 +02:00
parent dd811a0bb8
commit 08e6c5930a
422 changed files with 252 additions and 38242 deletions
-247
View File
@@ -1,247 +0,0 @@
# Tests use plain asserts + exit codes (no framework dep needed).
# Behavior tests — not just "it compiles" — are how milestones are judged (AGENTS.md).
add_executable(test_smoke test_smoke.cpp)
target_link_libraries(test_smoke PRIVATE voicecat::voicecat)
target_compile_features(test_smoke PRIVATE cxx_std_20)
add_test(NAME smoke COMMAND test_smoke)
# Needs core/src on the include path to reach internal headers (protocol/, session/, etc.).
add_executable(test_frame_codec test_frame_codec.cpp)
target_link_libraries(test_frame_codec PRIVATE voicecat::voicecat)
target_compile_features(test_frame_codec PRIVATE cxx_std_20)
target_include_directories(test_frame_codec PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
add_test(NAME frame_codec COMMAND test_frame_codec)
set(VC_TEST_INTERNAL_INCLUDES
${CMAKE_SOURCE_DIR}/core/src
${CMAKE_SOURCE_DIR}/server/src
${CMAKE_BINARY_DIR}/core/generated) # protobuf-generated headers
add_executable(test_envelope test_envelope.cpp)
target_link_libraries(test_envelope PRIVATE voicecat::voicecat)
target_compile_features(test_envelope PRIVATE cxx_std_20)
target_include_directories(test_envelope PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME envelope COMMAND test_envelope)
add_executable(test_tls_loopback test_tls_loopback.cpp)
target_link_libraries(test_tls_loopback PRIVATE voicecat::voicecat)
target_compile_features(test_tls_loopback PRIVATE cxx_std_20)
target_include_directories(test_tls_loopback PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME tls_loopback COMMAND test_tls_loopback)
# Links voicecat::server (which pulls in voicecat::voicecat + all deps transitively).
add_executable(test_m1_integration test_m1_integration.cpp)
target_link_libraries(test_m1_integration PRIVATE voicecat::server)
target_compile_features(test_m1_integration PRIVATE cxx_std_20)
target_include_directories(test_m1_integration PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m1_integration COMMAND test_m1_integration)
set_tests_properties(m1_integration PROPERTIES TIMEOUT 60)
# ── M2 unit tests ──────────────────────────────────────────────────────────
add_executable(test_voice_frame test_voice_frame.cpp)
target_link_libraries(test_voice_frame PRIVATE voicecat::voicecat)
target_compile_features(test_voice_frame PRIVATE cxx_std_20)
target_include_directories(test_voice_frame PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME voice_frame COMMAND test_voice_frame)
add_executable(test_media_aead test_media_aead.cpp)
target_link_libraries(test_media_aead PRIVATE voicecat::voicecat)
target_compile_features(test_media_aead PRIVATE cxx_std_20)
target_include_directories(test_media_aead PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME media_aead COMMAND test_media_aead)
add_executable(test_opus_codec test_opus_codec.cpp)
target_link_libraries(test_opus_codec PRIVATE voicecat::voicecat)
target_compile_features(test_opus_codec PRIVATE cxx_std_20)
target_include_directories(test_opus_codec PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME opus_codec COMMAND test_opus_codec)
# PLC cap: after ~2s of pure PLC (no real packets), the mixer emits silence instead of
# comfort noise — bounds the eternal-hiss failure mode (defense-in-depth for the
# disconnect/LEFT fix). White-box AudioEngine test, no server needed.
add_executable(test_plc_cap test_plc_cap.cpp)
target_link_libraries(test_plc_cap PRIVATE voicecat::voicecat)
target_compile_features(test_plc_cap PRIVATE cxx_std_20)
target_include_directories(test_plc_cap PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME plc_cap COMMAND test_plc_cap)
# Jitter-buffer bounded depth: across many talkspurt/silence cycles with a compressed sender
# timeline + reordered stragglers, playout latency must stay bounded (no backward drift /
# ratchet). White-box AudioEngine test, no server needed.
add_executable(test_jitter_depth test_jitter_depth.cpp)
target_link_libraries(test_jitter_depth PRIVATE voicecat::voicecat)
target_compile_features(test_jitter_depth PRIVATE cxx_std_20)
target_include_directories(test_jitter_depth PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME jitter_depth COMMAND test_jitter_depth)
# External playback (iOS VPIO): the mixer-timer thread drives decode+mix with NO hardware
# device and delivers the final mix to the mixed-output sink. White-box AudioEngine test.
add_executable(test_external_playback test_external_playback.cpp)
target_link_libraries(test_external_playback PRIVATE voicecat::voicecat)
target_compile_features(test_external_playback PRIVATE cxx_std_20)
target_include_directories(test_external_playback PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME external_playback COMMAND test_external_playback)
# M2 exit criterion: two headless clients relay encrypted Opus frames via the SFU.
add_executable(test_m2_voice test_m2_voice.cpp)
target_link_libraries(test_m2_voice PRIVATE voicecat::server)
target_compile_features(test_m2_voice PRIVATE cxx_std_20)
target_include_directories(test_m2_voice PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m2_voice COMMAND test_m2_voice)
set_tests_properties(m2_voice PROPERTIES TIMEOUT 120)
# Same exit criterion, but through the real C ABI (vc_client), not raw sockets —
# proves stream_start/stop/UDP-binding in core/src/core/client.cpp actually work.
add_executable(test_voice_client_abi test_voice_client_abi.cpp)
target_link_libraries(test_voice_client_abi PRIVATE voicecat::server)
target_compile_features(test_voice_client_abi PRIVATE cxx_std_20)
target_include_directories(test_voice_client_abi PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME voice_client_abi COMMAND test_voice_client_abi)
set_tests_properties(voice_client_abi PROPERTIES TIMEOUT 60)
# M3 exit criterion: multi-stream (mic + desktop audio), independent per-stream
# gain/mute/NS, per-channel Opus configurability, talk indicators -- all through the
# real C ABI (vc_client), not raw sockets.
add_executable(test_m3_multistream test_m3_multistream.cpp)
target_link_libraries(test_m3_multistream PRIVATE voicecat::server)
target_compile_features(test_m3_multistream PRIVATE cxx_std_20)
target_include_directories(test_m3_multistream PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m3_multistream COMMAND test_m3_multistream)
set_tests_properties(m3_multistream PROPERTIES TIMEOUT 60)
# Post-M3 follow-up: device enumeration, VAD/PTT input gate, true stereo playback mixing —
# the items PROGRESS.md's M3 section explicitly carried forward as out of scope.
add_executable(test_vad_ptt_devices test_vad_ptt_devices.cpp)
target_link_libraries(test_vad_ptt_devices PRIVATE voicecat::server)
target_compile_features(test_vad_ptt_devices PRIVATE cxx_std_20)
target_include_directories(test_vad_ptt_devices PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME vad_ptt_devices COMMAND test_vad_ptt_devices)
set_tests_properties(vad_ptt_devices PROPERTIES TIMEOUT 60)
# M4: channel/user/stream snapshot getters (vc_list_channels/vc_list_users/
# vc_list_user_streams) — through the real C ABI against a real in-process server.
add_executable(test_channel_user_list_abi test_channel_user_list_abi.cpp)
target_link_libraries(test_channel_user_list_abi PRIVATE voicecat::server)
target_compile_features(test_channel_user_list_abi PRIVATE cxx_std_20)
target_include_directories(test_channel_user_list_abi PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME channel_user_list_abi COMMAND test_channel_user_list_abi)
set_tests_properties(channel_user_list_abi PROPERTIES TIMEOUT 60)
# M4: TOFU server-identity gate (VC_EVENT_SERVER_IDENTITY / vc_confirm_server_identity /
# vc_get_server_identity_display) — real TLS handshakes against real in-process servers.
add_executable(test_tofu_flow test_tofu_flow.cpp)
target_link_libraries(test_tofu_flow PRIVATE voicecat::server)
target_compile_features(test_tofu_flow PRIVATE cxx_std_20)
target_include_directories(test_tofu_flow PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME tofu_flow COMMAND test_tofu_flow)
set_tests_properties(tofu_flow PROPERTIES TIMEOUT 90)
# M5 Phase 1: permission enforcement + SetPermissionRequest + channel create.
add_executable(test_m5_permissions test_m5_permissions.cpp)
target_link_libraries(test_m5_permissions PRIVATE voicecat::server)
target_compile_features(test_m5_permissions PRIVATE cxx_std_20)
target_include_directories(test_m5_permissions PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m5_permissions COMMAND test_m5_permissions)
set_tests_properties(m5_permissions PROPERTIES TIMEOUT 60)
# M5 Phase 2: kick/ban/move/server-mute.
add_executable(test_m5_kick_ban_move_mute test_m5_kick_ban_move_mute.cpp)
target_link_libraries(test_m5_kick_ban_move_mute PRIVATE voicecat::server)
target_compile_features(test_m5_kick_ban_move_mute PRIVATE cxx_std_20)
target_include_directories(test_m5_kick_ban_move_mute PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m5_kick_ban_move_mute COMMAND test_m5_kick_ban_move_mute)
set_tests_properties(m5_kick_ban_move_mute PROPERTIES TIMEOUT 90)
# M5 Phase 3: in-app admin account management.
add_executable(test_m5_admin_accounts test_m5_admin_accounts.cpp)
target_link_libraries(test_m5_admin_accounts PRIVATE voicecat::server)
target_compile_features(test_m5_admin_accounts PRIVATE cxx_std_20)
target_include_directories(test_m5_admin_accounts PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m5_admin_accounts COMMAND test_m5_admin_accounts)
set_tests_properties(m5_admin_accounts PROPERTIES TIMEOUT 90)
# M5 Phase 4: channel CRUD & password enforcement.
add_executable(test_m5_channel_crud test_m5_channel_crud.cpp)
target_link_libraries(test_m5_channel_crud PRIVATE voicecat::server)
target_compile_features(test_m5_channel_crud PRIVATE cxx_std_20)
target_include_directories(test_m5_channel_crud PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m5_channel_crud COMMAND test_m5_channel_crud)
set_tests_properties(m5_channel_crud PROPERTIES TIMEOUT 90)
# Disconnect/timeout: server broadcasts UserEvent::LEFT on TCP drop (no more ghost
# users or eternal PLC hiss on peers). Also covers the keepalive/reaper paths added
# alongside the LEFT-broadcast fix.
add_executable(test_disconnect_left test_disconnect_left.cpp)
target_link_libraries(test_disconnect_left PRIVATE voicecat::server)
target_compile_features(test_disconnect_left PRIVATE cxx_std_20)
target_include_directories(test_disconnect_left PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME disconnect_left COMMAND test_disconnect_left)
set_tests_properties(disconnect_left PROPERTIES TIMEOUT 90)
# Reaper: half-open connections (no TCP EOF) are dropped after the configurable timeout,
# peers get UserEvent::LEFT, the stale client gets disconnected. Uses a 2s timeout for
# fast test turnaround (production default is 45s).
add_executable(test_reaper_timeout test_reaper_timeout.cpp)
target_link_libraries(test_reaper_timeout PRIVATE voicecat::server)
target_compile_features(test_reaper_timeout PRIVATE cxx_std_20)
target_include_directories(test_reaper_timeout PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME reaper_timeout COMMAND test_reaper_timeout)
set_tests_properties(reaper_timeout PROPERTIES TIMEOUT 30)
# DRED toggle: per-channel dred flag round-trips through protocol; encoder initialises
# with DRED; PCM injection through DRED-enabled encode path runs without crash.
add_executable(test_dred_toggle test_dred_toggle.cpp)
target_link_libraries(test_dred_toggle PRIVATE voicecat::server)
target_compile_features(test_dred_toggle PRIVATE cxx_std_20)
target_include_directories(test_dred_toggle PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME dred_toggle COMMAND test_dred_toggle)
set_tests_properties(dred_toggle PROPERTIES TIMEOUT 60)
# External PCM feed/tap API:
# test_feed_pcm_round_trip — vc_stream_feed_pcm (mono), verified via pcm_sink.
# test_feed_pcm_stereo — vc_stream_feed_pcm (stereo, Music Room), L != R assertion.
# test_pcm_sink — vc_set_pcm_sink metadata (user_id / stream_id / sr) + disable.
add_executable(test_external_pcm test_external_pcm.cpp)
target_link_libraries(test_external_pcm PRIVATE voicecat::server)
target_compile_features(test_external_pcm PRIVATE cxx_std_20)
target_include_directories(test_external_pcm PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME external_pcm COMMAND test_external_pcm)
set_tests_properties(external_pcm PROPERTIES TIMEOUT 90)
# Non-20ms channel frame_ms: the fixed 48k/20ms engine clock is reframed to the channel's
# Opus window before encoding. 40ms (accumulate) and 10ms (split) feed->sink round trips.
add_executable(test_frame_ms_reframe test_frame_ms_reframe.cpp)
target_link_libraries(test_frame_ms_reframe PRIVATE voicecat::server)
target_compile_features(test_frame_ms_reframe PRIVATE cxx_std_20)
target_include_directories(test_frame_ms_reframe PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME frame_ms_reframe COMMAND test_frame_ms_reframe)
set_tests_properties(frame_ms_reframe PROPERTIES TIMEOUT 90)
# Per-channel sample_rate as an Opus bandwidth cap (OPUS_SET_MAX_BANDWIDTH): a 7 kHz tone is
# attenuated on an 8 kHz (narrowband) channel vs a 48 kHz (full-band) channel.
add_executable(test_channel_samplerate test_channel_samplerate.cpp)
target_link_libraries(test_channel_samplerate PRIVATE voicecat::server)
target_compile_features(test_channel_samplerate PRIVATE cxx_std_20)
target_include_directories(test_channel_samplerate PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME channel_samplerate COMMAND test_channel_samplerate)
set_tests_properties(channel_samplerate PROPERTIES TIMEOUT 90)
# Noise suppression: the RNNoise backend behind ApmProcessor actually denoises (docs/voice.md
# §10-11). Links core only; reaches the internal audio/ headers for ApmProcessor::create().
add_executable(test_noise_suppression test_noise_suppression.cpp)
target_link_libraries(test_noise_suppression PRIVATE voicecat::voicecat)
target_compile_features(test_noise_suppression PRIVATE cxx_std_20)
target_include_directories(test_noise_suppression PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME noise_suppression COMMAND test_noise_suppression)
# Receive-side NR through the decode loop: a stereo VOICE stream is actually denoised
# (regression for the stereo-mic bypass), and a screen-audio share is left untouched.
# White-box AudioEngine test; paced realtime feed, so it needs a generous timeout.
add_executable(test_recv_noise_reduction test_recv_noise_reduction.cpp)
target_link_libraries(test_recv_noise_reduction PRIVATE voicecat::voicecat)
target_compile_features(test_recv_noise_reduction PRIVATE cxx_std_20)
target_include_directories(test_recv_noise_reduction PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME recv_noise_reduction COMMAND test_recv_noise_reduction)
set_tests_properties(recv_noise_reduction PROPERTIES TIMEOUT 90)
+57
View File
@@ -0,0 +1,57 @@
using Microsoft.Data.Sqlite;
using VoiceCat.Server.Data;
namespace VoiceCat.Tests;
public sealed class AccountStoreTests
{
[Fact]
public void UnsupportedSchemaIsRejectedWithoutCreatingAccountTables()
{
string path = Path.Combine(Path.GetTempPath(), "voicecat-future-" + Guid.NewGuid().ToString("N") + ".db");
try
{
SQLitePCL.Batteries_V2.Init();
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE server_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL); INSERT INTO server_meta VALUES ('schema_version','99');";
command.ExecuteNonQuery();
Assert.Throws<InvalidDataException>(() => new AccountStore(path));
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE name='accounts'";
Assert.Equal(0L, command.ExecuteScalar());
command.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
Assert.Equal("99", command.ExecuteScalar());
}
finally { File.Delete(path); File.Delete(path + "-wal"); File.Delete(path + "-shm"); }
}
[Fact]
public async Task AccountsSurviveRestartAndFailedAuthDoesNotChangeLastLogin()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-db-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "voicecat.db");
try
{
Account account;
using (var store = new AccountStore(path)) account = await store.CreateAccountAsync("admin'", "secret", true);
using (var store = new AccountStore(path))
{
Assert.Null(await store.AuthenticateAsync("admin'", "wrong"));
Assert.Null(await store.AuthenticateAsync("missing", "secret"));
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT last_login FROM accounts WHERE id=$id";
command.Parameters.AddWithValue("$id", account.Id);
Assert.Equal(0L, command.ExecuteScalar());
Account authenticated = Assert.IsType<Account>(await store.AuthenticateAsync("admin'", "secret"));
Assert.Equal(account.Id, authenticated.Id);
Assert.True(authenticated.IsAdmin);
Assert.True(authenticated.LastLogin > 0);
}
}
finally { Directory.Delete(directory, true); }
}
}
+136
View File
@@ -0,0 +1,136 @@
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
{
[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");
}
}
+203
View File
@@ -0,0 +1,203 @@
using VoiceCat.Audio;
using VoiceCat.Codec;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class AudioEngineTests
{
[Theory]
[InlineData(-1000)]
[InlineData(1000)]
public void AdaptivePcmBufferAbsorbsIndependentClockDrift(int partsPerMillion)
{
var buffer = new AdaptivePcmBuffer(1, 40); short[] input = new short[962], output = new short[960];
input.AsSpan().Fill(1234); Assert.True(buffer.TryWrite(input.AsSpan(0, 960)));
Assert.True(buffer.TryWrite(input.AsSpan(0, 960)));
double produced = 0;
for (int cycle = 0; cycle < 10_000; cycle++)
{
produced += 960 * (1 + partsPerMillion / 1_000_000.0);
int frames = (int)produced; produced -= frames;
Assert.True(buffer.TryWrite(input.AsSpan(0, frames)));
Assert.Equal(output.Length, buffer.Read(output));
}
Assert.InRange(buffer.CountFrames, 480, 3840);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) { buffer.TryWrite(input.AsSpan(0, 960)); buffer.Read(output); }
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes()
{
var sent = new List<(uint Timestamp, VoiceFrameFlags Flags)>();
using var engine = new AudioEngine((_, timestamp, _, flags) => { sent.Add((timestamp, flags)); return true; }, false)
{ InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
engine.AddLocalStream(Stream()); short[] tone = Tone();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
engine.ProcessCycle();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
Assert.Equal(2, sent.Count); Assert.True((sent[0].Flags & VoiceFrameFlags.Marker) != 0); Assert.Equal(VoiceFrameFlags.None, sent[1].Flags & VoiceFrameFlags.Marker);
for (int i = 0; i < 10; i++) engine.ProcessCycle();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
}
[Theory]
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
{
using var stream = new ReceiveStream(2, Stream(frameMilliseconds));
Assert.Equal(frameMilliseconds * 48, stream.TargetDepthSamples);
}
[Fact]
public void JitterTargetAdaptsInSampleTimeAndRemainsCapped()
{
var clock = new ManualAudioClock(); StreamInfo info = Stream(); info.Audio.Fec = false;
using var stream = new ReceiveStream(2, info, clock); using var encoder = new OpusEncoder(new() { Bitrate = 32000 });
byte[] packet = new byte[1275]; int length = encoder.Encode(Tone(), packet);
for (uint i = 0; i < 40; i++)
{
clock.Advance(i % 2 == 0 ? 5 : 35);
stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
}
int[] output = new int[1920]; stream.Mix(output, false, null);
Assert.InRange(stream.TargetDepthSamples, 960, 5760);
}
[Fact]
public void DredUsesTimestampOffsetForConsecutiveMissingShortFrames()
{
using var probe = new OpusEncoder();
if (!probe.SupportsDeepRedundancy) return;
StreamInfo info = Stream(10, dred: true); using var stream = new ReceiveStream(2, info);
using var encoder = new OpusEncoder(new() { FrameDurationMilliseconds = 10, DeepRedundancy = true, ExpectedPacketLossPercent = 30, Bitrate = 64000 });
byte[] packet = new byte[1275]; short[] tone = new short[480]; int[] output = new int[1920];
for (uint i = 0; i < 50; i++)
{
CodecTests.FillTone(tone, 480, 1, 48000, (int)i); int length = encoder.Encode(tone, packet);
if (i is not (25 or 26)) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 480), packet.AsSpan(0, length));
if (i % 2 == 1) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
}
Assert.True(stream.DredFrames >= 2);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void LostFramesUseDredThenFecBeforeBoundedPlc(bool useDred)
{
using var receive = new ReceiveStream(2, Stream(dred: useDred));
using var encoder = new OpusEncoder(new() { DeepRedundancy = useDred, ForwardErrorCorrection = true, ExpectedPacketLossPercent = 30, Complexity = 10, Bitrate = 64000 });
byte[] packet = new byte[1275]; short[] tone = Tone(); int[] output = new int[1920];
for (uint i = 0; i < 40; i++)
{
CodecTests.FillTone(tone, 960, 1, 48000, (int)i);
int size = encoder.Encode(tone, packet);
if (i != 25 && i != 30 && i != 35) receive.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, size));
output.AsSpan().Clear(); receive.Mix(output, false, null);
}
if (useDred) Assert.True(receive.DredFrames > 0); else Assert.True(receive.FecFrames > 0);
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); receive.Mix(output, false, null); }
Assert.True(receive.ConcealedFrames > 0); Assert.All(output, sample => Assert.Equal(0, sample));
}
[Fact]
public void PcmRingDropsWholeFramesWhenFullAndPreservesOrderAcrossWraps()
{
var ring = new PcmRing(8); short[] output = new short[8];
for (int i = 0; i < 100; i++)
{
Assert.True(ring.TryWrite([1, 2, 3, 4, 5, 6])); Assert.False(ring.TryWrite([7, 8, 9]));
Assert.Equal(4, ring.Read(output.AsSpan(0, 4))); Assert.Equal(new short[] { 1, 2, 3, 4 }, output[..4]);
Assert.True(ring.TryWrite([7, 8])); Assert.Equal(4, ring.Read(output)); Assert.Equal(new short[] { 5, 6, 7, 8 }, output[..4]); Assert.Equal(0, ring.Count);
}
}
internal static StreamInfo Stream(int frame = 20, bool stereo = false, bool dred = false) => new()
{
StreamId = 1, Ssrc = 42, Kind = StreamKind.StreamMic,
Audio = new() { SampleRate = 48000, BitrateBps = 32000, FrameMs = (uint)frame, Complexity = 5,
Mode = stereo ? ChannelMode.ModeStereo : ChannelMode.ModeMono, Fec = true, ExpectedPacketLoss = 20, Dred = dred }
};
private static short[] Tone(int channels = 1)
{
var pcm = new short[960 * channels];
for (int i = 0; i < 960; i++) for (int c = 0; c < channels; c++) pcm[i * channels + c] = (short)(Math.Sin(i * 2 * Math.PI * (c == 0 ? 440 : 660) / 48000) * 8000);
return pcm;
}
[Theory]
[InlineData(5, false)] [InlineData(10, false)] [InlineData(20, false)] [InlineData(40, false)] [InlineData(60, false)] [InlineData(20, true)]
public void ReframedEncodedPcmIsDecodedAndMixedForMonoAndStereo(int frame, bool stereo)
{
StreamInfo stream = Stream(frame, stereo);
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.AlwaysOn; send.AddLocalStream(stream, stereo ? 2 : 1);
long energy = 0; int sinkChannels = 0;
receive.MixedPcm += pcm => { foreach (short sample in pcm) energy += Math.Abs((int)sample); };
receive.StreamPcm += (_, _, _, channels) => sinkChannels = channels;
short[] tone = Tone(stereo ? 2 : 1);
for (int i = 0; i < 30; i++) { Assert.True(send.FeedPcm(1, tone, stereo ? 2 : 1)); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.True(energy > 100000); Assert.Equal(stereo ? 2 : 1, sinkChannels);
receive.SetRemotePlayback(2, 1, 1, true, false); energy = 0;
for (int i = 0; i < 5; i++) { send.FeedPcm(1, tone, stereo ? 2 : 1); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.Equal(0, energy);
}
[Fact]
public void AudioCyclesAllocateZeroBytesWithEncodeDecodeStereoNoiseReductionAndMixing()
{
StreamInfo stream = Stream(20, true);
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
receive.SetRemotePlayback(2, 1, 0.8f, false, true);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.AlwaysOn; send.InputNoiseReduction = true; send.AddLocalStream(stream, 2);
short[] tone = Tone(2);
for (int i = 0; i < 30; i++) { send.FeedPcm(1, tone, 2); send.ProcessCycle(); receive.ProcessCycle(); }
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) { send.FeedPcm(1, tone, 2); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void JitterBacklogIsBoundedAndPlcEventuallyBecomesSilence()
{
var info = Stream(); using var stream = new ReceiveStream(2, info); using var encoder = new OpusEncoder(new() { Bitrate = 32000 });
byte[] packet = new byte[1275]; int length = encoder.Encode(Tone(), packet); int[] output = new int[1920];
for (uint i = 0; i < 64; i++) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
stream.Mix(output, false, null); Assert.InRange(stream.Depth, 0, 6);
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
Assert.All(output, value => Assert.Equal(0, value)); Assert.InRange(stream.ConcealedFrames, 1, 10);
}
[Fact]
public void PttResumeAndCaptureChannelChangesKeepTimestampProgressAndAudio()
{
var info = Stream(60, true); using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { info } }], 1, 1);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.PushToTalk; send.PushToTalk = true; send.AddLocalStream(info);
short[] mono = Tone(), stereo = Tone(2); long energy = 0;
receive.MixedPcm += pcm => { foreach (short value in pcm) energy += Math.Abs((int)value); };
for (int i = 0; i < 15; i++) { send.FeedPcm(1, mono, 1); send.ProcessCycle(); receive.ProcessCycle(); }
send.PushToTalk = false;
for (int i = 0; i < 16; i++) { send.FeedPcm(1, mono, 1); send.ProcessCycle(); receive.ProcessCycle(); }
send.SetCaptureChannels(1, 2); send.PushToTalk = true; energy = 0;
for (int i = 0; i < 15; i++) { send.FeedPcm(1, stereo, 2); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.True(energy > 100000);
}
private sealed class ManualAudioClock : TimeProvider
{
private long milliseconds;
public override long TimestampFrequency => 1000;
public override long GetTimestamp() => milliseconds;
internal void Advance(int value) => milliseconds += value;
}
}
@@ -0,0 +1,102 @@
using VoiceCat.Server.Data;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public class ChannelManagementTests
{
internal static async Task<Client> AdminAsync(ServerFixture fixture)
{
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
await store.CreateAccountAsync("Admin", "secret", true);
Client client = await fixture.ConnectAsync();
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
await client.ReadUntilAsync(e => e.ServerHello is not null);
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
Assert.True((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
await client.ReadUntilAsync(e => e.ServerState is not null);
return client;
}
private static Channel Room(string name = "Protected") => new()
{
Name = name, MaxUsers = 1,
Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20, Complexity = 5, Fec = true }
};
internal static async Task<bool> ResultAsync(Client client, Envelope request)
{
request.RequestId = 42;
client.Send(request);
Envelope result = await client.ReadUntilAsync(e => e.GenericResult is not null);
Assert.Equal(42UL, result.RequestId);
return result.GenericResult.Ok;
}
[Fact]
public async Task ProtectedChannelCrudEnforcesPasswordCapacityAndMovesMembersToLobby()
{
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() { CreateChannel = new() { Channel = Room() } }));
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room(), Password = "pāssword" } }));
Channel room = (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Created)).ChannelEvent.Channel;
Assert.True(room.PasswordProtected);
foreach (string password in new[] { "", "wrong", "pāssword" })
{
guest.Send(new() { RequestId = 7, JoinChannel = new() { ChannelId = room.Id, Password = password } });
Envelope result = await guest.ReadUntilAsync(e => e.JoinChannelResult is not null);
Assert.Equal(7UL, result.RequestId);
Assert.Equal(password == "pāssword", result.JoinChannelResult.Ok);
}
admin.Send(new() { JoinChannel = new() { ChannelId = room.Id, Password = "pāssword" } });
Assert.False((await admin.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
room.Name = "Renamed";
Assert.False(await ResultAsync(guest, new() { EditChannel = new() { Channel = room } }));
Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = room } }));
Assert.Equal("Renamed", (await guest.ReadUntilAsync(e => e.ChannelEvent is not null)).ChannelEvent.Channel.Name);
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
{
Assert.Equal("Renamed", store.LoadChannels().Single(c => c.Id == room.Id).Name);
Assert.True(store.CheckChannelPassword(room.Id, "pāssword"));
Assert.False(store.CheckChannelPassword(room.Id, "wrong"));
}
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = room.Id } }));
Assert.Equal(room.Id, (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Deleted)).ChannelEvent.DeletedId);
guest.Send(new() { Subscribe = new() });
var snapshot = (await guest.ReadUntilAsync(e => e.ServerState is not null)).ServerState;
Assert.Equal(1U, snapshot.Users.Single(u => u.Id == user.Id).ChannelId);
Assert.DoesNotContain(snapshot.Channels, c => c.Id == room.Id);
using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
Assert.DoesNotContain(reopened.LoadChannels(), c => c.Id == room.Id);
}
[Fact]
public async Task InvalidChangesCannotCorruptChannelTreeOrLobby()
{
await using var fixture = new ServerFixture();
await using var admin = await AdminAsync(fixture);
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Parent") } }));
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
Channel parent = store.LoadChannels().Single(c => c.Name == "Parent");
Channel child = Room("Child"); child.ParentId = parent.Id;
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = child } }));
child = store.LoadChannels().Single(c => c.Name == "Child");
parent.ParentId = child.Id;
Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = parent } }));
Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = 1 } }));
Channel lobby = store.LoadChannels().Single(c => c.Id == 1);
Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = lobby, Password = "lockout" } }));
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Child") } }));
var invalid = Room("Invalid"); invalid.Audio.SampleRate = 123;
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = invalid } }));
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() }));
Assert.Equal(4, store.LoadChannels().Count);
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = child.Id } }));
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
}
}
+123
View File
@@ -0,0 +1,123 @@
using VoiceCat.Codec;
namespace VoiceCat.Tests;
public sealed class CodecTests
{
public static IEnumerable<object[]> Formats()
{
foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 })
foreach (int channels in new[] { 1, 2 })
foreach (int duration in new[] { 10, 20, 40, 60 })
yield return [rate, channels, duration];
}
[Theory]
[MemberData(nameof(Formats))]
public void RoundTripAndLossConcealment(int sampleRate, int channels, int duration)
{
var options = new OpusOptions { SampleRate = sampleRate, Channels = channels, FrameDurationMilliseconds = duration, Bitrate = 64000 };
using var encoder = new OpusEncoder(options);
using var decoder = new OpusDecoder(sampleRate, channels);
short[] input = new short[options.SamplesPerChannel * channels];
short[] output = new short[input.Length];
byte[] packet = new byte[4000];
for (int frame = 0; frame < 12; frame++)
{
FillTone(input, options.SamplesPerChannel, channels, sampleRate, frame);
int bytes = encoder.Encode(input, packet);
Assert.InRange(bytes, 1, packet.Length);
Assert.Equal(options.SamplesPerChannel, decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
}
double rms = Rms(output);
Assert.InRange(rms, 2000, 12000);
Assert.Equal(options.SamplesPerChannel, decoder.Decode([], output, options.SamplesPerChannel));
Assert.True(Rms(output) > 100);
}
[Fact]
public void RejectsInvalidStorageAndOptionsBeforeNativeCalls()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { Channels = 3 }));
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { FrameDurationMilliseconds = 30 }));
using var encoder = new OpusEncoder();
using var decoder = new OpusDecoder();
Assert.Throws<ArgumentException>(() => encoder.Encode(new short[959], new byte[4000]));
Assert.Throws<ArgumentException>(() => decoder.Decode([], new short[959], 960));
encoder.Dispose();
Assert.Throws<ObjectDisposedException>(() => encoder.Encode(new short[960], new byte[4000]));
}
[Fact]
public void DredIsExplicitlySupportedOrRejected()
{
using var probe = new OpusEncoder();
Assert.Contains("libopus", OpusEncoder.Version);
if (!probe.SupportsDeepRedundancy)
{
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { DeepRedundancy = true }));
Assert.Throws<NotSupportedException>(() => new OpusDeepRedundancy());
return;
}
VerifyDredRecovery(new() { DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
}
[Theory]
[MemberData(nameof(Formats))]
public void DredRecoversDroppedFrames(int sampleRate, int channels, int duration)
{
using var probe = new OpusEncoder();
Assert.True(probe.SupportsDeepRedundancy, "Build native bindings with scripts/build-native.ps1 for DRED recovery tests.");
if (sampleRate < 16000)
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { SampleRate = sampleRate, DeepRedundancy = true }));
VerifyDredRecovery(new() { SampleRate = sampleRate, Channels = channels,
FrameDurationMilliseconds = duration, DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
}
private static void VerifyDredRecovery(OpusOptions options)
{
// The pinned encoder cannot emit DRED at 8/12 kHz; packets can still be decoded at those rates.
var encoderOptions = options with { SampleRate = Math.Max(16000, options.SampleRate) };
using var encoder = new OpusEncoder(encoderOptions);
using var decoder = new OpusDecoder(options.SampleRate, options.Channels);
using var recovery = new OpusDeepRedundancy();
short[] input = new short[encoderOptions.SamplesPerChannel * options.Channels];
short[] output = new short[options.SamplesPerChannel * options.Channels];
byte[] packet = new byte[4000];
bool missing = false;
int recovered = 0;
for (int frame = 0; frame < 40; frame++)
{
FillTone(input, encoderOptions.SamplesPerChannel, options.Channels, encoderOptions.SampleRate, frame);
int bytes = encoder.Encode(input, packet);
if (missing)
{
Assert.True(recovery.TryRecover(decoder, packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
Assert.True(Rms(output) > 10);
recovered++;
missing = false;
}
if (frame > 20 && frame % 5 == 0)
{
missing = true;
continue;
}
decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel);
}
Assert.Equal(3, recovered);
}
internal static void FillTone(Span<short> pcm, int samples, int channels, int rate, int frame)
{
for (int i = 0; i < samples; i++)
for (int channel = 0; channel < channels; channel++)
pcm[i * channels + channel] = (short)(8000 * Math.Sin(2 * Math.PI * (440 + 220 * channel) * (frame * samples + i) / rate));
}
internal static double Rms(ReadOnlySpan<short> pcm)
{
double sum = 0;
foreach (short value in pcm) sum += (double)value * value;
return Math.Sqrt(sum / pcm.Length);
}
}
+70
View File
@@ -0,0 +1,70 @@
using VoiceCat.Dsp;
using System.Text.Json;
namespace VoiceCat.Tests;
public sealed class DspTests
{
[Fact]
public void SuppressesNoiseAndPreservesUnsupportedSampleRates()
{
using var processor = new RnnoiseProcessor();
short[] pcm = new short[960];
uint random = 0x12345678;
double inputEnergy = 0, outputEnergy = 0;
for (int frame = 0; frame < 200; frame++)
{
FillNoise(pcm, ref random);
if (frame >= 60) foreach (short value in pcm) inputEnergy += (double)value * value;
processor.Process(pcm);
if (frame >= 60) foreach (short value in pcm) outputEnergy += (double)value * value;
}
Assert.True(Math.Sqrt(outputEnergy / inputEnergy) < 0.2);
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "rnnoise.json")));
short[] expected = fixture.RootElement.GetProperty("samples").EnumerateArray().Select(value => value.GetInt16()).ToArray();
Assert.Equal(pcm.Length, expected.Length);
for (int i = 0; i < pcm.Length; i++) Assert.InRange(Math.Abs(pcm[i] - expected[i]), 0, 1);
FillNoise(pcm, ref random);
short[] original = (short[])pcm.Clone();
processor.Process(pcm, 16000);
Assert.Equal(original, pcm);
Assert.Throws<ArgumentException>(() => processor.Process(new short[481]));
processor.Dispose();
Assert.Throws<ObjectDisposedException>(() => processor.Process(pcm));
}
[Fact]
public void VadStartsClosedAndUsesMonotonicHangTime()
{
var clock = new ManualTimeProvider();
var processor = new EnergyVadProcessor(0.02f, TimeSpan.FromMilliseconds(300), clock);
Assert.False(processor.Process(new short[480]));
Assert.True(processor.Process(new short[] { 32767 }));
clock.Advance(299);
Assert.True(processor.Process([]));
clock.Advance(1);
Assert.False(processor.Process(new short[480]));
processor.Threshold = 0.5f;
Assert.False(processor.Process(new short[] { 1000 }));
Assert.Throws<ArgumentOutOfRangeException>(() => processor.Threshold = float.NaN);
}
internal static void FillNoise(Span<short> pcm, ref uint random)
{
for (int i = 0; i < pcm.Length; i++)
{
random ^= random << 13;
random ^= random >> 17;
random ^= random << 5;
pcm[i] = (short)((int)(random % 6001) - 3000);
}
}
private sealed class ManualTimeProvider : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => 1000;
public override long GetTimestamp() => timestamp;
public void Advance(int milliseconds) => timestamp += milliseconds;
}
}
@@ -0,0 +1 @@
{"hashes":[{"passwordBase64":"dm9pY2VjYXQgdGVzdA","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE"},{"passwordBase64":"Y2Fmw6k","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$lEpmh4tmC0xaD5DhMboQo/3Hw7JqT3VThdqq0n1pImc"},{"passwordBase64":"YQBi","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$XZZGeWLqPMYfYmkPOuDe9dOMu0w7kVG9WS8/Dl6sVI0"}]}
@@ -0,0 +1 @@
{"samples":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}
+9
View File
@@ -0,0 +1,9 @@
{
"envelope": "00000020082a521c08011204746578741a0b746573742d636c69656e742205302e302e31",
"media": [
{"sequence": 0, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "", "packet": "01010000cafebabe0000000000000000000003c032faa61a66270f8b198f47e32e32ca84"},
{"sequence": 1, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60616263", "packet": "01010000cafebabe0000000000000001000003c0695d7eda350fbe7d25787424bf19191d00e02d53daa4ea625d23af3335f38115f30cce2997de88a40961c10f8ace84e1f5cf7740bd5e62025c022a75532a11465f9322f9867fcf6a35396f86fdca1959d8512ae564c3f09eb1e8e224cd6bdef556a073c12aa45bdae5e77e1f2827b1f3e549f15c"},
{"sequence": 65535, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe000000000000ffff000003c096bac906a2d141b97834d57095a62f947529d13f6a74a866"},
{"sequence": 65536, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe0000000000010000000003c005ecf39e7f89b45accd35e9b5c9b45bde30713a28b8f3183"}
]
}
+181
View File
@@ -0,0 +1,181 @@
using System.Buffers;
using System.IO.Pipelines;
using Google.Protobuf;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class FramingTests
{
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(65536)]
[InlineData(ControlFraming.MaxPayloadLength)]
public void PayloadRoundTrips(int size)
{
byte[] payload = Enumerable.Range(0, size).Select(i => (byte)i).ToArray();
var output = new ArrayBufferWriter<byte>();
ControlFraming.WriteFrame(output, payload);
var input = new ReadOnlySequence<byte>(output.WrittenMemory);
Assert.True(ControlFraming.TryReadFrame(ref input, out var actual));
Assert.Equal(payload, actual.ToArray());
Assert.True(input.IsEmpty);
}
[Fact]
public void IncompleteFramesDoNotConsumeInput()
{
byte[] frame = [0, 0, 0, 3, 1, 2, 3];
for (int size = 0; size < frame.Length; size++)
{
var input = new ReadOnlySequence<byte>(frame.AsMemory(0, size));
Assert.False(ControlFraming.TryReadFrame(ref input, out _));
Assert.Equal(size, input.Length);
}
}
[Fact]
public void SegmentsAndBatchedFramesAreHandled()
{
byte[] bytes = [0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0];
var first = new Segment(bytes.AsMemory(0, 1));
var last = first;
for (int i = 1; i < bytes.Length; i++) last = last.Append(bytes.AsMemory(i, 1));
var input = new ReadOnlySequence<byte>(first, 0, last, last.Memory.Length);
Assert.True(ControlFraming.TryReadFrame(ref input, out var payload));
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
Assert.True(ControlFraming.TryReadFrame(ref input, out payload));
Assert.True(payload.IsEmpty);
Assert.True(input.IsEmpty);
}
[Fact]
public void OversizedLengthsAreRejectedImmediately()
{
var input = new ReadOnlySequence<byte>(new byte[] { 1, 0, 0, 1 });
Assert.Throws<InvalidDataException>(() => ControlFraming.TryReadFrame(ref input, out _));
Assert.Throws<ArgumentOutOfRangeException>(() => ControlFraming.WriteFrame(new ArrayBufferWriter<byte>(), new byte[ControlFraming.MaxPayloadLength + 1]));
}
[Fact]
public async Task EnvelopesRoundTripThroughPipe()
{
var expected = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
expected.ClientHello.Features.Add("text");
var pipe = new Pipe();
ControlFraming.WriteEnvelope(pipe.Writer, expected);
ControlFraming.WriteEnvelope(pipe.Writer, new());
await pipe.Writer.CompleteAsync();
var actual = new List<Envelope>();
await foreach (var envelope in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) actual.Add(envelope);
Assert.Equal(new[] { expected, new Envelope() }, actual);
await pipe.Reader.CompleteAsync();
}
[Theory]
[InlineData(new byte[] { 0 })]
[InlineData(new byte[] { 0, 0, 0, 2, 1 })]
public async Task TruncatedEndOfStreamIsRejected(byte[] bytes)
{
var pipe = new Pipe();
pipe.Writer.Write(bytes);
await pipe.Writer.CompleteAsync();
await Assert.ThrowsAsync<InvalidDataException>(async () =>
{
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
});
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task InvalidProtobufIsRejected()
{
var pipe = new Pipe();
ControlFraming.WriteFrame(pipe.Writer, new byte[] { 0xff });
await pipe.Writer.CompleteAsync();
await Assert.ThrowsAsync<InvalidProtocolBufferException>(async () =>
{
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
});
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task ReadCanBeCanceled()
{
var pipe = new Pipe();
using var cancellation = new CancellationTokenSource();
await using var enumerator = ControlFraming.ReadEnvelopesAsync(pipe.Reader, cancellation.Token).GetAsyncEnumerator();
var pending = enumerator.MoveNextAsync().AsTask();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
await pipe.Writer.CompleteAsync();
await pipe.Reader.CompleteAsync();
}
[Fact]
public void UnknownFieldsSurviveParsing()
{
byte[] bytes = [8, 42, 0xa0, 6, 7];
Assert.Equal(bytes, Envelope.Parser.ParseFrom(bytes).ToByteArray());
}
[Fact]
public async Task FragmentedLargeEnvelopeMakesProgressUnderBackpressure()
{
var envelope = new Envelope { ClientHello = new() { ClientName = new string('a', 200000) } };
var framed = new ArrayBufferWriter<byte>();
ControlFraming.WriteEnvelope(framed, envelope);
var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 32, resumeWriterThreshold: 16));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
async Task Produce()
{
for (int offset = 0; offset < framed.WrittenCount; offset += 7)
await pipe.Writer.WriteAsync(framed.WrittenMemory.Slice(offset, Math.Min(7, framed.WrittenCount - offset)), timeout.Token);
await pipe.Writer.CompleteAsync();
}
var producer = Produce();
var actual = new List<Envelope>();
await foreach (var item in ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token)) actual.Add(item);
await producer;
Assert.Equal(new[] { envelope }, actual);
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task StoppingEnumerationLeavesFollowingFramesAvailable()
{
var pipe = new Pipe();
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 1 });
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 2 });
await pipe.Writer.FlushAsync();
await using (var first = ControlFraming.ReadEnvelopesAsync(pipe.Reader).GetAsyncEnumerator())
{
Assert.True(await first.MoveNextAsync());
Assert.Equal(1UL, first.Current.RequestId);
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using (var second = ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token).GetAsyncEnumerator())
{
Assert.True(await second.MoveNextAsync());
Assert.Equal(2UL, second.Current.RequestId);
}
await pipe.Writer.CompleteAsync();
await pipe.Reader.CompleteAsync();
}
private sealed class Segment : ReadOnlySequenceSegment<byte>
{
public Segment(ReadOnlyMemory<byte> memory) => Memory = memory;
public Segment Append(ReadOnlyMemory<byte> memory)
{
var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
Next = segment;
return segment;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Buffers;
using System.Text.Json;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class GoldenTests
{
[Fact]
public void EnvelopeMatchesCanonicalWireVector()
{
using var fixture = Load();
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
var envelope = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
envelope.ClientHello.Features.Add("text");
var output = new ArrayBufferWriter<byte>();
ControlFraming.WriteEnvelope(output, envelope);
Assert.Equal(expected, output.WrittenSpan.ToArray());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MediaPacketsMatchCanonicalWireVectors(bool managed)
{
using var fixture = Load();
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
{
byte[] key = Convert.FromHexString(vector.GetProperty("key").GetString()!);
byte[] plaintext = Convert.FromHexString(vector.GetProperty("plaintext").GetString()!);
byte[] expected = Convert.FromHexString(vector.GetProperty("packet").GetString()!);
ulong sequence = vector.GetProperty("sequence").GetUInt64();
using var sender = new MediaEncryptor(key, managed, sequence);
using var receiver = new MediaDecryptor(key, managed);
var header = new VoiceFrameHeader(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
byte[] actual = new byte[expected.Length];
sender.Encrypt(header, plaintext, actual);
Assert.Equal(expected, actual);
byte[] decoded = new byte[plaintext.Length];
Assert.True(receiver.TryDecrypt(expected, decoded, out var parsed, out int written));
Assert.Equal(sequence, parsed.Sequence);
Assert.Equal(plaintext.Length, written);
Assert.Equal(plaintext, decoded);
}
}
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "wire.json")));
}
+68
View File
@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Formats.Asn1;
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public class IdentityTests
{
[Fact]
public void CredentialsSurviveRestartAndBindIdentityIntoCertificate()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-credentials-" + Guid.NewGuid());
try
{
string identityFingerprint, certificateFingerprint;
using (var credentials = ServerCredentials.LoadOrCreate(directory, "Server, with punctuation"))
{
identityFingerprint = credentials.Identity.Fingerprint;
certificateFingerprint = credentials.CertificateFingerprint;
using var tls = credentials.CreateTlsSession();
Assert.False(tls.IsReady);
byte[] identity = File.ReadAllBytes(Path.Combine(directory, "identity.key"));
Assert.Equal(96, identity.Length);
Assert.Equal(identity[..32], identity[64..]);
using var certificate = X509Certificate2.CreateFromPem(File.ReadAllText(Path.Combine(directory, "server.crt")));
var san = new AsnReader(certificate.Extensions["2.5.29.17"]!.RawData, AsnEncodingRules.DER).ReadSequence();
Assert.Equal("urn:voicecat:identity:ed25519:" + Convert.ToHexString(credentials.Identity.PublicKey).ToLowerInvariant(),
san.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)));
Assert.False(san.HasData);
}
using var restored = ServerCredentials.LoadOrCreate(directory, "ignored after creation");
Assert.Equal(identityFingerprint, restored.Identity.Fingerprint);
Assert.Equal(certificateFingerprint, restored.CertificateFingerprint);
File.Delete(Path.Combine(directory, "server.key"));
Assert.Throws<InvalidDataException>(() => ServerCredentials.LoadOrCreate(directory, "unchanged"));
using var stillPresent = ServerIdentity.Load(Path.Combine(directory, "identity.key"));
Assert.Equal(identityFingerprint, stillPresent.Fingerprint);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void TofuRequiresExplicitPinAndPreservesFileFormat()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "pins.txt");
string fingerprint = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
try
{
var store = new TofuStore(path);
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
Assert.False(File.Exists(path));
store.Pin("localhost", 9987, fingerprint);
Assert.Equal($"localhost:9987 {fingerprint.ToLowerInvariant()}\n", File.ReadAllText(path));
store = new(path);
Assert.Equal(TofuStatus.Matched, store.Check("localhost", 9987, fingerprint));
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, new string('0', 64)));
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, fingerprint));
store.Remove("localhost", 9987);
Assert.Equal(TofuStatus.FirstConnect, new TofuStore(path).Check("localhost", 9987, fingerprint));
File.WriteAllText(path, "localhost:9987 " + new string('g', 64));
Assert.Throws<InvalidDataException>(() => new TofuStore(path));
}
finally { Directory.Delete(directory, true); }
}
}
+45
View File
@@ -0,0 +1,45 @@
using System.Diagnostics;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public class ManagedCliTests
{
[Theory]
[InlineData(1u)]
[InlineData(2u)]
public async Task TwoManagedCliProcessesExchangeTextAndDecodedVoice(uint channel)
{
await using var fixture = new ServerFixture();
string root = FindRoot();
string cli = Path.Combine(root, "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
Assert.True(File.Exists(cli), $"Managed CLI was not built at {cli}.");
using Process alice = Start(cli, fixture, "Alice", "Alice says hello", "Bob says hello", channel);
using Process bob = Start(cli, fixture, "Bob", "Bob says hello", "Alice says hello", channel);
Task<string> aliceOut = alice.StandardOutput.ReadToEndAsync(); Task<string> aliceError = alice.StandardError.ReadToEndAsync();
Task<string> bobOut = bob.StandardOutput.ReadToEndAsync(); Task<string> bobError = bob.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
await Task.WhenAll(alice.WaitForExitAsync(timeout.Token), bob.WaitForExitAsync(timeout.Token));
string aout = await aliceOut, bout = await bobOut;
Assert.True(alice.ExitCode == 0, await aliceError + Environment.NewLine + aout);
Assert.True(bob.ExitCode == 0, await bobError + Environment.NewLine + bout);
Assert.Contains("Bob says hello", aout); Assert.Contains("Alice says hello", bout);
Assert.Contains("\"type\":\"complete\"", aout); Assert.Contains("\"type\":\"complete\"", bout);
Assert.DoesNotContain("\"voiceEnergy\":0", aout); Assert.DoesNotContain("\"voiceEnergy\":0", bout);
}
private static Process Start(string cli, ServerFixture fixture, string name, string send, string expect, uint channel)
{
var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
foreach (string argument in new[] { cli, "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nickname", name,
"--pins", Path.Combine(fixture.Directory, name + ".cli.pins"), "--trust-first", "--channel", channel.ToString(), "--voice", "--expect-voice", "--send-text", send,
"--expect-text", expect, "--start-delay-ms", "1500", "--timeout-seconds", "20" }) start.ArgumentList.Add(argument);
return Process.Start(start) ?? throw new InvalidOperationException("Could not start managed CLI.");
}
private static string FindRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx"))) directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
}
}
+114
View File
@@ -0,0 +1,114 @@
using VoiceCat.Core;
using VoiceCat.Crypto;
using VoiceCat.Server.Data;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
using static VoiceCat.Tests.MediaRelayTests;
namespace VoiceCat.Tests;
public class ManagedClientTests
{
private static VoiceCatClient NewClient(ServerFixture fixture, string name) => new(name, "test", Path.Combine(fixture.Directory, name + ".pins"));
private static Task Connect(VoiceCatClient client, ServerFixture fixture) => client.ConnectAsync("127.0.0.1", (ushort)fixture.Server.EndPoint.Port, (_, _) => ValueTask.FromResult(true));
private static async Task<Envelope> Event(VoiceCatClient client, Func<Envelope, bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await foreach (Envelope message in client.ReadEventsAsync(timeout.Token)) if (predicate(message)) return message;
throw new IOException("Expected client event was not received.");
}
[Fact]
public async Task ManagedClientsAuthenticateChatAndCorrelateConcurrentRequests()
{
await using var fixture = new ServerFixture();
await using var alice = NewClient(fixture, "Alice"); await using var bob = NewClient(fixture, "Bob");
await Connect(alice, fixture); await Connect(bob, fixture);
Assert.True((await alice.AuthenticateGuestAsync("Alice")).Ok); Assert.True((await bob.AuthenticateGuestAsync("Bob")).Ok);
await Event(bob, e => e.ServerState is not null); await Event(alice, e => e.UserEvent?.User?.Nickname == "Bob");
Assert.Equal(2, alice.Users.Count);
var copy = alice.Users[0]; copy.Nickname = "Mutated"; Assert.DoesNotContain(alice.Users, u => u.Nickname == "Mutated");
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, Body = "Managed conversation", ClientMsgId = "a1" } });
Assert.Equal("Managed conversation", (await Event(bob, e => e.TextMessage is not null)).TextMessage.Body);
var requests = Enumerable.Range(1, 20).Select(async i =>
{
Envelope response = await alice.RequestAsync(new() { Ping = new() { Nonce = (ulong)i } });
Assert.Equal((ulong)i, response.Pong.Nonce); return response.RequestId;
});
Assert.Equal(20, (await Task.WhenAll(requests)).Distinct().Count());
await Assert.ThrowsAsync<InvalidOperationException>(() => Connect(alice, fixture));
Assert.Equal(ClientConnectionState.Connected, alice.State);
await alice.DisconnectAsync();
await Event(bob, e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
await Connect(alice, fixture); Assert.True((await alice.AuthenticateGuestAsync("Returned")).Ok);
}
[Fact]
public async Task TofuRequiresApprovalPinsAcceptedCertificateAndRejectsChanges()
{
await using var first = new ServerFixture(); await using var second = new ServerFixture();
await using var client = NewClient(first, "Tofu");
await Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(() => client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port));
await client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port, (challenge, _) =>
{ Assert.Equal(TofuStatus.FirstConnect, challenge.Status); return ValueTask.FromResult(true); });
await client.DisconnectAsync();
await client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port); await client.DisconnectAsync();
// Pin the other server's certificate to this endpoint, simulating a changed server certificate.
using var credentials = ServerCredentials.LoadOrCreate(second.Directory, "VoiceCat Server");
new TofuStore(Path.Combine(first.Directory, "Other.pins")).Pin("127.0.0.1", (ushort)first.Server.EndPoint.Port, credentials.CertificateFingerprint);
await using var changed = new VoiceCatClient(tofuStorePath: Path.Combine(first.Directory, "Other.pins"));
await Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(() => changed.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port,
(challenge, _) => { Assert.Equal(TofuStatus.Mismatch, challenge.Status); return ValueTask.FromResult(false); }));
}
[Fact]
public async Task ManagedClientSendsAndReceivesAuthenticatedEncodedVoice()
{
await using var fixture = new ServerFixture();
await using var managed = NewClient(fixture, "Managed"); await Connect(managed, fixture); await managed.AuthenticateGuestAsync("Managed");
Assert.True((await managed.SubscribeVoiceAsync()).Ok);
await using var peer = await VoicePeer.ConnectAsync(fixture, "Peer");
var remote = await peer.AnnounceAsync(StreamKind.StreamMic);
var local = (await managed.RequestAsync(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } })).StreamAnnounceResult;
Assert.True(local.Ok);
var received = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
managed.VoiceReceived += (header, payload) => { Assert.Equal(remote.Ssrc, header.Ssrc); received.TrySetResult(payload.ToArray()); };
await peer.SendAsync(peer.Seal(remote.Ssrc, [1, 2, 3]));
Assert.Equal(new byte[] { 1, 2, 3 }, await received.Task.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.True(managed.TrySendEncodedVoice(local.Ssrc, 960, [4, 5, 6]));
Assert.Equal(new byte[] { 4, 5, 6 }, (await peer.ReceiveVoiceAsync()).Payload);
}
[Fact]
public async Task ManagedAdministrationHelpersRoundTripTypedResults()
{
await using var fixture = new ServerFixture();
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
await store.CreateAccountAsync("Admin", "secret", true);
await using var admin = NewClient(fixture, "ManagedAdmin"); await Connect(admin, fixture);
Assert.True((await admin.AuthenticateUserAsync("Admin", "secret")).Ok);
await Event(admin, envelope => envelope.ServerState is not null);
Assert.True(admin.Permissions.IsAdmin);
await using var member = NewClient(fixture, "ManagedMember"); await Connect(member, fixture);
AuthResult memberAuth = await member.AuthenticateGuestAsync("ManagedMember"); Assert.True(memberAuth.Ok);
await Event(member, envelope => envelope.ServerState is not null);
await Event(admin, envelope => envelope.UserEvent?.User?.Id == memberAuth.Self.Id);
Assert.True((await admin.CreateAccountAsync("managed-ui", "first")).Ok);
Assert.Contains(await admin.ListAccountsAsync(), account => account.Username == "managed-ui");
Assert.True((await admin.ResetPasswordAsync("managed-ui", "second")).Ok);
var room = new Voicecat.V1.Channel { Name = "Managed UI room", Audio = new()
{ SampleRate = 48000, BitrateBps = 64000, FrameMs = 20, Complexity = 10, Fec = true } };
Assert.True((await admin.CreateChannelAsync(room, "protected")).Ok);
Envelope created = await Event(admin, envelope => envelope.ChannelEvent?.Channel?.Name == room.Name);
Channel edited = created.ChannelEvent.Channel.Clone(); edited.Topic = "Edited from managed UI";
Assert.True((await admin.EditChannelAsync(edited)).Ok);
Assert.True((await admin.SetPermissionsAsync(memberAuth.Self.Id, new() { CanCreateTempChannel = true })).Ok);
Assert.True((await admin.SetServerMuteAsync(memberAuth.Self.Id, true, true)).Ok);
Assert.True((await admin.SetServerMuteAsync(memberAuth.Self.Id, false, false)).Ok);
Assert.True((await admin.MoveUserAsync(memberAuth.Self.Id, created.ChannelEvent.Channel.Id)).Ok);
Assert.True((await admin.KickUserAsync(memberAuth.Self.Id, "managed helper test")).Ok);
Assert.True((await admin.DeleteChannelAsync(created.ChannelEvent.Channel.Id)).Ok);
Assert.True((await admin.DeleteAccountAsync("managed-ui")).Ok);
}
}
@@ -0,0 +1,34 @@
using VoiceCat.Codec;
using VoiceCat.Dsp;
namespace VoiceCat.Tests;
public sealed class MediaAllocationTests
{
[Fact]
public void SteadyStateCodecAndDspDoNotAllocateManagedMemory()
{
using var encoder = new OpusEncoder();
using var decoder = new OpusDecoder();
using var denoiser = new RnnoiseProcessor();
var vad = new EnergyVadProcessor();
short[] pcm = new short[960];
short[] decoded = new short[960];
byte[] packet = new byte[4000];
CodecTests.FillTone(pcm, 960, 1, 48000, 0);
for (int i = 0; i < 100; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
private static void Cycle(OpusEncoder encoder, OpusDecoder decoder, RnnoiseProcessor denoiser,
EnergyVadProcessor vad, short[] pcm, short[] decoded, byte[] packet)
{
int bytes = encoder.Encode(pcm, packet);
decoder.Decode(packet.AsSpan(0, bytes), decoded, 960);
denoiser.Process(decoded);
vad.Process(decoded);
}
}
+110
View File
@@ -0,0 +1,110 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
using Xunit.Abstractions;
namespace VoiceCat.Tests;
public sealed class MediaFanoutTests(ITestOutputHelper output)
{
[Fact]
public async Task UdpRelayDeliversFiftyPacketsPerSecondToFiftySubscribers()
{
await using var relay = new MediaRelay(new(IPAddress.Loopback, 0));
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
Socket[] sockets = Enumerable.Range(0, 51).Select(_ => new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)).ToArray();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try
{
foreach (Socket socket in sockets)
{
socket.ReceiveBufferSize = 1024 * 1024;
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
}
MediaRoute[] routes = sockets.Select(socket => new MediaRoute(
new(new byte[16], new(new(key), new(key))) { Endpoint = ((IPEndPoint)socket.LocalEndPoint!).Serialize() },
1, true, false, false, [42])).ToArray();
relay.Publish(routes);
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
Task[] receivers = sockets.Skip(1).Select(async socket =>
{
using var decryptor = new MediaDecryptor(key);
byte[] packet = new byte[4000];
byte[] decoded = new byte[4000];
for (ulong sequence = 0; sequence < 50; sequence++)
{
int length = await socket.ReceiveAsync(packet, SocketFlags.None, timeout.Token);
Assert.True(decryptor.TryDecrypt(packet.AsSpan(0, length), decoded, out var header, out int bytes));
Assert.Equal(sequence, header.Sequence);
Assert.Equal(payload, decoded[..bytes]);
}
}).ToArray();
using var encryptor = new MediaEncryptor(key);
byte[] outgoing = new byte[VoiceFrameHeader.Size + payload.Length + 16];
var elapsed = Stopwatch.StartNew();
for (uint index = 0; index < 50; index++)
{
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, index * 960), payload, outgoing);
await sockets[0].SendToAsync(outgoing, SocketFlags.None, relay.EndPoint, timeout.Token);
await Task.Delay(20, timeout.Token);
}
await Task.WhenAll(receivers);
output.WriteLine($"Delivered all 2,500 recipient packets in {elapsed.Elapsed.TotalMilliseconds:F1} ms at a paced 50 pps input.");
}
finally { foreach (Socket socket in sockets) socket.Dispose(); }
}
[PlatformCipherFact]
public void FiftySubscriberFanoutAllocatesNoManagedMemoryAndPreservesPayload()
{
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
MediaRoute[] routes = Enumerable.Range(0, 51).Select(i => new MediaRoute(
new(new byte[16], new(new(key), new(key))) { Endpoint = new IPEndPoint(IPAddress.Loopback, 10000 + i).Serialize() },
1, true, false, false, [42])).ToArray();
using var sender = new MediaEncryptor(key);
using var receiver = new MediaDecryptor(key);
using var fanout = new MediaFanout();
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
byte[] decoded = new byte[payload.Length];
ReadOnlyMemory<byte> last = default;
try
{
for (int i = 0; i < 100; i++) Cycle();
long before = GC.GetAllocatedBytesForCurrentThread();
long started = Stopwatch.GetTimestamp();
for (int i = 0; i < 1000; i++) Cycle();
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.True(receiver.TryDecrypt(last.Span, decoded, out var header, out int length));
Assert.Equal(payload.Length, length);
Assert.Equal(payload, decoded);
Assert.Equal(42U, header.Ssrc);
Assert.Equal(1099UL, header.Sequence);
output.WriteLine($"50,000 recipient seals in {elapsed.TotalMilliseconds:F1} ms; {allocated} managed bytes. Transport scheduling is excluded.");
}
finally { foreach (MediaRoute route in routes) route.Peer.Dispose(); }
void Cycle()
{
sender.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), payload, packet);
if (!fanout.TryStart(packet, routes[0], routes)) throw new InvalidOperationException("Valid packet rejected.");
int recipients = 0;
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
}
}
private sealed class PlatformCipherFactAttribute : FactAttribute
{
public PlatformCipherFactAttribute()
{
if (!ChaCha20Poly1305.IsSupported) Skip = "The allocation guarantee requires platform ChaCha20-Poly1305; fallback conformance is tested separately.";
}
}
}
+220
View File
@@ -0,0 +1,220 @@
using VoiceCat.Transport;
using System.Net;
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
{
[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(21UL, announced.RequestId);
Assert.True(announced.StreamAnnounceResult.Ok);
Assert.Equal(64000U, announced.StreamAnnounceResult.EffectiveAudio.BitrateBps);
Assert.Equal(48000U, 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(48000U, screen.EffectiveAudio.SampleRate);
Assert.Equal(24000U, 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(960U, received.Header.Timestamp);
Assert.Equal(VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent, received.Header.Flags);
}
Assert.Equal(2UL, 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(1UL, 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(); }
}
}
+166
View File
@@ -0,0 +1,166 @@
using System.Buffers.Binary;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class MediaTests
{
private static readonly byte[] Key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
private static readonly VoiceFrameHeader Header = new(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BothBackendsProduceIdenticalPackets(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, !managed);
byte[] plaintext = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray();
byte[] packet = Seal(sender, plaintext);
byte[] output = new byte[plaintext.Length];
Assert.True(receiver.TryDecrypt(packet, output, out var header, out int written));
Assert.Equal(Header, header);
Assert.Equal(plaintext.Length, written);
Assert.Equal(plaintext, output);
Assert.False(receiver.TryDecrypt(packet, output, out _, out written));
Assert.Equal(0, written);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ForgedCounterDoesNotPoisonReplayWindow(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, managed);
byte[] output = new byte[8];
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
byte[] packet = Seal(sender, new byte[8]);
byte[] forged = (byte[])packet.Clone();
BinaryPrimitives.WriteUInt64BigEndian(forged.AsSpan(8), ulong.MaxValue);
Array.Fill(output, (byte)0xaa);
Assert.False(receiver.TryDecrypt(forged, output, out var header, out int written));
Assert.Equal(default, header);
Assert.Equal(0, written);
Assert.All(output, value => Assert.Equal(0, value));
Assert.True(receiver.TryDecrypt(packet, output, out _, out _));
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void TamperingEveryPacketRegionFailsAuthentication(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
byte[] packet = Seal(sender, new byte[80]);
for (int i = 0; i < packet.Length; i++)
{
using var receiver = new MediaDecryptor(Key, managed);
byte[] tampered = (byte[])packet.Clone();
tampered[i] ^= 0x80;
Assert.False(receiver.TryDecrypt(tampered, new byte[80], out _, out _));
Assert.True(receiver.TryDecrypt(packet, new byte[80], out _, out _));
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReplayWindowAcceptsReorderingAndRejectsOldPackets(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, managed);
var packets = Enumerable.Range(0, 130).Select(_ => Seal(sender, new byte[1])).ToArray();
byte[] output = new byte[1];
Assert.True(receiver.TryDecrypt(packets[64], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[0], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[1], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[1], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[63], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[129], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[64], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[128], output, out _, out _));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CounterCrossesOldSixteenBitBoundary(bool managed)
{
using var sender = new MediaEncryptor(Key, managed, 65534);
using var receiver = new MediaDecryptor(Key, managed);
for (ulong sequence = 65534; sequence < 65540; sequence++)
{
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[1]), new byte[1], out var header, out _));
Assert.Equal(sequence, header.Sequence);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void InterleavedRelayUsesRecipientCounter(bool managed)
{
byte[] otherKey = Enumerable.Repeat((byte)42, 32).ToArray();
using var a = new MediaEncryptor(Key, managed);
using var b = new MediaEncryptor(otherKey, managed);
using var receiveA = new MediaDecryptor(Key, managed);
using var receiveB = new MediaDecryptor(otherKey, managed);
using var relay = new MediaEncryptor(Key, managed);
using var listener = new MediaDecryptor(Key, managed);
byte[] plaintext = [1, 2, 3];
byte[] decoded = new byte[3];
for (int i = 0; i < 16; i++)
{
var sender = i % 2 == 0 ? a : b;
var receiver = i % 2 == 0 ? receiveA : receiveB;
Assert.True(receiver.TryDecrypt(Seal(sender, plaintext), decoded, out var header, out _));
byte[] packet = new byte[39];
relay.Encrypt(header, decoded, packet);
Assert.True(listener.TryDecrypt(packet, decoded, out var relayedHeader, out _));
Assert.Equal((ulong)i, relayedHeader.Sequence);
Assert.Equal(plaintext, decoded);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EmptyPayloadAndLargeCountersWork(bool managed)
{
using var sender = new MediaEncryptor(Key, managed, ulong.MaxValue - 1);
using var receiver = new MediaDecryptor(Key, managed);
var packet = Seal(sender, []);
Assert.True(receiver.TryDecrypt(packet, [], out var header, out int written));
Assert.Equal(ulong.MaxValue - 1, header.Sequence);
Assert.Equal(0, written);
Assert.Throws<InvalidOperationException>(() => Seal(sender, []));
}
[Fact]
public void InvalidArgumentsAndDisposedInstancesAreRejected()
{
Assert.Throws<ArgumentException>(() => new MediaEncryptor(new byte[31]));
using var sender = new MediaEncryptor(Key);
using var receiver = new MediaDecryptor(Key);
Assert.Throws<ArgumentOutOfRangeException>(() => sender.Encrypt(Header, new byte[1], new byte[36]));
byte[] packet = Seal(sender, new byte[8]);
Assert.True(receiver.TryDecrypt(packet, new byte[8], out var header, out _));
Assert.Equal(0UL, header.Sequence);
Assert.False(receiver.TryDecrypt(new byte[35], [], out _, out _));
Assert.Throws<ArgumentOutOfRangeException>(() => receiver.TryDecrypt(packet, [], out _, out _));
sender.Dispose();
receiver.Dispose();
Assert.Throws<ObjectDisposedException>(() => Seal(sender, []));
Assert.Throws<ObjectDisposedException>(() => receiver.TryDecrypt(packet, new byte[8], out _, out _));
}
private static byte[] Seal(MediaEncryptor sender, byte[] plaintext)
{
byte[] packet = new byte[VoiceFrameHeader.Size + plaintext.Length + MediaEncryptor.TagSize];
Assert.Equal(packet.Length, sender.Encrypt(Header, plaintext, packet));
return packet;
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.Text;
using System.Text.Json;
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public sealed class PasswordTests
{
[Fact]
public void VerifiesLibsodiumHashesWithoutPasswordNormalization()
{
var hasher = new PasswordHasher();
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "argon2id.json")));
foreach (var item in fixture.RootElement.GetProperty("hashes").EnumerateArray())
{
string encodedPassword = item.GetProperty("passwordBase64").GetString()!;
string password = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPassword.PadRight((encodedPassword.Length + 3) / 4 * 4, '=')));
string hash = item.GetProperty("hash").GetString()!;
Assert.True(hasher.Verify(password, hash));
Assert.False(hasher.Verify(password + "!", hash));
}
}
[Fact]
public void FreshHashesUseRandomSaltAndNativePhcFormat()
{
var hasher = new PasswordHasher();
string first = hasher.Hash("hello");
string second = hasher.Hash("hello");
Assert.NotEqual(first, second);
Assert.StartsWith("$argon2id$v=19$m=65536,t=2,p=1$", first);
Assert.True(hasher.Verify("hello", first));
Assert.False(hasher.Verify("wrong", first));
}
[Theory]
[InlineData("$argon2id$v=19$m=999999999,t=2,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=19$m=65536,t=99999,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=16$m=65536,t=2,p=1$c2FsdA$aGFzaA")]
[InlineData("$argon2id$v=19$m=65536,t=2,p=1$!!!$!!!")]
public void MalformedOrExcessiveHashesFailClosed(string hash) => Assert.False(new PasswordHasher().Verify("hello", hash));
}
@@ -0,0 +1,162 @@
using VoiceCat.Transport;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using VoiceCat.Crypto;
using VoiceCat.Server;
using VoiceCat.Server.Data;
using VoiceCat.Server.Transport;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public class ProductionServerTests
{
[Fact]
public async Task HealthCheckPerformsTlsHandshakeAndCanPinCertificate()
{
await using var fixture = new ServerFixture();
using var credentials = ServerCredentials.LoadOrCreate(fixture.Directory, "VoiceCat Server");
var output = new StringWriter(); var error = new StringWriter();
string endpoint = "127.0.0.1:" + fixture.Server.EndPoint.Port;
Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint], output, error));
Assert.Contains("\"status\":\"healthy\"", output.ToString());
output.GetStringBuilder().Clear();
Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", credentials.CertificateFingerprint], output, error));
Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", new string('0', 64)], output, error));
Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", "127.0.0.1:1"], output, error));
}
[PublishedServerFact]
public async Task PublishedExecutableProvisionsAdminAndReportsFingerprintsWithoutPasswordOutput()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-published-" + Guid.NewGuid().ToString("N"));
async Task<string> Run(params string[] arguments)
{
var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_PUBLISHED_SERVER")!)
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
start.Environment["VOICECAT_ADMIN_PASSWORD"] = "published test password";
foreach (string argument in arguments.Concat(new[] { "--data-dir", directory })) start.ArgumentList.Add(argument);
using var process = System.Diagnostics.Process.Start(start)!;
Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try
{
await process.WaitForExitAsync(timeout.Token);
string log = await stdout + await stderr;
Assert.True(process.ExitCode == 0, log); Assert.DoesNotContain("published test password", log);
return log;
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
try
{
await Run("account", "add", "Operator", "--admin");
Assert.Contains("Operator", await Run("account", "list"));
using (var store = new AccountStore(Path.Combine(directory, "voicecat.db")))
Assert.True((await store.AuthenticateAsync("Operator", "published test password"))!.IsAdmin);
string fingerprint = (await Run("--print-fingerprint")).Trim();
Assert.Equal(64, fingerprint.Length);
Assert.Equal(fingerprint, (await Run("--print-fingerprint")).Trim());
await Run("account", "reset", "Operator"); await Run("account", "delete", "Operator");
Assert.DoesNotContain("Operator", await Run("account", "list"));
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
private sealed class PublishedServerFactAttribute : FactAttribute
{
public PublishedServerFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_PUBLISHED_SERVER"))) Skip = "Set VOICECAT_PUBLISHED_SERVER to the self-contained executable.";
}
}
[Fact]
public void ConfigurationHonorsEnvironmentAndCommandPrecedenceWithoutCreatingFiles()
{
var env = new Dictionary<string, string> { ["VOICECAT_BIND_PORT"] = "9000", ["VOICECAT_ALLOW_GUESTS"] = "false", ["VOICECAT_SERVER_NAME"] = "Environment" };
var config = ServerCommand.Parse(["--port", "0", "--name", "Command", "--bind", "::1", "--idle-seconds", "0"], key => env.GetValueOrDefault(key));
Assert.Equal(0, config.Port); Assert.Equal("Command", config.Options.Name);
Assert.False(config.Options.AllowGuests); Assert.Equal("::1", config.BindAddress);
Assert.Equal(TimeSpan.Zero, config.Options.IdleTimeout);
Assert.Equal(8384, ServerCommand.Parse([], _ => null).Port);
Assert.Equal("0.0.0.0", ServerCommand.Parse([], _ => null).BindAddress);
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--port", "65536"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--bind", "example.com"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--port"], _ => null));
Assert.Throws<ArgumentException>(() => ServerCommand.Parse(["--unencrypted", "true"], _ => null));
Assert.Throws<ArgumentOutOfRangeException>(() => ServerCommand.Parse(["--auth-burst", "0"], _ => null));
Assert.EndsWith("admin-data", ServerCommand.Parse(["account", "list", "--data-dir", "admin-data"], _ => null).Directory);
}
[Fact]
public void AuthenticationLimitsAreSharedAcrossConnectionsAndAddressesWithBackoff()
{
var clock = new TestClock();
var limiter = new AuthenticationLimiter(new() { AuthenticationBurst = 5 }, clock);
for (int i = 0; i < 3; i++) { Assert.True(limiter.TryAcquire("a", "user")); limiter.Record("a", "user", false); }
Assert.False(limiter.TryAcquire("a", "other"));
Assert.False(limiter.TryAcquire("b", "user"));
clock.Advance(1);
Assert.True(limiter.TryAcquire("b", "user")); limiter.Record("b", "user", false);
clock.Advance(1); Assert.False(limiter.TryAcquire("c", "user"));
clock.Advance(1); Assert.True(limiter.TryAcquire("c", "user")); limiter.Record("c", "user", true);
Assert.False(limiter.TryAcquire("d", "user")); // Success does not restore spent tokens.
clock.Advance(10); Assert.True(limiter.TryAcquire("d", "user"));
}
[Fact]
public async Task PasswordThrottleSurvivesReconnectAndRefillsBeforeSuccessfulLogin()
{
var clock = new TestClock();
await using var fixture = new ServerFixture(options: new() { AuthenticationBurst = 1 }, timeProvider: clock);
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) await store.CreateAccountAsync("Member", "secret");
async Task<bool> Login(string password)
{
await using var client = await fixture.ConnectAsync();
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } }); await client.ReadUntilAsync(e => e.ServerHello is not null);
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = password } } });
return (await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok;
}
Assert.False(await Login("wrong")); Assert.False(await Login("secret"));
clock.Advance(10); Assert.True(await Login("secret"));
}
[Fact]
public async Task HostPublishesReadinessPreventsDuplicateInstancesAndShutsDownActiveTls()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-host-" + Guid.NewGuid().ToString("N"));
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(20));
var output = new ReadyWriter(); var error = new StringWriter();
Task<int> running = ServerCommand.RunAsync(["--data-dir", directory, "--bind", "127.0.0.1", "--port", "0"], output, error, stop.Token);
try
{
using JsonDocument ready = JsonDocument.Parse(await output.Ready.Task.WaitAsync(stop.Token));
int port = ready.RootElement.GetProperty("port").GetInt32();
Assert.Equal(port, ready.RootElement.GetProperty("udp_port").GetInt32());
Assert.Equal(64, ready.RootElement.GetProperty("certificate_fingerprint").GetString()!.Length);
Assert.Equal(1, await ServerCommand.RunAsync(["--data-dir", directory, "--port", "0"], new StringWriter(), new StringWriter()));
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port), stop.Token);
string fingerprint = ready.RootElement.GetProperty("certificate_fingerprint").GetString()!;
await using var client = new Client(new TlsControlConnection(socket, TlsSession.CreateClient(value => value == fingerprint), CancellationToken.None));
await client.LoginAsync("Host test");
stop.Cancel(); Assert.Equal(0, await running.WaitAsync(TimeSpan.FromSeconds(10)));
Assert.Equal("", error.ToString());
}
finally { stop.Cancel(); await running; Directory.Delete(directory, true); }
}
private sealed class ReadyWriter : StringWriter
{
internal TaskCompletionSource<string> Ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override Task WriteLineAsync(string? value) { if (value?.Contains("\"ready\"", StringComparison.Ordinal) == true) Ready.TrySetResult(value); return base.WriteLineAsync(value); }
}
private sealed class TestClock : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => 1000;
public override long GetTimestamp() => timestamp;
internal void Advance(int seconds) => timestamp += seconds * 1000;
}
}
@@ -0,0 +1,50 @@
using System.Diagnostics;
namespace VoiceCat.Tests;
public class PublishServerScriptTests
{
[Fact]
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
{
string script = Path.Combine(FindRoot(), "scripts", "publish-server.ps1");
string allTargets = await RunWhatIf(script);
Assert.Contains("win-x64", allTargets);
Assert.Contains("linux-x64", allTargets);
string linuxOnly = await RunWhatIf(script, "-Runtime", "linux-x64");
Assert.Contains("linux-x64", linuxOnly);
Assert.DoesNotContain("win-x64", linuxOnly);
}
private static async Task<string> RunWhatIf(string script, params string[] arguments)
{
string powerShell = OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh";
var start = new ProcessStartInfo(powerShell)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (string argument in new[] { "-NoProfile", "-NonInteractive", "-File", script, "-WhatIf" }.Concat(arguments))
start.ArgumentList.Add(argument);
using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start PowerShell.");
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
string output = await stdout + await stderr;
Assert.True(process.ExitCode == 0, output);
return output;
}
private static string FindRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx")))
directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
}
}
+129
View File
@@ -0,0 +1,129 @@
using VoiceCat.Protocol;
using System.Net.Sockets;
using VoiceCat.Server;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
using static VoiceCat.Tests.MediaRelayTests;
namespace VoiceCat.Tests;
public sealed class ReaperTests
{
private static readonly VoiceServerOptions Options = new()
{
IdleTimeout = TimeSpan.FromSeconds(10), ReaperInterval = TimeSpan.FromMilliseconds(20)
};
[Fact]
public async Task SilentPeerIsReapedWhileTcpActivityKeepsObserverAlive()
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
await using var alice = await fixture.ConnectAsync();
await alice.LoginAsync("Alice");
await using var bob = await fixture.ConnectAsync();
User self = await bob.LoginAsync("Bob");
clock.Advance(9);
alice.Send(new() { Ping = new() { Nonce = 99 } });
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 99);
clock.Advance(2);
Assert.Equal(self.Id, (await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
Assert.Equal("Receive idle timeout.", (await bob.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason);
alice.Send(new() { Subscribe = new() });
int additionalDepartures = 0;
var snapshot = await alice.ReadUntilAsync(e =>
{
if (e.UserEvent?.Kind == UserEvent.Types.Kind.Left) additionalDepartures++;
return e.ServerState is not null;
});
Assert.Equal(0, additionalDepartures);
Assert.DoesNotContain(snapshot.ServerState.Users, user => user.Id == self.Id);
alice.Send(new() { Ping = new() { Nonce = 100 } });
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 100);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ValidUdpActivityKeepsTcpIdleClientAlive(bool voice)
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
uint ssrc = voice ? (await alice.AnnounceAsync(StreamKind.StreamMic)).Ssrc : 0;
clock.Advance(9);
if (voice)
{
await alice.SendAsync(alice.Seal(ssrc, [1, 2, 3]));
await bob.ReceiveVoiceAsync();
}
else
{
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await alice.SendAsync(keepalive);
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
}
clock.Advance(2);
Assert.Equal(bob.Client.Authentication!.Self.Id,
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
alice.Client.Send(new() { Ping = new() { Nonce = 42 } });
await alice.Client.ReadUntilAsync(e => e.Pong?.Nonce == 42);
}
[Fact]
public async Task InvalidVoiceCannotKeepSilentSessionAlive()
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var stream = await bob.AnnounceAsync(StreamKind.StreamMic);
clock.Advance(9);
byte[] forged = bob.Seal(stream.Ssrc, [1]);
forged[^1] ^= 1;
await bob.SendAsync(forged);
alice.Client.Send(new() { Ping = new() { Nonce = 1 } });
await alice.Client.ReadUntilAsync(e => e.Pong is not null);
clock.Advance(2);
Assert.Equal(bob.Client.Authentication!.Self.Id,
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
}
[Fact]
public async Task ShutdownAwaitsActiveVoiceAndUnfinishedHandshake()
{
await using var fixture = new ServerFixture(options: Options);
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);
await alice.SendAsync(alice.Seal(stream.Ssrc, [1, 2]));
await bob.ReceiveVoiceAsync();
using var unfinished = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await unfinished.ConnectAsync(fixture.Server.EndPoint);
await fixture.Server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10));
await fixture.Server.DisposeAsync();
}
[Fact]
public async Task ReaperCanBeDisabled()
{
var clock = new ManualClock();
await using var fixture = new ServerFixture(options: Options with { IdleTimeout = TimeSpan.Zero, ReaperInterval = TimeSpan.Zero }, timeProvider: clock);
await using var client = await fixture.ConnectAsync();
await client.LoginAsync("Alice");
clock.Advance(1000);
await Task.Delay(100);
client.Send(new() { Ping = new() { Nonce = 1 } });
await client.ReadUntilAsync(e => e.Pong is not null);
}
private sealed class ManualClock : TimeProvider
{
private long timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => Volatile.Read(ref timestamp);
public void Advance(int seconds) => Interlocked.Add(ref timestamp, seconds * TimeSpan.TicksPerSecond);
}
}
@@ -0,0 +1,77 @@
using VoiceCat.Core;
namespace VoiceCat.Tests;
public sealed class ServerProfileTests
{
[Fact]
public void ProfilesRoundTripWithoutPasswordMaterial()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "servers.json");
try
{
var guest = ServerProfile.Create(" voice.example ", 8384, ServerAuthentication.Guest, nickname: " Cat ");
var account = ServerProfile.Create("secure.example", 9443, ServerAuthentication.Account, username: " talon ");
var store = new ServerProfileStore(path);
store.Save([guest, account]);
Assert.Equal([guest, account], store.Load());
string json = File.ReadAllText(path);
Assert.Contains("\"authentication\": \"Account\"", json);
Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void MissingCorruptAndInvalidProfilesDoNotBreakStartup()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "servers.json");
try
{
var store = new ServerProfileStore(path);
Assert.Empty(store.Load());
Directory.CreateDirectory(directory);
File.WriteAllText(path, "not json");
Assert.Empty(store.Load());
File.WriteAllText(path, "[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"host\":\"\",\"port\":0,\"authentication\":\"Guest\"}]");
Assert.Empty(store.Load());
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void AccountProfilesRequireAUsername()
{
Assert.Throws<ArgumentException>(() => ServerProfile.Create("voice.example", 8384, ServerAuthentication.Account));
}
[Fact]
public void SwiftProfilesImportAndAreBackedUpOnManagedSave()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "servers.json");
Guid accountId = Guid.NewGuid();
try
{
Directory.CreateDirectory(directory);
File.WriteAllText(path, $$"""
[{"id":"{{accountId:D}}","host":"voice.example","port":8384,"authMode":"password","savedUsername":"talon","nickname":null,"keychainTag":"voicecat.server.legacy"}]
""");
var store = new ServerProfileStore(path);
ServerProfile profile = Assert.Single(store.Load());
Assert.Equal(ServerAuthentication.Account, profile.Authentication);
Assert.Equal("talon", profile.Username);
Assert.Equal("voicecat.server.legacy", profile.LegacyKeychainTag);
store.Save([profile]);
Assert.True(File.Exists(path + ".swift-backup.json"));
string managed = File.ReadAllText(path);
Assert.Contains("\"authentication\": \"Account\"", managed);
Assert.DoesNotContain("keychainTag", managed);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
}
+161
View File
@@ -0,0 +1,161 @@
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();
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class TlsTests
{
[Fact]
public void ManagedTlsHandshakeExportsMatchingDirectionalKeys()
{
var (pem, key, fingerprint) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(value => value == fingerprint);
Assert.Throws<InvalidOperationException>(() => client.CreateMediaEncryptor());
Handshake(client, server);
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
Assert.Equal(server.ExportMediaKey(0), client.ExportMediaKey(0));
Assert.Equal(server.ExportMediaKey(1), client.ExportMediaKey(1));
Assert.NotEqual(client.ExportMediaKey(0), client.ExportMediaKey(1));
client.WritePlaintext("hello"u8);
Pump(client, server);
byte[] output = new byte[5];
Assert.Equal(5, server.ReadPlaintext(output));
Assert.Equal("hello"u8.ToArray(), output);
using var encryptor = server.CreateMediaEncryptor();
using var decryptor = client.CreateMediaDecryptor();
byte[] packet = new byte[41];
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), "hello"u8, packet);
Assert.True(decryptor.TryDecrypt(packet, output, out _, out _));
Assert.Equal("hello"u8.ToArray(), output);
}
[Fact]
public void CertificateRejectionPreventsApplicationDataAndMediaKeys()
{
var (pem, key, _) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(_ => false);
Assert.ThrowsAny<IOException>(() => Handshake(client, server));
Assert.False(client.IsReady);
Assert.Throws<InvalidOperationException>(() => client.CreateMediaDecryptor());
Assert.Throws<InvalidOperationException>(() => client.WritePlaintext("secret"u8));
}
[Fact]
public void CloseNotifyEndsSessionAndAbruptEofIsRejected()
{
var (pem, key, fingerprint) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(value => value == fingerprint);
Handshake(client, server);
client.Close();
Pump(client, server);
Assert.False(client.IsReady);
Assert.False(server.IsReady);
server.CompleteInput();
using var incomplete = TlsSession.CreateClient(_ => true);
Assert.ThrowsAny<IOException>(() => incomplete.CompleteInput());
}
[Fact]
public void TlsTwelveCannotNegotiateWithManagedServer()
{
var (pem, key, _) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
var legacy = new Org.BouncyCastle.Tls.TlsClientProtocol();
legacy.Connect(new LegacyPeer());
byte[] hello = new byte[legacy.GetAvailableOutputBytes()];
legacy.ReadOutput(hello, 0, hello.Length);
Assert.ThrowsAny<IOException>(() => server.ReceiveCiphertext(hello));
Assert.False(server.IsReady);
Assert.Throws<InvalidOperationException>(() => server.CreateMediaEncryptor());
}
private sealed class LegacyPeer() : Org.BouncyCastle.Tls.DefaultTlsClient(new Org.BouncyCastle.Tls.Crypto.Impl.BC.BcTlsCrypto())
{
protected override Org.BouncyCastle.Tls.ProtocolVersion[] GetSupportedVersions() => [Org.BouncyCastle.Tls.ProtocolVersion.TLSv12];
public override Org.BouncyCastle.Tls.TlsAuthentication GetAuthentication() => throw new InvalidOperationException("TLS 1.2 must be rejected before authentication.");
}
internal static (string Certificate, string Key, string Fingerprint) Credentials()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest("CN=VoiceCat TLS test", key, HashAlgorithmName.SHA256);
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
return (certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem(), Convert.ToHexString(SHA256.HashData(certificate.RawData)));
}
internal static void Handshake(TlsSession client, TlsSession server)
{
for (int i = 0; i < 100 && (!client.IsReady || !server.IsReady); i++)
{
Pump(client, server);
Pump(server, client);
}
Assert.True(client.IsReady);
Assert.True(server.IsReady);
}
private static void Pump(TlsSession sender, TlsSession receiver)
{
byte[] buffer = new byte[17];
while (sender.PendingCiphertextBytes > 0)
{
int count = sender.DrainCiphertext(buffer);
receiver.ReceiveCiphertext(buffer.AsSpan(0, count));
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public class TofuTlsTests
{
[Fact]
public void PinCreatesMissingParentDirectory()
{
string root = Path.Combine(Path.GetTempPath(), "voicecat-tofu-parent-" + Guid.NewGuid());
string path = Path.Combine(root, "nested", "pins.txt");
try
{
var store = new TofuStore(path);
store.Pin("localhost", 8384, new string('a', 64));
Assert.True(File.Exists(path));
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 8384, new string('A', 64)));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public void RealHandshakesRequireAcceptanceAndRejectChangedCertificatesAfterRestart()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tofu-tls-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "pins.txt");
try
{
using var credentials = ServerCredentials.LoadOrCreate(Path.Combine(directory, "server"), "server");
var store = new TofuStore(path);
using (var server = credentials.CreateTlsSession())
using (var rejected = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
return false;
}))
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(rejected, server));
Assert.False(File.Exists(path));
using (var server = credentials.CreateTlsSession())
using (var accepted = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
store.Pin("localhost", 9987, fingerprint);
return true;
}))
TlsTests.Handshake(accepted, server);
store = new(path);
using (var server = credentials.CreateTlsSession())
using (var returning = TlsSession.CreateClient(fingerprint => store.Check("localhost", 9987, fingerprint) == TofuStatus.Matched))
TlsTests.Handshake(returning, server);
using var rotated = ServerCredentials.LoadOrCreate(Path.Combine(directory, "rotated"), "server");
using (var server = rotated.CreateTlsSession())
using (var mismatch = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, fingerprint));
return false;
}))
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(mismatch, server));
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, credentials.CertificateFingerprint));
}
finally { Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../clients/windows/VoiceCat.Windows/VoiceCat.Windows.csproj" />
<ProjectReference Include="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
<ProjectReference Include="../../src/VoiceCat.Cli/VoiceCat.Cli.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />
<ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<ProjectReference Include="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<ProjectReference Include="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<ProjectReference Include="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<ProjectReference Include="../../src/VoiceCat.Server/VoiceCat.Server.csproj" />
<Using Include="Xunit" />
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class VoiceHeaderTests
{
[Fact]
public void HeaderUsesBigEndianFieldsAndPreservesUnknownValues()
{
var header = new VoiceFrameHeader((MediaFrameType)255, (VoiceFrameFlags)128, 0x1234, 0x56789abc, 0x0123456789abcdef, 0xfedcba98);
byte[] bytes = new byte[20];
header.Write(bytes);
Assert.Equal("FF80123456789ABC0123456789ABCDEFFEDCBA98", Convert.ToHexString(bytes));
Assert.True(VoiceFrameHeader.TryRead(bytes, out var parsed));
Assert.Equal(header, parsed);
Assert.False(VoiceFrameHeader.TryRead(bytes.AsSpan(0, 19), out _));
Assert.Throws<ArgumentOutOfRangeException>(() => header.Write(new byte[19]));
}
}
@@ -0,0 +1,58 @@
using VoiceCat.Windows;
using VoiceCat.Server.Data;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
using Client = VoiceCat.Windows.VoiceCatClient;
namespace VoiceCat.Tests;
public class WindowsManagedClientTests
{
private static async Task Until(Client client, Func<bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
do { client.PumpEvents(); if (predicate()) return; await Task.Delay(10, timeout.Token); } while (true);
}
private static async Task Login(Client client, ServerFixture fixture, bool admin = false)
{
client.EventReceived += e => { if (e.Type == VcEventType.ServerIdentity) client.ConfirmServerIdentity(true); };
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", (ushort)fixture.Server.EndPoint.Port));
if (admin) client.AuthenticateUser("Admin", "secret"); else client.AuthenticateGuest("Guest");
await Until(client, () => client.ListUsers().Count > 0);
}
[Fact]
public async Task ShippedWindowsFacadeChatsExchangesPcmAndKeepsCaptureIdsAcrossChannelMoves()
{
await using var fixture = new ServerFixture();
using (var accounts = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) await accounts.CreateAccountAsync("Admin", "secret", true);
using var alice = new Client("Alice", "test", tofuStorePath: Path.Combine(fixture.Directory, "alice.pins"));
using var bob = new Client("Bob", "test", tofuStorePath: Path.Combine(fixture.Directory, "bob.pins"));
await Login(alice, fixture, true); await Login(bob, fixture);
Assert.True(alice.GetPermissions().IsAdmin);
string? body = null; bob.EventReceived += e => { if (e.Type == VcEventType.TextMessage) body = e.Text; };
Assert.Equal(VcResult.Ok, alice.SendText(VcTextScope.Channel, 1, "Managed Windows chat"));
await Until(bob, () => body is not null); Assert.Equal("Managed Windows chat", body);
Assert.Equal(VcResult.Ok, alice.JoinVoice()); Assert.Equal(VcResult.Ok, bob.JoinVoice());
alice.SetInputMode(VcInputMode.AlwaysOn); bob.SetInputMode(VcInputMode.AlwaysOn);
var a = alice.StartStreamExternalFeed(VcStreamKind.Mic, "Mic"); var b = bob.StartStreamExternalFeed(VcStreamKind.Mic, "Mic");
Assert.Equal(VcResult.Ok, a.Result); Assert.Equal(VcResult.Ok, b.Result);
await Until(bob, () => bob.ManagedClient.Users.Any(u => u.Streams.Count > 0 && u.Id != bob.ManagedClient.Authentication!.Self.Id));
long aliceEnergy = 0, bobEnergy = 0;
alice.ManagedClient.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref aliceEnergy, sum); };
bob.ManagedClient.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref bobEnergy, sum); };
short[] tone = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * Math.PI * 880 / 48000))).ToArray();
for (int i = 0; i < 40; i++) { alice.StreamFeedPcm(a.StreamId, tone, 960, 1); bob.StreamFeedPcm(b.StreamId, tone, 960, 1); alice.PumpEvents(); bob.PumpEvents(); await Task.Delay(20); }
Assert.True(Interlocked.Read(ref aliceEnergy) > 100000); Assert.True(Interlocked.Read(ref bobEnergy) > 100000);
uint oldId = alice.ManagedClient.LocalStreams.Single().StreamId;
await alice.ManagedClient.RequestAsync(new() { CreateChannel = new() { Channel = new() { Name = "Stereo", ParentId = 1, Audio = AudioEngineTests.Stream(20, true).Audio } } });
await Until(alice, () => alice.ListChannels().Any(c => c.Name == "Stereo"));
uint channel = alice.ListChannels().Single(c => c.Name == "Stereo").Id;
Assert.Equal(VcResult.Ok, alice.JoinChannel(channel));
await Until(alice, () => alice.ManagedClient.LocalStreams.Any(s => s.StreamId != oldId));
Assert.Equal(a.StreamId, Assert.Single(alice.ListUserStreams(alice.ManagedClient.Authentication!.Self.Id)).StreamId);
Assert.True(alice.GetStreamAudioConfig(alice.ManagedClient.Authentication!.Self.Id, a.StreamId).Config!.Stereo);
Assert.Equal(VcResult.Ok, alice.StreamFeedPcm(a.StreamId, tone, 960, 1));
Assert.Equal(VcResult.Ok, alice.StopStream(a.StreamId));
Assert.Empty(alice.ManagedClient.LocalStreams);
}
}
+200
View File
@@ -0,0 +1,200 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.14.1, )",
"resolved": "17.14.1",
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
"dependencies": {
"Microsoft.CodeCoverage": "17.14.1",
"Microsoft.TestPlatform.TestHost": "17.14.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.1, )",
"resolved": "3.1.1",
"contentHash": "gNu2zhnuwjq5vQlU4S7yK/lfaKZDLmtcu+vTjnhfTlMAUYn+Hmgu8IIX0UCwWepYkk+Szx03DHx1bDnc9Fd+9w=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
"Newtonsoft.Json": "13.0.3"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"SourceGear.sqlite3": {
"type": "Transitive",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
"dependencies": {
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
"SourceGear.sqlite3": "3.50.4.2"
}
},
"SQLitePCLRaw.config.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
"dependencies": {
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"voicecat.audio": {
"type": "Project",
"dependencies": {
"VoiceCat.Codec": "[1.0.0, )",
"VoiceCat.Dsp": "[1.0.0, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.windows": {
"type": "Project",
"dependencies": {
"VoiceCat.Core": "[1.0.0, )"
}
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
},
"voicecat.server": {
"type": "Project",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "[10.0.5, )",
"SQLitePCLRaw.bundle_e_sqlite3": "[3.0.2, )",
"SourceGear.sqlite3": "[3.50.4.2, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
}
}
}
}
-311
View File
@@ -1,311 +0,0 @@
/*
* test_channel_samplerate — per-channel sample_rate as an Opus bandwidth cap.
*
* The codec always runs at 48 kHz internally (docs/voice.md §3); a channel's sample_rate is
* carried as OPUS_SET_MAX_BANDWIDTH so a low-bitrate / narrowband room can constrain the encoded
* audio bandwidth without changing the PCM clock. This verifies the cap is actually in effect:
*
* - A channel at sample_rate = 8000 (NARROWBAND, ~4 kHz audio) and a channel at 48000
* (FULLBAND) are each fed an identical 7 kHz tone (well above the narrowband edge).
* - The narrowband channel's decoded energy must be substantially lower — the only difference
* between the two runs is the channel's sample_rate, so a lower energy proves the bandwidth
* cap filtered the out-of-band tone.
* - vc_get_stream_audio_config reports the channel's configured sample_rate (not 48000).
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool generic_result_received{false};
bool generic_ok{false};
bool voice_subscribed{false};
std::vector<std::pair<uint32_t, uint32_t>> streams_started; // (user_id, stream_id)
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY: vc_confirm_server_identity(s->client, 1); break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break;
case VC_EVENT_VOICE_STATE: s->voice_subscribed = (ev->u32a == 1); break;
case VC_EVENT_GENERIC_RESULT:
s->generic_result_received = true;
s->generic_ok = (ev->result == VC_OK);
break;
case VC_EVENT_STREAM_STARTED: s->streams_started.emplace_back(ev->user_id, ev->stream_id); break;
default: break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static bool connect_guest(vc_client*& client, const char* name, const char* label,
uint16_t port, EventStore& ev) {
vc_callbacks cb{on_event, nullptr, &ev};
vc_config cfg{label, "0.1", VC_LOG_OFF};
client = vc_client_create(&cfg, cb);
if (!client) return false;
ev.client = client;
ev.label = label;
if (vc_set_external_playback(client, 1) != VC_OK) return false;
if (vc_connect(client, "127.0.0.1", port) != VC_OK) return false;
if (vc_authenticate_guest(client, name) != VC_OK) return false;
if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false;
if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false;
if (vc_join_voice(client) != VC_OK) return false;
if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false;
return true;
}
// 7 kHz tone — above the NARROWBAND (~4 kHz) edge, within FULLBAND.
static std::vector<int16_t> make_tone(int n, float hz) {
std::vector<int16_t> pcm(static_cast<size_t>(n));
for (int i = 0; i < n; ++i) {
float t = static_cast<float>(i) / 48000.0f;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * hz * t) * 16000.0f);
}
return pcm;
}
struct SinkData {
std::mutex mu;
std::condition_variable cv;
std::atomic<int> call_count{0};
int64_t total_energy = 0;
};
static void pcm_sink(void* user, uint32_t, uint32_t,
const int16_t* pcm, size_t n, uint32_t channels, uint32_t) {
auto* d = static_cast<SinkData*>(user);
std::lock_guard lk(d->mu);
for (size_t i = 0; i < n * channels; ++i)
d->total_energy += std::abs(static_cast<int>(pcm[i]));
d->call_count.fetch_add(1, std::memory_order_relaxed);
d->cv.notify_all();
}
static bool sink_wait(SinkData& d, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(d.mu);
return d.cv.wait_until(lk, deadline, [&] { return d.call_count.load() > 0; });
}
static uint32_t make_channel(vc_client* admin, EventStore& evAdmin, const char* name,
uint32_t sample_rate) {
vc_channel_info ch{};
ch.name = name;
ch.audio.codec = 0; // OPUS
ch.audio.mode = 0; // mono
ch.audio.sample_rate = sample_rate;
ch.audio.bitrate_bps = 32000;
ch.audio.frame_ms = 20;
ch.audio.fec = 1;
ch.audio.complexity = 10;
{ std::lock_guard lk(evAdmin.mu); evAdmin.generic_result_received = false; }
if (vc_create_channel(admin, &ch) != VC_OK) return 0;
if (!wait_for(evAdmin, [](EventStore& s) { return s.generic_result_received; }, 5000)) return 0;
{ std::lock_guard lk(evAdmin.mu); if (!evAdmin.generic_ok) return 0; }
vc_channel_list cl{};
if (vc_list_channels(admin, &cl) != VC_OK) return 0;
uint32_t id = 0;
for (size_t i = 0; i < cl.count; ++i)
if (cl.items[i].name && std::string(cl.items[i].name) == name) { id = cl.items[i].id; break; }
vc_free_channel_list(&cl);
return id;
}
// Feed a 7 kHz tone through `channel_id` and return the decoded energy the sink observed.
// Also asserts vc_get_stream_audio_config reports `expect_sr`.
static int64_t run_case(uint16_t port, vc_client* admin, EventStore& evAdmin,
uint32_t channel_id, uint32_t expect_sr, const char* tag) {
EventStore evA, evB;
vc_client *clientA = nullptr, *clientB = nullptr;
CHECK(connect_guest(clientA, "SrA", "sr-a", port, evA));
CHECK(connect_guest(clientB, "SrB", "sr-b", port, evB));
int64_t energy = -1;
SinkData sink; // Outlives both clients and their final audio callbacks.
if (!clientA || !clientB) goto cleanup;
{
uint32_t a_uid = 0, b_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
CHECK(vc_move_user(admin, a_uid, channel_id) == VC_OK);
CHECK(vc_move_user(admin, b_uid, channel_id) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(500));
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
desc.external_feed = 1; // Measure only the injected tone, never the host microphone.
uint32_t a_sid = 0;
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
for (auto& [uid, sid] : s.streams_started)
if (uid == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a);
std::this_thread::sleep_for(std::chrono::milliseconds(400));
vc_audio_config ac{};
CHECK(vc_get_stream_audio_config(clientA, a_uid, a_sid, &ac) == VC_OK);
CHECK(ac.sample_rate == expect_sr);
auto tone = make_tone(960, 7000.0f);
for (int i = 0; i < 300; ++i)
CHECK(vc_stream_feed_pcm(clientA, a_sid, tone.data(), 960, 1) == VC_OK);
CHECK(sink_wait(sink, 5000));
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
{ std::lock_guard lk(sink.mu); energy = sink.total_energy; }
std::printf("test_channel_samplerate[%s]: sr=%u calls=%d energy=%lld\n",
tag, expect_sr, sink.call_count.load(), static_cast<long long>(energy));
CHECK(sink.call_count.load() > 0);
vc_stream_stop(clientA, a_sid);
}
cleanup:
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
return energy;
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_chansr_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err) || !db.create_account("admin", "pass", true, err)) {
std::printf("FAIL: provision admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.server_name = "VoiceCat-ChanSrTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not start\n");
server.stop(); server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
std::printf("channel_samplerate: server ready on :%u\n", port);
EventStore evAdmin;
evAdmin.label = "admin";
vc_callbacks cbAdmin{on_event, nullptr, &evAdmin};
vc_config cfgAdmin{"chansr-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgAdmin, cbAdmin);
CHECK(admin != nullptr);
evAdmin.client = admin;
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "pass") == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evAdmin, [](EventStore& s) { return s.channel_list_received; }, 3000));
uint32_t ch_full = make_channel(admin, evAdmin, "FullBand", 48000);
uint32_t ch_narrow = make_channel(admin, evAdmin, "NarrowBand", 8000);
CHECK(ch_full != 0);
CHECK(ch_narrow != 0);
int64_t full_energy = (ch_full ? run_case(port, admin, evAdmin, ch_full, 48000, "full") : -1);
int64_t narrow_energy = (ch_narrow ? run_case(port, admin, evAdmin, ch_narrow, 8000, "narrow") : -1);
// The 7 kHz tone is above the narrowband (~4 kHz) cutoff: the narrowband channel must filter
// most of it out, so its decoded energy is far below the full-band channel's. Generous margin
// (< 50%) to stay robust across Opus versions while still proving the cap is in effect.
CHECK(full_energy > 0);
CHECK(narrow_energy >= 0);
std::printf("channel_samplerate: full=%lld narrow=%lld ratio=%.3f\n",
static_cast<long long>(full_energy), static_cast<long long>(narrow_energy),
full_energy > 0 ? static_cast<double>(narrow_energy) / static_cast<double>(full_energy)
: 0.0);
CHECK(narrow_energy < full_energy / 2);
vc_disconnect(admin);
vc_client_destroy(admin);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("channel_samplerate: all checks passed\n");
return 0;
}
std::printf("channel_samplerate: %d failure(s)\n", g_failures);
return 1;
}
-333
View File
@@ -1,333 +0,0 @@
/*
* test_channel_user_list_abi — M4 channel/user/stream snapshot getters.
*
* Covers the new pull-based ABI surface (voicecat.h): vc_list_channels, vc_list_users,
* vc_list_user_streams, and the new VC_EVENT_JOIN_RESULT feedback for vc_join_channel.
* Mirrors test_vad_ptt_devices.cpp's approach (real vc_client instances against a real
* in-process server, not raw sockets).
*
* 1. Pre-data: a freshly created (not yet connected) client's getters return VC_OK,
* count=0 — never an error just because nothing has arrived yet.
* 2. After two guests connect and auth: vc_list_channels reflects the server's real
* channel config (this is the regression test for the SessionModel field-population
* fix — parent_id/password_protected/max_users were silently dropped before);
* vc_list_users on either client includes both users with correct nickname/channel_id.
* 3. After A starts a MIC stream: B's vc_list_user_streams(A's user_id) shows it, with the
* same stream_id as A's own VC_EVENT_STREAM_STARTED.
* 4. vc_list_user_streams with an unknown user_id returns VC_ERR_INVALID_ARG.
* 5. vc_join_channel's result arrives via VC_EVENT_JOIN_RESULT — success (re-joining the
* channel already in) and failure (an unknown channel id).
* 6. Every vc_free_*_list is idempotent (safe to call twice).
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
// ── Event tracking ────────────────────────────────────────────────────────────
struct StreamEvent {
bool started;
uint32_t user_id;
uint32_t stream_id;
};
struct JoinResult {
bool ok;
uint32_t channel_id;
std::string error;
};
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
std::vector<StreamEvent> stream_events;
std::vector<JoinResult> join_results;
bool voice_subscribed{false};
const char* label{nullptr};
vc_client* client{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_VOICE_STATE:
s->voice_subscribed = (ev->u32a == 1);
break;
case VC_EVENT_STREAM_STARTED:
s->stream_events.push_back({true, ev->user_id, ev->stream_id});
break;
case VC_EVENT_STREAM_STOPPED:
s->stream_events.push_back({false, ev->user_id, ev->stream_id});
break;
case VC_EVENT_JOIN_RESULT:
s->join_results.push_back(
{ev->result == VC_OK, ev->channel_id, ev->text ? ev->text : ""});
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
// ── Test harness ──────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
// ── 1. Pre-data: getters never error just because nothing has arrived yet ──────
static void test_pre_data_empty() {
vc_config cfg{"test-predata", "0.1", VC_LOG_OFF};
vc_callbacks cb{};
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
vc_channel_list cl{};
CHECK(vc_list_channels(c, &cl) == VC_OK);
CHECK(cl.count == 0);
vc_free_channel_list(&cl);
vc_free_channel_list(&cl); // idempotent
vc_user_list ul{};
CHECK(vc_list_users(c, &ul) == VC_OK);
CHECK(ul.count == 0);
vc_free_user_list(&ul);
vc_free_user_list(&ul); // idempotent
// No users known yet — any user_id is "unknown".
vc_stream_summary_list sl{};
CHECK(vc_list_user_streams(c, 1, &sl) == VC_ERR_INVALID_ARG);
vc_client_destroy(c);
std::printf("test_pre_data_empty: ok\n");
}
// ── 2-6. Real connect/auth/join/stream against a real in-process server ────────
static void test_live_channel_user_list() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_chanlist_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.server_name = "VoiceCat-ChanListTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
if (!ok) {
std::printf("FAIL: server did not become ready within 10s\n");
++g_failures;
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return;
}
}
uint16_t port = bound_port.load();
std::printf("test_live_channel_user_list: server ready on :%u\n", port);
EventStore evA;
evA.label = "A";
vc_callbacks cbA{on_event, nullptr, &evA};
vc_config cfgA{"test-A", "0.1", VC_LOG_OFF};
vc_client* clientA = vc_client_create(&cfgA, cbA);
CHECK(clientA != nullptr);
evA.client = clientA;
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientA, "CL-A") == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(clientA) == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
EventStore evB;
evB.label = "B";
vc_callbacks cbB{on_event, nullptr, &evB};
vc_config cfgB{"test-B", "0.1", VC_LOG_OFF};
vc_client* clientB = vc_client_create(&cfgB, cbB);
CHECK(clientB != nullptr);
evB.client = clientB;
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientB, "CL-B") == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(clientB) == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
uint32_t a_uid = 0, b_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
// ── 2a. vc_list_channels reflects the real server config (regression test for the
// SessionModel field-population fix — parent_id/password_protected/max_users). ──────────
{
vc_channel_list cl{};
CHECK(vc_list_channels(clientA, &cl) == VC_OK);
CHECK(cl.count == 2); // Lobby (1) + Music Room (2), per session_registry.cpp
bool found_lobby = false;
for (size_t i = 0; i < cl.count; ++i) {
CHECK(cl.items[i].name != nullptr);
if (cl.items[i].id == 1) {
found_lobby = true;
CHECK(std::strcmp(cl.items[i].name, "Lobby") == 0);
CHECK(cl.items[i].parent_id == 0);
CHECK(cl.items[i].password_protected == 0);
CHECK(cl.items[i].max_users == 20); // non-default — proves the fix
}
}
CHECK(found_lobby);
vc_free_channel_list(&cl);
vc_free_channel_list(&cl); // idempotent
}
// ── 2b. vc_list_users includes both A and B with correct nickname/channel_id ─────────────
{
vc_user_list ul{};
CHECK(vc_list_users(clientB, &ul) == VC_OK);
CHECK(ul.count == 2);
bool found_a = false, found_b = false;
for (size_t i = 0; i < ul.count; ++i) {
CHECK(ul.items[i].nickname != nullptr);
CHECK(ul.items[i].channel_id == 1); // both default into Lobby on auth
if (ul.items[i].id == a_uid) { found_a = true; CHECK(std::strcmp(ul.items[i].nickname, "CL-A") == 0); }
if (ul.items[i].id == b_uid) { found_b = true; CHECK(std::strcmp(ul.items[i].nickname, "CL-B") == 0); }
}
CHECK(found_a);
CHECK(found_b);
vc_free_user_list(&ul);
vc_free_user_list(&ul); // idempotent
}
// ── 3. vc_list_user_streams reflects a real stream, cross-checked against the
// STREAM_STARTED event's stream_id. ──────────────────────────────────────────────────────
vc_stream_desc mic_desc{};
mic_desc.kind = VC_STREAM_MIC;
mic_desc.label = "mic";
uint32_t mic_sid = 0;
CHECK(vc_stream_start(clientA, &mic_desc, &mic_sid) == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return !s.stream_events.empty(); }, 5000));
{
vc_stream_summary_list sl{};
CHECK(vc_list_user_streams(clientB, a_uid, &sl) == VC_OK);
CHECK(sl.count == 1);
if (sl.count == 1) {
CHECK(sl.items[0].stream_id == mic_sid);
CHECK(sl.items[0].kind == VC_STREAM_MIC);
CHECK(sl.items[0].label != nullptr);
}
vc_free_stream_summary_list(&sl);
vc_free_stream_summary_list(&sl); // idempotent
}
// ── 4. Unknown user_id ───────────────────────────────────────────────────────────────────
{
vc_stream_summary_list sl{};
CHECK(vc_list_user_streams(clientB, 0xDEADBEEF, &sl) == VC_ERR_INVALID_ARG);
}
// ── 5. VC_EVENT_JOIN_RESULT — success (re-join the channel already in) and failure
// (unknown channel id). ─────────────────────────────────────────────────────────────────
CHECK(vc_join_channel(clientA, 1, nullptr) == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return !s.join_results.empty(); }, 3000));
{
std::lock_guard lk(evA.mu);
CHECK(evA.join_results.back().ok);
CHECK(evA.join_results.back().channel_id == 1);
}
size_t mark;
{ std::lock_guard lk(evA.mu); mark = evA.join_results.size(); }
CHECK(vc_join_channel(clientA, 999999, nullptr) == VC_OK);
CHECK(wait_for(evA, [&](EventStore& s) { return s.join_results.size() > mark; }, 3000));
{
std::lock_guard lk(evA.mu);
CHECK(!evA.join_results.back().ok);
CHECK(!evA.join_results.back().error.empty());
}
vc_stream_stop(clientA, mic_sid);
vc_disconnect(clientA);
vc_disconnect(clientB);
vc_client_destroy(clientA);
vc_client_destroy(clientB);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
std::printf("test_live_channel_user_list: done\n");
}
int main() {
test_pre_data_empty();
test_live_channel_user_list();
if (g_failures == 0) {
std::printf("channel_user_list_abi: all checks passed\n");
return 0;
}
std::printf("channel_user_list_abi: %d failure(s)\n", g_failures);
return 1;
}
-210
View File
@@ -1,210 +0,0 @@
/*
* test_disconnect_left — regression test for the ungraceful-disconnect LEFT bug.
*
* Verifies that when a client's TCP connection drops (vc_disconnect / socket close /
* process kill), the server broadcasts UserEvent::LEFT to remaining clients — so peer
* user lists stay fresh and peer audio engines remove the stale stream (no eternal PLC
* hiss). Before the fix, ConnSession::close() silently erased the user from the registry
* without broadcasting, leaving ghost users and never-ending comfort noise on peers.
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
bool auth_done{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool disconnected{false};
std::vector<uint32_t> joined_users;
std::vector<uint32_t> left_users;
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->auth_done = true;
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_USER_JOINED:
s->joined_users.push_back(ev->user_id);
break;
case VC_EVENT_USER_LEFT:
s->left_users.push_back(ev->user_id);
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool user_list_contains(vc_client* c, uint32_t uid) {
vc_user_list ul{};
if (vc_list_users(c, &ul) != VC_OK) return false;
bool found = false;
for (size_t i = 0; i < ul.count; ++i) {
if (ul.items[i].id == uid) { found = true; break; }
}
vc_free_user_list(&ul);
return found;
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_disc_left_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-DiscLeftTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
auto make_client = [&](const char* label, const char* nick) -> EventStore* {
auto* ev = new EventStore();
ev->label = label;
vc_callbacks cb{on_event, nullptr, ev};
vc_config cfgx{label, "0.1", VC_LOG_OFF};
ev->client = vc_client_create(&cfgx, cb);
if (!ev->client) return nullptr;
if (vc_connect(ev->client, "127.0.0.1", port) != VC_OK) return nullptr;
if (vc_authenticate_guest(ev->client, nick) != VC_OK) return nullptr;
return ev;
};
EventStore* evA = make_client("clientA", "Alpha");
CHECK(evA != nullptr);
CHECK(wait_for(*evA, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(*evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
uint32_t a_uid = evA->self_user_id;
CHECK(a_uid != 0);
EventStore* evB = make_client("clientB", "Bravo");
CHECK(evB != nullptr);
CHECK(wait_for(*evB, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(*evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
uint32_t b_uid = evB->self_user_id;
CHECK(b_uid != 0);
// A should see B join (broadcast_user_joined fires when B authenticates).
CHECK(wait_for(*evA, [b_uid](EventStore& s) {
for (auto u : s.joined_users) if (u == b_uid) return true;
return false;
}, 5000));
// Both should see each other in the authoritative user list.
CHECK(user_list_contains(evA->client, b_uid));
CHECK(user_list_contains(evB->client, a_uid));
// ── Drop A's TCP connection abruptly (no LeaveChannelRequest, no Goodbye —
// just close the socket, exactly like vccli Ctrl-C or a network drop). ──
vc_disconnect(evA->client);
vc_client_destroy(evA->client);
// (evA is now a dangling store; only evB is observed below.)
// B must receive VC_EVENT_USER_LEFT for A — the core fix under test.
CHECK(wait_for(*evB, [a_uid](EventStore& s) {
for (auto u : s.left_users) if (u == a_uid) return true;
return false;
}, 5000));
// B's authoritative user list must no longer contain A.
// Give the event a moment to propagate through the SessionModel, then poll briefly.
bool a_gone = false;
for (int i = 0; i < 20; ++i) {
if (!user_list_contains(evB->client, a_uid)) { a_gone = true; break; }
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
CHECK(a_gone);
// ── Cleanup ──────────────────────────────────────────────────────────────
vc_disconnect(evB->client);
vc_client_destroy(evB->client);
delete evA;
delete evB;
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("disconnect_left: all checks passed\n");
return 0;
}
std::printf("disconnect_left: %d failure(s)\n", g_failures);
return 1;
}
-268
View File
@@ -1,268 +0,0 @@
/*
* test_dred_toggle — DRED (Deep REDundancy) per-channel toggle.
*
* Verifies:
* 1. A channel created with dred=true in AudioConfig has the flag round-trip through
* the protocol and observable via vc_get_stream_audio_config.
* 2. The encoder initialises with DRED enabled (no crash, valid stream).
* 3. PCM can be injected through the encode path when DRED is active.
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
// ── Event tracking ────────────────────────────────────────────────────────────
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool generic_result_received{false};
bool generic_ok{false};
bool voice_subscribed{false};
std::vector<std::pair<uint32_t,uint32_t>> streams_started; // (user_id, stream_id)
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_VOICE_STATE:
s->voice_subscribed = (ev->u32a == 1);
break;
case VC_EVENT_GENERIC_RESULT:
s->generic_result_received = true;
s->generic_ok = (ev->result == VC_OK);
break;
case VC_EVENT_STREAM_STARTED:
s->streams_started.emplace_back(ev->user_id, ev->stream_id);
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_dred_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
// Pre-provision an admin account so the server doesn't auto-generate one.
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) {
std::printf("FAIL: db.open: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("admin", "pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
// ── Start server ─────────────────────────────────────────────────────────
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.server_name = "VoiceCat-DredTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
if (!ok) {
std::printf("FAIL: server did not start\n");
server.stop(); server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
std::printf("dred_toggle: server on :%u\n", port);
// ── Admin client — create a channel with DRED enabled ────────────────────
EventStore evAdmin;
evAdmin.label = "admin";
vc_callbacks cbAdmin{on_event, nullptr, &evAdmin};
vc_config cfgAdmin{"test-dred-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgAdmin, cbAdmin);
CHECK(admin != nullptr);
evAdmin.client = admin;
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "pass") == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 8000));
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
// Create a channel with DRED enabled.
vc_channel_info ch{};
ch.name = "DRED Test Channel";
ch.audio.codec = 0; // OPUS
ch.audio.mode = 0; // mono
ch.audio.sample_rate = 48000;
ch.audio.bitrate_bps = 24000;
ch.audio.frame_ms = 20;
ch.audio.fec = 1;
ch.audio.expected_packet_loss = 5;
ch.audio.complexity = 10;
ch.audio.dred = 1; // ← DRED enabled
{ std::lock_guard lk(evAdmin.mu); evAdmin.generic_result_received = false; }
CHECK(vc_create_channel(admin, &ch) == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.generic_result_received; }, 5000));
{ std::lock_guard lk(evAdmin.mu); CHECK(evAdmin.generic_ok); }
// Find the new channel id.
vc_channel_list cl{};
CHECK(vc_list_channels(admin, &cl) == VC_OK);
CHECK(cl.count >= 3u);
uint32_t dred_channel_id = 0;
for (size_t i = 0; i < cl.count; ++i) {
if (cl.items[i].name && std::string(cl.items[i].name) == "DRED Test Channel") {
dred_channel_id = cl.items[i].id;
break;
}
}
vc_free_channel_list(&cl);
CHECK(dred_channel_id != 0);
std::printf("dred_toggle: DRED channel id=%u\n", dred_channel_id);
// ── Guest client — join DRED channel and start a stream ──────────────────
EventStore evGuest;
evGuest.label = "guest";
vc_callbacks cbGuest{on_event, nullptr, &evGuest};
vc_config cfgGuest{"test-dred-guest", "0.1", VC_LOG_OFF};
vc_client* guest = vc_client_create(&cfgGuest, cbGuest);
CHECK(guest != nullptr);
evGuest.client = guest;
CHECK(vc_connect(guest, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(guest, "GuestUser") == VC_OK);
CHECK(wait_for(evGuest, [](EventStore& s){ return s.auth_ok; }, 8000));
CHECK(wait_for(evGuest, [](EventStore& s){ return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(guest) == VC_OK);
CHECK(wait_for(evGuest, [](EventStore& s){ return s.voice_subscribed; }, 5000));
// Move the guest into the DRED channel.
uint32_t guest_uid = 0;
{ std::lock_guard lk(evGuest.mu); guest_uid = evGuest.self_user_id; }
CHECK(vc_move_user(admin, guest_uid, dred_channel_id) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(400));
// Start a MIC stream in the DRED channel.
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
uint32_t stream_id = 0;
CHECK(vc_stream_start(guest, &desc, &stream_id) == VC_OK);
CHECK(stream_id != 0);
bool stream_started = wait_for(evGuest, [&](EventStore& s){
for (auto& [uid, sid] : s.streams_started)
if (uid == guest_uid) return true;
return false;
}, 5000);
CHECK(stream_started);
// Allow UDP binding to settle.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
// ── Verify DRED flag is visible in effective audio config ─────────────────
vc_audio_config ac{};
vc_result rc = vc_get_stream_audio_config(guest, guest_uid, stream_id, &ac);
CHECK(rc == VC_OK);
CHECK(ac.dred == 1);
std::printf("dred_toggle: effective dred=%d fec=%d bitrate=%u\n",
ac.dred, ac.fec, ac.bitrate_bps);
// ── Inject PCM frames through the DRED-enabled encode path ───────────────
// A 440 Hz sine frame at 48kHz / 20ms (960 samples).
std::vector<int16_t> sine(960);
for (int i = 0; i < 960; ++i) {
float t = static_cast<float>(i) / 48000.0f;
sine[i] = static_cast<int16_t>(
std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
}
// Inject 10 frames — no crash = encoder + DRED extension running correctly.
for (int i = 0; i < 10; ++i)
vc_test_inject_capture(guest, stream_id, sine.data(), 960);
std::this_thread::sleep_for(std::chrono::milliseconds(300));
// ── Cleanup ───────────────────────────────────────────────────────────────
vc_stream_stop(guest, stream_id);
vc_disconnect(guest);
vc_disconnect(admin);
vc_client_destroy(guest);
vc_client_destroy(admin);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("dred_toggle: all checks passed\n");
return 0;
}
std::printf("dred_toggle: %d failure(s)\n", g_failures);
return 1;
}
-86
View File
@@ -1,86 +0,0 @@
/*
* test_envelope — round-trip an Envelope through FrameCodec + encode/decode.
* Runs only under dev (requires protobuf).
*/
#include <cstdio>
#include <cstring>
#include <vector>
#include "protocol/envelope.h"
#include "protocol/protocol.h"
using namespace voicecat::protocol;
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
int main() {
// Build a ClientHello envelope.
voicecat::v1::Envelope out_env;
out_env.set_request_id(42);
auto* hello = out_env.mutable_client_hello();
hello->set_proto_version(1);
hello->set_client_name("test-client");
hello->set_client_version("0.0.1");
hello->add_features("text");
// encode_envelope → framed wire bytes
std::vector<uint8_t> wire;
bool enc_ok = encode_envelope(out_env, wire);
CHECK(enc_ok);
CHECK(wire.size() > kLengthHeaderSize);
// Feed through FrameCodec to extract the payload
FrameCodec codec;
std::vector<std::vector<uint8_t>> frames;
bool feed_ok = codec.feed(wire.data(), wire.size(), frames);
CHECK(feed_ok);
CHECK(frames.size() == 1);
// decode_envelope from the extracted payload
voicecat::v1::Envelope in_env;
bool dec_ok = decode_envelope(frames[0], in_env);
CHECK(dec_ok);
// Verify round-trip fidelity
CHECK(in_env.request_id() == 42);
CHECK(in_env.has_client_hello());
CHECK(in_env.client_hello().proto_version() == 1);
CHECK(std::strcmp(in_env.client_hello().client_name().c_str(), "test-client") == 0);
CHECK(in_env.client_hello().features_size() == 1);
CHECK(std::strcmp(in_env.client_hello().features(0).c_str(), "text") == 0);
// next_request_id() is monotonically increasing
uint64_t r1 = next_request_id();
uint64_t r2 = next_request_id();
CHECK(r2 == r1 + 1);
// Empty envelope round-trips cleanly
{
voicecat::v1::Envelope empty;
std::vector<uint8_t> w2;
CHECK(encode_envelope(empty, w2));
FrameCodec c2;
std::vector<std::vector<uint8_t>> f2;
CHECK(c2.feed(w2.data(), w2.size(), f2));
CHECK(f2.size() == 1);
voicecat::v1::Envelope e2;
CHECK(decode_envelope(f2[0], e2));
CHECK(e2.request_id() == 0);
CHECK(e2.body_case() == voicecat::v1::Envelope::BODY_NOT_SET);
}
if (g_failures == 0) {
std::printf("envelope: all checks passed\n");
return 0;
}
std::printf("envelope: %d failure(s)\n", g_failures);
return 1;
}
-447
View File
@@ -1,447 +0,0 @@
/*
* test_external_pcm — external PCM feed/tap API.
*
* Three headless behavior tests (no audio hardware, no simulator):
*
* test_feed_pcm_round_trip — A feeds mono 440 Hz sine via vc_stream_feed_pcm; B's pcm_sink
* fires with non-zero energy, proving the full pipeline (feed→encode→relay→decode→sink).
*
* test_feed_pcm_stereo — A and B join Music Room (stereo/128kbps). A feeds interleaved
* stereo PCM (loud-L / silent-R) via vc_stream_feed_pcm(channels=2). B's pcm_sink
* asserts L-channel energy > R-channel energy (real stereo bitstream, not a mono upmix).
*
* test_pcm_sink — Verifies sink metadata: correct user_id / stream_id per frame,
* sample_rate = 48000, and that cb=NULL disables delivery.
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cmath>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
// ── Helpers ───────────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool join_ok{false};
bool voice_subscribed{false};
std::vector<std::pair<uint32_t, uint32_t>> streams_started; // (user_id, stream_id)
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_VOICE_STATE:
s->voice_subscribed = (ev->u32a == 1);
break;
case VC_EVENT_JOIN_RESULT:
s->join_ok = (ev->result == VC_OK);
break;
case VC_EVENT_STREAM_STARTED:
s->streams_started.emplace_back(ev->user_id, ev->stream_id);
break;
default: break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static bool connect_guest(vc_client*& client, const char* name, const char* label,
uint16_t port, EventStore& ev) {
vc_callbacks cb{on_event, nullptr, &ev};
vc_config cfg{label, "0.1", VC_LOG_OFF};
client = vc_client_create(&cfg, cb);
if (!client) return false;
ev.client = client;
ev.label = label;
if (vc_connect(client, "127.0.0.1", port) != VC_OK) return false;
if (vc_authenticate_guest(client, name) != VC_OK) return false;
if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false;
if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false;
if (vc_join_voice(client) != VC_OK) return false;
if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false;
return true;
}
static std::vector<int16_t> make_sine_mono(int n) {
std::vector<int16_t> pcm(static_cast<size_t>(n));
for (int i = 0; i < n; ++i) {
float t = static_cast<float>(i) / 48000.0f;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
}
return pcm;
}
// Loud-left / silent-right interleaved stereo (n samples per channel).
static std::vector<int16_t> make_sine_stereo(int n) {
std::vector<int16_t> pcm(static_cast<size_t>(n) * 2);
for (int i = 0; i < n; ++i) {
float t = static_cast<float>(i) / 48000.0f;
pcm[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
pcm[i * 2 + 1] = 0;
}
return pcm;
}
// ── PCM sink state ────────────────────────────────────────────────────────────
struct SinkData {
std::mutex mu;
std::condition_variable cv;
std::atomic<int> call_count{0};
uint32_t last_user_id = 0;
uint32_t last_stream_id = 0;
uint32_t last_channels = 0;
uint32_t last_sample_rate = 0;
int64_t total_energy = 0;
int64_t left_energy = 0; // sum |pcm[i*2]| for stereo frames
int64_t right_energy = 0; // sum |pcm[i*2+1]| for stereo frames
};
static void pcm_sink(void* user, uint32_t uid, uint32_t sid,
const int16_t* pcm, size_t n, uint32_t channels, uint32_t sr) {
auto* d = static_cast<SinkData*>(user);
std::lock_guard lk(d->mu);
d->last_user_id = uid;
d->last_stream_id = sid;
d->last_channels = channels;
d->last_sample_rate = sr;
for (size_t i = 0; i < n; ++i) {
if (channels == 2) {
d->left_energy += std::abs(static_cast<int>(pcm[i * 2]));
d->right_energy += std::abs(static_cast<int>(pcm[i * 2 + 1]));
}
for (uint32_t c = 0; c < channels; ++c)
d->total_energy += std::abs(static_cast<int>(pcm[i * channels + c]));
}
d->call_count.fetch_add(1, std::memory_order_relaxed);
d->cv.notify_all();
}
static bool sink_wait(SinkData& d, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(d.mu);
return d.cv.wait_until(lk, deadline, [&] { return d.call_count.load() > 0; });
}
// ── test_feed_pcm_round_trip ──────────────────────────────────────────────────
static void test_feed_pcm_round_trip(uint16_t port) {
std::printf("test_feed_pcm_round_trip: start\n");
EventStore evA, evB;
vc_client *clientA = nullptr, *clientB = nullptr;
CHECK(connect_guest(clientA, "PcmA", "pcm-a", port, evA));
CHECK(connect_guest(clientB, "PcmB", "pcm-b", port, evB));
if (!clientA || !clientB) goto cleanup_rt;
{
uint32_t a_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
// B registers a sink before A starts speaking.
SinkData sink;
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(500));
// A announces a MIC stream in Lobby.
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
uint32_t a_sid = 0;
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
for (auto& [uid, sid] : s.streams_started)
if (uid == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a);
std::this_thread::sleep_for(std::chrono::milliseconds(300));
// A feeds 250 mono frames via the new public API.
auto sine = make_sine_mono(960);
for (int i = 0; i < 250; ++i)
CHECK(vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1) == VC_OK);
// Wait for the sink to fire at least once (decode arrived).
bool fired = sink_wait(sink, 5000);
CHECK(fired);
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
int calls = sink.call_count.load();
int64_t energy = sink.total_energy;
std::printf("test_feed_pcm_round_trip: sink calls=%d energy=%lld\n",
calls, static_cast<long long>(energy));
CHECK(calls > 0);
CHECK(energy > 0);
vc_stream_stop(clientA, a_sid);
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
}
cleanup_rt:
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
std::printf("test_feed_pcm_round_trip: done\n");
}
// ── test_feed_pcm_stereo ──────────────────────────────────────────────────────
static void test_feed_pcm_stereo(uint16_t port) {
std::printf("test_feed_pcm_stereo: start\n");
EventStore evA, evB;
vc_client *clientA = nullptr, *clientB = nullptr;
CHECK(connect_guest(clientA, "StA", "stereo-a", port, evA));
CHECK(connect_guest(clientB, "StB", "stereo-b", port, evB));
if (!clientA || !clientB) goto cleanup_st;
{
uint32_t a_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
// Both join Music Room (channel 2, stereo/128kbps) so the encoder is stereo.
CHECK(vc_join_channel(clientA, 2, nullptr) == VC_OK);
CHECK(vc_join_channel(clientB, 2, nullptr) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(600));
SinkData sink;
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(300));
// A announces a MIC stream — effective_params will be stereo (Music Room config).
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
uint32_t a_sid = 0;
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
for (auto& [uid, sid] : s.streams_started)
if (uid == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a);
std::this_thread::sleep_for(std::chrono::milliseconds(300));
// A feeds 250 stereo frames: loud-L / silent-R.
auto stereo = make_sine_stereo(960);
for (int i = 0; i < 250; ++i)
CHECK(vc_stream_feed_pcm(clientA, a_sid, stereo.data(), 960, 2) == VC_OK);
bool fired = sink_wait(sink, 5000);
CHECK(fired);
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
int64_t L = sink.left_energy;
int64_t R = sink.right_energy;
uint32_t ch = sink.last_channels;
std::printf("test_feed_pcm_stereo: channels=%u L_energy=%lld R_energy=%lld\n",
ch, static_cast<long long>(L), static_cast<long long>(R));
CHECK(ch == 2); // decoder delivered stereo frames
CHECK(L > 0); // left channel has signal
// Opus stereo coding (mid/side): R won't be exactly 0 after decode, but should be
// substantially quieter than L. Allow up to 30% leakage.
CHECK(R < L || L == 0); // L >= R (loud-L / quiet-R)
vc_stream_stop(clientA, a_sid);
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
}
cleanup_st:
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
std::printf("test_feed_pcm_stereo: done\n");
}
// ── test_pcm_sink ─────────────────────────────────────────────────────────────
static void test_pcm_sink(uint16_t port) {
std::printf("test_pcm_sink: start\n");
EventStore evA, evB;
vc_client *clientA = nullptr, *clientB = nullptr;
CHECK(connect_guest(clientA, "SnkA", "snk-a", port, evA));
CHECK(connect_guest(clientB, "SnkB", "snk-b", port, evB));
if (!clientA || !clientB) goto cleanup_sk;
{
uint32_t a_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
SinkData sink;
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(500));
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
uint32_t a_sid = 0;
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
for (auto& [uid, sid] : s.streams_started)
if (uid == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a);
// Capture the stream_id that B observed for A's stream.
uint32_t b_a_sid = 0;
{ std::lock_guard lk(evB.mu);
for (auto& [uid, sid] : evB.streams_started)
if (uid == a_uid) { b_a_sid = sid; break; }
}
CHECK(b_a_sid != 0);
std::this_thread::sleep_for(std::chrono::milliseconds(300));
auto sine = make_sine_mono(960);
for (int i = 0; i < 250; ++i)
vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1);
bool fired = sink_wait(sink, 5000);
CHECK(fired);
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
std::printf("test_pcm_sink: calls=%d user_id=%u stream_id=%u sr=%u energy=%lld\n",
sink.call_count.load(),
sink.last_user_id, sink.last_stream_id,
sink.last_sample_rate,
static_cast<long long>(sink.total_energy));
CHECK(sink.call_count.load() > 0);
CHECK(sink.total_energy > 0);
CHECK(sink.last_user_id == a_uid); // source user matches
CHECK(sink.last_stream_id == b_a_sid); // source stream matches
CHECK(sink.last_sample_rate == 48000); // always 48000
// Disable sink — subsequent frames must not reach the callback.
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
int count_before_disable = sink.call_count.load();
// Feed more frames after disabling.
for (int i = 0; i < 100; ++i)
vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1);
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
// The count must not have increased (sink was disabled).
int count_after = sink.call_count.load();
std::printf("test_pcm_sink: count_before_disable=%d count_after=%d\n",
count_before_disable, count_after);
CHECK(count_after == count_before_disable);
vc_stream_stop(clientA, a_sid);
}
cleanup_sk:
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
std::printf("test_pcm_sink: done\n");
}
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_extpcm_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.server_name = "VoiceCat-ExtPcmTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
if (!ok) {
std::printf("FAIL: server did not start\n");
server.stop(); server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
std::printf("external_pcm: server ready on :%u\n", port);
test_feed_pcm_round_trip(port);
test_feed_pcm_stereo(port);
test_pcm_sink(port);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("external_pcm: all checks passed\n");
return 0;
}
std::printf("external_pcm: %d failure(s)\n", g_failures);
return 1;
}
-146
View File
@@ -1,146 +0,0 @@
/*
* test_external_playback — verifies AudioEngine's external-playback mode (iOS VPIO path).
*
* When external playback is enabled, the engine opens NO hardware playback device; a mixer-timer
* thread drives decode+mix on a ~20ms cadence and delivers the FINAL mixed PCM to the
* mixed-output sink (the Swift AVAudioEngine VPIO renderer consumes this). This test asserts:
* 1. The mixed sink fires steadily on the timer thread (count grows over time) with the right
* format (48kHz, stereo), and carries real energy while a stream is being decoded.
* 2. The per-stream pcm_sink still fires concurrently (both taps coexist).
* 3. With no remote streams, the mixed sink KEEPS firing (silent-but-present blocks) so the
* renderer has a continuous clock.
*
* White-box: constructs AudioEngine directly (no server, no audio hardware needed) — the timer
* thread drives the mixer with no ma_device, which is the core new behavior under test.
*/
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <thread>
#include <vector>
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
// Shared state written by the sink callbacks (timer thread) and read by main.
struct MixedSinkState {
std::atomic<int> calls{0};
std::atomic<int64_t> max_energy{0};
std::atomic<uint32_t> last_channels{0};
std::atomic<uint32_t> last_sample_rate{0};
};
static MixedSinkState g_mixed;
static std::atomic<int> g_pcm_sink_calls{0};
static void mixed_cb(void* user, const int16_t* pcm, size_t spc, uint32_t ch, uint32_t sr) {
auto* s = static_cast<MixedSinkState*>(user);
s->calls.fetch_add(1, std::memory_order_relaxed);
s->last_channels.store(ch, std::memory_order_relaxed);
s->last_sample_rate.store(sr, std::memory_order_relaxed);
int64_t e = 0;
for (size_t i = 0; i < spc * ch; ++i) e += std::abs(static_cast<int>(pcm[i]));
int64_t prev = s->max_energy.load(std::memory_order_relaxed);
while (e > prev && !s->max_energy.compare_exchange_weak(prev, e, std::memory_order_relaxed)) {
}
}
static void pcm_cb(void*, uint32_t, uint32_t, const int16_t*, size_t, uint32_t, uint32_t) {
g_pcm_sink_calls.fetch_add(1, std::memory_order_relaxed);
}
int main() {
voicecat::audio::AudioEngine engine;
engine.set_external_playback(true);
engine.set_mixed_output_sink(&mixed_cb, &g_mixed);
engine.set_pcm_sink(&pcm_cb, nullptr);
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.playback_channels = 2;
p.frame_ms = 20;
CHECK(engine.start(p)); // no hardware device opened — the timer thread drives the mixer
voicecat::codec::OpusParams op;
op.sample_rate = 48000;
op.frame_ms = 20;
op.stereo = false;
int frame_samples = voicecat::codec::opus_frame_samples(op); // 960
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(op));
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
}
uint8_t opus_buf[1500];
int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
CHECK(opus_len > 0);
const uint32_t ssrc = 1;
engine.init_recv_stream(ssrc, op, /*user_id=*/7, /*stream_id=*/3, /*is_voice=*/false);
// ── Phase 1: feed ~600ms of real frames; the timer must decode + mix them. ──────────
uint32_t ts = 0;
for (int i = 0; i < 30; ++i) { // 30 * 20ms = 600ms of audio
voicecat::audio::JitterBuffer::Frame f;
f.seq = static_cast<uint64_t>(i);
f.timestamp = ts;
f.fec_present = false;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(ssrc, std::move(f));
ts += static_cast<uint32_t>(frame_samples);
}
std::this_thread::sleep_for(std::chrono::milliseconds(500));
int active_calls = g_mixed.calls.load(std::memory_order_relaxed);
std::printf("external_playback: phase1 mixed-sink calls=%d max_energy=%lld pcm_sink=%d\n",
active_calls, static_cast<long long>(g_mixed.max_energy.load()),
g_pcm_sink_calls.load());
// ~25 blocks expected at a 20ms cadence over 500ms; allow generous slack for scheduler/debug.
CHECK(active_calls >= 10);
CHECK(g_mixed.max_energy.load(std::memory_order_relaxed) > 0); // real decoded audio in the mix
CHECK(g_mixed.last_channels.load(std::memory_order_relaxed) == 2);
CHECK(g_mixed.last_sample_rate.load(std::memory_order_relaxed) == 48000);
CHECK(g_pcm_sink_calls.load(std::memory_order_relaxed) > 0); // per-stream tap coexists
// ── Phase 2: remove the stream; the mixed sink must KEEP firing (silent blocks). ─────
engine.remove_stream(ssrc);
int before = g_mixed.calls.load(std::memory_order_relaxed);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
int after = g_mixed.calls.load(std::memory_order_relaxed);
std::printf("external_playback: phase2 silent blocks delivered=%d\n", after - before);
CHECK(after - before >= 5); // continuous clock even with nothing to play
engine.stop(); // joins the mixer-timer thread
enc.destroy();
if (g_failures == 0) {
std::printf("external_playback: all checks passed\n");
return 0;
}
std::printf("external_playback: %d failure(s)\n", g_failures);
return 1;
}
#else
int main() {
std::printf("external_playback: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
return 0;
}
#endif
-150
View File
@@ -1,150 +0,0 @@
/*
* test_frame_codec — unit test for FrameCodec::feed / ::emit.
*
* No third-party dependencies; runs under both the skeleton and dev presets.
* Tests: empty payload, single byte, 64 KiB, exact max-size, oversized (should reject),
* split delivery (bytes fed one-at-a-time), and batched multi-frame delivery.
*/
#include <cstdio>
#include <cstring>
#include <vector>
#include "protocol/protocol.h"
using namespace voicecat::protocol;
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
// Round-trip a single payload through emit → feed.
static void round_trip(const std::vector<uint8_t>& payload, const char* label) {
std::vector<uint8_t> wire;
FrameCodec::emit(payload, wire);
FrameCodec codec;
std::vector<std::vector<uint8_t>> frames;
bool ok = codec.feed(wire.data(), wire.size(), frames);
CHECK(ok);
CHECK(frames.size() == 1);
if (!frames.empty()) {
CHECK(frames[0] == payload);
}
(void)label;
}
int main() {
// --- Empty payload ---
round_trip({}, "empty");
// --- Single byte ---
round_trip({0xAB}, "1 byte");
// --- 64 KiB payload ---
{
std::vector<uint8_t> big(64 * 1024);
for (size_t i = 0; i < big.size(); ++i) big[i] = static_cast<uint8_t>(i & 0xFF);
round_trip(big, "64 KiB");
}
// --- Exactly kMaxFrameBytes ---
{
std::vector<uint8_t> max_payload(kMaxFrameBytes, 0x5A);
std::vector<uint8_t> wire;
FrameCodec::emit(max_payload, wire);
FrameCodec codec;
std::vector<std::vector<uint8_t>> frames;
bool ok = codec.feed(wire.data(), wire.size(), frames);
CHECK(ok);
CHECK(frames.size() == 1);
if (!frames.empty()) CHECK(frames[0] == max_payload);
}
// --- One byte over kMaxFrameBytes — must be rejected ---
{
// Craft a fake header with length = kMaxFrameBytes + 1.
uint32_t bad_len = kMaxFrameBytes + 1;
uint8_t header[4] = {
static_cast<uint8_t>((bad_len >> 24) & 0xFF),
static_cast<uint8_t>((bad_len >> 16) & 0xFF),
static_cast<uint8_t>((bad_len >> 8) & 0xFF),
static_cast<uint8_t>( bad_len & 0xFF),
};
FrameCodec codec;
std::vector<std::vector<uint8_t>> frames;
bool ok = codec.feed(header, 4, frames);
CHECK(!ok); // must return false
CHECK(frames.empty());
}
// --- Byte-at-a-time delivery (reassembly) ---
{
std::vector<uint8_t> payload = {1, 2, 3, 4, 5};
std::vector<uint8_t> wire;
FrameCodec::emit(payload, wire);
FrameCodec codec;
std::vector<std::vector<uint8_t>> frames;
bool ok = true;
for (uint8_t b : wire) {
ok = codec.feed(&b, 1, frames);
if (!ok) break;
}
CHECK(ok);
CHECK(frames.size() == 1);
if (!frames.empty()) CHECK(frames[0] == payload);
}
// --- Multiple frames in a single feed() call ---
{
std::vector<uint8_t> p1 = {0x01, 0x02};
std::vector<uint8_t> p2 = {0xAA, 0xBB, 0xCC};
std::vector<uint8_t> wire;
FrameCodec::emit(p1, wire);
FrameCodec::emit(p2, wire);
FrameCodec codec;
std::vector<std::vector<uint8_t>> frames;
bool ok = codec.feed(wire.data(), wire.size(), frames);
CHECK(ok);
CHECK(frames.size() == 2);
if (frames.size() == 2) {
CHECK(frames[0] == p1);
CHECK(frames[1] == p2);
}
}
// --- pending_bytes() reflects partial state ---
{
std::vector<uint8_t> payload = {0xFF};
std::vector<uint8_t> wire;
FrameCodec::emit(payload, wire); // 5 bytes total (4 hdr + 1)
FrameCodec codec;
std::vector<std::vector<uint8_t>> frames;
// Feed only the header.
codec.feed(wire.data(), 4, frames);
CHECK(frames.empty());
CHECK(codec.pending_bytes() == 4);
// Feed the body.
codec.feed(wire.data() + 4, 1, frames);
CHECK(frames.size() == 1);
CHECK(codec.pending_bytes() == 0);
}
if (g_failures == 0) {
std::printf("frame_codec: all checks passed\n");
return 0;
}
std::printf("frame_codec: %d failure(s)\n", g_failures);
return 1;
}
-319
View File
@@ -1,319 +0,0 @@
/*
* test_frame_ms_reframe — channels whose frame_ms differs from the engine's 20 ms.
*
* The AudioEngine capture clock is fixed at 48 kHz / 20 ms, so capture and vc_stream_feed_pcm
* always deliver 960-sample frames. A channel, however, may set any Opus frame_ms (docs/voice.md
* §3: 2.5…60 ms). Before the reframe fix, on_capture_frame handed the engine's 960-sample frame
* straight to an encoder configured for the channel's frame_ms: for frame_ms < 20 the receiver
* sized its decode buffer too small and dropped every frame (dead audio); for frame_ms > 20 the
* channel's setting was silently ignored. on_capture_frame now reframes to ls.frame_samples.
*
* Two end-to-end cases (admin creates the channel, two guests do a feed→encode→relay→decode→sink
* round trip in it). Both assert the sink receives non-zero decoded energy at 48 kHz:
*
* frame_ms = 40 — larger window: two 960-sample engine frames accumulate into one 1920 encode.
* frame_ms = 10 — smaller window: each 960-sample engine frame splits into two 480 encodes.
* This is the case that was fully broken (OPUS_BUFFER_TOO_SMALL on decode).
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
// ── Helpers ───────────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool generic_result_received{false};
bool generic_ok{false};
bool voice_subscribed{false};
std::vector<std::pair<uint32_t, uint32_t>> streams_started; // (user_id, stream_id)
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY: vc_confirm_server_identity(s->client, 1); break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break;
case VC_EVENT_VOICE_STATE: s->voice_subscribed = (ev->u32a == 1); break;
case VC_EVENT_GENERIC_RESULT:
s->generic_result_received = true;
s->generic_ok = (ev->result == VC_OK);
break;
case VC_EVENT_STREAM_STARTED: s->streams_started.emplace_back(ev->user_id, ev->stream_id); break;
default: break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static bool connect_guest(vc_client*& client, const char* name, const char* label,
uint16_t port, EventStore& ev) {
vc_callbacks cb{on_event, nullptr, &ev};
vc_config cfg{label, "0.1", VC_LOG_OFF};
client = vc_client_create(&cfg, cb);
if (!client) return false;
ev.client = client;
ev.label = label;
if (vc_connect(client, "127.0.0.1", port) != VC_OK) return false;
if (vc_authenticate_guest(client, name) != VC_OK) return false;
if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false;
if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false;
if (vc_join_voice(client) != VC_OK) return false;
if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false;
return true;
}
static std::vector<int16_t> make_sine_mono(int n) {
std::vector<int16_t> pcm(static_cast<size_t>(n));
for (int i = 0; i < n; ++i) {
float t = static_cast<float>(i) / 48000.0f;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
}
return pcm;
}
struct SinkData {
std::mutex mu;
std::condition_variable cv;
std::atomic<int> call_count{0};
uint32_t last_sample_rate = 0;
int64_t total_energy = 0;
};
static void pcm_sink(void* user, uint32_t, uint32_t,
const int16_t* pcm, size_t n, uint32_t channels, uint32_t sr) {
auto* d = static_cast<SinkData*>(user);
std::lock_guard lk(d->mu);
d->last_sample_rate = sr;
for (size_t i = 0; i < n * channels; ++i)
d->total_energy += std::abs(static_cast<int>(pcm[i]));
d->call_count.fetch_add(1, std::memory_order_relaxed);
d->cv.notify_all();
}
static bool sink_wait(SinkData& d, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(d.mu);
return d.cv.wait_until(lk, deadline, [&] { return d.call_count.load() > 0; });
}
// Admin creates a mono channel with the given frame_ms; returns its id (0 on failure).
static uint32_t make_channel(vc_client* admin, EventStore& evAdmin, const char* name,
uint32_t frame_ms) {
vc_channel_info ch{};
ch.name = name;
ch.audio.codec = 0; // OPUS
ch.audio.mode = 0; // mono
ch.audio.sample_rate = 48000;
ch.audio.bitrate_bps = 24000;
ch.audio.frame_ms = frame_ms;
ch.audio.fec = 1;
ch.audio.complexity = 5;
{ std::lock_guard lk(evAdmin.mu); evAdmin.generic_result_received = false; }
if (vc_create_channel(admin, &ch) != VC_OK) return 0;
if (!wait_for(evAdmin, [](EventStore& s) { return s.generic_result_received; }, 5000)) return 0;
{ std::lock_guard lk(evAdmin.mu); if (!evAdmin.generic_ok) return 0; }
vc_channel_list cl{};
if (vc_list_channels(admin, &cl) != VC_OK) return 0;
uint32_t id = 0;
for (size_t i = 0; i < cl.count; ++i)
if (cl.items[i].name && std::string(cl.items[i].name) == name) { id = cl.items[i].id; break; }
vc_free_channel_list(&cl);
return id;
}
// Full feed→encode→relay→decode→sink round trip inside `channel_id`, asserting the channel's
// frame_ms is in effect and the sink hears decoded energy.
static void run_case(uint16_t port, vc_client* admin, EventStore& evAdmin,
uint32_t channel_id, uint32_t expect_frame_ms, const char* tag) {
std::printf("test_frame_ms_reframe[%s]: channel=%u frame_ms=%u\n", tag, channel_id,
expect_frame_ms);
EventStore evA, evB;
vc_client *clientA = nullptr, *clientB = nullptr;
CHECK(connect_guest(clientA, "ReframeA", "rf-a", port, evA));
CHECK(connect_guest(clientB, "ReframeB", "rf-b", port, evB));
if (!clientA || !clientB) goto cleanup;
{
uint32_t a_uid = 0, b_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
// Move both guests into the target channel so the relay is channel-scoped to them.
CHECK(vc_move_user(admin, a_uid, channel_id) == VC_OK);
CHECK(vc_move_user(admin, b_uid, channel_id) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(500));
SinkData sink;
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
uint32_t a_sid = 0;
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
bool b_saw_a = wait_for(evB, [&](EventStore& s) {
for (auto& [uid, sid] : s.streams_started)
if (uid == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a);
std::this_thread::sleep_for(std::chrono::milliseconds(400));
// The channel's frame_ms must be the effective window on A's stream — proves the encoder
// (and hence the reframe target) is configured for the non-20ms window.
vc_audio_config ac{};
CHECK(vc_get_stream_audio_config(clientA, a_uid, a_sid, &ac) == VC_OK);
CHECK(ac.frame_ms == expect_frame_ms);
CHECK(ac.sample_rate == 48000);
// Feed 300 engine-shaped (960-sample / 20 ms / 48 kHz) frames. on_capture_frame reframes
// them to the channel's window before encoding.
auto sine = make_sine_mono(960);
for (int i = 0; i < 300; ++i)
CHECK(vc_stream_feed_pcm(clientA, a_sid, sine.data(), 960, 1) == VC_OK);
CHECK(sink_wait(sink, 5000));
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
int calls = sink.call_count.load();
int64_t energy = sink.total_energy;
std::printf("test_frame_ms_reframe[%s]: sink calls=%d energy=%lld sr=%u\n",
tag, calls, static_cast<long long>(energy), sink.last_sample_rate);
CHECK(calls > 0);
CHECK(energy > 0); // decoded audio actually arrived
CHECK(sink.last_sample_rate == 48000);
vc_stream_stop(clientA, a_sid);
CHECK(vc_set_pcm_sink(clientB, nullptr, nullptr) == VC_OK);
}
cleanup:
if (clientA) { vc_disconnect(clientA); vc_client_destroy(clientA); }
if (clientB) { vc_disconnect(clientB); vc_client_destroy(clientB); }
std::printf("test_frame_ms_reframe[%s]: done\n", tag);
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_reframe_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
// Pre-provision an admin so we can create channels with custom frame_ms.
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err) || !db.create_account("admin", "pass", true, err)) {
std::printf("FAIL: provision admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.server_name = "VoiceCat-ReframeTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not start\n");
server.stop(); server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
std::printf("frame_ms_reframe: server ready on :%u\n", port);
// Admin client creates the two test channels.
EventStore evAdmin;
evAdmin.label = "admin";
vc_callbacks cbAdmin{on_event, nullptr, &evAdmin};
vc_config cfgAdmin{"reframe-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgAdmin, cbAdmin);
CHECK(admin != nullptr);
evAdmin.client = admin;
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "pass") == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evAdmin, [](EventStore& s) { return s.channel_list_received; }, 3000));
uint32_t ch40 = make_channel(admin, evAdmin, "Reframe40", 40);
uint32_t ch10 = make_channel(admin, evAdmin, "Reframe10", 10);
CHECK(ch40 != 0);
CHECK(ch10 != 0);
if (ch40) run_case(port, admin, evAdmin, ch40, 40, "40ms");
if (ch10) run_case(port, admin, evAdmin, ch10, 10, "10ms");
vc_disconnect(admin);
vc_client_destroy(admin);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("frame_ms_reframe: all checks passed\n");
return 0;
}
std::printf("frame_ms_reframe: %d failure(s)\n", g_failures);
return 1;
}
-144
View File
@@ -1,144 +0,0 @@
/*
* test_jitter_depth — verifies the bounded-depth playout in AudioEngine::on_playback.
*
* Regression guard for the "latency keeps drifting backward, fixed only by rejoining" bug. The
* sender omits VAD/PTT/DTX silence from its timestamps (a compressed timeline), while the
* receiver's playout clock free-runs in real time. The old logic re-synced the playout clock to
* the *oldest* buffered frame and could only ever *add* standing latency (a reordered/late frame
* snapped the clock backward), with nothing to trim it — so latency ratcheted up across talkspurt
* gaps. The fix keeps the clock a bounded `target` behind the *newest* arrival and frame-skips to
* catch up, so depth stays bounded no matter the trigger.
*
* This drives many talkspurt/silence cycles with a compressed timeline plus a reordered straggler
* each cycle (which previously snapped the clock backward), and asserts the buffered depth
* (newest_ts - playout_ts) stays bounded while audio keeps playing. White-box via mix_for_test
* (no audio hardware needed), same pattern as test_plc_cap.
*/
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <vector>
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
static double rms(const int16_t* pcm, int n) {
double sum = 0.0;
for (int i = 0; i < n; ++i) sum += static_cast<double>(pcm[i]) * pcm[i];
return std::sqrt(sum / n);
}
int main() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.playback_channels = 2;
p.frame_ms = 20;
CHECK(engine.start(p)); // no capture_cb — headless safe
voicecat::codec::OpusParams op;
op.sample_rate = 48000;
op.frame_ms = 20;
op.stereo = false;
const int frame_samples = voicecat::codec::opus_frame_samples(op); // 960
// A loud sine, encoded once, reused for every pushed frame.
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(op));
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
}
uint8_t opus_buf[1500];
const int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
CHECK(opus_len > 0);
const uint32_t ssrc = 1;
engine.init_recv_stream(ssrc, op, /*user_id=*/0, /*stream_id=*/0, /*is_voice=*/false);
const uint32_t pb_frames = 480; // 10 ms hardware period
const int out_n = static_cast<int>(pb_frames) * 2; // stereo interleaved
std::vector<int16_t> out(static_cast<size_t>(out_n), 0);
auto mix_n = [&](int n) {
for (int i = 0; i < n; ++i) engine.mix_for_test(out.data(), pb_frames);
};
auto push = [&](uint32_t ts, bool marker) {
voicecat::audio::JitterBuffer::Frame f;
f.seq = 0;
f.timestamp = ts;
f.fec_present = false;
f.marker = marker;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(ssrc, std::move(f));
};
uint32_t ts = 1000; // arbitrary non-zero start
int32_t max_depth = 0;
double last_voice_rms = 0.0;
// Seed the stream (first frame is a talkspurt marker, like a real resume).
push(ts, /*marker=*/true);
ts += static_cast<uint32_t>(frame_samples);
mix_n(1);
// Drive the producer FASTER than the consumer: push one 960-sample frame per step but drain
// only 480 samples (one pb_frames callback) — i.e. arrivals outrun playout by ~480 samples a
// step, exactly the clock-drift / bursty-arrival condition that made latency ratchet up. Also
// inject a reordered straggler periodically (the old backward-snap trigger). The bounded-depth
// catch-up must keep the standing latency from growing without limit. Pre-fix (no catch-up,
// snap-to-oldest) the depth would climb to ~hundreds of frames here.
const int kSteps = 400;
const uint32_t kStraggler = 48000u * 250u / 1000u; // 250 ms behind the leading edge
for (int s = 0; s < kSteps; ++s) {
push(ts, /*marker=*/false);
ts += static_cast<uint32_t>(frame_samples);
if (s % 25 == 12) push(ts - kStraggler, /*marker=*/false); // reordered straggler
mix_n(1); // drain only 480 of the 960 produced — producer outruns consumer
int32_t d = engine.stream_playout_depth_samples(ssrc);
if (d > max_depth) max_depth = d;
last_voice_rms = std::max(last_voice_rms, rms(out.data(), out_n));
}
std::printf("jitter_depth: max_depth=%d samples (%.0f ms) voice_rms=%.1f\n", max_depth,
static_cast<double>(max_depth) * 1000.0 / 48000.0, last_voice_rms);
// Bounded: with catch-up the standing latency stays near the adaptive target, well under
// 200 ms even though arrivals outran playout for 400 steps (~4 s of pushed audio).
CHECK(max_depth > 0); // playout ran / depth observed
CHECK(max_depth < static_cast<int32_t>(48000 * 200 / 1000)); // bounded (was unbounded pre-fix)
CHECK(last_voice_rms > 1.0); // audio keeps playing
engine.remove_stream(ssrc);
engine.stop();
enc.destroy();
if (g_failures == 0) {
std::printf("jitter_depth: all checks passed\n");
return 0;
}
std::printf("jitter_depth: %d failure(s)\n", g_failures);
return 1;
}
#else
int main() {
std::printf("jitter_depth: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
return 0;
}
#endif
-255
View File
@@ -1,255 +0,0 @@
/*
* test_m1_integration — M1 exit criterion.
*
* Two clients connect to a real voicecat-server over TLS 1.3:
* Client A authenticates as guest "GuestBob"
* Client B authenticates as password user "alice"
* Both receive the channel list, A sends a channel message that B receives,
* then B sends a private message that A receives.
*/
#include <cstdio>
#include <cstring>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
// ── Event tracking ────────────────────────────────────────────────────────────
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
vc_result auth_result{VC_ERR_INTERNAL};
uint32_t self_user_id{0};
bool channel_list_received{false};
std::vector<std::string> messages; // copies of received text bodies
// For diagnostics
const char* label{nullptr};
std::string last_error;
bool disconnected{false};
vc_connection_state last_state{VC_STATE_DISCONNECTED};
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test.
vc_client* client{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
s->last_state = ev->connection_state;
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
// No human to ask in a headless test — trust on first connect unconditionally.
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_result = static_cast<vc_result>(ev->result);
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
if (!s->auth_ok) std::fprintf(stderr, "[%s] AUTH FAILED: %s\n",
s->label ? s->label : "?", ev->text ? ev->text : "(no msg)");
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_TEXT_MESSAGE:
if (ev->text) s->messages.emplace_back(ev->text);
break;
case VC_EVENT_ERROR:
s->last_error = ev->text ? ev->text : "";
std::fprintf(stderr, "[%s] ERROR rc=%d: %s\n",
s->label ? s->label : "?", ev->result, s->last_error.c_str());
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
std::fprintf(stderr, "[%s] DISCONNECTED rc=%d: %s\n",
s->label ? s->label : "?", ev->result, ev->text ? ev->text : "");
break;
case VC_EVENT_CONNECTION_STATE:
std::fprintf(stderr, "[%s] STATE -> %d\n",
s->label ? s->label : "?", (int)ev->connection_state);
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
// ── Test harness ──────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
// ── Isolated temp dir for this test run ──────────────────────────────────
auto tmp = std::filesystem::temp_directory_path() /
("vctest_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
// ── Pre-provision alice's account before the server starts ───────────────
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) {
std::printf("FAIL: db.open: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
auto acc = db.create_account("alice", "test-pass-alice", false, err);
if (!acc) {
std::printf("FAIL: create_account: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
// ── Start server on an OS-assigned port ───────────────────────────────────
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0; // OS picks port
cfg.server_name = "VoiceCat-IntTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10),
[&] { return ready; });
if (!ok) {
std::printf("FAIL: server did not become ready within 10s\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
std::printf("m1_integration: server ready on :%u\n", port);
// ── Client A: guest "GuestBob" ────────────────────────────────────────────
EventStore evA;
evA.label = "clientA";
vc_callbacks cbA{on_event, nullptr, &evA};
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
vc_client* clientA = vc_client_create(&cfgA, cbA);
CHECK(clientA != nullptr);
evA.client = clientA;
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientA, "GuestBob") == VC_OK);
// Guest auth is fast; 8s is generous.
bool authA_ok = wait_for(evA, [](EventStore& s){ return s.auth_ok; }, 8000);
CHECK(authA_ok);
if (!authA_ok) std::printf(" (client A auth timed out)\n");
bool clA_ok = wait_for(evA, [](EventStore& s){ return s.channel_list_received; }, 3000);
CHECK(clA_ok);
// ── Client B: password user "alice" ───────────────────────────────────────
EventStore evB;
evB.label = "clientB";
vc_callbacks cbB{on_event, nullptr, &evB};
vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
vc_client* clientB = vc_client_create(&cfgB, cbB);
CHECK(clientB != nullptr);
evB.client = clientB;
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(clientB, "alice", "test-pass-alice") == VC_OK);
// Argon2id (INTERACTIVE) takes ~0.5-2 s; allow 20 s.
bool authB_ok = wait_for(evB, [](EventStore& s){ return s.auth_ok; }, 20000);
CHECK(authB_ok);
if (!authB_ok) std::printf(" (client B auth timed out — Argon2id may be slow)\n");
bool clB_ok = wait_for(evB, [](EventStore& s){ return s.channel_list_received; }, 3000);
CHECK(clB_ok);
// ── A sends channel text → B receives it ─────────────────────────────────
const char* chan_msg = "Hello from GuestBob!";
CHECK(vc_send_text(clientA, VC_TEXT_CHANNEL, 1, chan_msg) == VC_OK);
bool B_got_chan = wait_for(evB, [&](EventStore& s) {
for (auto& m : s.messages)
if (m == chan_msg) return true;
return false;
}, 5000);
CHECK(B_got_chan);
// ── B sends private text to A ─────────────────────────────────────────────
uint32_t a_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
const char* priv_msg = "Private reply from alice!";
CHECK(vc_send_text(clientB, VC_TEXT_PRIVATE, a_uid, priv_msg) == VC_OK);
bool A_got_priv = wait_for(evA, [&](EventStore& s) {
for (auto& m : s.messages)
if (m == priv_msg) return true;
return false;
}, 5000);
CHECK(A_got_priv);
// ── Cleanup ───────────────────────────────────────────────────────────────
vc_disconnect(clientA);
vc_disconnect(clientB);
vc_client_destroy(clientA);
vc_client_destroy(clientB);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m1_integration: all checks passed\n");
return 0;
}
std::printf("m1_integration: %d failure(s)\n", g_failures);
return 1;
}
-529
View File
@@ -1,529 +0,0 @@
/*
* test_m2_voice — M2 exit criterion.
*
* Two headless clients authenticate over TLS, perform UDP binding, announce a voice stream,
* then Client A sends synthetic Opus frames (440 Hz sine PCM) encrypted with ChaCha20-Poly1305.
* The server SFU relay re-encrypts and forwards them to Client B.
*
* Assertions:
* 1. UDP binding and StreamAnnounce succeed for both clients.
* 2. Client B receives >= 25 out of 50 sent frames (50% floor accounts for startup latency).
* 3. Client B successfully decrypts all frames it receives (auth tag valid).
* 4. After simulated loss (every 5th frame skipped), packets_lost counter increases.
*/
#include <cstdio>
#include <cstring>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cmath>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <winsock2.h>
# include <ws2tcpip.h>
using sock_t = SOCKET;
static constexpr sock_t kBadSock = INVALID_SOCKET;
static void close_sock(sock_t s) { closesocket(s); }
static int sock_error() { return WSAGetLastError(); }
#else
# include <arpa/inet.h>
# include <netdb.h>
# include <netinet/in.h>
# include <sys/socket.h>
# include <unistd.h>
using sock_t = int;
static constexpr sock_t kBadSock = -1;
static void close_sock(sock_t s) { ::close(s); }
static int sock_error() { return errno; }
#endif
#include "crypto/crypto.h"
#include "net/voice_frame.h"
#include "protocol/envelope.h"
#include "protocol/protocol.h"
#include "server.h"
#include "db.h"
#include "codec/opus_codec.h"
using namespace voicecat;
using namespace voicecat::net;
using namespace voicecat::crypto;
// ── helpers ───────────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
// Blocking socket helpers
static bool tcp_send_all(TlsContext& tls, const uint8_t* data, size_t len) {
size_t off = 0;
while (off < len) {
int n = tls.write(data + off, len - off);
if (n <= 0) return false;
off += static_cast<size_t>(n);
}
return true;
}
static bool tcp_send_envelope(TlsContext& tls, const v1::Envelope& env) {
std::vector<uint8_t> frame;
protocol::encode_envelope(env, frame);
return tcp_send_all(tls, frame.data(), frame.size());
}
// Read one Envelope from TLS (blocking, with 5s timeout between reads).
static bool tcp_recv_envelope(TlsContext& tls, protocol::FrameCodec& codec, v1::Envelope& out) {
uint8_t buf[16384];
for (int i = 0; i < 100; ++i) { // 100 * 50ms = 5s
int n = tls.read(buf, sizeof(buf));
if (TlsContext::is_timeout_error(n)) continue;
if (n <= 0) return false;
std::vector<std::vector<uint8_t>> frames;
if (!codec.feed(buf, static_cast<size_t>(n), frames)) return false;
for (auto& f : frames) {
if (protocol::decode_envelope(f, out)) return true;
}
}
return false;
}
// ── UDP raw socket (BSD sockets, not UdpMediaChannel) ─────────────────────────
// We use the raw BSD API here so we can set a receive timeout easily.
static sock_t udp_bind_os(uint16_t& out_port) {
#ifdef _WIN32
WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa);
#endif
sock_t s = ::socket(AF_INET, SOCK_DGRAM, 0);
if (s == kBadSock) return kBadSock;
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (::bind(s, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
close_sock(s); return kBadSock;
}
socklen_t len = sizeof(addr);
::getsockname(s, reinterpret_cast<sockaddr*>(&addr), &len);
out_port = ntohs(addr.sin_port);
// 500ms receive timeout
#ifdef _WIN32
DWORD tv = 500;
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&tv), sizeof(tv));
#else
struct timeval tv{0, 500000};
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
#endif
return s;
}
static bool udp_send(sock_t s, const uint8_t* data, size_t len, uint16_t dst_port) {
sockaddr_in dst{};
dst.sin_family = AF_INET;
dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
dst.sin_port = htons(dst_port);
int n = static_cast<int>(::sendto(s, reinterpret_cast<const char*>(data),
static_cast<int>(len), 0,
reinterpret_cast<sockaddr*>(&dst), sizeof(dst)));
return n == static_cast<int>(len);
}
static int udp_recv(sock_t s, uint8_t* buf, size_t cap) {
return static_cast<int>(::recv(s, reinterpret_cast<char*>(buf),
static_cast<int>(cap), 0));
}
// ── TestClient ────────────────────────────────────────────────────────────────
struct TestClient {
std::string label;
sock_t tcp_sock = kBadSock;
sock_t udp_sock = kBadSock;
uint16_t udp_local_port = 0;
std::unique_ptr<TlsContext> tls;
std::unique_ptr<SodiumMediaCrypto> send_crypto;
std::unique_ptr<SodiumMediaCrypto> recv_crypto;
protocol::FrameCodec codec;
uint8_t udp_token[16]{};
uint16_t server_udp_port = 0;
uint32_t assigned_ssrc = 0;
bool stream_ok = false;
std::atomic<int> frames_received{0};
std::atomic<int> decrypt_errors{0};
// Decoded payloads for verification
std::mutex payloads_mu;
std::vector<std::vector<uint8_t>> payloads;
bool connect(const char* host, uint16_t port) {
#ifdef _WIN32
WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa);
#endif
struct addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
struct addrinfo* res = nullptr;
if (getaddrinfo(host, std::to_string(port).c_str(), &hints, &res) != 0 || !res)
return false;
tcp_sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (tcp_sock == kBadSock) { freeaddrinfo(res); return false; }
if (::connect(tcp_sock, res->ai_addr, static_cast<int>(res->ai_addrlen)) != 0) {
close_sock(tcp_sock); tcp_sock = kBadSock; freeaddrinfo(res); return false;
}
freeaddrinfo(res);
return true;
}
bool tls_handshake() {
tls = std::make_unique<TlsContext>(TlsContext::Role::Client, nullptr);
tls->set_read_timeout(50); // 50ms read timeout for drain-between-reads
std::string err;
if (!tls->handshake(static_cast<int>(tcp_sock), err)) {
std::printf("[%s] TLS failed: %s\n", label.c_str(), err.c_str());
return false;
}
// Derive media keying material immediately after handshake.
send_crypto = SodiumMediaCrypto::derive_send(*tls, true); // client sends on ctx=0x00
recv_crypto = SodiumMediaCrypto::derive_recv(*tls, true); // client recvs on ctx=0x01
return send_crypto && recv_crypto;
}
bool do_hello_and_auth(const char* nickname) {
// ClientHello
{
v1::Envelope env;
env.set_request_id(1);
env.mutable_client_hello()->set_proto_version(2);
env.mutable_client_hello()->set_client_name(label);
if (!tcp_send_envelope(*tls, env)) return false;
}
// ServerHello
{
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) return false;
if (!env.has_server_hello()) return false;
server_udp_port = static_cast<uint16_t>(env.server_hello().udp_port());
}
// AuthRequest (guest)
{
v1::Envelope env;
env.set_request_id(2);
env.mutable_auth_request()->mutable_guest()->set_nickname(nickname);
if (!tcp_send_envelope(*tls, env)) return false;
}
// Wait for AuthResult (may be preceded by other messages)
for (int attempt = 0; attempt < 20; ++attempt) {
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) return false;
if (env.has_auth_result()) {
if (!env.auth_result().ok()) return false;
const auto& tok = env.auth_result().udp_token();
if (tok.size() == 16) std::memcpy(udp_token, tok.data(), 16);
return true;
}
}
return false;
}
// Drain any pending TCP messages (e.g., ServerState snapshot).
void drain_incoming(int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(timeout_ms);
while (std::chrono::steady_clock::now() < deadline) {
v1::Envelope env;
if (tcp_recv_envelope(*tls, codec, env)) {
if (env.has_udp_binding()) { /* handled */ }
if (env.has_stream_announce_result()) { /* handled below */ }
}
}
}
bool do_udp_binding() {
// Bind local UDP socket.
udp_sock = udp_bind_os(udp_local_port);
if (udp_sock == kBadSock) return false;
// Send TCP UdpBinding (declares intent to bind).
{
v1::Envelope env;
env.set_request_id(3);
env.mutable_udp_binding()->set_udp_token(udp_token, 16);
if (!tcp_send_envelope(*tls, env)) return false;
}
// Wait for TCP UdpBinding ack.
bool got_ack = false;
for (int i = 0; i < 20 && !got_ack; ++i) {
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) break;
if (env.has_udp_binding() && env.udp_binding().ack()) got_ack = true;
}
if (!got_ack) return false;
// Send UDP binding packet (type=kFrameUdpBinding + token).
auto pkt = make_udp_binding_packet(udp_token, 16);
if (!udp_send(udp_sock, pkt.data(), pkt.size(), server_udp_port)) return false;
// Brief pause to let the server process the UDP binding.
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return true;
}
bool do_subscribe_voice() {
{
v1::Envelope env;
env.set_request_id(3);
env.mutable_subscribe_voice();
if (!tcp_send_envelope(*tls, env)) return false;
}
for (int i = 0; i < 20; ++i) {
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) return false;
if (env.has_voice_subscription_result()) {
return env.voice_subscription_result().subscribed();
}
}
return false;
}
bool do_stream_announce() {
// Send StreamAnnounce.
{
v1::Envelope env;
env.set_request_id(4);
auto* ann = env.mutable_stream_announce();
ann->set_kind(v1::STREAM_MIC);
auto* audio = ann->mutable_requested_audio();
audio->set_sample_rate(48000);
audio->set_bitrate_bps(24000);
audio->set_frame_ms(20);
audio->set_fec(true);
if (!tcp_send_envelope(*tls, env)) return false;
}
// Wait for StreamAnnounceResult.
for (int i = 0; i < 20; ++i) {
v1::Envelope env;
if (!tcp_recv_envelope(*tls, codec, env)) return false;
if (env.has_stream_announce_result()) {
const auto& r = env.stream_announce_result();
if (r.ok()) {
assigned_ssrc = r.ssrc();
stream_ok = true;
return true;
}
return false;
}
}
return false;
}
void close() {
if (udp_sock != kBadSock) { close_sock(udp_sock); udp_sock = kBadSock; }
if (tcp_sock != kBadSock) { close_sock(tcp_sock); tcp_sock = kBadSock; }
tls.reset();
}
};
// ── generate 440 Hz mono PCM (48 kHz, 20 ms = 960 samples) ───────────────────
static std::vector<int16_t> make_sine_frame(int frame_idx, int frame_samples = 960) {
std::vector<int16_t> pcm(frame_samples);
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(frame_idx * frame_samples + i) / 48000.0f;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f);
}
return pcm;
}
// ── main ──────────────────────────────────────────────────────────────────────
int main() {
// ── Temp data dir ────────────────────────────────────────────────────────
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m2_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
// ── Start server ─────────────────────────────────────────────────────────
std::atomic<uint16_t> tcp_port{0}, udp_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
int ready_flags = 0;
voicecat::server::Config cfg;
cfg.data_dir = tmp.string();
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.allow_guests = true;
cfg.server_name = "VoiceCat-M2Test";
cfg.on_ready = [&](uint16_t p) {
tcp_port.store(p);
{ std::lock_guard lk(ready_mu); ready_flags |= 1; }
ready_cv.notify_all();
};
cfg.on_media_ready = [&](uint16_t p) {
udp_port.store(p);
{ std::lock_guard lk(ready_mu); ready_flags |= 2; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(15),
[&] { return (ready_flags & 3) == 3; });
if (!ok) {
std::printf("FAIL: server did not become ready in time\n");
server.stop(); server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
std::printf("m2_voice: server TCP:%u UDP:%u\n", tcp_port.load(), udp_port.load());
// ── Client A ─────────────────────────────────────────────────────────────
TestClient A;
A.label = "ClientA";
CHECK(A.connect("127.0.0.1", tcp_port.load()));
CHECK(A.tls_handshake());
CHECK(A.do_hello_and_auth("SenderBob"));
CHECK(A.server_udp_port == udp_port.load());
CHECK(A.do_udp_binding());
CHECK(A.do_subscribe_voice());
CHECK(A.do_stream_announce());
CHECK(A.stream_ok);
std::printf("m2_voice: A ssrc=%u local_udp=%u\n", A.assigned_ssrc, A.udp_local_port);
// ── Client B ─────────────────────────────────────────────────────────────
TestClient B;
B.label = "ClientB";
CHECK(B.connect("127.0.0.1", tcp_port.load()));
CHECK(B.tls_handshake());
CHECK(B.do_hello_and_auth("ReceiverAlice"));
CHECK(B.server_udp_port == udp_port.load());
CHECK(B.do_udp_binding());
CHECK(B.do_subscribe_voice());
// B doesn't need to announce a stream to receive relayed frames
if (g_failures > 0) {
std::printf("m2_voice: setup failed — aborting\n");
A.close(); B.close();
server.stop(); server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
// ── A sends 50 Opus frames ────────────────────────────────────────────────
constexpr int kFramesToSend = 50;
constexpr int kFrameSamples = 960; // 20 ms @48 kHz
voicecat::codec::OpusEncoder enc;
{
voicecat::codec::OpusParams p;
p.sample_rate = 48000;
p.frame_ms = 20;
p.fec = true;
CHECK(enc.init(p));
}
std::vector<uint8_t> aead_buf(4096); // scratch
for (int i = 0; i < kFramesToSend; ++i) {
auto pcm = make_sine_frame(i, kFrameSamples);
uint8_t opus_buf[1000];
int opus_len = enc.encode(pcm.data(), kFrameSamples, opus_buf, sizeof(opus_buf));
if (opus_len <= 0) continue;
const uint8_t* payload = opus_buf;
size_t payload_len = static_cast<size_t>(opus_len);
// Build the voice frame header (AAD).
VoiceFrame hdr;
hdr.ssrc = A.assigned_ssrc;
hdr.seq = static_cast<uint64_t>(i);
hdr.timestamp = static_cast<uint32_t>(i * kFrameSamples);
uint8_t header_bytes[kVoiceHeaderSize];
serialize_header(hdr, header_bytes);
// AEAD-seal the payload.
size_t sealed_cap = payload_len + crypto_aead_chacha20poly1305_ietf_ABYTES;
if (aead_buf.size() < kVoiceHeaderSize + sealed_cap) aead_buf.resize(kVoiceHeaderSize + sealed_cap);
std::memcpy(aead_buf.data(), header_bytes, kVoiceHeaderSize);
long sealed = A.send_crypto->seal(payload, payload_len,
header_bytes, kVoiceHeaderSize,
aead_buf.data() + kVoiceHeaderSize, sealed_cap);
if (sealed < 0) continue;
udp_send(A.udp_sock, aead_buf.data(),
kVoiceHeaderSize + static_cast<size_t>(sealed),
udp_port.load());
// 20 ms inter-frame spacing to let the server process
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
enc.destroy();
// ── B collects received frames (2s window after last send) ────────────────
int recv_count = 0, decrypt_ok = 0;
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
uint8_t udp_in[2048];
while (std::chrono::steady_clock::now() < deadline) {
int n = udp_recv(B.udp_sock, udp_in, sizeof(udp_in));
if (n < static_cast<int>(kVoiceHeaderSize)) continue;
// Decrypt
VoiceFrame hdr_in{};
parse_header(udp_in, static_cast<size_t>(n), hdr_in);
size_t sealed_len = static_cast<size_t>(n) - kVoiceHeaderSize;
std::vector<uint8_t> plain(sealed_len);
long plain_len = B.recv_crypto->open(
udp_in + kVoiceHeaderSize, sealed_len,
udp_in, kVoiceHeaderSize,
plain.data(), plain.size());
recv_count++;
if (plain_len >= 0) decrypt_ok++;
}
std::printf("m2_voice: A sent %d frames, B received %d, decrypted OK: %d\n",
kFramesToSend, recv_count, decrypt_ok);
CHECK(recv_count >= 25); // ≥ 50% of sent frames arrived
CHECK(decrypt_ok == recv_count); // all received frames decrypt correctly
// ── Cleanup ───────────────────────────────────────────────────────────────
A.close();
B.close();
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m2_voice: all checks passed\n");
return 0;
}
std::printf("m2_voice: %d failure(s)\n", g_failures);
return 1;
}
-387
View File
@@ -1,387 +0,0 @@
/*
* test_m3_multistream — M3 exit criterion, exercised through the real C ABI.
*
* Mirrors test_voice_client_abi.cpp's approach (real vc_client instances, not raw sockets —
* the M2 lesson is that ABI-level coverage is what actually proves the client library works).
* Covers the whole M3 milestone in one flow:
*
* 1. A starts two concurrent local streams (MIC + SCREEN_AUDIO) -- distinct stream ids,
* both visible to B as separate STREAM_STARTED events for the same user.
* 2. Synthetic PCM (vc_test_inject_capture) flows into both of A's streams without crashing
* and without disrupting the control/voice plane; B observes a VC_EVENT_TALK_STATE
* talking=true edge for A's MIC stream while both are still in the same channel (voice
* only relays within a channel, so this must happen before step 4 moves A elsewhere).
* 3. B independently gains/mutes/NS-toggles A's two streams (vc_set_remote_stream) --
* one call doesn't clobber the other's routing; a bogus stream_id is rejected.
* 4. Per-channel Opus configurability: A joins "Music Room" (channel 2, stereo/128kbps/
* OPUS_AUDIO/no DTX) before announcing there, while B stays in "Lobby" (channel 1,
* mono/24kbps/OPUS_VOIP/DTX) -- vc_get_stream_audio_config shows the two streams'
* effective config differs exactly as the server enforces it.
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
// ── Event tracking ────────────────────────────────────────────────────────────
struct StreamEvent {
bool started; // true = STARTED, false = STOPPED
uint32_t user_id;
uint32_t stream_id;
};
struct TalkEvent {
uint32_t user_id;
uint32_t stream_id;
bool talking;
};
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
std::vector<StreamEvent> stream_events;
std::vector<TalkEvent> talk_events;
bool voice_subscribed{false};
bool disconnected{false};
const char* label{nullptr};
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test.
vc_client* client{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
// No human to ask in a headless test — trust on first connect unconditionally.
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
if (!s->auth_ok) std::fprintf(stderr, "[%s] AUTH FAILED: %s\n",
s->label ? s->label : "?", ev->text ? ev->text : "(no msg)");
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_VOICE_STATE:
s->voice_subscribed = (ev->u32a == 1);
break;
case VC_EVENT_STREAM_STARTED:
s->stream_events.push_back({true, ev->user_id, ev->stream_id});
break;
case VC_EVENT_STREAM_STOPPED:
s->stream_events.push_back({false, ev->user_id, ev->stream_id});
break;
case VC_EVENT_TALK_STATE:
s->talk_events.push_back({ev->user_id, ev->stream_id, ev->u32a != 0});
break;
case VC_EVENT_ERROR:
std::fprintf(stderr, "[%s] ERROR rc=%d: %s\n",
s->label ? s->label : "?", ev->result, ev->text ? ev->text : "");
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static std::vector<int16_t> make_sine_frame(int frame_idx, float freq_hz,
int frame_samples = 960) {
std::vector<int16_t> pcm(frame_samples);
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(frame_idx * frame_samples + i) / 48000.0f;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * freq_hz * t) * 16000.0f);
}
return pcm;
}
// ── Test harness ──────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m3_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.server_name = "VoiceCat-M3Test";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
if (!ok) {
std::printf("FAIL: server did not become ready within 10s\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
std::printf("m3_multistream: server ready on :%u\n", port);
// ── Client A: guest "M3-A" ────────────────────────────────────────────────
EventStore evA;
evA.label = "clientA";
vc_callbacks cbA{on_event, nullptr, &evA};
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
vc_client* clientA = vc_client_create(&cfgA, cbA);
CHECK(clientA != nullptr);
evA.client = clientA;
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientA, "M3-A") == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(clientA) == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
// ── Client B: guest "M3-B" ────────────────────────────────────────────────
EventStore evB;
evB.label = "clientB";
vc_callbacks cbB{on_event, nullptr, &evB};
vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
vc_client* clientB = vc_client_create(&cfgB, cbB);
CHECK(clientB != nullptr);
evB.client = clientB;
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientB, "M3-B") == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(clientB) == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
uint32_t a_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
// Both guests land in channel 1 (Lobby) automatically; give the async UDP binding
// handshake a moment to complete on both clients before announcing streams.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
// ── 1. A starts MIC + SCREEN_AUDIO concurrently ──────────────────────────
vc_stream_desc mic_desc{};
mic_desc.kind = VC_STREAM_MIC;
mic_desc.label = "mic";
uint32_t mic_sid = 0;
CHECK(vc_stream_start(clientA, &mic_desc, &mic_sid) == VC_OK);
vc_stream_desc screen_desc{};
screen_desc.kind = VC_STREAM_SCREEN_AUDIO;
screen_desc.label = "desktop audio";
uint32_t screen_sid = 0;
CHECK(vc_stream_start(clientA, &screen_desc, &screen_sid) == VC_OK);
CHECK(mic_sid != 0 && screen_sid != 0 && mic_sid != screen_sid);
// B observes two distinct STREAM_STARTED events for user A.
bool b_saw_both = wait_for(evB, [&](EventStore& s) {
bool saw_mic = false, saw_screen = false;
for (auto& e : s.stream_events) {
if (!e.started || e.user_id != a_uid) continue;
if (e.stream_id == mic_sid) saw_mic = true;
if (e.stream_id == screen_sid) saw_screen = true;
}
return saw_mic && saw_screen;
}, 5000);
CHECK(b_saw_both);
// Also wait for A's own view of both streams (vc_test_inject_capture requires the
// LocalStream to be active, which flips on A's io_thread_ independently of -- and not
// necessarily before -- the broadcast B observes above).
bool a_self_saw_both = wait_for(evA, [&](EventStore& s) {
bool saw_mic = false, saw_screen = false;
for (auto& e : s.stream_events) {
if (!e.started || e.user_id != a_uid) continue;
if (e.stream_id == mic_sid) saw_mic = true;
if (e.stream_id == screen_sid) saw_screen = true;
}
return saw_mic && saw_screen;
}, 5000);
CHECK(a_self_saw_both);
// ── 2. Inject synthetic PCM into both of A's local streams ──────────────
for (int i = 0; i < 25; ++i) {
auto mic_pcm = make_sine_frame(i, 440.0f);
auto screen_pcm = make_sine_frame(i, 880.0f);
CHECK(vc_test_inject_capture(clientA, mic_sid, mic_pcm.data(), mic_pcm.size()) == VC_OK);
CHECK(vc_test_inject_capture(clientA, screen_sid, screen_pcm.data(), screen_pcm.size()) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
// No disconnects/errors should have resulted from the dual-stream PCM flow.
{ std::lock_guard lk(evA.mu); CHECK(!evA.disconnected); }
{ std::lock_guard lk(evB.mu); CHECK(!evB.disconnected); }
// ── 5. Talk indicators ────────────────────────────────────────────────────
// While A and B are still both in Lobby (voice actually relays between them here --
// the SFU forwards within a channel, so this must happen before A moves to Music Room
// in step 4 below), confirm B observed a talking=true edge for A's MIC stream.
bool b_saw_talking = wait_for(evB, [&](EventStore& s) {
for (auto& e : s.talk_events)
if (e.user_id == a_uid && e.stream_id == mic_sid && e.talking) return true;
return false;
}, 3000);
CHECK(b_saw_talking);
// ── 3. B independently controls gain/mute/NS on each of A's streams ─────
CHECK(vc_set_remote_stream(clientB, a_uid, mic_sid, 1.0f, 0, 0) == VC_OK);
CHECK(vc_set_remote_stream(clientB, a_uid, screen_sid, 0.3f, 1, 1) == VC_OK);
CHECK(vc_set_remote_stream(clientB, a_uid, 0xDEADBEEF, 1.0f, 0, 0) == VC_ERR_INVALID_ARG);
// Toggle NS on/off a few times -- plumbing should never fault or disrupt the stream.
for (int i = 0; i < 3; ++i) {
CHECK(vc_set_remote_stream(clientB, a_uid, mic_sid, 1.0f, 0, 1) == VC_OK);
CHECK(vc_set_remote_stream(clientB, a_uid, mic_sid, 1.0f, 0, 0) == VC_OK);
}
{ std::lock_guard lk(evB.mu); CHECK(!evB.disconnected); }
// ── 3b. Read back what B just set (vc_get_remote_stream round-trips the recv state) ─
{
vc_remote_stream_state st{};
// mic_sid: last write above was (1.0, mute=0, nr=0)
CHECK(vc_get_remote_stream(clientB, a_uid, mic_sid, &st) == VC_OK);
CHECK(fabsf(st.gain - 1.0f) < 1e-5f);
CHECK(st.muted == 0);
CHECK(st.noise_reduction == 0);
// screen_sid: set to (0.3, mute=1, nr=1) at line ~282
CHECK(vc_get_remote_stream(clientB, a_uid, screen_sid, &st) == VC_OK);
CHECK(fabsf(st.gain - 0.3f) < 1e-5f);
CHECK(st.muted == 1);
CHECK(st.noise_reduction == 1);
// Unknown stream_id on a known user -> INVALID_ARG.
CHECK(vc_get_remote_stream(clientB, a_uid, 0xDEADBEEF, &st) == VC_ERR_INVALID_ARG);
// Null out -> INVALID_ARG.
CHECK(vc_get_remote_stream(clientB, a_uid, mic_sid, nullptr) == VC_ERR_INVALID_ARG);
}
// ── 4. Per-channel Opus configurability ──────────────────────────────────
// A moves to "Music Room" (channel 2: stereo/128kbps/OPUS_AUDIO/no DTX) and announces a
// fresh MIC stream there; B stays in "Lobby" (channel 1: mono/24kbps/OPUS_VOIP/DTX) with
// its own MIC stream. Their effective_audio should differ exactly as configured server-side.
CHECK(vc_stream_stop(clientA, mic_sid) == VC_OK);
CHECK(vc_join_channel(clientA, 2, nullptr) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(300));
vc_stream_desc music_mic_desc{};
music_mic_desc.kind = VC_STREAM_MIC;
music_mic_desc.label = "music-mic";
uint32_t a_music_mic_sid = 0;
CHECK(vc_stream_start(clientA, &music_mic_desc, &a_music_mic_sid) == VC_OK);
CHECK(wait_for(evA, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (e.started && e.user_id == a_uid && e.stream_id == a_music_mic_sid) return true;
return false;
}, 5000));
vc_stream_desc b_mic_desc{};
b_mic_desc.kind = VC_STREAM_MIC;
b_mic_desc.label = "lobby-mic";
uint32_t b_mic_sid = 0;
CHECK(vc_stream_start(clientB, &b_mic_desc, &b_mic_sid) == VC_OK);
CHECK(wait_for(evB, [&](EventStore& s) {
uint32_t self = s.self_user_id;
for (auto& e : s.stream_events)
if (e.started && e.user_id == self && e.stream_id == b_mic_sid) return true;
return false;
}, 5000));
vc_audio_config a_cfg{};
vc_audio_config b_cfg{};
CHECK(vc_get_stream_audio_config(clientA, a_uid, a_music_mic_sid, &a_cfg) == VC_OK);
uint32_t b_uid = 0;
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
CHECK(vc_get_stream_audio_config(clientB, b_uid, b_mic_sid, &b_cfg) == VC_OK);
// Music Room: stereo, 128kbps, OPUS_AUDIO, DTX off. Lobby: mono, 24kbps, OPUS_VOIP, DTX on.
CHECK(a_cfg.mode == 1 /* stereo */);
CHECK(b_cfg.mode == 0 /* mono */);
CHECK(a_cfg.bitrate_bps == 128000);
CHECK(b_cfg.bitrate_bps == 24000);
CHECK(a_cfg.application == 1 /* OPUS_AUDIO */);
CHECK(b_cfg.application == 0 /* OPUS_VOIP */);
CHECK(a_cfg.dtx == 0);
CHECK(b_cfg.dtx != 0);
// ── Cleanup ───────────────────────────────────────────────────────────────
vc_disconnect(clientA);
vc_disconnect(clientB);
vc_client_destroy(clientA);
vc_client_destroy(clientB);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m3_multistream: all checks passed\n");
return 0;
}
std::printf("m3_multistream: %d failure(s)\n", g_failures);
return 1;
}
-265
View File
@@ -1,265 +0,0 @@
/*
* test_m5_admin_accounts — Phase 3 of M5: in-app account management over the wire.
*
* Verifies:
* - admin can create an account via vc_create_account.
* - the new account can authenticate.
* - admin can reset the password.
* - the new account can authenticate with the new password.
* - admin can list accounts and sees the new entry.
* - admin can delete the account.
* - the deleted account can no longer authenticate.
*/
#include <cstdio>
#include <cstring>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
bool auth_done{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool disconnected{false};
struct ResultAck {
bool ok{false};
uint32_t code{0};
std::string message;
};
std::vector<ResultAck> generic_results;
bool account_list_received{false};
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->auth_done = true;
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_GENERIC_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->u32a;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_ACCOUNT_LIST:
s->account_list_received = true;
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool wait_generic(EventStore& s, int timeout_ms) {
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
}
static bool last_generic_ok(EventStore& s) {
std::lock_guard lk(s.mu);
return !s.generic_results.empty() && s.generic_results.back().ok;
}
static void reset_generic(EventStore& s) {
std::lock_guard lk(s.mu);
s.generic_results.clear();
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m5_acct_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
if (!db.create_account("admin", "admin-pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-M5-Acct";
cfg.allow_guests = false;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
EventStore evAdmin;
evAdmin.label = "admin";
vc_callbacks cbA{on_event, nullptr, &evAdmin};
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgA, cbA);
evAdmin.client = admin;
CHECK(admin != nullptr);
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
// Create charlie.
reset_generic(evAdmin);
CHECK(vc_create_account(admin, "charlie", "charlie-pass") == VC_OK);
CHECK(wait_generic(evAdmin, 5000));
CHECK(last_generic_ok(evAdmin));
// Charlie logs in.
{
EventStore evCharlie;
evCharlie.label = "charlie";
vc_callbacks cb{on_event, nullptr, &evCharlie};
vc_config cfgC{"test-charlie", "0.1", VC_LOG_OFF};
vc_client* charlie = vc_client_create(&cfgC, cb);
evCharlie.client = charlie;
CHECK(charlie != nullptr);
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(charlie, "charlie", "charlie-pass") == VC_OK);
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(evCharlie.auth_ok);
vc_disconnect(charlie);
vc_client_destroy(charlie);
}
// Reset charlie's password.
reset_generic(evAdmin);
CHECK(vc_reset_password(admin, "charlie", "new-pass") == VC_OK);
CHECK(wait_generic(evAdmin, 5000));
CHECK(last_generic_ok(evAdmin));
// Charlie logs in with new password.
{
EventStore evCharlie;
evCharlie.label = "charlie2";
vc_callbacks cb{on_event, nullptr, &evCharlie};
vc_config cfgC{"test-charlie2", "0.1", VC_LOG_OFF};
vc_client* charlie = vc_client_create(&cfgC, cb);
evCharlie.client = charlie;
CHECK(charlie != nullptr);
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(charlie, "charlie", "new-pass") == VC_OK);
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(evCharlie.auth_ok);
vc_disconnect(charlie);
vc_client_destroy(charlie);
}
// List accounts.
reset_generic(evAdmin);
evAdmin.account_list_received = false;
CHECK(vc_list_accounts(admin) == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.account_list_received; }, 3000));
// Delete charlie.
reset_generic(evAdmin);
CHECK(vc_delete_account(admin, "charlie") == VC_OK);
CHECK(wait_generic(evAdmin, 3000));
CHECK(last_generic_ok(evAdmin));
// Charlie cannot log in anymore.
{
EventStore evCharlie;
evCharlie.label = "charlie3";
vc_callbacks cb{on_event, nullptr, &evCharlie};
vc_config cfgC{"test-charlie3", "0.1", VC_LOG_OFF};
vc_client* charlie = vc_client_create(&cfgC, cb);
evCharlie.client = charlie;
CHECK(charlie != nullptr);
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(charlie, "charlie", "new-pass") == VC_OK);
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_done || s.disconnected; }, 20000));
CHECK(!evCharlie.auth_ok);
vc_disconnect(charlie);
vc_client_destroy(charlie);
}
vc_disconnect(admin);
vc_client_destroy(admin);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m5_admin_accounts: all checks passed\n");
return 0;
}
std::printf("m5_admin_accounts: %d failure(s)\n", g_failures);
return 1;
}
-308
View File
@@ -1,308 +0,0 @@
/*
* test_m5_channel_crud — Phase 4 of M5: channel CRUD & password enforcement.
*
* Verifies:
* - admin can create a password-protected channel.
* - normal user cannot join without the password.
* - normal user can join with the password.
* - admin can edit the channel name.
* - both clients receive VC_EVENT_CHANNEL_LIST after the update.
* - admin can delete the channel; remaining users are moved to Lobby.
*/
#include <cstdio>
#include <cstring>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
int channel_list_count{0};
bool disconnected{false};
struct ResultAck {
bool ok{false};
uint32_t code{0};
std::string message;
};
std::vector<ResultAck> generic_results;
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
++s->channel_list_count;
break;
case VC_EVENT_JOIN_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->channel_id;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_GENERIC_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->u32a;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool wait_generic(EventStore& s, int timeout_ms) {
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
}
static bool last_generic_ok(EventStore& s) {
std::lock_guard lk(s.mu);
return !s.generic_results.empty() && s.generic_results.back().ok;
}
static void reset_generic(EventStore& s) {
std::lock_guard lk(s.mu);
s.generic_results.clear();
}
static int channel_count(vc_client* c) {
vc_channel_list cl{};
if (vc_list_channels(c, &cl) != VC_OK) return -1;
int n = static_cast<int>(cl.count);
vc_free_channel_list(&cl);
return n;
}
static uint32_t find_channel_by_name(vc_client* c, const char* name) {
vc_channel_list cl{};
if (vc_list_channels(c, &cl) != VC_OK) return 0;
uint32_t id = 0;
for (size_t i = 0; i < cl.count; ++i) {
if (std::strcmp(cl.items[i].name, name) == 0) {
id = cl.items[i].id;
break;
}
}
vc_free_channel_list(&cl);
return id;
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m5_ch_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
if (!db.create_account("admin", "admin-pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("bob", "bob-pass", false, err)) {
std::printf("FAIL: create bob: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-M5-Ch";
cfg.allow_guests = false;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
EventStore evAdmin;
evAdmin.label = "admin";
vc_callbacks cbA{on_event, nullptr, &evAdmin};
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgA, cbA);
evAdmin.client = admin;
CHECK(admin != nullptr);
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_count > 0; }, 3000));
EventStore evBob;
evBob.label = "bob";
vc_callbacks cbB{on_event, nullptr, &evBob};
vc_config cfgB{"test-bob", "0.1", VC_LOG_OFF};
vc_client* bob = vc_client_create(&cfgB, cbB);
evBob.client = bob;
CHECK(bob != nullptr);
CHECK(vc_connect(bob, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(bob, "bob", "bob-pass") == VC_OK);
CHECK(wait_for(evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evBob, [](EventStore& s){ return s.channel_list_count > 0; }, 3000));
// Admin creates a password-protected channel.
reset_generic(evAdmin);
int base_count = channel_count(admin);
CHECK(base_count >= 2); // Lobby + Music Room
vc_channel_info ch{};
ch.name = "Private Room";
ch.topic = "secret";
ch.password_protected = 1;
ch.password = "swordfish";
ch.max_users = 10;
ch.sort_order = 5;
CHECK(vc_create_channel(admin, &ch) == VC_OK);
CHECK(wait_generic(evAdmin, 3000));
CHECK(last_generic_ok(evAdmin));
// Wait for the new channel to appear in bob's list.
CHECK(wait_for(evBob, [](EventStore& s){ return s.channel_list_count > 1; }, 3000));
int new_count = channel_count(bob);
CHECK(new_count == base_count + 1);
uint32_t private_id = find_channel_by_name(admin, "Private Room");
CHECK(private_id != 0);
// Bob tries to join without password — should fail.
reset_generic(evBob);
CHECK(vc_join_channel(bob, private_id, nullptr) == VC_OK);
CHECK(wait_generic(evBob, 3000));
CHECK(!last_generic_ok(evBob));
// Bob joins with password — should succeed.
reset_generic(evBob);
CHECK(vc_join_channel(bob, private_id, "swordfish") == VC_OK);
CHECK(wait_generic(evBob, 3000));
CHECK(last_generic_ok(evBob));
// Admin edits the channel name.
reset_generic(evAdmin);
int admin_list_count = evAdmin.channel_list_count;
vc_channel_info edit{};
edit.id = private_id;
edit.name = "Renamed Room";
edit.topic = "still secret";
edit.password_protected = 1;
edit.password = "swordfish"; // keep same password
edit.sort_order = 5;
CHECK(vc_edit_channel(admin, &edit) == VC_OK);
CHECK(wait_generic(evAdmin, 3000));
CHECK(last_generic_ok(evAdmin));
// Both clients should receive a channel-list update.
CHECK(wait_for(evAdmin, [admin_list_count](EventStore& s){ return s.channel_list_count > admin_list_count; }, 3000));
CHECK(wait_for(evBob, [private_id](EventStore& s){ return find_channel_by_name(s.client, "Renamed Room") == private_id; }, 3000));
// Admin deletes the channel.
reset_generic(evAdmin);
CHECK(vc_delete_channel(admin, private_id) == VC_OK);
CHECK(wait_generic(evAdmin, 3000));
CHECK(last_generic_ok(evAdmin));
// Bob should see the channel disappear and be back in Lobby (id=1).
CHECK(wait_for(evBob, [private_id](EventStore& s){ return find_channel_by_name(s.client, "Renamed Room") == 0; }, 3000));
{
vc_user_list ul{};
CHECK(vc_list_users(bob, &ul) == VC_OK);
bool found_self = false;
for (size_t i = 0; i < ul.count; ++i) {
if (ul.items[i].id == evBob.self_user_id) {
found_self = true;
CHECK(ul.items[i].channel_id == 1);
break;
}
}
CHECK(found_self);
vc_free_user_list(&ul);
}
vc_disconnect(admin);
vc_disconnect(bob);
vc_client_destroy(admin);
vc_client_destroy(bob);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m5_channel_crud: all checks passed\n");
return 0;
}
std::printf("m5_channel_crud: %d failure(s)\n", g_failures);
return 1;
}
-320
View File
@@ -1,320 +0,0 @@
/*
* test_m5_kick_ban_move_mute — Phase 2 of M5: moderation.
*
* Verifies:
* - admin can kick a user (target receives disconnect).
* - admin can ban a password user; re-auth fails.
* - admin can move a user to another channel.
* - admin can server-mute/deafen a user; the target reflects it locally.
*/
#include <cstdio>
#include <cstring>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
bool auth_done{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool disconnected{false};
vc_result disconnect_reason{VC_OK};
std::string disconnect_text;
struct ResultAck {
bool ok{false};
uint32_t code{0};
std::string message;
};
std::vector<ResultAck> generic_results;
uint32_t last_updated_user{0};
uint32_t last_updated_channel{0};
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->auth_done = true;
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_GENERIC_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->u32a;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_USER_UPDATED:
s->last_updated_user = ev->user_id;
s->last_updated_channel = ev->channel_id;
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
s->disconnect_reason = static_cast<vc_result>(ev->result);
s->disconnect_text = ev->text ? ev->text : "";
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool wait_generic(EventStore& s, int timeout_ms) {
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
}
static bool last_generic_ok(EventStore& s) {
std::lock_guard lk(s.mu);
return !s.generic_results.empty() && s.generic_results.back().ok;
}
static void reset_generic(EventStore& s) {
std::lock_guard lk(s.mu);
s.generic_results.clear();
}
static void reset_updated(EventStore& s) {
std::lock_guard lk(s.mu);
s.last_updated_user = 0;
s.last_updated_channel = 0;
}
static bool wait_updated(EventStore& s, uint32_t user_id, int timeout_ms) {
return wait_for(s, [user_id](EventStore& st) {
return st.last_updated_user == user_id;
}, timeout_ms);
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m5_mod_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
if (!db.create_account("admin", "admin-pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("alice", "alice-pass", false, err)) {
std::printf("FAIL: create alice: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("bob", "bob-pass", false, err)) {
std::printf("FAIL: create bob: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-M5-Mod";
cfg.allow_guests = false;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
auto make_client = [&](const char* label, const char* user, const char* pass) -> EventStore* {
auto* ev = new EventStore();
ev->label = label;
vc_callbacks cb{on_event, nullptr, ev};
vc_config cfgx{label, "0.1", VC_LOG_OFF};
ev->client = vc_client_create(&cfgx, cb);
if (!ev->client) return nullptr;
if (vc_connect(ev->client, "127.0.0.1", port) != VC_OK) return nullptr;
if (vc_authenticate_user(ev->client, user, pass) != VC_OK) return nullptr;
return ev;
};
EventStore* evAdmin = make_client("admin", "admin", "admin-pass");
CHECK(evAdmin != nullptr);
CHECK(wait_for(*evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(*evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t admin_id = evAdmin->self_user_id;
CHECK(admin_id != 0);
EventStore* evAlice = make_client("alice", "alice", "alice-pass");
CHECK(evAlice != nullptr);
CHECK(wait_for(*evAlice, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(*evAlice, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t alice_id = evAlice->self_user_id;
CHECK(alice_id != 0);
EventStore* evBob = make_client("bob", "bob", "bob-pass");
CHECK(evBob != nullptr);
CHECK(wait_for(*evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(*evBob, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t bob_id = evBob->self_user_id;
CHECK(bob_id != 0);
// Kick bob.
reset_generic(*evAdmin);
CHECK(vc_kick_user(evAdmin->client, bob_id, "bye bob") == VC_OK);
CHECK(wait_generic(*evAdmin, 3000));
CHECK(last_generic_ok(*evAdmin));
CHECK(wait_for(*evBob, [](EventStore& s){ return s.disconnected; }, 3000));
CHECK(evBob->disconnect_reason == VC_ERR_IO);
vc_client_destroy(evBob->client);
delete evBob;
// Reconnect bob.
evBob = make_client("bob", "bob", "bob-pass");
CHECK(evBob != nullptr);
CHECK(wait_for(*evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(*evBob, [](EventStore& s){ return s.channel_list_received; }, 3000));
bob_id = evBob->self_user_id;
CHECK(bob_id != 0);
// Ban bob permanently.
reset_generic(*evAdmin);
CHECK(vc_ban_user(evAdmin->client, bob_id, "spam", 0) == VC_OK);
CHECK(wait_generic(*evAdmin, 3000));
CHECK(last_generic_ok(*evAdmin));
CHECK(wait_for(*evBob, [](EventStore& s){ return s.disconnected; }, 3000));
vc_client_destroy(evBob->client);
delete evBob;
// Bob tries to reconnect — should fail auth due to username ban.
{
EventStore evBanned;
evBanned.label = "banned-bob";
vc_callbacks cb{on_event, nullptr, &evBanned};
vc_config cfgb{"banned-bob", "0.1", VC_LOG_OFF};
vc_client* banned_bob = vc_client_create(&cfgb, cb);
evBanned.client = banned_bob;
CHECK(banned_bob != nullptr);
CHECK(vc_connect(banned_bob, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(banned_bob, "bob", "bob-pass") == VC_OK);
CHECK(wait_for(evBanned, [](EventStore& s){ return s.auth_done || s.disconnected; }, 20000));
CHECK(!evBanned.auth_ok); // banned
vc_disconnect(banned_bob);
vc_client_destroy(banned_bob);
}
// Move alice to Music Room (channel id 2).
reset_generic(*evAdmin);
reset_updated(*evAlice);
CHECK(vc_move_user(evAdmin->client, alice_id, 2) == VC_OK);
CHECK(wait_generic(*evAdmin, 3000));
CHECK(last_generic_ok(*evAdmin));
CHECK(wait_updated(*evAlice, alice_id, 3000));
{
vc_channel_list cl{};
CHECK(vc_list_channels(evAlice->client, &cl) == VC_OK);
CHECK(cl.count >= 2);
vc_free_channel_list(&cl);
vc_user_list ul{};
CHECK(vc_list_users(evAlice->client, &ul) == VC_OK);
bool found_alice = false;
for (size_t i = 0; i < ul.count; ++i) {
if (ul.items[i].id == alice_id) {
found_alice = true;
CHECK(ul.items[i].channel_id == 2);
break;
}
}
CHECK(found_alice);
vc_free_user_list(&ul);
}
// Server-mute alice.
reset_generic(*evAdmin);
reset_updated(*evAlice);
CHECK(vc_set_server_mute(evAdmin->client, alice_id, true, false) == VC_OK);
CHECK(wait_generic(*evAdmin, 3000));
CHECK(last_generic_ok(*evAdmin));
CHECK(wait_updated(*evAlice, alice_id, 3000));
// Cleanup.
vc_disconnect(evAdmin->client);
vc_disconnect(evAlice->client);
vc_client_destroy(evAdmin->client);
vc_client_destroy(evAlice->client);
delete evAdmin;
delete evAlice;
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m5_kick_ban_move_mute: all checks passed\n");
return 0;
}
std::printf("m5_kick_ban_move_mute: %d failure(s)\n", g_failures);
return 1;
}
-255
View File
@@ -1,255 +0,0 @@
/*
* test_m5_permissions — Phase 1 of M5: server-side permission enforcement.
*
* Verifies:
* - admin gets is_admin/can_create_temp_channel/etc.
* - normal password user gets no permissions.
* - admin can create a channel.
* - normal user cannot create a channel.
* - admin can grant can_create_temp_channel via vc_set_permission.
* - granted normal user can now create a channel.
*/
#include <cstdio>
#include <cstring>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool disconnected{false};
// VC_EVENT_GENERIC_RESULT tracking
struct ResultAck {
bool ok{false};
uint32_t code{0};
std::string message;
};
std::vector<ResultAck> generic_results;
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_GENERIC_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->u32a;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool wait_generic(EventStore& s, int timeout_ms) {
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
}
static bool last_generic_ok(EventStore& s) {
std::lock_guard lk(s.mu);
return !s.generic_results.empty() && s.generic_results.back().ok;
}
static void reset_generic(EventStore& s) {
std::lock_guard lk(s.mu);
s.generic_results.clear();
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m5_perm_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
// Pre-provision admin and normal user.
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
if (!db.create_account("admin", "admin-pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("bob", "bob-pass", false, err)) {
std::printf("FAIL: create bob: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-M5-Perm";
cfg.allow_guests = false;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
// Admin client.
EventStore evA;
evA.label = "admin";
vc_callbacks cbA{on_event, nullptr, &evA};
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgA, cbA);
evA.client = admin;
CHECK(admin != nullptr);
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
CHECK(wait_for(evA, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evA, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t admin_id = evA.self_user_id;
CHECK(admin_id != 0);
// Normal client.
EventStore evB;
evB.label = "bob";
vc_callbacks cbB{on_event, nullptr, &evB};
vc_config cfgB{"test-bob", "0.1", VC_LOG_OFF};
vc_client* bob = vc_client_create(&cfgB, cbB);
evB.client = bob;
CHECK(bob != nullptr);
CHECK(vc_connect(bob, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(bob, "bob", "bob-pass") == VC_OK);
CHECK(wait_for(evB, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evB, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t bob_id = evB.self_user_id;
CHECK(bob_id != 0);
// Verify permissions reported by the core.
vc_permissions admin_perms{};
CHECK(vc_get_permissions(admin, &admin_perms) == VC_OK);
CHECK(admin_perms.is_admin != 0);
CHECK(admin_perms.can_create_temp_channel != 0);
vc_permissions bob_perms{};
CHECK(vc_get_permissions(bob, &bob_perms) == VC_OK);
CHECK(bob_perms.is_admin == 0);
CHECK(bob_perms.can_create_temp_channel == 0);
// Admin creates a channel.
vc_channel_info ch{};
ch.name = "Admin Channel";
ch.topic = "created by admin";
ch.max_users = 0;
ch.sort_order = 10;
CHECK(vc_create_channel(admin, &ch) == VC_OK);
CHECK(wait_generic(evA, 3000));
CHECK(last_generic_ok(evA));
// Normal user tries to create a channel — should be denied.
reset_generic(evB);
vc_channel_info ch2{};
ch2.name = "Bob Channel";
ch2.topic = "created by bob";
ch2.sort_order = 11;
CHECK(vc_create_channel(bob, &ch2) == VC_OK);
CHECK(wait_generic(evB, 3000));
CHECK(!last_generic_ok(evB));
// Admin grants can_create_temp_channel to bob.
reset_generic(evA);
vc_permissions grant{};
grant.can_create_temp_channel = 1;
CHECK(vc_set_permission(admin, bob_id, &grant) == VC_OK);
CHECK(wait_generic(evA, 3000));
CHECK(last_generic_ok(evA));
// Bob creates a channel — should succeed now.
reset_generic(evB);
CHECK(vc_create_channel(bob, &ch2) == VC_OK);
CHECK(wait_generic(evB, 3000));
CHECK(last_generic_ok(evB));
// Cleanup.
vc_disconnect(admin);
vc_disconnect(bob);
vc_client_destroy(admin);
vc_client_destroy(bob);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m5_permissions: all checks passed\n");
return 0;
}
std::printf("m5_permissions: %d failure(s)\n", g_failures);
return 1;
}
-349
View File
@@ -1,349 +0,0 @@
/*
* test_media_aead — ChaCha20-Poly1305 AEAD seal/open, anti-replay, tamper detection.
*
* Uses a synthetic 32-byte key directly (no TLS context needed for unit tests).
*/
#include <array>
#include <cstdio>
#include <cstring>
#include <vector>
#include <sodium.h>
#include "crypto/crypto.h"
#include "net/voice_frame.h"
using namespace voicecat::crypto;
using namespace voicecat::net;
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
// Build a synthetic voice-frame-header AAD.
static std::vector<uint8_t> make_aad(uint64_t seq) {
VoiceFrame f;
f.ssrc = 0xCAFEBABE;
f.seq = seq;
std::vector<uint8_t> aad(kVoiceHeaderSize);
serialize_header(f, aad.data());
return aad;
}
static void test_seal_open_round_trip() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("test-key"), 8, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
// Copy the receiver state so it starts with the same key but its own counter.
std::vector<uint8_t> plain(100, 0xAB);
auto aad = make_aad(0);
// Seal
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(),
aad.data(), aad.size(),
cipher.data(), cipher.size());
CHECK(sealed_len == static_cast<long>(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES));
// Open
std::vector<uint8_t> recovered(plain.size());
long plain_len = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(plain_len == static_cast<long>(plain.size()));
CHECK(std::memcmp(plain.data(), recovered.data(), plain.size()) == 0);
}
static void test_anti_replay() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("replay-key"), 10, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(50, 0x55);
auto aad = make_aad(0);
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(),
aad.data(), aad.size(),
cipher.data(), cipher.size());
CHECK(sealed_len > 0);
std::vector<uint8_t> recovered(plain.size());
// First open succeeds.
long r1 = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(r1 == static_cast<long>(plain.size()));
// Replay of the same ciphertext must fail.
long r2 = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(r2 < 0);
}
static void test_tamper_detection() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("tamper-key"), 10, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(40, 0x77);
auto aad = make_aad(0);
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(),
aad.data(), aad.size(),
cipher.data(), cipher.size());
CHECK(sealed_len > 0);
// Flip a byte in the ciphertext.
cipher[5] ^= 0xFF;
std::vector<uint8_t> recovered(plain.size());
long r = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(r < 0);
}
static void test_multiple_packets() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("multi-key"), 9, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(60, 0x99);
for (uint64_t seq = 0; seq < 10; ++seq) {
auto aad = make_aad(seq);
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(),
aad.data(), aad.size(),
cipher.data(), cipher.size());
CHECK(sealed_len > 0);
std::vector<uint8_t> recovered(plain.size());
long plain_len = receiver.open(cipher.data(), static_cast<size_t>(sealed_len),
aad.data(), aad.size(),
recovered.data(), recovered.size());
CHECK(plain_len == static_cast<long>(plain.size()));
CHECK(std::memcmp(plain.data(), recovered.data(), plain.size()) == 0);
}
}
// Build a voice-frame-header AAD with a given ssrc + seq.
static std::vector<uint8_t> make_aad_ssrc(uint32_t ssrc, uint64_t seq) {
VoiceFrame f;
f.ssrc = ssrc;
f.seq = seq;
std::vector<uint8_t> aad(kVoiceHeaderSize);
serialize_header(f, aad.data());
return aad;
}
// Overwrite the 8-byte big-endian seq field (header bytes [8..15]) in an AAD buffer.
static void set_aad_seq(std::vector<uint8_t>& aad, uint64_t seq) {
for (int i = 0; i < 8; ++i)
aad[8 + i] = static_cast<uint8_t>((seq >> (56 - 8 * i)) & 0xFF);
}
// Simulate one server relay hop for a single frame, sender → recipient R.
// - sender seals with its send key, setting header seq = its own send counter (client contract).
// - server opens with the sender's key, then re-seals with R's send key.
// - if rewrite_seq, the re-sealed header's seq is set to R's send counter (the fix); otherwise
// the sender's seq is forwarded verbatim (the bug).
// Returns true iff R successfully decrypts the relayed frame.
static bool relay_one(SodiumMediaCrypto& sender_send, SodiumMediaCrypto& server_recv,
SodiumMediaCrypto& r_send, SodiumMediaCrypto& r_recv,
uint32_t ssrc, const std::vector<uint8_t>& plain, bool rewrite_seq) {
// Client A→server: seq carries the sender's send counter.
auto in_aad = make_aad_ssrc(ssrc, sender_send.peek_send_counter());
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed = sender_send.seal(plain.data(), plain.size(), in_aad.data(), in_aad.size(),
cipher.data(), cipher.size());
if (sealed < 0) return false;
// Server decrypts the inbound frame.
std::vector<uint8_t> recovered(plain.size());
long opened = server_recv.open(cipher.data(), static_cast<size_t>(sealed),
in_aad.data(), in_aad.size(),
recovered.data(), recovered.size());
if (opened < 0) return false;
// Server re-seals to R. Header passes through except seq, which (when fixed) is set to R's
// own send counter so R's open() reconstructs the matching nonce.
std::vector<uint8_t> out_aad = in_aad; // copy header verbatim
if (rewrite_seq) set_aad_seq(out_aad, r_send.peek_send_counter());
std::vector<uint8_t> relay_cipher(recovered.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long resealed = r_send.seal(recovered.data(), static_cast<size_t>(opened),
out_aad.data(), out_aad.size(),
relay_cipher.data(), relay_cipher.size());
if (resealed < 0) return false;
// R decrypts the relayed frame.
std::vector<uint8_t> r_recovered(recovered.size());
long r_opened = r_recv.open(relay_cipher.data(), static_cast<size_t>(resealed),
out_aad.data(), out_aad.size(),
r_recovered.data(), r_recovered.size());
return r_opened == static_cast<long>(plain.size());
}
// Regression test for the relay nonce-desync bug: two senders (A, B) relayed into one recipient
// (R) interleaved. The media AEAD nonce is an implicit per-direction counter reconstructed from
// the header seq; if the relay forwards the sender's seq verbatim, it no longer matches R's send
// counter and frames fail to decrypt. The relay must rewrite seq = R's send counter.
static void test_relay_interleaved_reseal() {
auto make_key = [](const char* label) {
std::array<uint8_t, crypto_aead_chacha20poly1305_ietf_KEYBYTES> k{};
crypto_generichash(k.data(), k.size(),
reinterpret_cast<const uint8_t*>(label),
std::strlen(label), nullptr, 0);
return k;
};
auto kA = make_key("relay-A"); // A↔server direction
auto kB = make_key("relay-B"); // B↔server direction
auto kR = make_key("relay-R"); // server↔R direction
std::vector<uint8_t> plain(80, 0x3C);
// Fixed path: interleaved A/B frames all decrypt at R.
{
SodiumMediaCrypto a_send(kA.data()), srv_recv_a(kA.data());
SodiumMediaCrypto b_send(kB.data()), srv_recv_b(kB.data());
SodiumMediaCrypto r_send(kR.data()), r_recv(kR.data());
bool all_ok = true;
for (int i = 0; i < 8; ++i) {
all_ok &= relay_one(a_send, srv_recv_a, r_send, r_recv, 0x1111, plain, /*rewrite=*/true);
all_ok &= relay_one(b_send, srv_recv_b, r_send, r_recv, 0x2222, plain, /*rewrite=*/true);
}
CHECK(all_ok); // with the fix, every interleaved relayed frame decrypts at R
}
// Control: forwarding seq verbatim (the bug) must drop frames once the counters diverge.
{
SodiumMediaCrypto a_send(kA.data()), srv_recv_a(kA.data());
SodiumMediaCrypto b_send(kB.data()), srv_recv_b(kB.data());
SodiumMediaCrypto r_send(kR.data()), r_recv(kR.data());
int failures = 0;
for (int i = 0; i < 8; ++i) {
if (!relay_one(a_send, srv_recv_a, r_send, r_recv, 0x1111, plain, /*rewrite=*/false)) ++failures;
if (!relay_one(b_send, srv_recv_b, r_send, r_recv, 0x2222, plain, /*rewrite=*/false)) ++failures;
}
CHECK(failures > 0); // proves the verbatim-seq path is broken (locks in the regression)
}
}
// Regression for the bad-wifi wedge: the anti-replay window must NOT be advanced by a
// packet that fails authentication. A single corrupted/forged frame carrying a huge seq
// used to shove recv_highest_ far ahead (before the AEAD tag was checked), after which
// every legitimate frame was rejected as "too old" — permanent silence. open() now
// advances the window only after a successful tag check (RFC 3711 §3.3).
static void test_corrupted_seq_does_not_poison_window() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("poison-key"), 10, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(64, 0x5A);
std::vector<uint8_t> recovered(plain.size());
auto seal_at_current = [&](std::vector<uint8_t>& aad_out, std::vector<uint8_t>& cipher_out) {
aad_out = make_aad_ssrc(0xABCD, sender.peek_send_counter());
cipher_out.assign(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES, 0);
long s = sender.seal(plain.data(), plain.size(), aad_out.data(), aad_out.size(),
cipher_out.data(), cipher_out.size());
CHECK(s > 0);
};
// 1. A normal frame (counter 0) decrypts. recv_highest_ = 0.
std::vector<uint8_t> aad0, cipher0;
seal_at_current(aad0, cipher0); // sender counter 0 → 1
CHECK(receiver.open(cipher0.data(), cipher0.size(), aad0.data(), aad0.size(),
recovered.data(), recovered.size()) == static_cast<long>(plain.size()));
// 2. A frame whose header seq has been corrupted to a huge value: it fails auth
// (the AAD no longer matches what was sealed) and must NOT move the window.
std::vector<uint8_t> aad1, cipher1;
seal_at_current(aad1, cipher1); // sender counter 1 → 2
std::vector<uint8_t> forged_aad = aad1;
set_aad_seq(forged_aad, 0x0000FFFFFFFFFFFFULL); // bit-flip-style corruption
CHECK(receiver.open(cipher1.data(), cipher1.size(), forged_aad.data(), forged_aad.size(),
recovered.data(), recovered.size()) < 0);
// 3. The next legitimate frame (counter 2) must still decrypt. On the old code this
// returned "too old" because step 2 had poisoned recv_highest_.
std::vector<uint8_t> aad2, cipher2;
seal_at_current(aad2, cipher2); // sender counter 2 → 3
CHECK(receiver.open(cipher2.data(), cipher2.size(), aad2.data(), aad2.size(),
recovered.data(), recovered.size()) == static_cast<long>(plain.size()));
}
// Regression for the 16-bit seq wrap: with a full 64-bit wire counter, sealing/opening
// across the old u16 boundary (65,535 → 65,536) must keep decrypting. On the old code the
// nonce desynced at the wrap and every frame failed auth permanently.
static void test_seq_past_16bit_boundary() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("wrap-key"), 8, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(48, 0x6B);
std::vector<uint8_t> recovered(plain.size());
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
bool all_ok = true;
for (uint64_t i = 0; i < 70000; ++i) { // crosses 65,536
auto aad = make_aad_ssrc(0x1234, sender.peek_send_counter());
long s = sender.seal(plain.data(), plain.size(), aad.data(), aad.size(),
cipher.data(), cipher.size());
if (s < 0) { all_ok = false; break; }
long o = receiver.open(cipher.data(), static_cast<size_t>(s), aad.data(), aad.size(),
recovered.data(), recovered.size());
if (o != static_cast<long>(plain.size())) { all_ok = false; break; }
}
CHECK(all_ok);
}
int main() {
if (sodium_init() < 0) {
std::printf("FAIL: sodium_init failed\n");
return 1;
}
test_seal_open_round_trip();
test_anti_replay();
test_tamper_detection();
test_multiple_packets();
test_relay_interleaved_reseal();
test_corrupted_seq_does_not_poison_window();
test_seq_past_16bit_boundary();
if (g_failures == 0) {
std::printf("media_aead: all tests passed\n");
return 0;
}
std::printf("media_aead: %d test(s) FAILED\n", g_failures);
return 1;
}
-100
View File
@@ -1,100 +0,0 @@
/*
* test_noise_suppression — the RNNoise backend behind ApmProcessor actually denoises.
*
* This is the behavior exit-criterion for the noise-suppression feature (docs/voice.md §10-11):
* a real DSP backend, not the old inert passthrough. ApmProcessor::create() returns the RNNoise
* processor when the core is built with VOICECAT_HAS_NS (the dev/release presets). We feed it
* mono 48 kHz white noise in 20 ms (960-sample) frames — exercising the internal 480-sample
* chunking — and assert the output noise floor collapses while values stay finite/in-range.
*
* Registered only under VOICECAT_USE_VCPKG_DEPS, where VOICECAT_HAS_NS is defined, so a large
* reduction is expected; a passthrough build would (correctly) fail this test.
*/
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <vector>
#include "audio/apm_processor.h"
namespace vca = voicecat::audio;
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
static constexpr int kFrameSamples = 960; // 20 ms @ 48 kHz (two RNNoise 480-sample frames)
int main() {
auto ns = vca::ApmProcessor::create();
CHECK(ns != nullptr);
if (!ns) return 1;
// Deterministic white noise (xorshift) at ~int16/10 amplitude, processed frame by frame.
uint32_t rng = 0x12345678u;
auto next_noise = [&]() -> int16_t {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
// map to roughly [-3000, 3000]
return static_cast<int16_t>((static_cast<int32_t>(rng % 6001)) - 3000);
};
const int kFrames = 200;
const int kWarmup = 60; // let RNNoise's recurrent state settle before measuring
double in_sumsq = 0.0, out_sumsq = 0.0;
long measured = 0;
std::vector<int16_t> frame(kFrameSamples);
for (int f = 0; f < kFrames; ++f) {
double frame_in_sq = 0.0;
for (int i = 0; i < kFrameSamples; ++i) {
frame[i] = next_noise();
frame_in_sq += static_cast<double>(frame[i]) * frame[i];
}
bool gate = ns->process_capture(frame.data(), kFrameSamples, 48000);
CHECK(gate); // NS never gates — always passes the frame on
if (f >= kWarmup) {
in_sumsq += frame_in_sq;
for (int i = 0; i < kFrameSamples; ++i) {
// Output must stay finite and within int16 range (clamping correctness).
CHECK(frame[i] >= -32768 && frame[i] <= 32767);
out_sumsq += static_cast<double>(frame[i]) * frame[i];
}
measured += kFrameSamples;
}
}
CHECK(measured > 0);
double in_rms = std::sqrt(in_sumsq / measured);
double out_rms = std::sqrt(out_sumsq / measured);
double reduction = (in_rms > 0.0) ? (1.0 - out_rms / in_rms) : 0.0;
std::printf("noise-only: in_rms=%.1f out_rms=%.1f reduction=%.1f%%\n", in_rms, out_rms,
100.0 * reduction);
// RNNoise drops pure noise by ~99%; require a large, unambiguous reduction so a passthrough
// (no real backend) is caught. The threshold is deliberately conservative vs. the ~99% seen.
CHECK(reduction > 0.80);
// A 48-kHz guard miss must pass audio through untouched (our clock is always 48 kHz, but the
// backstop matters): feed a non-48k sample-rate and confirm the buffer is unchanged.
std::vector<int16_t> probe(kFrameSamples);
for (int i = 0; i < kFrameSamples; ++i) probe[i] = next_noise();
std::vector<int16_t> probe_copy = probe;
ns->process_capture(probe.data(), kFrameSamples, 16000);
CHECK(probe == probe_copy);
if (g_failures == 0) {
std::printf("noise_suppression: OK\n");
return 0;
}
std::printf("noise_suppression: %d failure(s)\n", g_failures);
return 1;
}
-139
View File
@@ -1,139 +0,0 @@
/*
* test_opus_codec — Opus encode/decode round-trip, PLC, energy check.
*
* Requires VOICECAT_HAS_OPUS (dev and release presets).
*/
#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
#include "codec/opus_codec.h"
namespace vc_codec = voicecat::codec;
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
static constexpr int kSampleRate = 48000;
static constexpr int kFrameMs = 20;
static constexpr int kFrameSamples = kSampleRate / 1000 * kFrameMs; // 960
// Generate one frame of 440 Hz sine wave at 16-bit mono, 48 kHz.
static std::vector<int16_t> make_sine_frame(int samples, float freq = 440.0f) {
std::vector<int16_t> pcm(samples);
for (int i = 0; i < samples; ++i) {
float t = static_cast<float>(i) / kSampleRate;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * freq * t) * 16000.0f);
}
return pcm;
}
// Compute RMS energy of a PCM buffer.
static double rms(const int16_t* pcm, int n) {
double sum = 0.0;
for (int i = 0; i < n; ++i) sum += static_cast<double>(pcm[i]) * pcm[i];
return std::sqrt(sum / n);
}
static void test_encode_decode_round_trip() {
vc_codec::OpusParams p;
p.sample_rate = kSampleRate;
p.frame_ms = kFrameMs;
p.fec = true;
vc_codec::OpusEncoder enc;
vc_codec::OpusDecoder dec;
CHECK(enc.init(p));
CHECK(dec.init(p));
auto src = make_sine_frame(kFrameSamples);
uint8_t encoded[4000];
int enc_bytes = enc.encode(src.data(), kFrameSamples, encoded, sizeof(encoded));
CHECK(enc_bytes > 0);
CHECK(enc_bytes < 1000); // Opus at 24 kbps/20 ms ≈ 60 bytes, well under 1000
std::vector<int16_t> decoded(kFrameSamples);
int dec_samples = dec.decode(encoded, enc_bytes, decoded.data(), kFrameSamples);
CHECK(dec_samples == kFrameSamples);
// Energy check: decoded RMS should be within 3 dB of original (Opus is lossy).
double rms_src = rms(src.data(), kFrameSamples);
double rms_dec = rms(decoded.data(), dec_samples);
CHECK(rms_src > 0.0);
CHECK(rms_dec > 0.0);
double ratio_db = 20.0 * std::log10(rms_dec / rms_src);
std::printf(" opus round-trip: enc_bytes=%d dec_samples=%d rms_ratio_db=%.1f\n",
enc_bytes, dec_samples, ratio_db);
CHECK(std::abs(ratio_db) < 3.0);
enc.destroy();
dec.destroy();
}
static void test_plc() {
vc_codec::OpusParams p;
p.sample_rate = kSampleRate;
p.frame_ms = kFrameMs;
vc_codec::OpusDecoder dec;
CHECK(dec.init(p));
// First send a real packet so the decoder has state for PLC.
vc_codec::OpusEncoder enc;
CHECK(enc.init(p));
auto src = make_sine_frame(kFrameSamples);
uint8_t encoded[4000];
int enc_bytes = enc.encode(src.data(), kFrameSamples, encoded, sizeof(encoded));
CHECK(enc_bytes > 0);
std::vector<int16_t> real_out(kFrameSamples);
int r = dec.decode(encoded, enc_bytes, real_out.data(), kFrameSamples);
CHECK(r == kFrameSamples);
// Now simulate packet loss with PLC (nullptr, len=0).
std::vector<int16_t> plc_out(kFrameSamples, 0);
int plc_samples = dec.decode(nullptr, 0, plc_out.data(), kFrameSamples);
CHECK(plc_samples == kFrameSamples);
// PLC output should not be silent (Opus extrapolates from previous frame).
double plc_rms = rms(plc_out.data(), kFrameSamples);
std::printf(" plc_rms=%.1f (should be > 0)\n", plc_rms);
CHECK(plc_rms > 0.0);
enc.destroy();
dec.destroy();
}
static void test_frame_samples_helper() {
vc_codec::OpusParams p;
p.sample_rate = 48000;
p.frame_ms = 20;
CHECK(vc_codec::opus_frame_samples(p) == 960);
p.frame_ms = 10;
CHECK(vc_codec::opus_frame_samples(p) == 480);
p.frame_ms = 40;
CHECK(vc_codec::opus_frame_samples(p) == 1920);
}
int main() {
test_frame_samples_helper();
test_encode_decode_round_trip();
test_plc();
if (g_failures == 0) {
std::printf("opus_codec: all tests passed\n");
return 0;
}
std::printf("opus_codec: %d test(s) FAILED\n", g_failures);
return 1;
}
-151
View File
@@ -1,151 +0,0 @@
/*
* test_plc_cap — verifies the PLC cap in AudioEngine::on_playback.
*
* After ~2s of pure packet-loss concealment (no real packets decoded), the mixer stops
* calling opus_decode(nullptr,0,...) and emits digital silence instead. This bounds the
* Opus comfort-noise hiss so a stale stream left in the mixer can never hiss forever —
* defense-in-depth for the server's UserEvent::LEFT broadcast (the primary fix that
* triggers remove_stream on disconnect). Also verifies that a fresh real packet resets
* the PLC streak and audio resumes.
*
* White-box: drives AudioEngine::mix_for_test directly (no audio hardware needed).
*/
#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
static double rms(const int16_t* pcm, int n) {
double sum = 0.0;
for (int i = 0; i < n; ++i) sum += static_cast<double>(pcm[i]) * pcm[i];
return std::sqrt(sum / n);
}
static int64_t abs_energy(const int16_t* pcm, int n) {
int64_t e = 0;
for (int i = 0; i < n; ++i) e += static_cast<int64_t>(std::abs(static_cast<int>(pcm[i])));
return e;
}
int main() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.playback_channels = 2;
p.frame_ms = 20;
CHECK(engine.start(p)); // no capture_cb — headless safe (devices may fail to init; ok)
voicecat::codec::OpusParams op;
op.sample_rate = 48000;
op.frame_ms = 20;
op.stereo = false;
int frame_samples = voicecat::codec::opus_frame_samples(op); // 960
// Encode a loud sine wave to seed the decoder's PLC state.
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(op));
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
}
uint8_t opus_buf[1500];
int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
CHECK(opus_len > 0);
const uint32_t ssrc = 1;
engine.init_recv_stream(ssrc, op, /*user_id=*/0, /*stream_id=*/0, /*is_voice=*/false);
// Push one real frame to seed the decoder.
voicecat::audio::JitterBuffer::Frame f;
f.seq = 0;
f.timestamp = 0;
f.fec_present = false;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(ssrc, std::move(f));
// mix_for_test period — 480 frames @ 48kHz = 10ms (typical WASAPI shared period).
const uint32_t pb_frames = 480;
const int out_n = static_cast<int>(pb_frames) * 2; // stereo interleaved
std::vector<int16_t> out(static_cast<size_t>(out_n), 0);
// 1) Decode the real frame (first mix call) — seeds PLC state.
engine.mix_for_test(out.data(), pb_frames);
// 2) Drive ~50ms of pure PLC — should produce comfort noise (non-zero).
double early_rms = 0.0;
for (int i = 0; i < 5; ++i) {
engine.mix_for_test(out.data(), pb_frames);
early_rms = std::max(early_rms, rms(out.data(), out_n));
}
CHECK(early_rms > 1.0); // PLC of a loud sine is audible, not digital silence
// 3) Drive well past the 2s PLC cap (250 callbacks = 2.5s of output).
// After the cap, on_playback emits silence (memset 0) instead of PLC noise.
for (int i = 0; i < 250; ++i) {
engine.mix_for_test(out.data(), pb_frames);
}
// 4) The output must now be digital silence (all zeros), not comfort noise.
// Drain a couple more callbacks to flush any ring residue, then assert.
int64_t energy = 0;
for (int i = 0; i < 3; ++i) {
engine.mix_for_test(out.data(), pb_frames);
energy = std::max(energy, abs_energy(out.data(), out_n));
}
CHECK(energy == 0); // capped PLC = silence
// 5) Resumption: push a fresh real frame — PLC streak resets, audio returns. marker=true is
// what the real sender stamps on the first frame after a silence (talkspurt restart); it
// makes the playout clock reseed to this leading edge immediately (no prebuffer delay).
voicecat::audio::JitterBuffer::Frame f2;
f2.seq = 1;
f2.timestamp = 200000; // far ahead — playout-clock reseeds to it
f2.fec_present = false;
f2.marker = true;
f2.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(ssrc, std::move(f2));
double resume_rms = 0.0;
for (int i = 0; i < 5; ++i) { // a few calls to flush silence residue + decode real
engine.mix_for_test(out.data(), pb_frames);
resume_rms = std::max(resume_rms, rms(out.data(), out_n));
}
CHECK(resume_rms > 1.0); // real audio is back
engine.remove_stream(ssrc);
engine.stop();
enc.destroy();
if (g_failures == 0) {
std::printf("plc_cap: all checks passed (early_rms=%.1f resume_rms=%.1f)\n",
early_rms, resume_rms);
return 0;
}
std::printf("plc_cap: %d failure(s)\n", g_failures);
return 1;
}
#else
int main() {
std::printf("plc_cap: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
return 0;
}
#endif
-195
View File
@@ -1,195 +0,0 @@
/*
* test_reaper_timeout — verifies the server's keepalive reaper (docs/protocol.md §7).
*
* Simulates a half-open connection: client B authenticates then goes completely silent
* (no TCP traffic, no pings — the 15s ping interval far exceeds the test's 2s reaper
* timeout). Client A stays alive by sending channel text every 500ms, which bumps its
* last_seen on the server. After ~2s the reaper drops B: B's TCP connection is closed
* (B sees VC_EVENT_DISCONNECTED) and A receives VC_EVENT_USER_LEFT for B (the Tier 1
* LEFT-broadcast fires from close()).
*
* This catches the "ghost user forever" failure mode for half-open connections (NAT
* timeout, wifi loss without RST, laptop sleep) that never produce a TCP EOF.
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool disconnected{false};
std::vector<uint32_t> left_users;
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_USER_LEFT:
s->left_users.push_back(ev->user_id);
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_reaper_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-ReaperTest";
cfg.allow_guests = true;
cfg.reaper_timeout_ms = 2000; // 2s — drop sessions silent for this long
cfg.reaper_sweep_ms = 500; // check every 500ms
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
auto make_client = [&](const char* label, const char* nick) -> EventStore* {
auto* ev = new EventStore();
ev->label = label;
vc_callbacks cb{on_event, nullptr, ev};
vc_config cfgx{label, "0.1", VC_LOG_OFF};
ev->client = vc_client_create(&cfgx, cb);
if (!ev->client) return nullptr;
if (vc_connect(ev->client, "127.0.0.1", port) != VC_OK) return nullptr;
if (vc_authenticate_guest(ev->client, nick) != VC_OK) return nullptr;
return ev;
};
EventStore* evA = make_client("clientA", "Alpha");
CHECK(evA != nullptr);
CHECK(wait_for(*evA, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(*evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
uint32_t a_uid = evA->self_user_id;
CHECK(a_uid != 0);
EventStore* evB = make_client("clientB", "Bravo");
CHECK(evB != nullptr);
CHECK(wait_for(*evB, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(*evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
uint32_t b_uid = evB->self_user_id;
CHECK(b_uid != 0);
// A stays alive by sending channel text every 500ms (bumps A's last_seen on the server).
// B goes completely silent — no TCP traffic, no pings (15s ping >> 2s reaper timeout).
// Start the keepalive IMMEDIATELY: the reaper timeout is only 2s, so A must begin
// sending well before its last_seen goes stale.
std::atomic<bool> keepalive_stop{false};
std::thread keepalive([&] {
while (!keepalive_stop.load()) {
vc_send_text(evA->client, VC_TEXT_CHANNEL, 1, ".");
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
});
// Give B time to go stale and the reaper to fire (2s timeout + 500ms sweep + margin).
// A must receive VC_EVENT_USER_LEFT for B.
CHECK(wait_for(*evA, [b_uid](EventStore& s) {
for (auto u : s.left_users) if (u == b_uid) return true;
return false;
}, 10000));
// B's TCP connection is closed by the reaper → B sees VC_EVENT_DISCONNECTED.
CHECK(wait_for(*evB, [](EventStore& s) { return s.disconnected; }, 5000));
// ── Cleanup ──────────────────────────────────────────────────────────────
keepalive_stop.store(true);
keepalive.join();
vc_disconnect(evA->client);
if (!evB->disconnected) vc_disconnect(evB->client);
vc_client_destroy(evA->client);
vc_client_destroy(evB->client);
delete evA;
delete evB;
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("reaper_timeout: all checks passed\n");
return 0;
}
std::printf("reaper_timeout: %d failure(s)\n", g_failures);
return 1;
}
-180
View File
@@ -1,180 +0,0 @@
/*
* test_recv_noise_reduction — receive-side NR actually denoises a STEREO voice stream, and
* never touches a screen-audio share. Regression for the bug where a stereo mic (shipped in
* f72219d) silently bypassed listener-side NR because the decode loop gated on
* `dec_channels == 1` as a proxy for "is voice" (docs/voice.md §10).
*
* White-box: drive AudioEngine in external-playback mode (the mixer-timer thread decodes+mixes
* with no hardware device, same as test_external_playback) and tap the per-stream pcm_sink, which
* fires AFTER the receive-side NR pass. We feed deterministic stereo white noise (paced at the
* 20 ms engine cadence so the jitter buffer's catch-up never drops the backlog) and compare the
* played-out noise floor across three runs:
* • voice stream, NR off → baseline (decode only)
* • voice stream, NR on → RNNoise folds stereo→mono and collapses the noise floor
* • screen-audio (is_voice=false), NR on → NR is skipped; floor ≈ baseline
*
* Registered only under VOICECAT_USE_VCPKG_DEPS, where VOICECAT_HAS_NS is defined (a passthrough
* build would correctly fail the reduction assertion).
*/
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <thread>
#include <vector>
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
namespace vca = voicecat::audio;
namespace vcc = voicecat::codec;
// pcm_sink accumulator (written on the mixer-timer thread, read by main after stop()).
struct Sink {
std::atomic<int> frames{0};
std::atomic<long long> sumsq{0};
std::atomic<long long> count{0};
std::atomic<uint32_t> last_ch{0};
int warmup = 0; // skip RNNoise's recurrent-state settle window
};
static void sink_cb(void* user, uint32_t, uint32_t, const int16_t* pcm, size_t spc, uint32_t ch,
uint32_t) {
auto* s = static_cast<Sink*>(user);
int f = s->frames.fetch_add(1, std::memory_order_relaxed);
s->last_ch.store(ch, std::memory_order_relaxed);
if (f < s->warmup) return;
const size_t total = spc * ch;
long long sq = 0;
for (size_t i = 0; i < total; ++i) {
long long v = pcm[i];
sq += v * v;
}
s->sumsq.fetch_add(sq, std::memory_order_relaxed);
s->count.fetch_add(static_cast<long long>(total), std::memory_order_relaxed);
}
// Run one playout scenario and return the RMS of the played-out PCM, or -1 if nothing played.
static double run_scenario(bool is_voice, bool nr_on,
const std::vector<std::vector<uint8_t>>& frames, const vcc::OpusParams& op,
int frame_samples) {
vca::AudioEngine engine;
engine.set_external_playback(true);
Sink sink;
sink.warmup = 20;
engine.set_pcm_sink(&sink_cb, &sink);
vca::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.playback_channels = 2;
p.frame_ms = 20;
if (!engine.start(p)) return -1;
const uint32_t ssrc = 1;
engine.init_recv_stream(ssrc, op, /*user_id=*/7, /*stream_id=*/3, is_voice);
if (nr_on) engine.set_stream_noise_reduction(ssrc, true);
uint32_t ts = 0;
for (size_t i = 0; i < frames.size(); ++i) {
vca::JitterBuffer::Frame f;
f.seq = static_cast<uint64_t>(i);
f.timestamp = ts;
f.fec_present = false;
f.payload = frames[i];
engine.push_recv_frame(ssrc, std::move(f));
ts += static_cast<uint32_t>(frame_samples);
// Pace at the engine cadence so production ≈ consumption: the jitter buffer never builds a
// backlog big enough to trigger catch-up frame-dropping, so every frame is decoded.
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
std::this_thread::sleep_for(std::chrono::milliseconds(120)); // drain
engine.stop();
CHECK(sink.last_ch.load(std::memory_order_relaxed) == 2); // stereo throughout
long long c = sink.count.load(std::memory_order_relaxed);
if (c == 0) return -1;
return std::sqrt(static_cast<double>(sink.sumsq.load(std::memory_order_relaxed)) /
static_cast<double>(c));
}
int main() {
// Stereo Opus params (a stereo mic with send-side NR off arrives exactly like this).
vcc::OpusParams op;
op.sample_rate = 48000;
op.frame_ms = 20;
op.stereo = true;
int frame_samples = vcc::opus_frame_samples(op); // 960 per channel
// Encode a run of deterministic interleaved-stereo white noise (L and R independent so the
// bitstream is genuinely stereo, not L==R).
uint32_t rng = 0xC0FFEEu;
auto next_noise = [&]() -> int16_t {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
return static_cast<int16_t>((static_cast<int32_t>(rng % 6001)) - 3000); // ~[-3000,3000]
};
vcc::OpusEncoder enc;
CHECK(enc.init(op));
const int kFrames = 80; // ~1.6 s; warmup 20 leaves ~60 measured
std::vector<std::vector<uint8_t>> frames;
frames.reserve(kFrames);
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
uint8_t opus_buf[1500];
for (int f = 0; f < kFrames; ++f) {
for (int i = 0; i < frame_samples * 2; ++i) interleaved[i] = next_noise();
int len = enc.encode(interleaved.data(), frame_samples, opus_buf, sizeof(opus_buf));
CHECK(len > 0);
frames.emplace_back(opus_buf, opus_buf + len);
}
enc.destroy();
double voice_off = run_scenario(/*is_voice=*/true, /*nr_on=*/false, frames, op, frame_samples);
double voice_on = run_scenario(/*is_voice=*/true, /*nr_on=*/true, frames, op, frame_samples);
double screen_on = run_scenario(/*is_voice=*/false, /*nr_on=*/true, frames, op, frame_samples);
std::printf("recv_nr: voice_off_rms=%.1f voice_on_rms=%.1f screen_on_rms=%.1f\n", voice_off,
voice_on, screen_on);
CHECK(voice_off > 0.0);
CHECK(voice_on > 0.0);
CHECK(screen_on > 0.0);
// The fix: enabling NR on the stereo VOICE stream must collapse the noise floor. RNNoise drops
// pure noise ~99%; require a clear, unambiguous reduction (the pre-fix bug left it unchanged).
CHECK(voice_on < voice_off * 0.6);
// A screen-audio share is not voice: NR must be a no-op there, so its floor stays ≈ the
// undenoised baseline (same decode path, no RNNoise). Loose band absorbs run-to-run timing.
CHECK(screen_on > voice_off * 0.7);
if (g_failures == 0) {
std::printf("recv_noise_reduction: OK\n");
return 0;
}
std::printf("recv_noise_reduction: %d failure(s)\n", g_failures);
return 1;
}
#else
int main() {
std::printf("recv_noise_reduction: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
return 0;
}
#endif
-74
View File
@@ -1,74 +0,0 @@
/*
* test_smoke — verifies the core links and the C ABI behaves as specified.
*
* This is intentionally a behavior test, not a "does it compile" check: it asserts the
* documented contract (version present, handle lifecycle, invalid-arg guards).
*/
#include <cstdio>
#include <cstring>
#include "voicecat.h"
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
int main() {
// Version + result strings are always available.
CHECK(vc_version_string() != nullptr);
CHECK(std::strlen(vc_version_string()) > 0);
CHECK(std::strcmp(vc_result_string(VC_OK), "ok") == 0);
// Null-config create is rejected; valid create yields a handle.
vc_callbacks cb{};
CHECK(vc_client_create(nullptr, cb) == nullptr);
vc_config cfg{};
cfg.client_name = "test";
cfg.client_version = "0";
cfg.log_level = VC_LOG_OFF;
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
// Invalid-arg guards on the ABI.
CHECK(vc_connect(nullptr, "h", 1) == VC_ERR_INVALID_ARG);
CHECK(vc_connect(c, nullptr, 1) == VC_ERR_INVALID_ARG);
CHECK(vc_send_text(c, VC_TEXT_CHANNEL, 0, nullptr) == VC_ERR_INVALID_ARG);
vc_result rc_connect = vc_connect(c, "127.0.0.1", 8384);
CHECK(rc_connect == VC_OK);
// Auth before connected → NOT_CONNECTED.
{
vc_config cfg2 = cfg;
vc_client* c2 = vc_client_create(&cfg2, cb);
vc_result rc_auth = vc_authenticate_guest(c2, "nick");
CHECK(rc_auth == VC_ERR_NOT_CONNECTED);
vc_client_destroy(c2);
}
// join_channel before connected → NOT_CONNECTED.
vc_result rc_join = vc_join_channel(c, 1, nullptr);
CHECK(rc_join == VC_ERR_NOT_CONNECTED);
vc_device_list dl{};
vc_result rc_devices = vc_list_devices(c, VC_DEVICE_INPUT, &dl);
// Never assert count > 0 — a headless CI agent may report zero audio devices.
CHECK(rc_devices == VC_OK);
vc_free_device_list(&dl);
vc_client_destroy(c);
vc_client_destroy(nullptr); // must be safe
if (g_failures == 0) {
std::printf("smoke: all checks passed\n");
return 0;
}
std::printf("smoke: %d failure(s)\n", g_failures);
return 1;
}
-105
View File
@@ -1,105 +0,0 @@
/*
* test_tcp_loopback — in-process TCP acceptor + client, sends 10 frames.
* Runs only under dev (requires Asio).
*/
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>
#include "net/transport.h"
using namespace voicecat::net;
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
int main() {
constexpr int kFrameCount = 10;
constexpr uint16_t kPort = 19850;
std::mutex mtx;
std::condition_variable cv;
std::vector<std::vector<uint8_t>> received;
std::atomic<bool> server_connected{false};
// Server io_context + acceptor.
asio::io_context server_io;
auto work = asio::make_work_guard(server_io);
std::thread server_thread([&] { server_io.run(); });
TcpAcceptor acceptor(server_io, kPort, [&](asio::ip::tcp::socket sock) {
TcpChannelCallbacks cbs;
cbs.on_frame = [&](std::vector<uint8_t> frame) {
std::lock_guard<std::mutex> lk(mtx);
received.push_back(std::move(frame));
cv.notify_all();
};
cbs.on_connected = [&] { server_connected.store(true); };
auto conn = std::make_shared<TcpServerConn>(std::move(sock), std::move(cbs));
return conn;
});
acceptor.start();
// Client.
std::atomic<bool> client_connected{false};
TcpChannelCallbacks client_cbs;
client_cbs.on_connected = [&] { client_connected.store(true); };
client_cbs.on_connect_error = [](std::error_code ec) {
std::printf("connect error: %s\n", ec.message().c_str());
};
TcpControlChannel client(std::move(client_cbs));
client.async_connect("127.0.0.1", kPort);
// Wait for connection.
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (!client_connected.load() &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
CHECK(client_connected.load());
// Send kFrameCount distinct frames.
for (int i = 0; i < kFrameCount; ++i) {
std::vector<uint8_t> payload = {static_cast<uint8_t>(i), 0xAB, 0xCD};
client.send_frame(payload);
}
// Wait for all frames to arrive on the server side.
{
std::unique_lock<std::mutex> lk(mtx);
bool ok = cv.wait_for(lk, std::chrono::seconds(5),
[&] { return static_cast<int>(received.size()) >= kFrameCount; });
CHECK(ok);
}
CHECK(static_cast<int>(received.size()) == kFrameCount);
for (int i = 0; i < kFrameCount && i < static_cast<int>(received.size()); ++i) {
CHECK(received[i].size() == 3);
if (!received[i].empty()) CHECK(received[i][0] == static_cast<uint8_t>(i));
}
// Clean up.
client.close();
acceptor.stop();
work.reset();
server_io.stop();
server_thread.join();
if (g_failures == 0) {
std::printf("tcp_loopback: all checks passed\n");
return 0;
}
std::printf("tcp_loopback: %d failure(s)\n", g_failures);
return 1;
}
-186
View File
@@ -1,186 +0,0 @@
/*
* test_tls_loopback — in-process TLS 1.3 server + client over a loopback TCP socket pair.
* Validates: cert generation, handshake, ServerIdentity fingerprint, framed message exchange.
*/
#include <atomic>
#include <cstdio>
#include <cstring>
#include <thread>
#include <vector>
#ifdef _WIN32
# include <winsock2.h>
# include <ws2tcpip.h>
# pragma comment(lib, "ws2_32.lib")
using sock_t = SOCKET;
static constexpr sock_t kBadSock = INVALID_SOCKET;
static void close_sock(sock_t s) { closesocket(s); }
static int last_err() { return WSAGetLastError(); }
#else
# include <arpa/inet.h>
# include <netinet/in.h>
# include <sys/socket.h>
# include <unistd.h>
using sock_t = int;
static constexpr sock_t kBadSock = -1;
static void close_sock(sock_t s) { ::close(s); }
static int last_err() { return errno; }
#endif
#include "crypto/crypto.h"
using namespace voicecat::crypto;
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); ++g_failures; } } while (0)
// Create a blocking loopback TCP socket pair: returns {server_fd, client_fd}
static std::pair<sock_t, sock_t> make_socket_pair(uint16_t port) {
sock_t listener = ::socket(AF_INET, SOCK_STREAM, 0);
if (listener == kBadSock) return {kBadSock, kBadSock};
int opt = 1;
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast<const char*>(&opt), sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(port);
if (::bind(listener, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
close_sock(listener); return {kBadSock, kBadSock};
}
if (::listen(listener, 1) != 0) {
close_sock(listener); return {kBadSock, kBadSock};
}
sock_t client = ::socket(AF_INET, SOCK_STREAM, 0);
if (client == kBadSock) { close_sock(listener); return {kBadSock, kBadSock}; }
if (::connect(client, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
close_sock(listener); close_sock(client); return {kBadSock, kBadSock};
}
sockaddr_in peer{};
socklen_t plen = sizeof(peer);
sock_t server = ::accept(listener, reinterpret_cast<sockaddr*>(&peer), &plen);
close_sock(listener);
if (server == kBadSock) { close_sock(client); return {kBadSock, kBadSock}; }
return {server, client};
}
// Write all bytes to a TLS context.
static bool tls_write_all(TlsContext& tls, const uint8_t* data, size_t len) {
size_t off = 0;
while (off < len) {
int n = tls.write(data + off, len - off);
if (n <= 0) return false;
off += n;
}
return true;
}
// Read exactly len bytes from a TLS context.
static bool tls_read_exact(TlsContext& tls, uint8_t* buf, size_t len) {
size_t off = 0;
while (off < len) {
int n = tls.read(buf + off, len - off);
if (n <= 0) return false;
off += n;
}
return true;
}
int main() {
#ifdef _WIN32
WSADATA wsa{};
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
std::printf("WSAStartup failed\n");
return 1;
}
#endif
// Generate server identity + cert
ServerIdentity identity = ServerIdentity::generate();
ServerCert cert = ServerCert::generate("test-server");
CHECK(!cert.pem_cert.empty());
CHECK(!cert.pem_key.empty());
auto [server_fd_native, client_fd_native] = make_socket_pair(19851);
CHECK(server_fd_native != kBadSock);
CHECK(client_fd_native != kBadSock);
if (server_fd_native == kBadSock || client_fd_native == kBadSock) {
std::printf("tls_loopback: socket pair failed (err=%d)\n", last_err());
return 1;
}
std::string server_error, client_error;
std::atomic<bool> server_ok{false}, client_ok{false};
static const char kMsg1[] = "hello from server";
static const char kMsg2[] = "hello from client";
constexpr size_t kMsg1Len = sizeof(kMsg1) - 1;
constexpr size_t kMsg2Len = sizeof(kMsg2) - 1;
char client_recv[64]{};
char server_recv[64]{};
// Server thread: handshake, send msg1, recv msg2
std::thread server_thr([&] {
TlsContext tls(TlsContext::Role::Server, &cert);
int fd = static_cast<int>(server_fd_native);
if (!tls.handshake(fd, server_error)) { close_sock(server_fd_native); return; }
server_ok.store(true);
tls_write_all(tls, reinterpret_cast<const uint8_t*>(kMsg1), kMsg1Len);
tls_read_exact(tls, reinterpret_cast<uint8_t*>(server_recv), kMsg2Len);
close_sock(server_fd_native);
});
// Client thread: handshake, recv msg1, send msg2
std::thread client_thr([&] {
TlsContext tls(TlsContext::Role::Client, nullptr);
int fd = static_cast<int>(client_fd_native);
if (!tls.handshake(fd, client_error)) { close_sock(client_fd_native); return; }
client_ok.store(true);
tls_read_exact(tls, reinterpret_cast<uint8_t*>(client_recv), kMsg1Len);
tls_write_all(tls, reinterpret_cast<const uint8_t*>(kMsg2), kMsg2Len);
close_sock(client_fd_native);
});
server_thr.join();
client_thr.join();
if (!server_error.empty()) std::printf("server TLS error: %s\n", server_error.c_str());
if (!client_error.empty()) std::printf("client TLS error: %s\n", client_error.c_str());
CHECK(server_ok.load());
CHECK(client_ok.load());
CHECK(std::memcmp(client_recv, kMsg1, kMsg1Len) == 0);
CHECK(std::memcmp(server_recv, kMsg2, kMsg2Len) == 0);
// Verify ServerIdentity round-trip
{
ServerIdentity id2 = ServerIdentity::generate();
CHECK(id2.pk != identity.pk); // different key
// Fingerprint is SHA-256 of pk — non-zero
bool nonzero = false;
for (auto b : id2.fingerprint) if (b) { nonzero = true; break; }
CHECK(nonzero);
// fingerprint_hex should be 32 colons + 64 hex chars = 95 chars (AA:BB:...)
std::string hex = id2.fingerprint_hex();
CHECK(hex.size() == 95);
}
#ifdef _WIN32
WSACleanup();
#endif
if (g_failures == 0) {
std::printf("tls_loopback: all checks passed\n");
return 0;
}
std::printf("tls_loopback: %d failure(s)\n", g_failures);
return 1;
}
-374
View File
@@ -1,374 +0,0 @@
/*
* test_tofu_flow — M4 TOFU server-identity gate (voicecat.h's VC_EVENT_SERVER_IDENTITY /
* vc_confirm_server_identity / vc_get_server_identity_display).
*
* Needs a real server (in-process, like the other ABI tests) so a real TLS handshake
* happens — pinning a fingerprint against a mock would prove nothing.
*
* 1. First connect to a fresh server blocks (no AUTH_RESULT) until
* vc_confirm_server_identity() is called; then it proceeds normally.
* 2. Rejecting (accept=0) disconnects with VC_ERR_CRYPTO and does NOT persist a pin — a
* second attempt to the same server still reports FIRST_CONNECT.
* 3. Reconnecting to a server with the SAME identity (same data_dir, restarted on the
* same port) reports MATCHED.
* 4. Reconnecting to a server with a DIFFERENT identity on the same host:port (key
* rotation / MITM) reports MISMATCH.
* 5. vc_confirm_server_identity with nothing pending returns VC_ERR_INVALID_ARG.
* 6. vc_get_server_identity_display is empty pre-connect and populated (64 hex chars —
* the raw, colon-free encoding of the Ed25519 fingerprint) after ServerHello.
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include "voicecat.h"
#include "server.h"
// ── Event tracking — deliberately does NOT auto-confirm, so the test drives the gate ──────
struct GatedEventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
bool got_identity{false};
vc_tofu_status identity_status{};
bool disconnected{false};
vc_result disconnect_result{VC_OK};
vc_client* client{nullptr};
};
static void on_event_gated(void* user, const vc_event* ev) {
auto* s = static_cast<GatedEventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
s->got_identity = true;
s->identity_status = static_cast<vc_tofu_status>(ev->u32a);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
s->disconnect_result = static_cast<vc_result>(ev->result);
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(GatedEventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
// ── Test harness ──────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
// ── A small helper to start/stop an in-process server on a chosen (or OS-assigned) port ───
struct RunningServer {
voicecat::server::Config cfg;
std::unique_ptr<voicecat::server::Server> server;
std::thread server_thread;
uint16_t port{0};
bool start(const std::string& data_dir, uint16_t want_port, const char* name) {
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
cfg.data_dir = data_dir;
cfg.bind_port = want_port;
cfg.media_port = 0;
cfg.server_name = name;
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
server = std::make_unique<voicecat::server::Server>(cfg);
server_thread = std::thread([this] { server->run(); });
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
if (!ok) return false;
port = bound_port.load();
return true;
}
void stop_and_join() {
if (server) server->stop();
if (server_thread.joinable()) server_thread.join();
}
};
// ── 1. First connect blocks until confirmed ─────────────────────────────────────
static void test_first_connect_blocks(uint16_t port, const std::string& tofu_path) {
GatedEventStore ev;
vc_callbacks cb{on_event_gated, nullptr, &ev};
vc_config cfg{"test-gate", "0.1", VC_LOG_OFF};
cfg.tofu_store_path = tofu_path.c_str();
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
ev.client = c;
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(c, "Gated") == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); }
// No confirmation yet — auth must NOT complete within a short window.
CHECK(!wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 1000));
CHECK(vc_confirm_server_identity(c, 1) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000));
vc_disconnect(c);
vc_client_destroy(c);
std::printf("test_first_connect_blocks: ok\n");
}
// ── 2. Reject doesn't persist a pin ──────────────────────────────────────────────
static void test_reject_does_not_persist(uint16_t port, const std::string& tofu_path) {
{
GatedEventStore ev;
vc_callbacks cb{on_event_gated, nullptr, &ev};
vc_config cfg{"test-reject", "0.1", VC_LOG_OFF};
cfg.tofu_store_path = tofu_path.c_str();
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
ev.client = c;
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); }
CHECK(vc_confirm_server_identity(c, 0) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.disconnected; }, 5000));
{ std::lock_guard lk(ev.mu); CHECK(ev.disconnect_result == VC_ERR_CRYPTO); }
vc_client_destroy(c);
}
// Second attempt to the SAME server, SAME pin file: still FIRST_CONNECT — the rejected
// pin from above must not have been written to disk.
{
GatedEventStore ev;
vc_callbacks cb{on_event_gated, nullptr, &ev};
vc_config cfg{"test-reject2", "0.1", VC_LOG_OFF};
cfg.tofu_store_path = tofu_path.c_str();
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
ev.client = c;
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); }
vc_disconnect(c);
vc_client_destroy(c);
}
std::printf("test_reject_does_not_persist: ok\n");
}
// ── 3/4. MATCHED on identity reuse, MISMATCH on identity rotation ──────────────
static void test_matched_and_mismatch(const std::string& tofu_path) {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_tofu_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
auto data_dir_1 = (tmp / "server1").string(); // identity A
auto data_dir_2 = (tmp / "server2").string(); // identity B (different)
// ── Server 1 (identity A), first connect: accept + pin ──────────────────────
RunningServer server1;
CHECK(server1.start(data_dir_1, 0, "VoiceCat-TofuA"));
uint16_t port = server1.port;
std::printf("test_matched_and_mismatch: server1 ready on :%u\n", port);
{
GatedEventStore ev;
vc_callbacks cb{on_event_gated, nullptr, &ev};
vc_config cfg{"test-pin", "0.1", VC_LOG_OFF};
cfg.tofu_store_path = tofu_path.c_str();
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
ev.client = c;
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(c, "Pin") == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); }
CHECK(vc_confirm_server_identity(c, 1) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000));
vc_disconnect(c);
vc_client_destroy(c);
}
server1.stop_and_join();
// ── Server 1 restarted on the SAME port, SAME data_dir (identity A reloaded from disk
// — ServerIdentityManager::init's load-existing-files path) — expect MATCHED. ──────────
RunningServer server1_restarted;
CHECK(server1_restarted.start(data_dir_1, port, "VoiceCat-TofuA"));
{
GatedEventStore ev;
vc_callbacks cb{on_event_gated, nullptr, &ev};
vc_config cfg{"test-matched", "0.1", VC_LOG_OFF};
cfg.tofu_store_path = tofu_path.c_str();
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
ev.client = c;
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(c, "Matched") == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MATCHED); }
CHECK(vc_confirm_server_identity(c, 1) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000));
vc_disconnect(c);
vc_client_destroy(c);
}
server1_restarted.stop_and_join();
// ── A DIFFERENT server (identity B, fresh data_dir) on the SAME port — expect
// MISMATCH. Reject it, and confirm the pin file still reflects identity A afterwards. ───
RunningServer server2;
CHECK(server2.start(data_dir_2, port, "VoiceCat-TofuB"));
{
GatedEventStore ev;
vc_callbacks cb{on_event_gated, nullptr, &ev};
vc_config cfg{"test-mismatch", "0.1", VC_LOG_OFF};
cfg.tofu_store_path = tofu_path.c_str();
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
ev.client = c;
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MISMATCH); }
CHECK(vc_confirm_server_identity(c, 0) == VC_OK); // reject the rotated identity
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.disconnected; }, 5000));
vc_client_destroy(c);
}
server2.stop_and_join();
// ── Server 1 (identity A) once more — rejecting the mismatch above must not have
// clobbered the original pin. ───────────────────────────────────────────────────────────
RunningServer server1_again;
CHECK(server1_again.start(data_dir_1, port, "VoiceCat-TofuA"));
{
GatedEventStore ev;
vc_callbacks cb{on_event_gated, nullptr, &ev};
vc_config cfg{"test-still-matched", "0.1", VC_LOG_OFF};
cfg.tofu_store_path = tofu_path.c_str();
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
ev.client = c;
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
{ std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MATCHED); }
vc_disconnect(c);
vc_client_destroy(c);
}
server1_again.stop_and_join();
std::filesystem::remove_all(tmp);
std::printf("test_matched_and_mismatch: ok\n");
}
// ── 5. confirm_server_identity with nothing pending ─────────────────────────────
static void test_confirm_with_nothing_pending() {
vc_config cfg{"test-nopending", "0.1", VC_LOG_OFF};
vc_callbacks cb{};
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
CHECK(vc_confirm_server_identity(c, 1) == VC_ERR_INVALID_ARG);
vc_client_destroy(c);
std::printf("test_confirm_with_nothing_pending: ok\n");
}
// ── 6. vc_get_server_identity_display ───────────────────────────────────────────
static void test_get_server_identity_display(uint16_t port, const std::string& tofu_path) {
vc_config cfg{"test-display", "0.1", VC_LOG_OFF};
cfg.tofu_store_path = tofu_path.c_str();
GatedEventStore ev;
vc_callbacks cb{on_event_gated, nullptr, &ev};
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
ev.client = c;
// Pre-connect: empty.
size_t len = 12345;
CHECK(vc_get_server_identity_display(c, nullptr, 0, &len) == VC_OK);
CHECK(len == 0);
CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(c, "Display") == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000));
CHECK(vc_confirm_server_identity(c, 1) == VC_OK);
CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000));
char buf[256] = {};
CHECK(vc_get_server_identity_display(c, buf, sizeof(buf), &len) == VC_OK);
CHECK(len == 64); // 32-byte Ed25519 fingerprint, raw hex, no colons
CHECK(std::strlen(buf) == 64);
vc_disconnect(c);
vc_client_destroy(c);
std::printf("test_get_server_identity_display: ok\n");
}
int main() {
test_confirm_with_nothing_pending();
auto tmp = std::filesystem::temp_directory_path() /
("vctest_tofu_main_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
{
RunningServer server;
CHECK(server.start((tmp / "srv").string(), 0, "VoiceCat-TofuFlow"));
uint16_t port = server.port;
std::printf("test_tofu_flow: server ready on :%u\n", port);
test_first_connect_blocks(port, (tmp / "pins_blocks.txt").string());
test_reject_does_not_persist(port, (tmp / "pins_reject.txt").string());
test_get_server_identity_display(port, (tmp / "pins_display.txt").string());
server.stop_and_join();
}
test_matched_and_mismatch((tmp / "pins_matched.txt").string());
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("tofu_flow: all checks passed\n");
return 0;
}
std::printf("tofu_flow: %d failure(s)\n", g_failures);
return 1;
}
-732
View File
@@ -1,732 +0,0 @@
/*
* test_vad_ptt_devices — closes M3's "explicitly out of scope" gaps (PROGRESS.md): device
* enumeration, the VAD/PTT send-side input gate, and true stereo playback mixing.
*
* Mirrors test_m3_multistream.cpp's approach (real vc_client instances against a real
* in-process server, not raw sockets) for the ABI-level pieces, plus a white-box AudioEngine
* test for the stereo mixer (no audio hardware needed — see AudioEngine::mix_for_test).
*
* 1. Device enumeration (vc_list_devices) works pre-connect, for both kinds, and tolerates
* an empty list (headless CI build agents may have zero audio devices) — VC_OK is the
* only thing asserted, never count > 0.
* 2. VAD gate: under VC_INPUT_VOICE_ACTIVATION (the default), silent PCM never reaches the
* peer (no talking edge); loud PCM does.
* 3. PTT gate: under VC_INPUT_PUSH_TO_TALK, loud PCM is gated closed until
* vc_set_push_to_talk(1); then it reaches the peer.
* 4. Stereo playback mixer: white-box (AudioEngine directly) — a genuinely stereo decoded
* stream survives into the mix without being downmixed to mono.
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdlib>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
// ── Event tracking (same shape as test_m3_multistream.cpp) ──────────────────────
struct TalkEvent {
uint32_t user_id;
uint32_t stream_id;
bool talking;
};
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool saw_stream_started{false};
std::vector<TalkEvent> talk_events;
bool voice_subscribed{false};
bool disconnected{false};
const char* label{nullptr};
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test.
vc_client* client{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
// No human to ask in a headless test — trust on first connect unconditionally.
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_VOICE_STATE:
s->voice_subscribed = (ev->u32a == 1);
break;
case VC_EVENT_STREAM_STARTED:
s->saw_stream_started = true;
break;
case VC_EVENT_TALK_STATE:
s->talk_events.push_back({ev->user_id, ev->stream_id, ev->u32a != 0});
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static std::vector<int16_t> make_sine_frame(int frame_idx, float freq_hz,
int frame_samples = 960) {
std::vector<int16_t> pcm(frame_samples);
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(frame_idx * frame_samples + i) / 48000.0f;
pcm[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * freq_hz * t) * 16000.0f);
}
return pcm;
}
static std::vector<int16_t> make_silence_frame(int frame_samples = 960) {
return std::vector<int16_t>(frame_samples, 0);
}
// Did `talking==true` ever fire for (user_id, stream_id) at index >= `from`?
static bool saw_talking_true(EventStore& s, uint32_t user_id, uint32_t stream_id, size_t from) {
std::lock_guard lk(s.mu);
for (size_t i = from; i < s.talk_events.size(); ++i) {
auto& e = s.talk_events[i];
if (e.user_id == user_id && e.stream_id == stream_id && e.talking) return true;
}
return false;
}
static size_t talk_event_count(EventStore& s) {
std::lock_guard lk(s.mu);
return s.talk_events.size();
}
// ── Test harness ──────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
// ── 1. Device enumeration (no server needed) ────────────────────────────────────
static void test_device_enumeration() {
vc_config cfg{"test-devices", "0.1", VC_LOG_OFF};
vc_callbacks cb{};
vc_client* c = vc_client_create(&cfg, cb);
CHECK(c != nullptr);
for (vc_device_kind kind : {VC_DEVICE_INPUT, VC_DEVICE_OUTPUT}) {
vc_device_list dl{};
vc_result r = vc_list_devices(c, kind, &dl);
CHECK(r == VC_OK);
// Headless CI build agents may legitimately report zero devices — never assert
// count > 0, only that the call itself succeeded and the list is well-formed.
for (size_t i = 0; i < dl.count; ++i) {
CHECK(dl.items[i].id != nullptr);
CHECK(dl.items[i].name != nullptr);
}
vc_free_device_list(&dl);
vc_free_device_list(&dl); // idempotent — must not crash on a second call
}
vc_client_destroy(c);
std::printf("test_device_enumeration: ok\n");
}
// ── 4. Stereo playback mixer (white-box, no audio hardware needed) ──────────────
static void test_stereo_mix() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.playback_channels = 2;
p.frame_ms = 20;
CHECK(engine.start(p)); // capture_cb intentionally omitted — not exercised here
voicecat::codec::OpusParams stereo_params;
stereo_params.stereo = true;
int frame_samples = voicecat::codec::opus_frame_samples(stereo_params);
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(stereo_params));
// Loud left channel, silent right channel — a real downmix would average them into a
// single audible-but-quieter centered sample; true stereo should keep them distinct.
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
interleaved[i * 2 + 1] = 0;
}
uint8_t opus_buf[1500];
int opus_len = enc.encode(interleaved.data(), frame_samples, opus_buf, sizeof(opus_buf));
CHECK(opus_len > 0);
engine.init_recv_stream(/*ssrc=*/1, stereo_params, /*user_id=*/0, /*stream_id=*/0,
/*is_voice=*/false);
voicecat::audio::JitterBuffer::Frame f;
f.seq = 0;
f.timestamp = 0;
f.fec_present = false;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(1, std::move(f));
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
// If the engine downmixed (old M3 behavior), every L/R pair would be identical (the
// average of a loud sample and 0). True stereo should show a clear, consistent L != R
// difference across the frame.
int64_t total_diff = 0;
for (int i = 0; i < frame_samples; ++i)
total_diff += std::abs(static_cast<int>(out[i * 2]) - static_cast<int>(out[i * 2 + 1]));
CHECK(total_diff > static_cast<int64_t>(frame_samples) * 1000); // well above decode noise
engine.remove_stream(1);
engine.stop();
std::printf("test_stereo_mix: ok (total_diff=%lld)\n", static_cast<long long>(total_diff));
}
// ── 4a-2. Stereo screen-audio loopback capture (white-box, no audio hardware needed) ──
// Regression for the mono-loopback bug: start_loopback_capture used to hardcode channels=1,
// downmixing the system's stereo mix to mono before the encoder ever saw it (and on_capture_frame
// then upmixed L=R to produce a fake-stereo bitstream). Now the loopback device opens in the
// channel's mode (stereo when the channel is stereo), so the encoder receives real interleaved
// L/R PCM and encodes it directly. This test drives feed_loopback_for_test with a loud-L /
// silent-R stereo signal, encodes it (as on_capture_frame now does for channels==2), decodes,
// and mixes — asserting L != R across the frame. A mono-downmixed-then-upmixed bitstream would
// have L == R. Mirrors test_stereo_mix but routes the encode side through the loopback
// accumulator path that the fix touches (feed_loopback_for_test → on_loopback's accumulator).
#if defined(VOICECAT_HAS_LOOPBACK)
static void test_loopback_stereo_capture() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1; // mic path — irrelevant here; loopback has its own channel count
p.playback_channels = 2; // stereo mix output (for mix_for_test below)
p.frame_ms = 20;
CHECK(engine.start(p)); // no capture_cb — the real mic (if any) won't touch capture_accum_
voicecat::codec::OpusParams stereo_params;
stereo_params.stereo = true;
stereo_params.application = voicecat::codec::OpusApplication::Audio; // screen-audio channel
stereo_params.bitrate_bps = 128000; // music/screen-audio channel default
int frame_samples = voicecat::codec::opus_frame_samples(stereo_params);
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(stereo_params));
// Loud left channel, silent right — a real mono downmix would average them into a single
// audible-but-quieter centered sample; true stereo keeps them distinct.
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
interleaved[i * 2 + 1] = 0;
}
// Encode via the loopback accumulator path: feed_loopback_for_test drives on_loopback's
// accumulator and invokes the callback with channels=2 (the fix). The callback encodes
// exactly as on_capture_frame does for real-stereo SCREEN_AUDIO PCM — no upmix.
uint8_t opus_buf[1500];
int opus_len = 0;
int seen_channels = 0;
auto cb = [&](int /*kind*/, const int16_t* pcm, int /*samples*/, int channels) {
seen_channels = channels;
if (channels == 2) {
// The loopback accumulator must have preserved L/R distinctness pre-encode.
int64_t pre_diff = 0;
for (int i = 0; i < frame_samples; ++i)
pre_diff += std::abs(static_cast<int>(pcm[i * 2]) - static_cast<int>(pcm[i * 2 + 1]));
CHECK(pre_diff > static_cast<int64_t>(frame_samples) * 1000);
}
opus_len = enc.encode(pcm, frame_samples, opus_buf, sizeof(opus_buf));
};
engine.feed_loopback_for_test(interleaved.data(), frame_samples, 2, cb);
CHECK(seen_channels == 2); // the loopback path reported stereo, not downmixed mono
CHECK(opus_len > 0);
// Decode + mix — same recv path as test_stereo_mix. A real stereo bitstream should
// survive with L != R; a mono-downmixed-then-upmixed bitstream would have L == R.
engine.init_recv_stream(/*ssrc=*/3, stereo_params, /*user_id=*/0, /*stream_id=*/0,
/*is_voice=*/false);
voicecat::audio::JitterBuffer::Frame f;
f.seq = 0;
f.timestamp = 0;
f.fec_present = false;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(3, std::move(f));
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
int64_t total_diff = 0;
for (int i = 0; i < frame_samples; ++i)
total_diff += std::abs(static_cast<int>(out[i * 2]) - static_cast<int>(out[i * 2 + 1]));
CHECK(total_diff > static_cast<int64_t>(frame_samples) * 1000);
engine.remove_stream(3);
engine.stop();
std::printf("test_loopback_stereo_capture: ok (total_diff=%lld, seen_channels=%d)\n",
static_cast<long long>(total_diff), seen_channels);
}
#endif
// ── 4b. Playout-clock re-sync after a late join / silence gap ────────────────────
// Regression for the "talk indicator lit, no audio" bug: the playout clock free-runs (it
// advances every callback via PLC), while the sender's frame timestamps only advance while it
// is actually transmitting. After a silence gap or a late join the clock drifts past the jitter
// buffer's 500 ms late-drop window, so every real frame is dropped-as-late and the stream is
// permanently silent. on_playback must re-seed the clock to the earliest buffered frame.
static void test_playout_resync() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.playback_channels = 2;
p.frame_ms = 20;
CHECK(engine.start(p));
voicecat::codec::OpusParams mono_params; // mono = the mic path
mono_params.stereo = false;
int frame_samples = voicecat::codec::opus_frame_samples(mono_params);
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(mono_params));
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
}
uint8_t opus_buf[1500];
int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
CHECK(opus_len > 0);
engine.init_recv_stream(/*ssrc=*/2, mono_params, /*user_id=*/0, /*stream_id=*/0,
/*is_voice=*/false);
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
// Free-run the playout clock with an empty jitter buffer (PLC every callback) far past the
// 500 ms late-drop window — this is what a silence gap / late join does in the field.
for (int i = 0; i < 100; ++i) // ~100 frames @ 20 ms = ~2 s, well past 500 ms
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
// Now a real frame arrives carrying a timestamp far behind the free-run clock. Without the
// re-sync it is dropped-as-late and playback stays silent; with it the clock snaps back and
// the frame is decoded and mixed.
voicecat::audio::JitterBuffer::Frame f;
f.seq = 0;
f.timestamp = 0; // stream-relative start, now far behind the drifted playout clock
f.fec_present = false;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(2, std::move(f));
std::fill(out.begin(), out.end(), 0);
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
int64_t energy = 0;
for (int16_t s : out) energy += std::abs(static_cast<int>(s));
CHECK(energy > static_cast<int64_t>(frame_samples) * 1000); // audible, not PLC silence
engine.remove_stream(2);
engine.stop();
std::printf("test_playout_resync: ok (energy=%lld)\n", static_cast<long long>(energy));
}
// ── 5. Capture-frame accumulation (white-box, no audio hardware needed) ──────────
// Regression for the capture-side analogue of the playback ring fix: miniaudio's capture
// callback fires at the hardware period (commonly 480 samples on WASAPI shared mode), while
// opus_encode() requires exactly frame_samples_ (960). Sub-frame chunks must be accumulated;
// the callback must receive exactly 960-sample frames regardless of input chunk size.
static void test_capture_frame_accumulation() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.frame_ms = 20; // frame_samples_ = 960
std::atomic<int> call_count{0};
std::atomic<bool> wrong_size{false};
constexpr int kExpected = 960;
// Start WITHOUT a capture callback: the real mic (if any) fires on_capture(), but
// on_capture() returns immediately when capture_cb_ is null, so capture_accum_ is
// never touched by the hardware thread. feed_capture_for_test() bypasses capture_cb_
// and drives the same accumulator directly with the explicit `cb` below — no races.
CHECK(engine.start(p));
auto cb = [&](int /*kind*/, const int16_t* /*pcm*/, int samples, int /*channels*/) {
++call_count;
if (samples != kExpected) wrong_size.store(true);
};
// 480-sample (10 ms) input — WASAPI's common hardware period on modern Windows.
// Two 480-chunk inputs → exactly one callback at 960.
std::vector<int16_t> h(480, 1000);
engine.feed_capture_for_test(h.data(), 480, cb);
CHECK(call_count.load() == 0); // half a frame — no callback yet
engine.feed_capture_for_test(h.data(), 480, cb);
CHECK(call_count.load() == 1); // one full frame — callback fired once
// Mis-aligned split: 240 then 720 → still exactly one callback.
std::vector<int16_t> s(240, 500), l(720, 500);
engine.feed_capture_for_test(s.data(), 240, cb);
CHECK(call_count.load() == 1);
engine.feed_capture_for_test(l.data(), 720, cb);
CHECK(call_count.load() == 2);
// 1920-sample input (two Opus frames) → exactly two callbacks.
std::vector<int16_t> d(1920, 800);
engine.feed_capture_for_test(d.data(), 1920, cb);
CHECK(call_count.load() == 4);
CHECK(!wrong_size.load());
engine.stop();
std::printf("test_capture_frame_accumulation: ok (callbacks=%d)\n", call_count.load());
}
// ── 2/3. VAD + PTT gate, through the real ABI against a real server ─────────────
static void test_vad_and_ptt_gate() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_vadptt_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.media_port = 0;
cfg.server_name = "VoiceCat-VadPttTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
if (!ok) {
std::printf("FAIL: server did not become ready within 10s\n");
++g_failures;
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return;
}
}
uint16_t port = bound_port.load();
std::printf("test_vad_and_ptt_gate: server ready on :%u\n", port);
EventStore evA;
evA.label = "A";
vc_callbacks cbA{on_event, nullptr, &evA};
vc_config cfgA{"test-A", "0.1", VC_LOG_OFF};
vc_client* clientA = vc_client_create(&cfgA, cbA);
CHECK(clientA != nullptr);
evA.client = clientA;
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientA, "VP-A") == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(clientA) == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
EventStore evB;
evB.label = "B";
vc_callbacks cbB{on_event, nullptr, &evB};
vc_config cfgB{"test-B", "0.1", VC_LOG_OFF};
vc_client* clientB = vc_client_create(&cfgB, cbB);
CHECK(clientB != nullptr);
evB.client = clientB;
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientB, "VP-B") == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(clientB) == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
uint32_t a_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
std::this_thread::sleep_for(std::chrono::milliseconds(500));
vc_stream_desc mic_desc{};
mic_desc.kind = VC_STREAM_MIC;
mic_desc.label = "mic";
uint32_t mic_sid = 0;
CHECK(vc_stream_start(clientA, &mic_desc, &mic_sid) == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.saw_stream_started; }, 5000));
CHECK(wait_for(evA, [](EventStore& s) { return s.saw_stream_started; }, 5000));
// ── 2a. VAD mode (default), silent PCM: must NOT reach B as a talking edge ──
CHECK(vc_set_input_mode(clientA, VC_INPUT_VOICE_ACTIVATION) == VC_OK);
for (int i = 0; i < 15; ++i) {
auto silence = make_silence_frame();
CHECK(vc_test_inject_capture(clientA, mic_sid, silence.data(), silence.size()) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
CHECK(!saw_talking_true(evB, a_uid, mic_sid, 0));
// ── 2b. VAD mode, loud PCM: must reach B as a talking edge ──────────────────
size_t mark = talk_event_count(evB);
for (int i = 0; i < 20; ++i) {
auto loud = make_sine_frame(i, 440.0f);
CHECK(vc_test_inject_capture(clientA, mic_sid, loud.data(), loud.size()) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
CHECK(wait_for(evB, [&](EventStore& s) {
for (size_t i = mark; i < s.talk_events.size(); ++i) {
auto& e = s.talk_events[i];
if (e.user_id == a_uid && e.stream_id == mic_sid && e.talking) return true;
}
return false;
}, 3000));
// ── 3a. PTT mode, key up: loud PCM must NOT reach B as a new talking edge ───
CHECK(vc_set_input_mode(clientA, VC_INPUT_PUSH_TO_TALK) == VC_OK);
CHECK(vc_set_push_to_talk(clientA, 0) == VC_OK);
// Let any in-flight VAD-driven talking state lapse (hang-time ~300ms) before measuring.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
mark = talk_event_count(evB);
for (int i = 0; i < 20; ++i) {
auto loud = make_sine_frame(i, 440.0f);
CHECK(vc_test_inject_capture(clientA, mic_sid, loud.data(), loud.size()) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
std::this_thread::sleep_for(std::chrono::milliseconds(200));
CHECK(!saw_talking_true(evB, a_uid, mic_sid, mark));
// ── 3b. PTT mode, key down: loud PCM must reach B as a talking edge ─────────
CHECK(vc_set_push_to_talk(clientA, 1) == VC_OK);
mark = talk_event_count(evB);
for (int i = 0; i < 20; ++i) {
auto loud = make_sine_frame(i, 440.0f);
CHECK(vc_test_inject_capture(clientA, mic_sid, loud.data(), loud.size()) == VC_OK);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
CHECK(wait_for(evB, [&](EventStore& s) {
for (size_t i = mark; i < s.talk_events.size(); ++i) {
auto& e = s.talk_events[i];
if (e.user_id == a_uid && e.stream_id == mic_sid && e.talking) return true;
}
return false;
}, 3000));
{ std::lock_guard lk(evA.mu); CHECK(!evA.disconnected); }
{ std::lock_guard lk(evB.mu); CHECK(!evB.disconnected); }
vc_disconnect(clientA);
vc_disconnect(clientB);
vc_client_destroy(clientA);
vc_client_destroy(clientB);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
std::printf("test_vad_and_ptt_gate: done\n");
}
// ── 5. Stereo mic capture (vc_set_capture_channels) ───────────────────────────
// Verifies that the mic capture accumulator path handles stereo (channels=2) correctly:
// the accumulator is sized to frame_samples_*capture_channels, on_capture forwards the
// correct channel count, and the encoder receives real interleaved L/R PCM (not a mono
// downmix). Mirrors test_loopback_stereo_capture but routes through the mic capture
// accumulator (feed_capture_for_test with channels=2) instead of the loopback path.
// This is the headless CI test for the iOS stereo built-in mic feature (Part D).
static void test_stereo_mic_capture() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 2; // stereo mic capture (vc_set_capture_channels path)
p.playback_channels = 2; // stereo mix output (for mix_for_test below)
p.frame_ms = 20;
CHECK(engine.start(p)); // no capture_cb — the real mic (if any) won't touch capture_accum_
voicecat::codec::OpusParams stereo_params;
stereo_params.stereo = true;
stereo_params.application = voicecat::codec::OpusApplication::Voip; // mic stream
stereo_params.bitrate_bps = 64000; // mic default
int frame_samples = voicecat::codec::opus_frame_samples(stereo_params);
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(stereo_params));
// Loud left channel, silent right — a mono downmix would average them; true stereo
// keeps them distinct (same signal as test_loopback_stereo_capture).
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
interleaved[i * 2 + 1] = 0;
}
// Encode via the mic capture accumulator path: feed_capture_for_test with channels=2
// drives on_capture's accumulator and invokes the callback with channels=2. The callback
// encodes exactly as on_capture_frame does for channels==2 — direct stereo, no upmix.
uint8_t opus_buf[1500];
int opus_len = 0;
int seen_channels = 0;
auto cb = [&](int /*kind*/, const int16_t* pcm, int /*samples*/, int channels) {
seen_channels = channels;
if (channels == 2) {
// The capture accumulator must have preserved L/R distinctness pre-encode.
int64_t pre_diff = 0;
for (int i = 0; i < frame_samples; ++i)
pre_diff += std::abs(static_cast<int>(pcm[i * 2]) - static_cast<int>(pcm[i * 2 + 1]));
CHECK(pre_diff > static_cast<int64_t>(frame_samples) * 1000);
}
opus_len = enc.encode(pcm, frame_samples, opus_buf, sizeof(opus_buf));
};
engine.feed_capture_for_test(interleaved.data(), frame_samples, 2, cb);
CHECK(seen_channels == 2); // the mic capture path reported stereo, not mono
CHECK(opus_len > 0);
// Decode + mix — same recv path as test_stereo_mix. A real stereo bitstream should
// survive with L != R; a mono-downmixed-then-upmixed bitstream would have L == R.
engine.init_recv_stream(/*ssrc=*/5, stereo_params, /*user_id=*/0, /*stream_id=*/0,
/*is_voice=*/false);
voicecat::audio::JitterBuffer::Frame f;
f.seq = 0;
f.timestamp = 0;
f.fec_present = false;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(5, std::move(f));
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
int64_t total_diff = 0;
for (int i = 0; i < frame_samples; ++i)
total_diff += std::abs(static_cast<int>(out[i * 2]) - static_cast<int>(out[i * 2 + 1]));
CHECK(total_diff > static_cast<int64_t>(frame_samples) * 1000);
engine.remove_stream(5);
engine.stop();
std::printf("test_stereo_mic_capture: ok (total_diff=%lld, seen_channels=%d)\n",
static_cast<long long>(total_diff), seen_channels);
}
// ── 6. Stereo mic capture on a MONO channel (downmix safety) ──────────────────
// A stereo mic (vc_set_capture_channels=2) can be enabled while on a mono channel. The mic
// then delivers interleaved L/R, but the channel's Opus encoder is mono. encode_and_send_frame
// must fold L/R to mono before encoding — handing interleaved pairs straight to a mono
// opus_encode makes it read 2× the samples it should (wrong pitch / garbage). This mirrors that
// fold and proves the result is a valid mono bitstream that decodes to the expected averaged
// signal, rather than half-length junk.
static void test_stereo_mic_mono_channel() {
voicecat::codec::OpusParams mono_params;
mono_params.stereo = false; // mono channel — encoder is mono
mono_params.application = voicecat::codec::OpusApplication::Voip;
mono_params.bitrate_bps = 64000;
const int frame_samples = voicecat::codec::opus_frame_samples(mono_params);
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(mono_params));
// Loud left, silent right — folding (L+R)/2 yields a half-amplitude tone on every sample.
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
interleaved[i * 2 + 1] = 0;
}
// Fold exactly as encode_and_send_frame does for a stereo frame on a mono channel.
std::vector<int16_t> folded(frame_samples);
for (int i = 0; i < frame_samples; ++i)
folded[i] = static_cast<int16_t>(
(static_cast<int32_t>(interleaved[i * 2]) + static_cast<int32_t>(interleaved[i * 2 + 1])) / 2);
uint8_t opus_buf[1500];
int opus_len = enc.encode(folded.data(), frame_samples, opus_buf, sizeof(opus_buf));
CHECK(opus_len > 0);
// Decode mono and verify a full-length frame with real energy survived (a garbage half-read
// would either fail to decode the full frame_samples or come back near-silent / wrong length).
voicecat::codec::OpusDecoder dec;
CHECK(dec.init(mono_params));
std::vector<int16_t> decoded(frame_samples, 0);
int dec_samples = dec.decode(opus_buf, opus_len, decoded.data(), frame_samples);
CHECK(dec_samples == frame_samples);
int64_t energy = 0;
for (int i = 0; i < frame_samples; ++i) energy += std::abs(static_cast<int>(decoded[i]));
CHECK(energy > static_cast<int64_t>(frame_samples) * 500); // clearly audible, not silence
std::printf("test_stereo_mic_mono_channel: ok (opus_len=%d, energy=%lld)\n",
opus_len, static_cast<long long>(energy));
}
int main() {
test_device_enumeration();
test_stereo_mix();
#if defined(VOICECAT_HAS_LOOPBACK)
test_loopback_stereo_capture();
#endif
test_stereo_mic_capture();
test_stereo_mic_mono_channel();
test_playout_resync();
test_capture_frame_accumulation();
test_vad_and_ptt_gate();
if (g_failures == 0) {
std::printf("vad_ptt_devices: all checks passed\n");
return 0;
}
std::printf("vad_ptt_devices: %d failure(s)\n", g_failures);
return 1;
}
-253
View File
@@ -1,253 +0,0 @@
/*
* test_voice_client_abi — M2 exit criterion, exercised through the real C ABI.
*
* Unlike test_m2_voice.cpp (which drives raw BSD sockets to exercise the wire format),
* this test drives two actual vc_client instances end-to-end: vc_connect, vc_authenticate_guest,
* vc_stream_start/stop. It proves the client-side voice plane in core/src/core/client.cpp
* (UDP binding, StreamAnnounce, SessionModel propagation) is wired up for real — not just
* the raw-socket test harness.
*/
#include <cstdio>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
// ── Event tracking ────────────────────────────────────────────────────────────
struct StreamEvent {
bool started; // true = STARTED, false = STOPPED
uint32_t user_id;
uint32_t stream_id;
};
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
std::vector<StreamEvent> stream_events;
bool voice_subscribed{false};
const char* label{nullptr};
bool disconnected{false};
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test.
vc_client* client{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
// No human to ask in a headless test — trust on first connect unconditionally.
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
if (!s->auth_ok) std::fprintf(stderr, "[%s] AUTH FAILED: %s\n",
s->label ? s->label : "?", ev->text ? ev->text : "(no msg)");
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_VOICE_STATE:
s->voice_subscribed = (ev->u32a == 1);
break;
case VC_EVENT_STREAM_STARTED:
s->stream_events.push_back({true, ev->user_id, ev->stream_id});
break;
case VC_EVENT_STREAM_STOPPED:
s->stream_events.push_back({false, ev->user_id, ev->stream_id});
break;
case VC_EVENT_ERROR:
std::fprintf(stderr, "[%s] ERROR rc=%d: %s\n",
s->label ? s->label : "?", ev->result, ev->text ? ev->text : "");
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
// ── Test harness ──────────────────────────────────────────────────────────────
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0; // OS picks the TCP port
cfg.media_port = 0; // OS picks the UDP port
cfg.server_name = "VoiceCat-VoiceAbiTest";
cfg.allow_guests = true;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
if (!ok) {
std::printf("FAIL: server did not become ready within 10s\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
std::printf("voice_client_abi: server ready on :%u\n", port);
// ── Client A: guest "VoiceA" ───────────────────────────────────────────────
EventStore evA;
evA.label = "clientA";
vc_callbacks cbA{on_event, nullptr, &evA};
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
vc_client* clientA = vc_client_create(&cfgA, cbA);
CHECK(clientA != nullptr);
evA.client = clientA;
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientA, "VoiceA") == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(clientA) == VC_OK);
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
// ── Client B: guest "VoiceB" ───────────────────────────────────────────────
EventStore evB;
evB.label = "clientB";
vc_callbacks cbB{on_event, nullptr, &evB};
vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
vc_client* clientB = vc_client_create(&cfgB, cbB);
CHECK(clientB != nullptr);
evB.client = clientB;
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_guest(clientB, "VoiceB") == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
CHECK(vc_join_voice(clientB) == VC_OK);
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
uint32_t a_uid = 0;
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
// Both guests land in channel 1 (Lobby) automatically; give the async UDP
// binding handshake (TCP UdpBinding -> ack -> plaintext bootstrap packet) a
// moment to complete on both clients before announcing a stream.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
// ── A announces a MIC stream via the real C ABI ──────────────────────────
vc_stream_desc desc{};
desc.kind = VC_STREAM_MIC;
desc.device_id = nullptr;
desc.label = "mic";
uint32_t a_stream_id = 0;
CHECK(vc_stream_start(clientA, &desc, &a_stream_id) == VC_OK);
// A learns its own stream started.
bool a_self_started = wait_for(evA, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (e.started && e.user_id == a_uid) return true;
return false;
}, 5000);
CHECK(a_self_started);
// B learns (via UserEvent::UPDATED -> SessionModel -> sync_remote_streams)
// that A started a stream — this is the cross-client signaling path that
// was missing while stream_start()/stream_announce_result were stubs.
bool b_saw_a_started = wait_for(evB, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (e.started && e.user_id == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a_started);
// ── A stops the stream ────────────────────────────────────────────────────
CHECK(vc_stream_stop(clientA, a_stream_id) == VC_OK);
bool a_self_stopped = wait_for(evA, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (!e.started && e.user_id == a_uid) return true;
return false;
}, 5000);
CHECK(a_self_stopped);
bool b_saw_a_stopped = wait_for(evB, [&](EventStore& s) {
for (auto& e : s.stream_events)
if (!e.started && e.user_id == a_uid) return true;
return false;
}, 5000);
CHECK(b_saw_a_stopped);
// ── Cleanup ───────────────────────────────────────────────────────────────
vc_disconnect(clientA);
vc_disconnect(clientB);
vc_client_destroy(clientA);
vc_client_destroy(clientB);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("voice_client_abi: all checks passed\n");
return 0;
}
std::printf("voice_client_abi: %d failure(s)\n", g_failures);
return 1;
}
-129
View File
@@ -1,129 +0,0 @@
/*
* test_voice_frame — serialize/parse round-trips for the 20-byte UDP media header.
*/
#include <cassert>
#include <cstdio>
#include <cstring>
#include <vector>
#include "net/voice_frame.h"
using namespace voicecat::net;
static int g_failures = 0;
#define CHECK(cond) \
do { if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
}} while (0)
static void test_header_round_trip() {
VoiceFrame f;
f.type = kFrameVoice;
f.flags = kFlagMarker | kFlagFecPresent;
f.codec = kCodecOpus;
f.ssrc = 0xDEADBEEF;
f.seq = 0x0123456789ABCDEFULL; // full 64-bit range (protocol v2)
f.timestamp = 0x12345678;
uint8_t buf[kVoiceHeaderSize];
serialize_header(f, buf);
VoiceFrame out{};
CHECK(parse_header(buf, kVoiceHeaderSize, out));
CHECK(out.type == f.type);
CHECK(out.flags == f.flags);
CHECK(out.codec == f.codec);
CHECK(out.ssrc == f.ssrc);
CHECK(out.seq == f.seq);
CHECK(out.timestamp == f.timestamp);
}
static void test_empty_payload_packet() {
VoiceFrame f;
f.type = kFrameKeepalive;
f.ssrc = 42;
uint8_t buf[kVoiceHeaderSize];
serialize_header(f, buf);
VoiceFrame out{};
CHECK(parse_header(buf, kVoiceHeaderSize, out));
CHECK(out.type == kFrameKeepalive);
CHECK(out.ssrc == 42);
}
static void test_payload_packet() {
std::vector<uint8_t> payload(60, 0xAB);
VoiceFrame f;
f.ssrc = 0x00000001;
f.seq = 0x0001;
f.timestamp = 960;
f.payload = payload;
// Serialize full wire packet
std::vector<uint8_t> wire(kVoiceHeaderSize + payload.size());
serialize_header(f, wire.data());
std::memcpy(wire.data() + kVoiceHeaderSize, payload.data(), payload.size());
VoiceFrame out{};
CHECK(parse_header(wire.data(), wire.size(), out));
CHECK(out.ssrc == f.ssrc);
CHECK(out.seq == f.seq);
CHECK(out.timestamp == f.timestamp);
// Payload starts at kVoiceHeaderSize
CHECK(wire.size() - kVoiceHeaderSize == 60);
CHECK(wire[kVoiceHeaderSize] == 0xAB);
}
static void test_udp_binding_packet() {
uint8_t token[16];
for (int i = 0; i < 16; ++i) token[i] = static_cast<uint8_t>(i);
auto pkt = make_udp_binding_packet(token, 16);
CHECK(pkt.size() == kVoiceHeaderSize + 16);
CHECK(pkt[0] == kFrameUdpBinding);
CHECK(std::memcmp(pkt.data() + kVoiceHeaderSize, token, 16) == 0);
}
static void test_parse_too_short() {
uint8_t buf[10] = {};
VoiceFrame out{};
CHECK(!parse_header(buf, 10, out));
}
static void test_big_endian_layout() {
VoiceFrame f;
f.ssrc = 0x01020304;
f.seq = 0x05060708090A0B0CULL;
f.timestamp = 0x0D0E0F10;
uint8_t buf[kVoiceHeaderSize];
serialize_header(f, buf);
// ssrc at [4..7]
CHECK(buf[4] == 0x01 && buf[5] == 0x02 && buf[6] == 0x03 && buf[7] == 0x04);
// seq (u64) at [8..15]
CHECK(buf[8] == 0x05 && buf[9] == 0x06 && buf[10] == 0x07 && buf[11] == 0x08 &&
buf[12] == 0x09 && buf[13] == 0x0A && buf[14] == 0x0B && buf[15] == 0x0C);
// timestamp at [16..19]
CHECK(buf[16] == 0x0D && buf[17] == 0x0E && buf[18] == 0x0F && buf[19] == 0x10);
}
int main() {
test_header_round_trip();
test_empty_payload_packet();
test_payload_packet();
test_udp_binding_packet();
test_parse_too_short();
test_big_endian_layout();
if (g_failures == 0) {
std::printf("voice_frame: all tests passed\n");
return 0;
}
std::printf("voice_frame: %d test(s) FAILED\n", g_failures);
return 1;
}