feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode
Core ABI extensions (voicecat.h): - vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads - VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password - VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_ until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519) - vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only - VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate - vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores it atomically so the audio RT path reads without a lock C++ implementation: - SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id, password_protected, and max_users (were permanently zeroed) - TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS - TofuStore split into peek (read-only) + pin (write) so first-connect only persists after user approval; tofu_store_path in vc_config for per-user pin file location - TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows) - windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests - New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green) Windows client (clients/windows/ — .NET 10 WinForms): - VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks, Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer - VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity- Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode, per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar) - PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation) - PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams - Accessibility: explicit AccessibleName/Description on every control, & mnemonics, Activity log ListBox as durable screen-reader record, AutomationNotification for curated live announcements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -95,4 +95,22 @@ if(VOICECAT_USE_VCPKG_DEPS)
|
||||
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)
|
||||
endif()
|
||||
|
||||
334
tests/test_channel_user_list_abi.cpp
Normal file
334
tests/test_channel_user_list_abi.cpp
Normal file
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* 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>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#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;
|
||||
|
||||
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_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));
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
|
||||
int main() {
|
||||
std::printf("channel_user_list_abi: SKIP (VOICECAT_HAS_NET not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
@@ -42,6 +42,10 @@ struct EventStore {
|
||||
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) {
|
||||
@@ -49,6 +53,10 @@ static void on_event(void* user, const vc_event* ev) {
|
||||
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);
|
||||
@@ -171,6 +179,7 @@ int main() {
|
||||
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);
|
||||
@@ -190,6 +199,7 @@ int main() {
|
||||
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);
|
||||
|
||||
@@ -62,12 +62,20 @@ struct EventStore {
|
||||
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;
|
||||
@@ -176,6 +184,7 @@ int main() {
|
||||
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);
|
||||
@@ -189,6 +198,7 @@ int main() {
|
||||
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);
|
||||
|
||||
385
tests/test_tofu_flow.cpp
Normal file
385
tests/test_tofu_flow.cpp
Normal file
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
* 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>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
|
||||
int main() {
|
||||
std::printf("tofu_flow: SKIP (VOICECAT_HAS_NET not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
@@ -62,12 +62,20 @@ struct EventStore {
|
||||
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;
|
||||
@@ -273,6 +281,7 @@ static void test_vad_and_ptt_gate() {
|
||||
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));
|
||||
@@ -284,6 +293,7 @@ static void test_vad_and_ptt_gate() {
|
||||
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));
|
||||
|
||||
@@ -43,12 +43,20 @@ struct EventStore {
|
||||
|
||||
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;
|
||||
@@ -144,6 +152,7 @@ int main() {
|
||||
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);
|
||||
@@ -157,6 +166,7 @@ int main() {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user