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