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:
@@ -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
86
tests/test_envelope.cpp
Normal 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
150
tests/test_frame_codec.cpp
Normal 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;
|
||||
}
|
||||
256
tests/test_m1_integration.cpp
Normal file
256
tests/test_m1_integration.cpp
Normal 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
|
||||
@@ -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
105
tests/test_tcp_loopback.cpp
Normal 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
186
tests/test_tls_loopback.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user