// SPDX-License-Identifier: MIT // // Mocks the clawdforge HTTP API with cpp-httplib and exercises the SDK end to // end. Each TEST_CASE spins up a fresh server bound to 127.0.0.1:0 (random // free port) so cases can run in parallel without colliding. #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include #include #include #include #include #include #include #include #include #include #include #include #include namespace cf = clawdforge; using nlohmann::json; namespace { /// Owns a httplib::Server bound to 127.0.0.1:0 in a background thread. RAII — /// the destructor stops the server and joins the thread. class MockServer { public: explicit MockServer(std::function wire_routes) { // Cap the worker thread pool low — cpp-httplib defaults to one worker // per CPU core, which on big hosts piles up under ASan and trips the // per-process map limit. Two workers is plenty for these tests. srv_.new_task_queue = [] { return new httplib::ThreadPool(2); }; wire_routes(srv_); port_ = srv_.bind_to_any_port("127.0.0.1"); REQUIRE(port_ > 0); thread_ = std::thread([this] { srv_.listen_after_bind(); }); // Wait for the server to be ready (cheap poll — `listen_after_bind` // doesn't expose a barrier). for (int i = 0; i < 200; ++i) { if (srv_.is_running()) break; std::this_thread::sleep_for(std::chrono::milliseconds(5)); } REQUIRE(srv_.is_running()); } ~MockServer() { srv_.stop(); if (thread_.joinable()) thread_.join(); } [[nodiscard]] std::string base_url() const { return "http://127.0.0.1:" + std::to_string(port_); } private: httplib::Server srv_; int port_{0}; std::thread thread_; }; [[nodiscard]] std::string write_temp_file(std::string_view content) { namespace fs = std::filesystem; const auto path = fs::temp_directory_path() / ("clawdforge-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".bin"); std::ofstream out(path, std::ios::binary); out.write(content.data(), static_cast(content.size())); out.close(); return path.string(); } } // namespace TEST_CASE("healthz returns parsed body") { MockServer mock{[](httplib::Server& s) { s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) { res.set_content( R"({"ok":true,"claude_present":true,"claude_version":"1.2.3"})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; const auto h = c.healthz(); CHECK(h.ok); CHECK(h.claude_present); CHECK(h.claude_version == "1.2.3"); } TEST_CASE("run with JSON result decodes into variant") { MockServer mock{[](httplib::Server& s) { s.Post("/run", [](const httplib::Request& req, httplib::Response& res) { CHECK(req.has_header("Authorization")); CHECK(req.get_header_value("Authorization") == "Bearer cf_test"); const auto body = json::parse(req.body); CHECK(body.at("prompt").get() == "hi"); CHECK(body.at("model").get() == "sonnet"); res.set_content( R"({"ok":true,"result":{"hello":"world"},"duration_ms":42,"stop_reason":"end_turn"})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; const auto r = c.run(cf::RunRequest{.prompt = "hi", .model = "sonnet"}); CHECK(r.ok); CHECK(r.duration_ms == 42); REQUIRE(r.stop_reason.has_value()); CHECK(*r.stop_reason == "end_turn"); const auto* j = std::get_if(&r.result); REQUIRE(j != nullptr); CHECK(j->at("hello").get() == "world"); } TEST_CASE("run with string result decodes into variant") { MockServer mock{[](httplib::Server& s) { s.Post("/run", [](const httplib::Request&, httplib::Response& res) { res.set_content( R"({"ok":true,"result":"plain text reply","duration_ms":12})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; const auto r = c.run(cf::RunRequest{.prompt = "hi"}); const auto* s = std::get_if(&r.result); REQUIRE(s != nullptr); CHECK(*s == "plain text reply"); } TEST_CASE("run failure -> APIError with status 502 and body") { MockServer mock{[](httplib::Server& s) { s.Post("/run", [](const httplib::Request&, httplib::Response& res) { res.status = 502; res.set_content( R"({"ok":false,"error":"timeout","duration_ms":60000,"stderr":"...","stop_reason":null})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; try { (void)c.run(cf::RunRequest{.prompt = "hi"}); FAIL("expected APIError"); } catch (const cf::APIError& e) { CHECK(e.status_code() == 502); const auto body = json::parse(e.body()); CHECK(body.at("error").get() == "timeout"); } } TEST_CASE("401 -> AuthError") { MockServer mock{[](httplib::Server& s) { s.Post("/run", [](const httplib::Request&, httplib::Response& res) { res.status = 401; res.set_content(R"({"detail":"bad token"})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_bad"}}; CHECK_THROWS_AS(c.run(cf::RunRequest{.prompt = "hi"}), cf::AuthError); } TEST_CASE("run with files attaches list to body") { MockServer mock{[](httplib::Server& s) { s.Post("/run", [](const httplib::Request& req, httplib::Response& res) { const auto body = json::parse(req.body); REQUIRE(body.contains("files")); REQUIRE(body.at("files").is_array()); CHECK(body.at("files").size() == 1); CHECK(body.at("files")[0].get() == "ff_abcd"); res.set_content( R"({"ok":true,"result":"got file","duration_ms":1})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; const auto r = c.run(cf::RunRequest{ .prompt = "extract", .files = {"ff_abcd"}, .timeout_secs = 30}); CHECK(r.ok); } TEST_CASE("upload_file streams a multipart and parses FileToken") { const std::string payload(1024 * 200, 'x'); // 200 KB const auto path = write_temp_file(payload); std::atomic saw_field{false}; std::atomic received_size{0}; std::atomic saw_ttl{false}; MockServer mock{[&](httplib::Server& s) { s.Post("/files", [&](const httplib::Request& req, httplib::Response& res) { if (req.has_file("file")) { saw_field = true; received_size = req.get_file_value("file").content.size(); } if (req.has_file("ttl_secs")) { saw_ttl = true; CHECK(req.get_file_value("ttl_secs").content == "120"); } res.set_content( R"({"file_token":"ff_xyz","ttl_secs":120,"size":204800})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; const auto ft = c.upload_file(path, 120); CHECK(ft.file_token == "ff_xyz"); CHECK(ft.ttl_secs == 120); CHECK(ft.size == 204800); CHECK(saw_field.load()); CHECK(saw_ttl.load()); CHECK(received_size.load() == payload.size()); std::filesystem::remove(path); } TEST_CASE("admin token CRUD round-trip") { std::vector seen_methods; std::vector seen_paths; MockServer mock{[&](httplib::Server& s) { s.Post("/admin/tokens", [&](const httplib::Request& req, httplib::Response& res) { seen_methods.emplace_back("POST"); seen_paths.emplace_back("/admin/tokens"); CHECK(req.get_header_value("Authorization") == "Bearer admin_secret"); const auto body = json::parse(req.body); CHECK(body.at("name").get() == "consumer-1"); res.set_content( R"({"name":"consumer-1","token":"cf_NEWTOKEN","ip_cidrs":["10.0.0.0/8"]})", "application/json"); }); s.Get("/admin/tokens", [&](const httplib::Request& req, httplib::Response& res) { seen_methods.emplace_back("GET"); seen_paths.emplace_back("/admin/tokens"); CHECK(req.get_header_value("Authorization") == "Bearer admin_secret"); res.set_content( R"({"tokens":[ {"name":"consumer-1","ip_cidrs":["10.0.0.0/8"],"created_at":1700000000,"extra":"yo"} ]})", "application/json"); }); s.Delete(R"(/admin/tokens/(.+))", [&](const httplib::Request& req, httplib::Response& res) { seen_methods.emplace_back("DELETE"); seen_paths.emplace_back(req.path); res.set_content(R"({"ok":true})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test", .admin_token = "admin_secret"}}; const auto created = c.create_token(cf::TokenCreateRequest{ .name = "consumer-1", .ip_cidrs = {"10.0.0.0/8"}}); CHECK(created.token == "cf_NEWTOKEN"); const auto list = c.list_tokens(); REQUIRE(list.size() == 1); CHECK(list[0].name == "consumer-1"); REQUIRE(list[0].created_at.has_value()); CHECK(*list[0].created_at == 1700000000); CHECK(list[0].extra.contains("extra")); c.revoke_token("consumer-1"); REQUIRE(seen_methods.size() == 3); CHECK(seen_methods[0] == "POST"); CHECK(seen_methods[1] == "GET"); CHECK(seen_methods[2] == "DELETE"); CHECK(seen_paths[2] == "/admin/tokens/consumer-1"); } TEST_CASE("revoke_token without admin token throws AuthError") { cf::Client c{cf::ClientOptions{.base_url = "http://127.0.0.1:1", .token = "cf_test"}}; CHECK_THROWS_AS(c.revoke_token("foo"), cf::AuthError); } TEST_CASE("transport-level failure surfaces as TransportError") { // Port 1 is reserved (tcpmux); connect should refuse fast on Linux. cf::Client c{cf::ClientOptions{ .base_url = "http://127.0.0.1:1", .token = "cf_test", .timeout = std::chrono::seconds{2}, .connect_timeout = std::chrono::seconds{1}, }}; auto fire = [&] { auto h = c.healthz(); (void)h; }; CHECK_THROWS_AS(fire(), cf::TransportError); } TEST_CASE("base_url is normalized (trailing slash trimmed)") { MockServer mock{[](httplib::Server& s) { s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) { res.set_content(R"({"ok":true,"claude_present":false})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url() + "/", .token = "cf_test"}}; CHECK(c.base_url() == mock.base_url()); const auto h = c.healthz(); CHECK(h.ok); CHECK_FALSE(h.claude_present); CHECK(h.claude_version.empty()); } TEST_CASE("invalid base_url scheme is rejected at construction") { auto make_ftp = [] { cf::ClientOptions opts; opts.base_url = "ftp://nope"; opts.token = "x"; cf::Client c{std::move(opts)}; (void)c; }; auto make_empty = [] { cf::ClientOptions opts; opts.base_url = ""; opts.token = "x"; cf::Client c{std::move(opts)}; (void)c; }; CHECK_THROWS_AS(make_ftp(), cf::ProtocolError); CHECK_THROWS_AS(make_empty(), cf::ProtocolError); } // --------------------------------------------------------------------------- // Regression: malformed JSON bodies must surface as ProtocolError, NOT as // raw nlohmann::json::exception (which would bypass the documented // `clawdforge::Error` catch-all base — H1 fix). // --------------------------------------------------------------------------- TEST_CASE("healthz: malformed response surfaces as ProtocolError") { MockServer mock{[](httplib::Server& s) { s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) { // Well-formed JSON, but wrong shape — `claude_version` is a // number, not a string. Triggers nlohmann type_error during // from_json, which must be wrapped. res.set_content(R"({"ok":true,"claude_version":42})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; CHECK_THROWS_AS(c.healthz(), cf::ProtocolError); // Sanity: the catch-all base must also catch. CHECK_THROWS_AS(c.healthz(), cf::Error); } TEST_CASE("run: malformed response surfaces as ProtocolError") { MockServer mock{[](httplib::Server& s) { s.Post("/run", [](const httplib::Request&, httplib::Response& res) { // Missing required `result` field — `from_json(RunResult)` calls // `j.at("result")` which throws out_of_range. res.set_content(R"({"ok":true,"duration_ms":1})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; CHECK_THROWS_AS(c.run(cf::RunRequest{.prompt = "hi"}), cf::ProtocolError); } TEST_CASE("upload_file: malformed response surfaces as ProtocolError") { const auto path = write_temp_file("payload"); MockServer mock{[](httplib::Server& s) { s.Post("/files", [](const httplib::Request&, httplib::Response& res) { // Missing required `file_token`. res.set_content(R"({"ttl_secs":120,"size":7})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; CHECK_THROWS_AS(c.upload_file(path), cf::ProtocolError); std::filesystem::remove(path); } TEST_CASE("create_token: malformed response surfaces as ProtocolError") { MockServer mock{[](httplib::Server& s) { s.Post("/admin/tokens", [](const httplib::Request&, httplib::Response& res) { // Missing required `name` + `token`. res.set_content(R"({"foo":"bar"})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test", .admin_token = "admin_secret"}}; CHECK_THROWS_AS( c.create_token(cf::TokenCreateRequest{.name = "x"}), cf::ProtocolError); } TEST_CASE("list_tokens: malformed entries surface as ProtocolError") { MockServer mock{[](httplib::Server& s) { s.Get("/admin/tokens", [](const httplib::Request&, httplib::Response& res) { // Array is present, but entries are missing `name` (required). res.set_content(R"({"tokens":[{"ip_cidrs":["10.0.0.0/8"]}]})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test", .admin_token = "admin_secret"}}; CHECK_THROWS_AS(c.list_tokens(), cf::ProtocolError); } // --------------------------------------------------------------------------- // Regression: libcurl redirect clamps (H2). Verify MAXREDIRS=5 caps the chain // and REDIR_PROTOCOLS rejects ftp:// targets. // --------------------------------------------------------------------------- TEST_CASE("redirect_clamp: more than 5 redirects -> TransportError") { std::atomic hops{0}; MockServer mock{[&](httplib::Server& s) { s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) { res.status = 302; res.set_header("Location", "/loop0"); }); s.Get(R"(/loop(\d+))", [&](const httplib::Request& req, httplib::Response& res) { hops.fetch_add(1, std::memory_order_relaxed); const int n = std::stoi(req.matches[1]); res.status = 302; res.set_header("Location", "/loop" + std::to_string(n + 1)); }); }}; cf::Client c{cf::ClientOptions{ .base_url = mock.base_url(), .token = "cf_test", .timeout = std::chrono::seconds{5}, .connect_timeout = std::chrono::seconds{2}, }}; CHECK_THROWS_AS(c.healthz(), cf::TransportError); // libcurl follows up to MAXREDIRS+1 hops total before giving up. CHECK(hops.load() <= 6); } TEST_CASE("redirect_protocol_clamp: ftp:// Location is rejected") { MockServer mock{[](httplib::Server& s) { s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) { res.status = 302; res.set_header("Location", "ftp://example.invalid/data"); }); }}; cf::Client c{cf::ClientOptions{ .base_url = mock.base_url(), .token = "cf_test", .timeout = std::chrono::seconds{5}, .connect_timeout = std::chrono::seconds{2}, }}; CHECK_THROWS_AS(c.healthz(), cf::TransportError); } // --------------------------------------------------------------------------- // Regression: upload_file canonicalizes its path argument (M1). A symlink to // a real file must work; broken / loop symlinks must be rejected up front // with ProtocolError, never reach libcurl. // --------------------------------------------------------------------------- TEST_CASE("upload_file: symlink to regular file is accepted") { namespace fs = std::filesystem; const auto target = write_temp_file("symlinked-payload"); const auto link = fs::temp_directory_path() / ("clawdforge-link-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); std::error_code ec; fs::create_symlink(target, link, ec); REQUIRE(!ec); MockServer mock{[](httplib::Server& s) { s.Post("/files", [](const httplib::Request& req, httplib::Response& res) { REQUIRE(req.has_file("file")); CHECK(req.get_file_value("file").content == "symlinked-payload"); res.set_content( R"({"file_token":"ff_link","ttl_secs":120,"size":17})", "application/json"); }); }}; cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}}; const auto ft = c.upload_file(link.string(), 120); CHECK(ft.file_token == "ff_link"); fs::remove(link); fs::remove(target); } TEST_CASE("upload_file: broken symlink is rejected with ProtocolError") { namespace fs = std::filesystem; const auto link = fs::temp_directory_path() / ("clawdforge-broken-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); std::error_code ec; fs::create_symlink("/no/such/path/clawdforge-target", link, ec); REQUIRE(!ec); cf::Client c{cf::ClientOptions{.base_url = "http://127.0.0.1:1", .token = "cf_test"}}; CHECK_THROWS_AS(c.upload_file(link.string()), cf::ProtocolError); fs::remove(link); } TEST_CASE("upload_file: symlink loop is rejected with ProtocolError") { namespace fs = std::filesystem; const auto stamp = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); const auto a = fs::temp_directory_path() / ("clawdforge-loopA-" + stamp); const auto b = fs::temp_directory_path() / ("clawdforge-loopB-" + stamp); std::error_code ec; fs::create_symlink(b, a, ec); REQUIRE(!ec); fs::create_symlink(a, b, ec); REQUIRE(!ec); cf::Client c{cf::ClientOptions{.base_url = "http://127.0.0.1:1", .token = "cf_test"}}; CHECK_THROWS_AS(c.upload_file(a.string()), cf::ProtocolError); fs::remove(a); fs::remove(b); } TEST_CASE("upload_file: directory is rejected") { namespace fs = std::filesystem; const auto dir = fs::temp_directory_path() / ("clawdforge-dir-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); std::error_code ec; fs::create_directory(dir, ec); REQUIRE(!ec); cf::Client c{cf::ClientOptions{.base_url = "http://127.0.0.1:1", .token = "cf_test"}}; CHECK_THROWS_AS(c.upload_file(dir.string()), cf::ProtocolError); fs::remove(dir); }