// 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); }