HIGH: - H1: nlohmann::json::exception wrapped as ProtocolError at 5 sites in client.cpp via with_protocol_guard helper. Preserves the documented clawdforge::Error catch-all base contract; nlohmann types never leak into the message (e.what() only). - H2: libcurl MAXREDIRS=5, REDIR_PROTOCOLS_STR="http,https" (CURLOPT_REDIR_PROTOCOLS bitmask fallback for libcurl < 7.85.0), UNRESTRICTED_AUTH=0L. Defense-in-depth on top of libcurl's automatic bearer strip on cross-host redirects (>=7.64.0). MEDIUM: - M1: upload_file resolves the path via std::filesystem::canonical up front. Closes broken-symlink, symlink-loop, and TOCTOU-on-target classes without a doc burden on callers. - M2: README "Linking" section documents the public-ABI nlohmann_json implication. v0.2 wrapper deferred. - M3: README "Threat model" section documents the parse-depth concern on the result field of /run replies. Runtime guard skipped for v0.1 per audit recommendation (low yield, complexity). LOW: - L1: cxx_std_20 → cxx_std_17 in CMakeLists.txt (no C++20-only features in the library source; broader downstream reach). Examples and tests still build via designated initializers (g++ accepts these in C++17 mode). - L2: RunResult struct doc clarifies that missing ok/duration_ms decode to defaults — opt-out forward-compat. - L3: Client class doc clarifies that moved-from instances must not have any non-special-member methods invoked (UB), with explicit callout on base_url() returning an internal reference. Test-only: - cpp-httplib 0.15.3 → 0.20.1. Optional backends (OpenSSL / zlib / brotli / zstd) forced off to keep the dep graph minimal. Test-only, never on the consumer wire path. README "Test deps" section added for transparency. Tests added (12 → 23 cases, 70 → 106 assertions): - protocol_error on malformed response for healthz, run, upload_file, create_token, list_tokens (H1 regression) - redirect_clamp_test (H2 regression — TransportError after 5+ hops) - redirect_protocol_clamp (H2 regression — ftp:// Location rejected) - upload_file_canonicalize: symlink→file works, broken symlink rejected, symlink loop rejected, directory rejected (M1 regression) Verified: - cmake --build build clean (-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Wold-style-cast -Werror) - ctest --output-on-failure all green (Release) - ASan + UBSan: 23/23 cases, 106/106 assertions, zero diagnostics Audit: memory/clawdforge-audits/cpp-bae34a7.md
543 lines
21 KiB
C++
543 lines
21 KiB
C++
// 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 <doctest/doctest.h>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
#include <httplib.h>
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <clawdforge/client.hpp>
|
|
|
|
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<void(httplib::Server&)> 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<std::streamsize>(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<json>") {
|
|
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<std::string>() == "hi");
|
|
CHECK(body.at("model").get<std::string>() == "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<nlohmann::json>(&r.result);
|
|
REQUIRE(j != nullptr);
|
|
CHECK(j->at("hello").get<std::string>() == "world");
|
|
}
|
|
|
|
TEST_CASE("run with string result decodes into variant<string>") {
|
|
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<std::string>(&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<std::string>() == "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<std::string>() == "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<bool> saw_field{false};
|
|
std::atomic<std::size_t> received_size{0};
|
|
std::atomic<bool> 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<std::string> seen_methods;
|
|
std::vector<std::string> 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<std::string>() == "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<int> 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);
|
|
}
|