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 against7
// 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 a10
// 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"23
namespace req = cheatah::requests;24
namespace sk = cheatah::socket;26
namespace {28
// A loopback HTTP server that accepts `responses.size()` sequential connections and29
// replies to each with the corresponding scripted response (reading the request head30
// first). Used for single exchanges and multi-hop redirect chains.31
class 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 exists37
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-carrying111
// get() both get exercised.112
req::Options defaults() {113
return req::Options{.timeout_ms = 30000, .max_redirects = 5};114
}116
} // namespace118
// A plain 200 with Content-Length: status, ok(), body, header() (case-insensitive).119
TEST(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 lookup127
EXPECT_EQ(r.header(std::string("X-Missing")), "");128
}130
// A 404 is a completed exchange: ok() false but error empty.131
TEST(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");138
}140
// No Content-Length and no chunked framing: the body runs to connection close.141
TEST(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");146
}148
// Chunked transfer-encoding: hex sizes, a chunk extension (`;`), then the 0 terminator.149
TEST(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");155
}157
// A malformed chunk size (non-hex digit) is reported as an error.158
TEST(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, "");162
}164
// Chunked framing truncated before the declared chunk bytes -> error.165
TEST(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, "");169
}171
// Chunked stream that closes before any CRLF-terminated size line -> error.172
TEST(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, "");176
}178
// Query params are appended percent-encoded; existing '?' in the target uses '&'.179
TEST(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-encoded183
const auto r = req::get(s.url("/search?x=1"), o);184
EXPECT_TRUE(r.ok());185
EXPECT_EQ(r.body, "ok");186
}188
// CRLF INJECTION: a request is a CRLF-framed message built by concatenation, so a CR or LF reaching the189
// 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 in191
// 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.193
TEST(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
}222
}224
// ...and the guard must not cost an honest request: a percent-ENCODED CRLF is the correct way to put225
// those bytes in a URL and must still be sent.226
TEST(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");231
}233
// Custom headers are sent; a caller-supplied User-Agent suppresses the default.234
TEST(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());241
}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 bound245
// first so its real port can be embedded in the redirect target.246
TEST(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);273
}275
// A redirect with a host-relative Location ("/next") is resolved against scheme/host/port.276
TEST(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");282
}284
// A redirect whose Location is neither absolute nor root-relative is unsupported.285
TEST(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());290
}292
// A 3xx without any Location header is an error.293
TEST(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, "");297
}299
// A redirect loop exhausts max_redirects and returns the "too many redirects" error.300
TEST(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);312
}314
// Zero/negative option fields fall back to documented defaults (timeout 30 s, 5 hops).315
TEST(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 in318
const auto r = req::get(s.url("/"), o);319
EXPECT_TRUE(r.ok());320
}322
// A malformed URL never connects: error set, status 0.323
TEST(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);327
}329
// A refused connection (nothing listening on port 9) comes back as a transport error.330
TEST(CheatahRequests, ConnectionRefused) {331
const auto r = req::get(std::string("http://127.0.0.1:9/"));332
EXPECT_NE(r.error, "");333
}335
// A response head with no HTTP/ prefix is malformed.336
TEST(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, "");340
}342
// A connection that closes before a complete head (\r\n\r\n) is an error.343
TEST(CheatahRequests, IncompleteHead) {344
LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n"}); // no blank line345
const auto r = req::get(s.url("/"));346
EXPECT_NE(r.error, "");347
}349
// A status line too short to hold a 3-digit code is malformed.350
TEST(CheatahRequests, MalformedStatusLine) {351
LoopbackServer s({"HTTP/1.1\r\nContent-Length: 0\r\n\r\n"}); // no space + code352
const auto r = req::get(s.url("/"));353
EXPECT_NE(r.error, "");354
}356
// Content-Length larger than the body actually received -> error.357
TEST(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, "");361
}363
// Lowercase hex chunk sizes decode too (parse_hex a-f branch).364
TEST(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 bytes367
const auto r = req::get(s.url("/"));368
EXPECT_TRUE(r.ok());369
EXPECT_EQ(r.body, "0123456789");370
}372
// The module's ABI identity marker returns its name.373
TEST(CheatahRequests, ModuleAbiMarker) {374
EXPECT_STREQ(req::module_abi(), "requests");375
}377
// A peer that accepts then immediately closes (RST) makes the request write fail; the378
// exchange returns a "send failed"/transport error rather than hanging.379
TEST(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 reading386
});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);397
}399
// The https path: connecting a TLS client to a peer that speaks plain bytes fails at400
// the handshake, surfacing a "tls:" error (never a silent success). Exercises the401
// scheme=="https" branch and the tls::client_connect(<0) failure handling in request_once.402
TEST(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);420
}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.427
namespace {428
std::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;430
}431
} // namespace433
// POST with json_body sets the method, application/json Content-Type, and Content-Length,434
// and sends the body verbatim.435
TEST(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);447
}449
// POST with form `data` is percent-encoded as application/x-www-form-urlencoded.450
TEST(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 body454
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);460
}462
// A raw `body` is sent verbatim with no auto Content-Type.463
TEST(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 body474
}476
// Body precedence: json_body wins over data wins over body.477
TEST(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);487
}489
// POST with no body still sends Content-Length: 0 (so a length-framed server is happy).490
TEST(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);496
}498
// GET never carries a body even if one is set in Options.499
TEST(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);510
}512
// Each verb sends its own request-line method. Also exercises the single-argument513
// (default-Options) form of every verb, covering their default-Options overloads.514
TEST(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
}528
}530
// HEAD sends the HEAD method and yields an empty body even when Content-Length is declared.531
TEST(CheatahRequests, HeadNoBody) {532
LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 123\r\n\r\n"}); // no body follows533
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 only537
EXPECT_EQ(s.last_request().rfind("HEAD /h ", 0), 0u);538
}540
// HTTP Basic auth emits the correct `Authorization: Basic <base64>` header.541
TEST(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);551
}553
// A caller-supplied Authorization header is not overridden by auth_user/auth_pass.554
TEST(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 top566
}568
// A caller-supplied Content-Type suppresses the auto application/json.569
TEST(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);580
}582
// The reason phrase is parsed from the status line; a reason-less status line yields "".583
TEST(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 token590
const auto r2 = req::get(s2.url("/y"));591
EXPECT_EQ(r2.status_code, 200);592
EXPECT_EQ(r2.reason, "");593
}595
// text()/content() alias the body.596
TEST(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");601
}603
// Set-Cookie headers (one or several) are captured into `cookies`; an attribute-only604
// cookie without '=' is skipped.605
TEST(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 cookie612
}614
// is_redirect()/is_permanent_redirect() classify the status.615
TEST(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 it619
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());626
}628
// no_redirect returns the 3xx directly (no follow, empty history).629
TEST(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());636
}638
// A followed redirect records the intermediate response in `history`.639
TEST(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);647
}649
// A 303 (and a 301/302 on a POST) follows as GET with the body dropped.650
TEST(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 dropped661
EXPECT_EQ(s.received()[1].find("{\"a\":1}"), std::string::npos);662
}664
// A 307/308 preserves the method AND the body across the redirect (unlike 301/302/303).665
TEST(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 preserved675
EXPECT_NE(s.received()[1].find("{\"a\":1}"), std::string::npos); // body preserved676
}678
// raise_for_status() throws on 4xx/5xx and is a no-op on 2xx.679
TEST(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());686
}688
// The typed JSON reader parses the body straight into a struct (accelerated path).689
namespace testjson {690
struct Quote {691
std::string symbol;692
double price;693
};694
} // namespace testjson695
namespace cheatah::parsers::json {696
template <>697
inline constexpr auto schema<testjson::Quote> =698
object(field("symbol", &testjson::Quote::symbol), field("price", &testjson::Quote::price));699
} // namespace cheatah::parsers::json701
TEST(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 -> false713
}715
// to_json serializes a flat dict, escaping quotes/backslash/control chars (json_escape).716
TEST(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 below725
// 0x20 inside a string, so anything not escaped here produces output that is not JSON and a726
// strict parser rejects the whole document rather than the one field. Backspace and form feed727
// 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 must732
// 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 \"}");735
}737
// Base64 for HTTP Basic auth is the single canonical hashlib.base64_encode (tested in the hashlib738
// 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 out744
// of the "never raises" request path (previously std::stoll would throw and crash the program).745
TEST(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)752
}754
// F6: a status line whose code field is not three digits sets `error`, status stays 0.755
TEST(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);760
}762
// F5 (OOM): a response larger than max_bytes is refused rather than buffered without bound.763
TEST(CheatahRequests, ResponseBodyCapEnforced) {764
LoopbackServer s({"HTTP/1.1 200 OK\r\n\r\n" + std::string(5000, 'x')}); // EOF-framed, 5000 B765
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);769
}771
// F5: a Content-Length larger than max_bytes is rejected up front (before reading that many bytes).772
TEST(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);778
}780
// F8: a chunk-size line big enough to overflow a naive counter is rejected as malformed (the781
// overflow guard prevents wrapping into a bogus positive size / bad offset math).782
TEST(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, "");786
}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 change790
// that still connects to the same test server.791
TEST(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-host799
o.headers["Cookie"] = "sid=secret"; // cookies -> stripped cross-host800
o.headers["X-Trace"] = "keep"; // a non-sensitive header -> preserved801
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 gone808
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 gone810
EXPECT_NE(to_other.find("X-Trace: keep"), std::string::npos) << to_other; // non-secret kept811
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 intact813
}815
// F7 counterpart: a SAME-host redirect (relative Location) keeps credentials, matching Python.816
TEST(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 host827
}