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 <atomic>13
#include <string>14
#include <thread>15
#include <vector>17
#include <gtest/gtest.h>19
#include "requests.hpp"20
#include "socket.hpp"22
namespace req = cheatah::requests;23
namespace sk = cheatah::socket;25
namespace {27
// A loopback HTTP server that accepts `responses.size()` sequential connections and28
// replies to each with the corresponding scripted response (reading the request head29
// first). Used for single exchanges and multi-hop redirect chains.30
class 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-carrying106
// get() both get exercised.107
req::Options defaults() {108
return req::Options{.timeout_ms = 30000, .max_redirects = 5};109
}111
} // namespace113
// A plain 200 with Content-Length: status, ok(), body, header() (case-insensitive).114
TEST(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 lookup122
EXPECT_EQ(r.header(std::string("X-Missing")), "");123
}125
// A 404 is a completed exchange: ok() false but error empty.126
TEST(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");133
}135
// No Content-Length and no chunked framing: the body runs to connection close.136
TEST(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");141
}143
// Chunked transfer-encoding: hex sizes, a chunk extension (`;`), then the 0 terminator.144
TEST(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");150
}152
// A malformed chunk size (non-hex digit) is reported as an error.153
TEST(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, "");157
}159
// Chunked framing truncated before the declared chunk bytes -> error.160
TEST(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, "");164
}166
// Chunked stream that closes before any CRLF-terminated size line -> error.167
TEST(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, "");171
}173
// Query params are appended percent-encoded; existing '?' in the target uses '&'.174
TEST(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-encoded178
const auto r = req::get(s.url("/search?x=1"), o);179
EXPECT_TRUE(r.ok());180
EXPECT_EQ(r.body, "ok");181
}183
// CRLF INJECTION: a request is a CRLF-framed message built by concatenation, so a CR or LF reaching the184
// 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 in186
// 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.188
TEST(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
}217
}219
// ...and the guard must not cost an honest request: a percent-ENCODED CRLF is the correct way to put220
// those bytes in a URL and must still be sent.221
TEST(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");226
}228
// Custom headers are sent; a caller-supplied User-Agent suppresses the default.229
TEST(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());236
}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 bound240
// first so its real port can be embedded in the redirect target.241
TEST(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);268
}270
// A redirect with a host-relative Location ("/next") is resolved against scheme/host/port.271
TEST(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");277
}279
// A redirect whose Location is neither absolute nor root-relative is unsupported.280
TEST(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());285
}287
// A 3xx without any Location header is an error.288
TEST(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, "");292
}294
// A redirect loop exhausts max_redirects and returns the "too many redirects" error.295
TEST(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);306
}308
// Zero/negative option fields fall back to documented defaults (timeout 30 s, 5 hops).309
TEST(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 in312
const auto r = req::get(s.url("/"), o);313
EXPECT_TRUE(r.ok());314
}316
// A malformed URL never connects: error set, status 0.317
TEST(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);321
}323
// A refused connection (nothing listening on port 9) comes back as a transport error.324
TEST(CheatahRequests, ConnectionRefused) {325
const auto r = req::get(std::string("http://127.0.0.1:9/"));326
EXPECT_NE(r.error, "");327
}329
// A response head with no HTTP/ prefix is malformed.330
TEST(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, "");334
}336
// A connection that closes before a complete head (\r\n\r\n) is an error.337
TEST(CheatahRequests, IncompleteHead) {338
LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n"}); // no blank line339
const auto r = req::get(s.url("/"));340
EXPECT_NE(r.error, "");341
}343
// A status line too short to hold a 3-digit code is malformed.344
TEST(CheatahRequests, MalformedStatusLine) {345
LoopbackServer s({"HTTP/1.1\r\nContent-Length: 0\r\n\r\n"}); // no space + code346
const auto r = req::get(s.url("/"));347
EXPECT_NE(r.error, "");348
}350
// Content-Length larger than the body actually received -> error.351
TEST(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, "");355
}357
// Lowercase hex chunk sizes decode too (parse_hex a-f branch).358
TEST(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 bytes361
const auto r = req::get(s.url("/"));362
EXPECT_TRUE(r.ok());363
EXPECT_EQ(r.body, "0123456789");364
}366
// The module's ABI identity marker returns its name.367
TEST(CheatahRequests, ModuleAbiMarker) {368
EXPECT_STREQ(req::module_abi(), "requests");369
}371
// A peer that accepts then immediately closes (RST) makes the request write fail; the372
// exchange returns a "send failed"/transport error rather than hanging.373
TEST(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 reading380
});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);391
}393
// The https path: connecting a TLS client to a peer that speaks plain bytes fails at394
// the handshake, surfacing a "tls:" error (never a silent success). Exercises the395
// scheme=="https" branch and the tls::client_connect(<0) failure handling in request_once.396
TEST(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);414
}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.421
static 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;423
}425
// POST with json_body sets the method, application/json Content-Type, and Content-Length,426
// and sends the body verbatim.427
TEST(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);439
}441
// POST with form `data` is percent-encoded as application/x-www-form-urlencoded.442
TEST(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 body446
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);452
}454
// A raw `body` is sent verbatim with no auto Content-Type.455
TEST(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 body466
}468
// Body precedence: json_body wins over data wins over body.469
TEST(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);479
}481
// POST with no body still sends Content-Length: 0 (so a length-framed server is happy).482
TEST(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);488
}490
// GET never carries a body even if one is set in Options.491
TEST(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);502
}504
// Each verb sends its own request-line method. Also exercises the single-argument505
// (default-Options) form of every verb, covering their default-Options overloads.506
TEST(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
}520
}522
// HEAD sends the HEAD method and yields an empty body even when Content-Length is declared.523
TEST(CheatahRequests, HeadNoBody) {524
LoopbackServer s({"HTTP/1.1 200 OK\r\nContent-Length: 123\r\n\r\n"}); // no body follows525
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 only529
EXPECT_EQ(s.last_request().rfind("HEAD /h ", 0), 0u);530
}532
// HTTP Basic auth emits the correct `Authorization: Basic <base64>` header.533
TEST(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);543
}545
// A caller-supplied Authorization header is not overridden by auth_user/auth_pass.546
TEST(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 top558
}560
// A caller-supplied Content-Type suppresses the auto application/json.561
TEST(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);572
}574
// The reason phrase is parsed from the status line; a reason-less status line yields "".575
TEST(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 token582
const auto r2 = req::get(s2.url("/y"));583
EXPECT_EQ(r2.status_code, 200);584
EXPECT_EQ(r2.reason, "");585
}587
// text()/content() alias the body.588
TEST(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");593
}595
// Set-Cookie headers (one or several) are captured into `cookies`; an attribute-only596
// cookie without '=' is skipped.597
TEST(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 cookie604
}606
// is_redirect()/is_permanent_redirect() classify the status.607
TEST(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 it611
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());618
}620
// no_redirect returns the 3xx directly (no follow, empty history).621
TEST(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());628
}630
// A followed redirect records the intermediate response in `history`.631
TEST(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);639
}641
// A 303 (and a 301/302 on a POST) follows as GET with the body dropped.642
TEST(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 dropped653
EXPECT_EQ(s.received()[1].find("{\"a\":1}"), std::string::npos);654
}656
// A 307/308 preserves the method AND the body across the redirect (unlike 301/302/303).657
TEST(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 preserved667
EXPECT_NE(s.received()[1].find("{\"a\":1}"), std::string::npos); // body preserved668
}670
// raise_for_status() throws on 4xx/5xx and is a no-op on 2xx.671
TEST(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());678
}680
// The typed JSON reader parses the body straight into a struct (accelerated path).681
namespace testjson {682
struct Quote {683
std::string symbol;684
double price;685
};686
} // namespace testjson687
namespace cheatah::parsers::json {688
template <>689
inline constexpr auto schema<testjson::Quote> =690
object(field("symbol", &testjson::Quote::symbol), field("price", &testjson::Quote::price));691
} // namespace cheatah::parsers::json693
TEST(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 -> false705
}707
// to_json serializes a flat dict, escaping quotes/backslash/control chars (json_escape).708
TEST(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 below717
// 0x20 inside a string, so anything not escaped here produces output that is not JSON and a718
// strict parser rejects the whole document rather than the one field. Backspace and form feed719
// 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 must724
// 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 \"}");727
}729
// Base64 for HTTP Basic auth is the single canonical hashlib.base64_encode (tested in the hashlib730
// 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 out736
// of the "never raises" request path (previously std::stoll would throw and crash the program).737
TEST(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)744
}746
// F6: a status line whose code field is not three digits sets `error`, status stays 0.747
TEST(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);752
}754
// F5 (OOM): a response larger than max_bytes is refused rather than buffered without bound.755
TEST(CheatahRequests, ResponseBodyCapEnforced) {756
LoopbackServer s({"HTTP/1.1 200 OK\r\n\r\n" + std::string(5000, 'x')}); // EOF-framed, 5000 B757
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);761
}763
// F5: a Content-Length larger than max_bytes is rejected up front (before reading that many bytes).764
TEST(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);770
}772
// F8: a chunk-size line big enough to overflow a naive counter is rejected as malformed (the773
// overflow guard prevents wrapping into a bogus positive size / bad offset math).774
TEST(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, "");778
}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 change782
// that still connects to the same test server.783
TEST(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-host791
o.headers["Cookie"] = "sid=secret"; // cookies -> stripped cross-host792
o.headers["X-Trace"] = "keep"; // a non-sensitive header -> preserved793
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 gone800
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 gone802
EXPECT_NE(to_other.find("X-Trace: keep"), std::string::npos) << to_other; // non-secret kept803
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 intact805
}807
// F7 counterpart: a SAME-host redirect (relative Location) keeps credentials, matching Python.808
TEST(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 host819
}