Files
voice-cat/tests/test_frame_codec.cpp
Talon 63f457fc54 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>
2026-06-15 23:48:44 +02:00

151 lines
4.7 KiB
C++

/*
* 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;
}