feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text

Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.

Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green

Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:48:44 +02:00
parent b332b0972b
commit 63f457fc54
42 changed files with 4180 additions and 190 deletions

View File

@@ -1,8 +1,42 @@
# Tests use plain asserts + exit codes for now (no framework dependency in the skeleton).
# A real framework (e.g. Catch2/GoogleTest via vcpkg) can be added when VOICECAT_USE_VCPKG_DEPS
# is on. Behavior tests — not just "it compiles" — are how milestones are judged (AGENTS.md).
# 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)
# frame_codec has no third-party deps; runs under both dev and m1-dev.
# 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)
if(VOICECAT_USE_VCPKG_DEPS)
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)
endif()

86
tests/test_envelope.cpp Normal file
View File

@@ -0,0 +1,86 @@
/*
* test_envelope — round-trip an Envelope through FrameCodec + encode/decode.
* Runs only under m1-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;
}

150
tests/test_frame_codec.cpp Normal file
View File

@@ -0,0 +1,150 @@
/*
* test_frame_codec — unit test for FrameCodec::feed / ::emit.
*
* No third-party dependencies; runs under both the dev and m1-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;
}

View File

@@ -0,0 +1,256 @@
/*
* 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>
#ifdef VOICECAT_HAS_NET
#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};
};
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_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);
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);
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;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("m1_integration: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET

View File

@@ -43,10 +43,22 @@ int main() {
CHECK(vc_connect(c, nullptr, 1) == VC_ERR_INVALID_ARG);
CHECK(vc_send_text(c, VC_TEXT_CHANNEL, 0, nullptr) == VC_ERR_INVALID_ARG);
// Unimplemented subsystems report NOT_IMPLEMENTED (not a crash) in the M0 skeleton.
CHECK(vc_connect(c, "127.0.0.1", 8384) == VC_ERR_NOT_IMPLEMENTED);
CHECK(vc_authenticate_guest(c, "nick") == VC_ERR_NOT_IMPLEMENTED);
CHECK(vc_join_channel(c, 1, nullptr) == VC_ERR_NOT_IMPLEMENTED);
// Under dev preset: NOT_IMPLEMENTED. Under m1-dev: VC_OK (async connect).
vc_result rc_connect = vc_connect(c, "127.0.0.1", 8384);
CHECK(rc_connect == VC_ERR_NOT_IMPLEMENTED || rc_connect == VC_OK);
// Auth before connected (or on a stub) → NOT_CONNECTED or NOT_IMPLEMENTED.
{
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_IMPLEMENTED || rc_auth == VC_ERR_NOT_CONNECTED);
vc_client_destroy(c2);
}
// join_channel before connected → NOT_CONNECTED or NOT_IMPLEMENTED.
vc_result rc_join = vc_join_channel(c, 1, nullptr);
CHECK(rc_join == VC_ERR_NOT_IMPLEMENTED || rc_join == VC_ERR_NOT_CONNECTED);
vc_device_list dl;
CHECK(vc_list_devices(c, VC_DEVICE_INPUT, &dl) == VC_ERR_NOT_IMPLEMENTED);

105
tests/test_tcp_loopback.cpp Normal file
View File

@@ -0,0 +1,105 @@
/*
* test_tcp_loopback — in-process TCP acceptor + client, sends 10 frames.
* Runs only under m1-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
tests/test_tls_loopback.cpp Normal file
View File

@@ -0,0 +1,186 @@
/*
* 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;
}