cheatah
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) behind
5// 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-cert
7// verify) plus this module's from-scratch RFC 6455 framing. No WebSocket or TLS
8// protocol code is mirrored in our tree — `ws` frames, Node does TLS, openssl mints
9// 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)
22namespace sock = cheatah::socket;
23namespace ws = cheatah::websocket;
25#ifndef PURR_TEST_TMP
26#define PURR_TEST_TMP "."
27#endif
28#ifndef WEBSOCKET_FIXTURE_DIR
29#define WEBSOCKET_FIXTURE_DIR "."
30#endif
31#ifndef NODE_EXECUTABLE
32#define NODE_EXECUTABLE "node"
33#endif
35namespace {
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 the
39// Node `ws` wss echo server (tests/fixtures/wss_echo_server.js) on @p port. The echo
40// server is the reference RFC 6455 peer; cheatah's client is what's under test. Waits
41// 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 path
43class WssEchoServer {
44public:
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 trust
56 // 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 under
70 // a machine that may be running the whole sanitizer suite in parallel, and the wait
71 // costs nothing when the server is quick (the loop exits on the marker). At ~5s this
72 // 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 }
90private:
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 can
106// 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.
108void 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.";
120} // namespace
122// 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.
124TEST(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);
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).
141TEST(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 field
149 EXPECT_EQ(ws::send_text(s, msg), 1000);
150 EXPECT_EQ(ws::recv(s), msg);
151 EXPECT_EQ(ws::close(s), 0);
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.
156TEST(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 field
164 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);
171// The RAII Client guard round trip: open_url / send_text / recv / is_open / id, then
172// move-construct, move-assign, shutdown and explicit close — mirrors tls's ConnGuard test.
173TEST(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 ownership
185 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: closed
191 EXPECT_FALSE(sink.is_open());
192 sink = std::move(active); // move-assign onto a closed guard
193 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 closed
201// The RAII open(host, port, path, server_name) form (guarded low-level connect), and
202// a move-assign that closes a still-OPEN session first (the operator=(&&) close branch).
203TEST(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 session
214 ASSERT_TRUE(b.is_open());
215 b = std::move(a); // b was open -> operator=(&&) must close b's old session first
216 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.
223// The server-initiated clean close: sending "close" makes the ws server send a real
224// RFC 6455 close frame. cheatah's recv sees opcode 0x8, echoes a close frame back, marks
225// the session closed and returns "" (EOF). A SECOND recv returns "" via the s->closed
226// short-circuit. Exercises recv's close-frame branch AND the closed-session fast path.
227TEST(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 handshake
237 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 teardown
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 0xA
244// branches, which the caller never sees.
245TEST(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 internally
254 EXPECT_EQ(ws::close(s), 0);
257// The fragmentation/continuation path: the server sends a message as two frames (text
258// fin=false + continuation fin=true) via ws's own framer; recv reassembles them into a
259// single message. Exercises the opcode 0x0 continuation branch and the reassembly buffer.
260TEST(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);
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 a
274// server->client frame (no frame bytes are hand-written in our tree) so recv's masked
275// branch (mask key read + mask_into) is exercised against the real library's masker.
276TEST(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 frame
285 EXPECT_EQ(ws::close(s), 0);
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).
290TEST(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);
306// The upgrade-response error paths against a REAL TLS peer (Node's built-in tls) that
307// completes the handshake but never sends a valid upgrade: "drop" closes the socket with
308// no response (connection closed during upgrade, 165-167), "flood" writes >64 KiB with no
309// blank line (upgrade response too large / not a WebSocket server, 171-174).
310TEST(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);
317TEST(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);
324// connect_url rejects a non-wss scheme, and accepts the no-port / no-path URL forms
325// (default port 443, default path "/") — exercising connect_url's parsing branches
326// even though the default-port connect then fails (no server on 443 here).
327TEST(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 parsing
333 // branches (colon==npos, slash==npos) run before the failing connect.
334 EXPECT_THROW(ws::connect_url("wss://127.0.0.1"), std::runtime_error);
337// The upgrade-refusal path: a peer that completes TLS but answers the HTTP upgrade
338// 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.
340TEST(WebSocketSys, RefusesNon101) {
341 require_node();
342 // A plain HTTPS server (no WebSocketServer): completes TLS, but answers the upgrade
343 // 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);