Source
tests/purrc/websocket_sys_test.cpp
1
// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).2
// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.3
// System tests for the `websocket` module: a REAL RFC 6455 WebSocket handshake +4
// echo against Node's `ws` library (the reference WebSocket implementation) behind5
// Node's built-in TLS — test infrastructure only. The client side is pure cheatah:6
// the from-scratch tls 1.3 client (x25519, ChaCha20-Poly1305, HKDF, leaf-cert7
// verify) plus this module's from-scratch RFC 6455 framing. No WebSocket or TLS8
// protocol code is mirrored in our tree — `ws` frames, Node does TLS, openssl mints9
// the cert. This mirrors tls_sys_test.cpp's OpensslServer pattern.10
#include <chrono>11
#include <cstdio>12
#include <cstdlib>13
#include <string>14
#include <thread>16
#include <gtest/gtest.h>18
#include "socket.hpp"19
#include "websocket.hpp"20
#include "websocket_lowlevel.hpp" // this C++ test drives the raw handle API (hidden from cheatah)22
namespace sock = cheatah::socket;23
namespace ws = cheatah::websocket;25
#ifndef PURR_TEST_TMP26
#define PURR_TEST_TMP "."27
#endif28
#ifndef WEBSOCKET_FIXTURE_DIR29
#define WEBSOCKET_FIXTURE_DIR "."30
#endif31
#ifndef NODE_EXECUTABLE32
#define NODE_EXECUTABLE "node"33
#endif35
namespace {37
// Generate a throwaway self-signed leaf cert (@p newkey selects the key algorithm —38
// "ec -pkeyopt ec_paramgen_curve:prime256v1", "rsa:2048", "ed25519") and launch the39
// Node `ws` wss echo server (tests/fixtures/wss_echo_server.js) on @p port. The echo40
// server is the reference RFC 6455 peer; cheatah's client is what's under test. Waits41
// for the server's "READY" marker (bound), then is pkill'd in the destructor.42
// @complexity O(1) (two subprocesses) @alloc the command strings + a ready-file path43
class WssEchoServer {44
public:45
explicit WssEchoServer(long long port,46
const std::string& newkey = "ec -pkeyopt ec_paramgen_curve:prime256v1",47
const std::string& mode = "")48
: port_(port) {49
const std::string tmp = PURR_TEST_TMP;50
const std::string tag = std::to_string(port_);51
cert_ = tmp + "/ws_test_cert_" + tag + ".pem";52
key_ = tmp + "/ws_test_key_" + tag + ".pem";53
ready_ = tmp + "/ws_test_ready_" + tag + ".log";54
std::remove(ready_.c_str());55
// Self-signed with a SAN so the cheatah client can authenticate it as its own trust56
// anchor (passed as ca_file) — the TLS client now validates the certificate by default.57
const std::string gen = "openssl req -x509 -newkey " + newkey + " -keyout '" + key_ +58
"' -out '" + cert_ +59
"' -days 2 -nodes -subj /CN=localhost "60
"-addext subjectAltName=DNS:localhost 2>/dev/null";61
cert_ok_ = std::system(gen.c_str()) == 0;62
if (!cert_ok_) return;63
const std::string script = std::string(WEBSOCKET_FIXTURE_DIR) + "/wss_echo_server.js";64
const std::string serve = "'" + std::string(NODE_EXECUTABLE) + "' '" + script + "' '" +65
cert_ + "' '" + key_ + "' " + tag + " " + mode + " >'" +66
ready_ + "' 2>&1 &";67
node_ok_ = std::system(serve.c_str()) == 0;68
// Wait (up to ~20s) for the server to print READY, i.e. it has bound the port.69
// The budget is deliberately generous: this waits on a Node process starting under70
// a machine that may be running the whole sanitizer suite in parallel, and the wait71
// costs nothing when the server is quick (the loop exits on the marker). At ~5s this72
// reported a spurious "could not start node ws echo server" under gate load.73
for (int i = 0; i < 400; ++i) {74
if (ready_marker_seen()) { bound_ = true; break; }75
std::this_thread::sleep_for(std::chrono::milliseconds(50));76
}77
}78
~WssEchoServer() {79
const std::string kill =80
"pkill -f 'wss_echo_server.js .* " + std::to_string(port_) + "'";81
std::system(kill.c_str());82
}83
[[nodiscard]] bool ok() const { return cert_ok_ && node_ok_ && bound_; }84
[[nodiscard]] long long port() const { return port_; }85
[[nodiscard]] const std::string& cert_path() const { return cert_; }86
[[nodiscard]] std::string url() const {87
return "wss://localhost:" + std::to_string(port_) + "/";88
}90
private:91
[[nodiscard]] bool ready_marker_seen() const {92
std::FILE* f = std::fopen(ready_.c_str(), "rb");93
if (f == nullptr) return false;94
char buf[256];95
const std::size_t n = std::fread(buf, 1, sizeof(buf) - 1, f);96
std::fclose(f);97
buf[n] = '\0';98
return std::string(buf).find("READY") != std::string::npos;99
}100
long long port_;101
std::string cert_, key_, ready_;102
bool cert_ok_ = false, node_ok_ = false, bound_ = false;103
};105
// node + the ws library must be present: the websocket handshake/framing paths can106
// only be exercised against a real peer, so (like the tls tests requiring openssl)107
// this is an accepted coverage-gate dependency. Fails loudly if the infra is missing.108
void require_node() {109
static const bool has_node = [] {110
const std::string check = "command -v '" + std::string(NODE_EXECUTABLE) +111
"' >/dev/null 2>&1 && test -d '" +112
std::string(WEBSOCKET_FIXTURE_DIR) + "/node_modules/ws'";113
return std::system(check.c_str()) == 0;114
}();115
ASSERT_TRUE(has_node)116
<< "node + the `ws` package are required test infrastructure (run: cd tests/fixtures "117
"&& npm install ws). Coverage runs require them.";118
}120
} // namespace122
// The full path: TLS 1.3 handshake + RFC 6455 upgrade + a text echo round trip +123
// clean close, against the real Node `ws` server. This is the @systest anchor.124
TEST(WebSocketSys, EchoRoundTrip) {125
require_node();126
WssEchoServer server(48951);127
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";129
const long long s = ws::connect_url(server.url(), false, server.cert_path());130
ASSERT_GE(s, 0);131
EXPECT_EQ(ws::send_text(s, "hello"), 5);132
EXPECT_EQ(ws::recv(s), "hello");133
// A second round trip on the same session (exercises the reused read buffer at steady state).134
EXPECT_EQ(ws::send_text(s, "world"), 5);135
EXPECT_EQ(ws::recv(s), "world");136
EXPECT_EQ(ws::close(s), 0);137
}139
// The low-level connect(host, port, path, server_name) entry point (not the URL form),140
// plus a medium message that forces the 16-bit extended length header (payload > 125).141
TEST(WebSocketSys, ConnectAndExtendedLength16) {142
require_node();143
WssEchoServer server(48952);144
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";146
const long long s = ws::connect("localhost", server.port(), "/", "localhost", false, server.cert_path());147
ASSERT_GE(s, 0);148
const std::string msg(1000, 'x'); // > 125 and <= 0xFFFF -> 2-byte length field149
EXPECT_EQ(ws::send_text(s, msg), 1000);150
EXPECT_EQ(ws::recv(s), msg);151
EXPECT_EQ(ws::close(s), 0);152
}154
// A large message that forces the 64-bit extended length header (payload > 0xFFFF),155
// exercising put_header's 8-byte length branch on send and recv's len==127 branch.156
TEST(WebSocketSys, ExtendedLength64) {157
require_node();158
WssEchoServer server(48953);159
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";161
const long long s = ws::connect_url(server.url(), false, server.cert_path());162
ASSERT_GE(s, 0);163
const std::string msg(70000, 'Z'); // > 0xFFFF -> 8-byte length field164
EXPECT_EQ(ws::send_text(s, msg), 70000);165
// ws MAY deliver a large echo as a single frame; recv reassembles regardless.166
std::string got = ws::recv(s);167
EXPECT_EQ(got, msg);168
EXPECT_EQ(ws::close(s), 0);169
}171
// The RAII Client guard round trip: open_url / send_text / recv / is_open / id, then172
// move-construct, move-assign, shutdown and explicit close — mirrors tls's ConnGuard test.173
TEST(WebSocketSys, ClientGuardRoundTrip) {174
require_node();175
WssEchoServer server(48954);176
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";178
ws::Client c = ws::open_url(server.url(), false, server.cert_path());179
ASSERT_TRUE(c.is_open());180
EXPECT_GT(c.id(), 0);181
EXPECT_EQ(c.send_text("guarded"), 7);182
EXPECT_EQ(c.recv(), "guarded");184
ws::Client active(std::move(c)); // move-construct: transfer ownership185
EXPECT_FALSE(c.is_open());186
EXPECT_TRUE(active.is_open());187
EXPECT_EQ(active.send_text("moved"), 5);188
EXPECT_EQ(active.recv(), "moved");190
ws::Client sink; // default-constructed: closed191
EXPECT_FALSE(sink.is_open());192
sink = std::move(active); // move-assign onto a closed guard193
EXPECT_FALSE(active.is_open());194
EXPECT_TRUE(sink.is_open());196
EXPECT_EQ(sink.shutdown(), 0); // half-close the socket (wake any reader)197
EXPECT_EQ(sink.close(), 0);198
EXPECT_EQ(sink.close(), -1); // idempotent: already closed199
}201
// The RAII open(host, port, path, server_name) form (guarded low-level connect), and202
// a move-assign that closes a still-OPEN session first (the operator=(&&) close branch).203
TEST(WebSocketSys, ClientOpenAndMoveAssignClosesOpen) {204
require_node();205
WssEchoServer server(48955);206
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";208
ws::Client a = ws::open("localhost", server.port(), "/", "localhost", false, server.cert_path());209
ASSERT_TRUE(a.is_open());210
EXPECT_EQ(a.send_text("a"), 1);211
EXPECT_EQ(a.recv(), "a");213
ws::Client b = ws::open_url(server.url(), false, server.cert_path()); // a SECOND open session214
ASSERT_TRUE(b.is_open());215
b = std::move(a); // b was open -> operator=(&&) must close b's old session first216
EXPECT_FALSE(a.is_open());217
EXPECT_TRUE(b.is_open());218
EXPECT_EQ(b.send_text("still-alive"), 11);219
EXPECT_EQ(b.recv(), "still-alive");220
// b's destructor closes the surviving session here.221
}223
// The server-initiated clean close: sending "close" makes the ws server send a real224
// RFC 6455 close frame. cheatah's recv sees opcode 0x8, echoes a close frame back, marks225
// the session closed and returns "" (EOF). A SECOND recv returns "" via the s->closed226
// short-circuit. Exercises recv's close-frame branch AND the closed-session fast path.227
TEST(WebSocketSys, ServerCloseYieldsEmptyRecv) {228
require_node();229
WssEchoServer server(48956);230
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";232
const long long s = ws::connect_url(server.url(), false, server.cert_path());233
ASSERT_GE(s, 0);234
EXPECT_EQ(ws::send_text(s, "ping-echo"), 9);235
EXPECT_EQ(ws::recv(s), "ping-echo");236
ws::send_text(s, "close"); // ask the server for a clean close handshake237
EXPECT_EQ(ws::recv(s), ""); // opcode 0x8 -> echo close, mark closed, return ""238
EXPECT_EQ(ws::recv(s), ""); // s->closed short-circuit (no I/O)239
EXPECT_EQ(ws::close(s), 0); // close() after a peer close: !closed is false, just teardown240
}242
// The control-frame paths: a server-initiated ping (recv answers pong transparently),243
// a server pong (ignored), then a real message. Exercises recv's opcode 0x9 and 0xA244
// branches, which the caller never sees.245
TEST(WebSocketSys, PingPongTransparent) {246
require_node();247
WssEchoServer server(48958);248
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";250
const long long s = ws::connect_url(server.url(), false, server.cert_path());251
ASSERT_GE(s, 0);252
ws::send_text(s, "ping"); // server: ping, then pong, then "after-ping"253
EXPECT_EQ(ws::recv(s), "after-ping"); // ping->pong + pong-ignore handled internally254
EXPECT_EQ(ws::close(s), 0);255
}257
// The fragmentation/continuation path: the server sends a message as two frames (text258
// fin=false + continuation fin=true) via ws's own framer; recv reassembles them into a259
// single message. Exercises the opcode 0x0 continuation branch and the reassembly buffer.260
TEST(WebSocketSys, ReassemblesFragmentedMessage) {261
require_node();262
WssEchoServer server(48959);263
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";265
const long long s = ws::connect_url(server.url(), false, server.cert_path());266
ASSERT_GE(s, 0);267
ws::send_text(s, "frag");268
EXPECT_EQ(ws::recv(s), "frag-one|frag-two");269
EXPECT_EQ(ws::close(s), 0);270
}272
// The defensive server-masked-frame path: RFC 6455 §5.1 forbids a server from masking,273
// but the cheatah client unmasks defensively anyway. We ask ws's OWN sender to mask a274
// server->client frame (no frame bytes are hand-written in our tree) so recv's masked275
// branch (mask key read + mask_into) is exercised against the real library's masker.276
TEST(WebSocketSys, UnmasksMaskedServerFrame) {277
require_node();278
WssEchoServer server(48960);279
ASSERT_TRUE(server.ok()) << "could not start node ws echo server (test infrastructure)";281
const long long s = ws::connect_url(server.url(), false, server.cert_path());282
ASSERT_GE(s, 0);283
ws::send_text(s, "masked");284
EXPECT_EQ(ws::recv(s), "masked-ok"); // recv must unmask the (irregular) masked frame285
EXPECT_EQ(ws::close(s), 0);286
}288
// TLS handshake failure: connect to a peer that accepts TCP but is not a TLS server,289
// so tls::client_connect fails -> connect() must throw and close the fd (142-144).290
TEST(WebSocketSys, RefusesTlsHandshakeFailure) {291
const long long listen_fd = sock::tcp_listen("127.0.0.1", 0, 4);292
ASSERT_GE(listen_fd, 0);293
const long long port = sock::local_port(listen_fd);294
std::thread peer([listen_fd]() {295
const long long client = sock::accept(listen_fd);296
if (client >= 0) {297
sock::sendall(client, "definitely not a TLS server\r\n");298
sock::close(client);299
}300
});301
EXPECT_THROW(ws::connect("127.0.0.1", port, "/", "localhost"), std::runtime_error);302
peer.join();303
sock::close(listen_fd);304
}306
// The upgrade-response error paths against a REAL TLS peer (Node's built-in tls) that307
// completes the handshake but never sends a valid upgrade: "drop" closes the socket with308
// no response (connection closed during upgrade, 165-167), "flood" writes >64 KiB with no309
// blank line (upgrade response too large / not a WebSocket server, 171-174).310
TEST(WebSocketSys, RefusesClosedDuringUpgrade) {311
require_node();312
WssEchoServer server(48961, "ec -pkeyopt ec_paramgen_curve:prime256v1", "drop");313
ASSERT_TRUE(server.ok()) << "could not start node tls drop server (test infrastructure)";314
EXPECT_THROW(ws::connect("localhost", server.port(), "/", "localhost", false, server.cert_path()), std::runtime_error);315
}317
TEST(WebSocketSys, RefusesOversizeUpgradeResponse) {318
require_node();319
WssEchoServer server(48962, "ec -pkeyopt ec_paramgen_curve:prime256v1", "flood");320
ASSERT_TRUE(server.ok()) << "could not start node tls flood server (test infrastructure)";321
EXPECT_THROW(ws::connect("localhost", server.port(), "/", "localhost", false, server.cert_path()), std::runtime_error);322
}324
// connect_url rejects a non-wss scheme, and accepts the no-port / no-path URL forms325
// (default port 443, default path "/") — exercising connect_url's parsing branches326
// even though the default-port connect then fails (no server on 443 here).327
TEST(WebSocketSys, ConnectUrlParsingBranches) {328
// Non-wss scheme -> immediate throw (no network).329
EXPECT_THROW(ws::connect_url("ws://localhost/"), std::runtime_error);330
EXPECT_THROW(ws::connect_url("https://localhost/"), std::runtime_error);331
// wss with NO explicit port and NO path: default port 443, default path "/".332
// The connect then fails (nothing on 443), which is expected — the parsing333
// branches (colon==npos, slash==npos) run before the failing connect.334
EXPECT_THROW(ws::connect_url("wss://127.0.0.1"), std::runtime_error);335
}337
// The upgrade-refusal path: a peer that completes TLS but answers the HTTP upgrade338
// with something OTHER than 101 must be rejected ("server did not switch protocols").339
// A plain https server (no `ws`) returns a normal HTTP status, exercising that branch.340
TEST(WebSocketSys, RefusesNon101) {341
require_node();342
// A plain HTTPS server (no WebSocketServer): completes TLS, but answers the upgrade343
// GET with HTTP 200 instead of 101 Switching Protocols.344
WssEchoServer server(48957, "ec -pkeyopt ec_paramgen_curve:prime256v1", "plain");345
ASSERT_TRUE(server.ok()) << "could not start node https server (test infrastructure)";346
// cheatah must reject: "server did not switch protocols".347
EXPECT_THROW(348
{349
const long long s = ws::connect("localhost", server.port(), "/", "localhost", false, server.cert_path());350
(void)s;351
},352
std::runtime_error);353
}