cheatah
Source

stdlib/tests/requests_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// In-process unit tests for `requests` — the pure-cheatah HTTP module (requests.hpp,
4// generated from requests.purr). The subprocess e2e suite (tests/purrc/requests_sys_test.cpp)
5// runs the module inside a real cheatah program and does NOT contribute to stdlib coverage;
6// these tests instantiate requests.hpp's templated functions directly and drive them against
7// a real cheatah::socket loopback HTTP server thread, so every line is exercised in-process.
8//
9// The one implementation, one real socket: the "server" here is just a C++ thread on a
10// loopback cheatah::socket replaying scripted HTTP/1.1 bytes — not a second HTTP client.
12#include <atomic>
13#include <string>
14#include <thread>
15#include <vector>
17#include <gtest/gtest.h>
19#include "requests.hpp"
20#include "socket.hpp"
22namespace req = cheatah::requests;
23namespace sk = cheatah::socket;
25namespace {
27// A loopback HTTP server that accepts `responses.size()` sequential connections and
28// replies to each with the corresponding scripted response (reading the request head
29// first). Used for single exchanges and multi-hop redirect chains.
30class LoopbackServer {
31 public:
32 explicit LoopbackServer(std::vector<std::string> responses)
33 : responses_(std::move(responses)) {
34 fd_ = sk::tcp_listen("127.0.0.1", 0, 8);
35 port_ = sk::local_port(fd_);
36 thread_ = std::thread([this] { run(); });
37 }
38 ~LoopbackServer() {
39 stop();
40 }
41 long long port() const { return port_; }
42 std::string url(const std::string& path) const {
43 return "http://127.0.0.1:" + std::to_string(port_) + path;
44 }
45 void stop() {
46 if (fd_ >= 0) {
47 done_ = true;
48 // Wake a thread parked in accept() with a throwaway self-connection —
49 // closing a listening socket does not reliably unblock accept() on Linux.
50 const long long waker = sk::tcp_connect("127.0.0.1", port_);
51 if (waker >= 0) sk::close(waker);
52 if (thread_.joinable()) thread_.join();
53 sk::close(fd_);
54 fd_ = -1;
55 }
56 }
58 // The requests the server received (full head + any Content-Length body), in order.
59 // Safe to read after stop() has joined the thread.
60 const std::vector<std::string>& received() const { return received_; }
61 std::string last_request() const { return received_.empty() ? std::string() : received_.back(); }
63 private:
64 void run() {
65 for (const auto& resp : responses_) {
66 const long long client = sk::accept(fd_);
67 if (client < 0 || done_) {
68 if (client >= 0) sk::close(client);
69 return;
70 }
71 std::string request;
72 while (request.find("\r\n\r\n") == std::string::npos) {
73 const std::string chunk = sk::recv(client, 4096);
74 if (chunk.empty()) break;
75 request += chunk;
76 }
77 // Read the declared body too (so tests can assert method/headers AND body).
78 const std::size_t head_end = request.find("\r\n\r\n");
79 if (head_end != std::string::npos) {
80 const std::size_t clp = request.find("Content-Length:");
81 if (clp != std::string::npos && clp < head_end) {
82 const long long want = std::atoll(request.c_str() + clp + 15);
83 const std::size_t body_start = head_end + 4;
84 while (want > 0 &&
85 static_cast<long long>(request.size() - body_start) < want) {
86 const std::string chunk = sk::recv(client, 4096);
87 if (chunk.empty()) break;
88 request += chunk;
89 }
90 }
91 }
92 received_.push_back(request);
93 sk::sendall(client, resp);
94 sk::close(client);
95 }
96 }
97 std::vector<std::string> responses_;
98 std::vector<std::string> received_;
99 long long fd_ = -1;
100 long long port_ = 0;
101 std::atomic<bool> done_{false};
102 std::thread thread_;
103};
105// Default Options (30 s timeout, 5 redirects) so single-arg get() and option-carrying
106// get() both get exercised.
107req::Options defaults() {
108 return req::Options{.timeout_ms = 30000, .max_redirects = 5};
111} // namespace
113// A plain 200 with Content-Length: status, ok(), body, header() (case-insensitive).
114TEST(CheatahRequests, BasicGetContentLength) {
115 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"});
116 const auto r = req::get(s.url("/greeting"));
117 EXPECT_EQ(r.status_code, 200);
118 EXPECT_TRUE(r.ok());
119 EXPECT_EQ(r.body, "hello");
120 EXPECT_EQ(r.error, "");
121 EXPECT_EQ(r.header(std::string("CONTENT-TYPE")), "text/plain"); // lowercased key lookup
122 EXPECT_EQ(r.header(std::string("X-Missing")), "");
125// A 404 is a completed exchange: ok() false but error empty.
126TEST(CheatahRequests, NotFoundIsCompleted) {
127 LoopbackServer s({"HTTP/1.1 404 Not Found\r\nContent-Length: 4\r\n\r\nnope"});
128 const auto r = req::get(s.url("/missing"), defaults());
129 EXPECT_EQ(r.status_code, 404);
130 EXPECT_FALSE(r.ok());
131 EXPECT_EQ(r.error, "");
132 EXPECT_EQ(r.body, "nope");
135// No Content-Length and no chunked framing: the body runs to connection close.
136TEST(CheatahRequests, EofFramedBody) {
137 LoopbackServer s({"HTTP/1.1 200 OK\r\n\r\nuntil the very end"});
138 const auto r = req::get(s.url("/"));
139 EXPECT_TRUE(r.ok());
140 EXPECT_EQ(r.body, "until the very end");
143// Chunked transfer-encoding: hex sizes, a chunk extension (`;`), then the 0 terminator.
144TEST(CheatahRequests, ChunkedBody) {
145 LoopbackServer s({"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
146 "4\r\nWiki\r\n5;ext=1\r\npedia\r\n0\r\n\r\n"});
147 const auto r = req::get(s.url("/"));
148 EXPECT_TRUE(r.ok());
149 EXPECT_EQ(r.body, "Wikipedia");
152// A malformed chunk size (non-hex digit) is reported as an error.
153TEST(CheatahRequests, ChunkedMalformedSize) {
154 LoopbackServer s({"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nZZ\r\noops\r\n0\r\n\r\n"});
155 const auto r = req::get(s.url("/"));
156 EXPECT_NE(r.error, "");
159// Chunked framing truncated before the declared chunk bytes -> error.
160TEST(CheatahRequests, ChunkedTruncatedBody) {
161 LoopbackServer s({"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nFF\r\nshort"});
162 const auto r = req::get(s.url("/"));
163 EXPECT_NE(r.error, "");
166// Chunked stream that closes before any CRLF-terminated size line -> error.
167TEST(CheatahRequests, ChunkedClosedInsideSizeLine) {
168 LoopbackServer s({"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4"});
169 const auto r = req::get(s.url("/"));
170 EXPECT_NE(r.error, "");
173// Query params are appended percent-encoded; existing '?' in the target uses '&'.
174TEST(CheatahRequests, QueryParamsPercentEncoded) {
175 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"});
176 auto o = defaults();
177 o.params["a b"] = "c/d"; // space and slash must be percent-encoded
178 const auto r = req::get(s.url("/search?x=1"), o);
179 EXPECT_TRUE(r.ok());
180 EXPECT_EQ(r.body, "ok");
183// CRLF INJECTION: a request is a CRLF-framed message built by concatenation, so a CR or LF reaching the
184// request-target or a header value lets whoever supplied it forge headers or split the request outright.
185// That is not hypothetical for a caller that fetches URLs found in documents rather than written in
186// source — a scraper following a `Location` header or an API's `thumb_url` is handed attacker data.
187// The request must be REFUSED before a byte reaches the socket, not sanitised into something plausible.
188TEST(CheatahRequests, CrlfInjectionRefused) {
189 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"});
191 // In the target: the classic smuggled second request line.
192 {
193 auto r = req::get(s.url("/a\r\nX-Injected: 1"), defaults());
194 EXPECT_FALSE(r.ok());
195 EXPECT_NE(r.error.find("control bytes"), std::string::npos) << r.error;
196 }
197 // A bare LF is enough on a lenient peer.
198 {
199 auto r = req::get(s.url("/a\nX-Injected: 1"), defaults());
200 EXPECT_FALSE(r.ok());
201 }
202 // In a header VALUE.
203 {
204 auto o = defaults();
205 o.headers["X-Test"] = "1\r\nX-Injected: 1";
206 auto r = req::get(s.url("/"), o);
207 EXPECT_FALSE(r.ok());
208 EXPECT_NE(r.error.find("control bytes"), std::string::npos) << r.error;
209 }
210 // In a header NAME.
211 {
212 auto o = defaults();
213 o.headers["X-Test\r\nX-Injected"] = "1";
214 auto r = req::get(s.url("/"), o);
215 EXPECT_FALSE(r.ok());
216 }
219// ...and the guard must not cost an honest request: a percent-ENCODED CRLF is the correct way to put
220// those bytes in a URL and must still be sent.
221TEST(CheatahRequests, PercentEncodedCrlfIsStillSent) {
222 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"});
223 const auto r = req::get(s.url("/a%0D%0Ab"), defaults());
224 EXPECT_TRUE(r.ok()) << r.error;
225 EXPECT_EQ(r.body, "ok");
228// Custom headers are sent; a caller-supplied User-Agent suppresses the default.
229TEST(CheatahRequests, CustomHeaders) {
230 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi"});
231 auto o = defaults();
232 o.headers["X-Test"] = "1";
233 o.headers["User-Agent"] = "mine/1.0";
234 const auto r = req::get(s.url("/"), o);
235 EXPECT_TRUE(r.ok());
238// A single redirect (302) with an absolute Location is followed to the final 200.
239// Two hops on the SAME server: /a -> absolute URL /b -> 200. The server is bound
240// first so its real port can be embedded in the redirect target.
241TEST(CheatahRequests, RedirectAbsolute) {
242 const long long fd = sk::tcp_listen("127.0.0.1", 0, 8);
243 ASSERT_GE(fd, 0);
244 const long long port = sk::local_port(fd);
245 const std::string base = "http://127.0.0.1:" + std::to_string(port);
246 std::thread server([fd, base] {
247 const std::vector<std::string> responses = {
248 "HTTP/1.1 302 Found\r\nLocation: " + base + "/b\r\nContent-Length: 0\r\n\r\n",
249 "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndone"};
250 for (const auto& resp : responses) {
251 const long long client = sk::accept(fd);
252 if (client < 0) return;
253 std::string request;
254 while (request.find("\r\n\r\n") == std::string::npos) {
255 const std::string chunk = sk::recv(client, 4096);
256 if (chunk.empty()) break;
257 request += chunk;
258 }
259 sk::sendall(client, resp);
260 sk::close(client);
261 }
262 });
263 const auto r = req::get(base + "/a");
264 EXPECT_TRUE(r.ok());
265 EXPECT_EQ(r.body, "done");
266 server.join();
267 sk::close(fd);
270// A redirect with a host-relative Location ("/next") is resolved against scheme/host/port.
271TEST(CheatahRequests, RedirectRelative) {
272 LoopbackServer srv({"HTTP/1.1 301 Moved\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n",
273 "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"});
274 const auto r = req::get(srv.url("/start"));
275 EXPECT_TRUE(r.ok());
276 EXPECT_EQ(r.body, "ok");
279// A redirect whose Location is neither absolute nor root-relative is unsupported.
280TEST(CheatahRequests, RedirectUnsupportedRelative) {
281 LoopbackServer srv({"HTTP/1.1 307 Temporary Redirect\r\nLocation: sideways\r\nContent-Length: 0\r\n\r\n"});
282 const auto r = req::get(srv.url("/x"));
283 EXPECT_NE(r.error, "");
284 EXPECT_FALSE(r.ok());
287// A 3xx without any Location header is an error.
288TEST(CheatahRequests, RedirectMissingLocation) {
289 LoopbackServer srv({"HTTP/1.1 308 Permanent Redirect\r\nContent-Length: 0\r\n\r\n"});
290 const auto r = req::get(srv.url("/x"));
291 EXPECT_NE(r.error, "");
294// A redirect loop exhausts max_redirects and returns the "too many redirects" error.
295TEST(CheatahRequests, RedirectLoopExhausts) {
296 // A server that always redirects to itself; max_redirects = 1 caps it quickly.
297 std::vector<std::string> loop;
298 for (int i = 0; i < 6; ++i)
299 loop.push_back("HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n");
300 LoopbackServer srv(std::move(loop));
301 auto o = defaults();
302 o.max_redirects = 1;
303 const auto r = req::get(srv.url("/loop"), o);
304 EXPECT_NE(r.error, "");
305 EXPECT_EQ(r.status_code, 0);
308// Zero/negative option fields fall back to documented defaults (timeout 30 s, 5 hops).
309TEST(CheatahRequests, OptionDefaultsApplied) {
310 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nz"});
311 req::Options o{}; // all-zero -> defaults kick in
312 const auto r = req::get(s.url("/"), o);
313 EXPECT_TRUE(r.ok());
316// A malformed URL never connects: error set, status 0.
317TEST(CheatahRequests, MalformedUrl) {
318 const auto r = req::get(std::string("not a url"));
319 EXPECT_NE(r.error, "");
320 EXPECT_EQ(r.status_code, 0);
323// A refused connection (nothing listening on port 9) comes back as a transport error.
324TEST(CheatahRequests, ConnectionRefused) {
325 const auto r = req::get(std::string("http://127.0.0.1:9/"));
326 EXPECT_NE(r.error, "");
329// A response head with no HTTP/ prefix is malformed.
330TEST(CheatahRequests, MalformedResponseHead) {
331 LoopbackServer s({"GARBAGE / not http\r\n\r\nbody"});
332 const auto r = req::get(s.url("/"));
333 EXPECT_NE(r.error, "");
336// A connection that closes before a complete head (\r\n\r\n) is an error.
337TEST(CheatahRequests, IncompleteHead) {
338 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n"}); // no blank line
339 const auto r = req::get(s.url("/"));
340 EXPECT_NE(r.error, "");
343// A status line too short to hold a 3-digit code is malformed.
344TEST(CheatahRequests, MalformedStatusLine) {
345 LoopbackServer s({"HTTP/1.1\r\nContent-Length: 0\r\n\r\n"}); // no space + code
346 const auto r = req::get(s.url("/"));
347 EXPECT_NE(r.error, "");
350// Content-Length larger than the body actually received -> error.
351TEST(CheatahRequests, ContentLengthUnderrun) {
352 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nonly-a-bit"});
353 const auto r = req::get(s.url("/"));
354 EXPECT_NE(r.error, "");
357// Lowercase hex chunk sizes decode too (parse_hex a-f branch).
358TEST(CheatahRequests, ChunkedLowercaseHexSize) {
359 LoopbackServer s({"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
360 "a\r\n0123456789\r\n0\r\n\r\n"}); // 0xa = 10 bytes
361 const auto r = req::get(s.url("/"));
362 EXPECT_TRUE(r.ok());
363 EXPECT_EQ(r.body, "0123456789");
366// The module's ABI identity marker returns its name.
367TEST(CheatahRequests, ModuleAbiMarker) {
368 EXPECT_STREQ(req::module_abi(), "requests");
371// A peer that accepts then immediately closes (RST) makes the request write fail; the
372// exchange returns a "send failed"/transport error rather than hanging.
373TEST(CheatahRequests, SendFailsToClosedPeer) {
374 const long long fd = sk::tcp_listen("127.0.0.1", 0, 4);
375 ASSERT_GE(fd, 0);
376 const long long port = sk::local_port(fd);
377 std::thread peer([fd] {
378 const long long client = sk::accept(fd);
379 if (client >= 0) sk::close(client); // drop immediately, before reading
380 });
381 // A large custom header forces a big write, so if the peer has gone the send fails.
382 auto o = defaults();
383 o.timeout_ms = 3000;
384 o.headers["X-Big"] = std::string(4 * 1024 * 1024, 'A');
385 const auto r = req::get("http://127.0.0.1:" + std::to_string(port) + "/", o);
386 // Either the send failed or the read saw an immediate EOF — both are non-ok errors,
387 // never a 2xx success against a peer that never answered.
388 EXPECT_FALSE(r.ok());
389 peer.join();
390 sk::close(fd);
393// The https path: connecting a TLS client to a peer that speaks plain bytes fails at
394// the handshake, surfacing a "tls:" error (never a silent success). Exercises the
395// scheme=="https" branch and the tls::client_connect(<0) failure handling in request_once.
396TEST(CheatahRequests, HttpsRefusedByNonTlsPeer) {
397 const long long fd = sk::tcp_listen("127.0.0.1", 0, 4);
398 ASSERT_GE(fd, 0);
399 const long long port = sk::local_port(fd);
400 std::thread peer([fd] {
401 const long long client = sk::accept(fd);
402 if (client >= 0) {
403 sk::sendall(client, "plain text, not TLS\r\n");
404 sk::close(client);
405 }
406 });
407 auto o = defaults();
408 o.timeout_ms = 3000;
409 const auto r = req::get("https://127.0.0.1:" + std::to_string(port) + "/", o);
410 EXPECT_EQ(r.status_code, 0);
411 EXPECT_NE(r.error.find("tls"), std::string::npos);
412 peer.join();
413 sk::close(fd);
416// ---------------------------------------------------------------------------
417// v1.2 surface: verbs, request bodies, auth, richer Response, cookies, history.
418// ---------------------------------------------------------------------------
420// A tiny 200-with-body response the body-carrying verb tests reuse.
421static std::string ok_body(const std::string& b) {
422 return "HTTP/1.1 200 OK\r\nContent-Length: " + std::to_string(b.size()) + "\r\n\r\n" + b;
425// POST with json_body sets the method, application/json Content-Type, and Content-Length,
426// and sends the body verbatim.
427TEST(CheatahRequests, PostJsonBody) {
428 LoopbackServer s({ok_body("done")});
429 auto o = defaults();
430 o.json_body = "{\"side\":\"buy\"}";
431 const auto r = req::post(s.url("/order"), o);
432 s.stop();
433 EXPECT_TRUE(r.ok());
434 const std::string req = s.last_request();
435 EXPECT_EQ(req.rfind("POST /order ", 0), 0u);
436 EXPECT_NE(req.find("Content-Type: application/json\r\n"), std::string::npos);
437 EXPECT_NE(req.find("Content-Length: 14\r\n"), std::string::npos);
438 EXPECT_NE(req.find("\r\n\r\n{\"side\":\"buy\"}"), std::string::npos);
441// POST with form `data` is percent-encoded as application/x-www-form-urlencoded.
442TEST(CheatahRequests, PostFormData) {
443 LoopbackServer s({ok_body("ok")});
444 auto o = defaults();
445 o.data["q"] = "a b"; // space must be percent-encoded in the body
446 const auto r = req::post(s.url("/f"), o);
447 s.stop();
448 EXPECT_TRUE(r.ok());
449 const std::string req = s.last_request();
450 EXPECT_NE(req.find("Content-Type: application/x-www-form-urlencoded\r\n"), std::string::npos);
451 EXPECT_NE(req.find("\r\n\r\nq=a%20b"), std::string::npos);
454// A raw `body` is sent verbatim with no auto Content-Type.
455TEST(CheatahRequests, PostRawBody) {
456 LoopbackServer s({ok_body("ok")});
457 auto o = defaults();
458 o.body = "raw-payload";
459 const auto r = req::post(s.url("/r"), o);
460 s.stop();
461 EXPECT_TRUE(r.ok());
462 const std::string req = s.last_request();
463 EXPECT_NE(req.find("Content-Length: 11\r\n"), std::string::npos);
464 EXPECT_NE(req.find("\r\n\r\nraw-payload"), std::string::npos);
465 EXPECT_EQ(req.find("Content-Type:"), std::string::npos); // none added for a raw body
468// Body precedence: json_body wins over data wins over body.
469TEST(CheatahRequests, BodyPrecedence) {
470 LoopbackServer s({ok_body("ok")});
471 auto o = defaults();
472 o.json_body = "{\"j\":1}";
473 o.data["d"] = "1";
474 o.body = "raw";
475 const auto r = req::put(s.url("/p"), o);
476 s.stop();
477 EXPECT_TRUE(r.ok());
478 EXPECT_NE(s.last_request().find("\r\n\r\n{\"j\":1}"), std::string::npos);
481// POST with no body still sends Content-Length: 0 (so a length-framed server is happy).
482TEST(CheatahRequests, PostEmptyBodyContentLengthZero) {
483 LoopbackServer s({ok_body("ok")});
484 const auto r = req::post(s.url("/e"), defaults());
485 s.stop();
486 EXPECT_TRUE(r.ok());
487 EXPECT_NE(s.last_request().find("Content-Length: 0\r\n"), std::string::npos);
490// GET never carries a body even if one is set in Options.
491TEST(CheatahRequests, GetIgnoresBody) {
492 LoopbackServer s({ok_body("ok")});
493 auto o = defaults();
494 o.json_body = "{\"x\":1}";
495 const auto r = req::get(s.url("/g"), o);
496 s.stop();
497 EXPECT_TRUE(r.ok());
498 const std::string req = s.last_request();
499 EXPECT_EQ(req.rfind("GET /g ", 0), 0u);
500 EXPECT_EQ(req.find("Content-Type:"), std::string::npos);
501 EXPECT_EQ(req.find("{\"x\":1}"), std::string::npos);
504// Each verb sends its own request-line method. Also exercises the single-argument
505// (default-Options) form of every verb, covering their default-Options overloads.
506TEST(CheatahRequests, VerbMethods) {
507 for (const std::string& m : {"GET", "PUT", "PATCH", "DELETE", "OPTIONS"}) {
508 LoopbackServer s({ok_body("x")});
509 const std::string url = s.url("/v");
510 req::Response r;
511 if (m == "GET") r = req::get(url);
512 else if (m == "PUT") r = req::put(url);
513 else if (m == "PATCH") r = req::patch(url);
514 else if (m == "DELETE") r = req::delete_(url);
515 else r = req::options(url);
516 s.stop();
517 EXPECT_TRUE(r.ok()) << m;
518 EXPECT_EQ(s.last_request().rfind(m + " /v ", 0), 0u) << m;
519 }
522// HEAD sends the HEAD method and yields an empty body even when Content-Length is declared.
523TEST(CheatahRequests, HeadNoBody) {
524 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 123\r\n\r\n"}); // no body follows
525 const auto r = req::head(s.url("/h"));
526 s.stop();
527 EXPECT_EQ(r.status_code, 200);
528 EXPECT_EQ(r.body, ""); // HEAD: headers only
529 EXPECT_EQ(s.last_request().rfind("HEAD /h ", 0), 0u);
532// HTTP Basic auth emits the correct `Authorization: Basic <base64>` header.
533TEST(CheatahRequests, BasicAuth) {
534 LoopbackServer s({ok_body("ok")});
535 auto o = defaults();
536 o.auth_user = "user";
537 o.auth_pass = "pass";
538 const auto r = req::get(s.url("/a"), o);
539 s.stop();
540 EXPECT_TRUE(r.ok());
541 // base64("user:pass") == "dXNlcjpwYXNz"
542 EXPECT_NE(s.last_request().find("Authorization: Basic dXNlcjpwYXNz\r\n"), std::string::npos);
545// A caller-supplied Authorization header is not overridden by auth_user/auth_pass.
546TEST(CheatahRequests, ExplicitAuthHeaderWins) {
547 LoopbackServer s({ok_body("ok")});
548 auto o = defaults();
549 o.auth_user = "user";
550 o.auth_pass = "pass";
551 o.headers["Authorization"] = "Bearer tok";
552 const auto r = req::get(s.url("/a"), o);
553 s.stop();
554 EXPECT_TRUE(r.ok());
555 const std::string req = s.last_request();
556 EXPECT_NE(req.find("Authorization: Bearer tok\r\n"), std::string::npos);
557 EXPECT_EQ(req.find("Basic"), std::string::npos); // no Basic added on top
560// A caller-supplied Content-Type suppresses the auto application/json.
561TEST(CheatahRequests, ExplicitContentTypeWins) {
562 LoopbackServer s({ok_body("ok")});
563 auto o = defaults();
564 o.json_body = "{}";
565 o.headers["Content-Type"] = "application/vnd.custom+json";
566 const auto r = req::post(s.url("/c"), o);
567 s.stop();
568 EXPECT_TRUE(r.ok());
569 const std::string req = s.last_request();
570 EXPECT_NE(req.find("Content-Type: application/vnd.custom+json\r\n"), std::string::npos);
571 EXPECT_EQ(req.find("application/json"), std::string::npos);
574// The reason phrase is parsed from the status line; a reason-less status line yields "".
575TEST(CheatahRequests, ReasonPhrase) {
576 LoopbackServer s({"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"});
577 const auto r = req::get(s.url("/x"));
578 EXPECT_EQ(r.status_code, 404);
579 EXPECT_EQ(r.reason, "Not Found");
581 LoopbackServer s2({"HTTP/1.1 200\r\nContent-Length: 1\r\n\r\nz"}); // no reason token
582 const auto r2 = req::get(s2.url("/y"));
583 EXPECT_EQ(r2.status_code, 200);
584 EXPECT_EQ(r2.reason, "");
587// text()/content() alias the body.
588TEST(CheatahRequests, TextAndContent) {
589 LoopbackServer s({ok_body("payload")});
590 const auto r = req::get(s.url("/t"));
591 EXPECT_EQ(r.text(), "payload");
592 EXPECT_EQ(r.content(), "payload");
595// Set-Cookie headers (one or several) are captured into `cookies`; an attribute-only
596// cookie without '=' is skipped.
597TEST(CheatahRequests, Cookies) {
598 LoopbackServer s({"HTTP/1.1 200 OK\r\nSet-Cookie: sid=abc; Path=/\r\n"
599 "Set-Cookie: theme=dark\r\nSet-Cookie: broken\r\nContent-Length: 0\r\n\r\n"});
600 const auto r = req::get(s.url("/c"));
601 EXPECT_EQ(r.cookies.at("sid"), "abc");
602 EXPECT_EQ(r.cookies.at("theme"), "dark");
603 EXPECT_EQ(r.cookies.count("broken"), 0u); // no '=' -> not a name=value cookie
606// is_redirect()/is_permanent_redirect() classify the status.
607TEST(CheatahRequests, RedirectPredicates) {
608 LoopbackServer s({"HTTP/1.1 308 Permanent Redirect\r\nContent-Length: 0\r\n\r\n"});
609 auto o = defaults();
610 o.no_redirect = true; // keep the 3xx to inspect it
611 const auto r = req::get(s.url("/r"), o);
612 EXPECT_TRUE(r.is_redirect());
613 EXPECT_TRUE(r.is_permanent_redirect());
614 LoopbackServer s2({ok_body("x")});
615 const auto r2 = req::get(s2.url("/ok"));
616 EXPECT_FALSE(r2.is_redirect());
617 EXPECT_FALSE(r2.is_permanent_redirect());
620// no_redirect returns the 3xx directly (no follow, empty history).
621TEST(CheatahRequests, AllowRedirectsFalse) {
622 LoopbackServer s({"HTTP/1.1 302 Found\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n"});
623 auto o = defaults();
624 o.no_redirect = true;
625 const auto r = req::get(s.url("/start"), o);
626 EXPECT_EQ(r.status_code, 302);
627 EXPECT_TRUE(r.history.empty());
630// A followed redirect records the intermediate response in `history`.
631TEST(CheatahRequests, RedirectHistory) {
632 LoopbackServer s({"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n",
633 ok_body("arrived")});
634 const auto r = req::get(s.url("/start"));
635 EXPECT_TRUE(r.ok());
636 EXPECT_EQ(r.body, "arrived");
637 ASSERT_EQ(r.history.size(), 1u);
638 EXPECT_EQ(r.history[0].status_code, 302);
641// A 303 (and a 301/302 on a POST) follows as GET with the body dropped.
642TEST(CheatahRequests, Redirect303PostBecomesGet) {
643 LoopbackServer s({"HTTP/1.1 303 See Other\r\nLocation: /result\r\nContent-Length: 0\r\n\r\n",
644 ok_body("ok")});
645 auto o = defaults();
646 o.json_body = "{\"a\":1}";
647 const auto r = req::post(s.url("/submit"), o);
648 s.stop();
649 EXPECT_TRUE(r.ok());
650 ASSERT_EQ(s.received().size(), 2u);
651 EXPECT_EQ(s.received()[0].rfind("POST /submit ", 0), 0u);
652 EXPECT_EQ(s.received()[1].rfind("GET /result ", 0), 0u); // method downgraded, body dropped
653 EXPECT_EQ(s.received()[1].find("{\"a\":1}"), std::string::npos);
656// A 307/308 preserves the method AND the body across the redirect (unlike 301/302/303).
657TEST(CheatahRequests, Redirect308PreservesMethod) {
658 LoopbackServer s({"HTTP/1.1 308 Permanent Redirect\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n",
659 ok_body("ok")});
660 auto o = defaults();
661 o.json_body = "{\"a\":1}";
662 const auto r = req::post(s.url("/submit"), o);
663 s.stop();
664 EXPECT_TRUE(r.ok());
665 ASSERT_EQ(s.received().size(), 2u);
666 EXPECT_EQ(s.received()[1].rfind("POST /final ", 0), 0u); // method preserved
667 EXPECT_NE(s.received()[1].find("{\"a\":1}"), std::string::npos); // body preserved
670// raise_for_status() throws on 4xx/5xx and is a no-op on 2xx.
671TEST(CheatahRequests, RaiseForStatus) {
672 LoopbackServer s({"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n"});
673 const auto bad = req::get(s.url("/e"));
674 EXPECT_THROW(bad.raise_for_status(), std::exception);
675 LoopbackServer s2({ok_body("ok")});
676 const auto good = req::get(s2.url("/ok"));
677 EXPECT_NO_THROW(good.raise_for_status());
680// The typed JSON reader parses the body straight into a struct (accelerated path).
681namespace testjson {
682struct Quote {
683 std::string symbol;
684 double price;
685};
686} // namespace testjson
687namespace cheatah::parsers::json {
688template <>
689inline constexpr auto schema<testjson::Quote> =
690 object(field("symbol", &testjson::Quote::symbol), field("price", &testjson::Quote::price));
691} // namespace cheatah::parsers::json
693TEST(CheatahRequests, JsonTyped) {
694 LoopbackServer s({ok_body("{\"symbol\":\"SPX\",\"price\":7386.65}")});
695 const auto r = req::get(s.url("/q"));
696 testjson::Quote q{};
697 ASSERT_TRUE(r.json(q));
698 EXPECT_EQ(q.symbol, "SPX");
699 EXPECT_DOUBLE_EQ(q.price, 7386.65);
701 LoopbackServer s2({ok_body("not json")});
702 const auto r2 = req::get(s2.url("/bad"));
703 testjson::Quote q2{};
704 EXPECT_FALSE(r2.json(q2)); // malformed -> false
707// to_json serializes a flat dict, escaping quotes/backslash/control chars (json_escape).
708TEST(CheatahRequests, ToJsonAndEscape) {
709 std::unordered_map<std::string, std::string> one{{"side", "buy"}};
710 EXPECT_EQ(req::to_json(one), "{\"side\":\"buy\"}");
711 std::unordered_map<std::string, std::string> esc{{"k", "a\"b\\c\n\r\td"}};
712 EXPECT_EQ(req::to_json(esc), "{\"k\":\"a\\\"b\\\\c\\n\\r\\td\"}");
713 std::unordered_map<std::string, std::string> empty;
714 EXPECT_EQ(req::to_json(empty), "{}");
716 // EVERY control byte, not just the five with short forms. RFC 8259 §7 forbids a raw byte below
717 // 0x20 inside a string, so anything not escaped here produces output that is not JSON and a
718 // strict parser rejects the whole document rather than the one field. Backspace and form feed
719 // have short forms; the rest become \u00XX.
720 std::unordered_map<std::string, std::string> ctrl{{"k", std::string("a\b\fb\x01\x1f", 6)}};
721 EXPECT_EQ(req::to_json(ctrl), "{\"k\":\"a\\b\\fb\\u0001\\u001f\"}");
723 // The boundary: 0x1F is a control character and must be escaped, 0x20 (space) is not and must
724 // survive verbatim — an off-by-one here would either mangle every space or leak a raw 0x1F.
725 std::unordered_map<std::string, std::string> edge{{"k", std::string("\x1f\x20", 2)}};
726 EXPECT_EQ(req::to_json(edge), "{\"k\":\"\\u001f \"}");
729// Base64 for HTTP Basic auth is the single canonical hashlib.base64_encode (tested in the hashlib
730// suite: CheatahHashlib.Base64KnownVectors / Base64RoundTrip). requests no longer re-implements it;
731// the auth-header integration is covered end-to-end by RequestsSys.BasicAuth.
733// === Red-team: a malicious/compromised server must not crash or exhaust the client. ===
735// F6: a non-numeric / overflowing / negative Content-Length sets `error` instead of throwing out
736// of the "never raises" request path (previously std::stoll would throw and crash the program).
737TEST(CheatahRequests, MalformedContentLengthIsError) {
738 LoopbackServer a({"HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\nbody"});
739 EXPECT_NE(req::get(a.url("/")).error, "");
740 LoopbackServer b({"HTTP/1.1 200 OK\r\nContent-Length: 999999999999999999999999\r\n\r\nx"});
741 EXPECT_NE(req::get(b.url("/")).error, "");
742 LoopbackServer c({"HTTP/1.1 200 OK\r\nContent-Length: -5\r\n\r\nhello"});
743 EXPECT_NE(req::get(c.url("/")).error, ""); // '-' is non-digit (was a silent-truncation bug)
746// F6: a status line whose code field is not three digits sets `error`, status stays 0.
747TEST(CheatahRequests, MalformedStatusCodeIsError) {
748 LoopbackServer s({"HTTP/1.1 xx Bad\r\nContent-Length: 0\r\n\r\n"});
749 const auto r = req::get(s.url("/"));
750 EXPECT_NE(r.error, "");
751 EXPECT_EQ(r.status_code, 0);
754// F5 (OOM): a response larger than max_bytes is refused rather than buffered without bound.
755TEST(CheatahRequests, ResponseBodyCapEnforced) {
756 LoopbackServer s({"HTTP/1.1 200 OK\r\n\r\n" + std::string(5000, 'x')}); // EOF-framed, 5000 B
757 auto o = defaults();
758 o.max_bytes = 1000;
759 const auto r = req::get(s.url("/"), o);
760 EXPECT_NE(r.error.find("max_bytes"), std::string::npos);
763// F5: a Content-Length larger than max_bytes is rejected up front (before reading that many bytes).
764TEST(CheatahRequests, ContentLengthCapEnforced) {
765 LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 5000\r\n\r\nshort"});
766 auto o = defaults();
767 o.max_bytes = 1000;
768 const auto r = req::get(s.url("/"), o);
769 EXPECT_NE(r.error.find("Content-Length"), std::string::npos);
772// F8: a chunk-size line big enough to overflow a naive counter is rejected as malformed (the
773// overflow guard prevents wrapping into a bogus positive size / bad offset math).
774TEST(CheatahRequests, ChunkSizeOverflowIsError) {
775 LoopbackServer s({"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
776 "FFFFFFFFFFFFFFFFFF\r\nx\r\n0\r\n\r\n"});
777 EXPECT_NE(req::get(s.url("/")).error, "");
780// F7: a redirect to a DIFFERENT host must NOT forward Basic-auth credentials (cross-origin leak),
781// and must not mutate the caller's Options. "localhost" vs the numeric loopback IP is a host change
782// that still connects to the same test server.
783TEST(CheatahRequests, CrossHostRedirectStripsCredentials) {
784 LoopbackServer target({ok_body("done")}); // the redirect destination (a "different host")
785 const std::string loc = "http://localhost:" + std::to_string(target.port()) + "/final";
786 LoopbackServer origin({"HTTP/1.1 302 Found\r\nLocation: " + loc + "\r\nContent-Length: 0\r\n\r\n"});
787 auto o = defaults();
788 o.auth_user = "user";
789 o.auth_pass = "pass";
790 o.headers["Authorization"] = "Bearer leak-me"; // explicit auth header -> stripped cross-host
791 o.headers["Cookie"] = "sid=secret"; // cookies -> stripped cross-host
792 o.headers["X-Trace"] = "keep"; // a non-sensitive header -> preserved
793 const auto r = req::get("http://127.0.0.1:" + std::to_string(origin.port()) + "/start", o);
794 origin.stop();
795 target.stop();
796 EXPECT_TRUE(r.ok());
797 ASSERT_FALSE(target.received().empty());
798 const std::string& to_other = target.received()[0];
799 EXPECT_EQ(to_other.find("Authorization"), std::string::npos) << to_other; // Basic + Bearer gone
800 EXPECT_EQ(to_other.find("leak-me"), std::string::npos) << to_other;
801 EXPECT_EQ(to_other.find("Cookie"), std::string::npos) << to_other; // cookie gone
802 EXPECT_NE(to_other.find("X-Trace: keep"), std::string::npos) << to_other; // non-secret kept
803 EXPECT_EQ(o.auth_user, "user"); // the caller's Options is untouched (request works on a copy)
804 EXPECT_EQ(o.headers.count("Authorization"), 1u); // ...and its headers are intact
807// F7 counterpart: a SAME-host redirect (relative Location) keeps credentials, matching Python.
808TEST(CheatahRequests, SameHostRedirectKeepsCredentials) {
809 LoopbackServer s({"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n",
810 ok_body("done")});
811 auto o = defaults();
812 o.auth_user = "user";
813 o.auth_pass = "pass";
814 const auto r = req::get(s.url("/start"), o);
815 s.stop();
816 EXPECT_TRUE(r.ok());
817 ASSERT_EQ(s.received().size(), 2u);
818 EXPECT_NE(s.received()[1].find("Authorization: Basic"), std::string::npos); // kept, same host