/* Test suite for the clawdforge C SDK. * * Strategy: spin up a tiny single-threaded HTTP server on 127.0.0.1 * inside this process. The server pulls a scripted response off a * queue per request and returns it. No external dependency beyond * the platform sockets API and pthreads (already a transitive dep * of libcurl on Linux). * * We test the wire surface of the client end-to-end through libcurl: * URL construction, auth header injection, JSON request shape, * JSON response parsing, error envelopes, multipart upload. */ #define _DEFAULT_SOURCE #define _GNU_SOURCE #define _POSIX_C_SOURCE 200809L #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "cJSON.h" /* ------------------------------------------------------------------ */ /* tiny test harness */ /* ------------------------------------------------------------------ */ static int g_failures = 0; static int g_tests = 0; static const char *g_current = "(none)"; #define TEST(name) do { g_current = name; g_tests++; } while (0) #define CHECK(cond) do { \ if (!(cond)) { \ fprintf(stderr, "FAIL [%s] %s:%d: %s\n", \ g_current, __FILE__, __LINE__, #cond); \ g_failures++; \ } \ } while (0) /* Portable case-insensitive prefix compare. Returns 0 on match. */ static int header_match(const char *line, const char *name) { size_t n = strlen(name); for (size_t i = 0; i < n; ++i) { if (!line[i]) return 1; if (tolower((unsigned char)line[i]) != tolower((unsigned char)name[i])) return 1; } return 0; } /* Portable memmem (avoids _GNU_SOURCE dep). */ static const void *find_bytes(const void *hay, size_t hay_len, const void *needle, size_t need_len) { if (need_len == 0) return hay; if (hay_len < need_len) return NULL; const char *h = (const char *)hay; const char *n = (const char *)needle; for (size_t i = 0; i + need_len <= hay_len; ++i) { if (memcmp(h + i, n, need_len) == 0) return h + i; } return NULL; } #define CHECK_STR_EQ(a, b) do { \ const char *_a = (a); const char *_b = (b); \ if (!_a || !_b || strcmp(_a, _b) != 0) { \ fprintf(stderr, "FAIL [%s] %s:%d: \"%s\" != \"%s\"\n", \ g_current, __FILE__, __LINE__, \ _a ? _a : "(null)", _b ? _b : "(null)"); \ g_failures++; \ } \ } while (0) /* ------------------------------------------------------------------ */ /* mock server */ /* ------------------------------------------------------------------ */ typedef struct mock_response { int status; char *content_type; char *body; } mock_response_t; #define MAX_RESPONSES 16 typedef struct mock_request { char method[16]; char path[256]; char *auth; /* "Bearer ..." or NULL */ char *content_type; char *body; size_t body_len; } mock_request_t; static int srv_fd = -1; static int srv_port = 0; static pthread_t srv_thread; static volatile int srv_stop = 0; static pthread_mutex_t q_mu = PTHREAD_MUTEX_INITIALIZER; static mock_response_t q_resp[MAX_RESPONSES]; static int q_resp_head = 0; static int q_resp_tail = 0; static mock_request_t captured[MAX_RESPONSES]; static int captured_count = 0; static void enqueue_response(int status, const char *content_type, const char *body) { pthread_mutex_lock(&q_mu); mock_response_t *r = &q_resp[q_resp_tail]; r->status = status; r->content_type = content_type ? strdup(content_type) : strdup("application/json"); r->body = body ? strdup(body) : strdup(""); q_resp_tail = (q_resp_tail + 1) % MAX_RESPONSES; pthread_mutex_unlock(&q_mu); } static int dequeue_response(mock_response_t *out) { pthread_mutex_lock(&q_mu); if (q_resp_head == q_resp_tail) { pthread_mutex_unlock(&q_mu); return -1; } *out = q_resp[q_resp_head]; q_resp_head = (q_resp_head + 1) % MAX_RESPONSES; pthread_mutex_unlock(&q_mu); return 0; } static void capture(const mock_request_t *r) { pthread_mutex_lock(&q_mu); if (captured_count < MAX_RESPONSES) { captured[captured_count++] = *r; } pthread_mutex_unlock(&q_mu); } /* Read the full HTTP request from a socket. Returns 0 on success. */ static int read_request(int fd, mock_request_t *req) { char buf[64 * 1024]; size_t off = 0; /* Read until headers complete */ while (off < sizeof(buf) - 1) { ssize_t n = read(fd, buf + off, sizeof(buf) - 1 - off); if (n <= 0) return -1; off += (size_t)n; buf[off] = '\0'; if (strstr(buf, "\r\n\r\n")) break; } char *blank_line = strstr(buf, "\r\n\r\n"); if (!blank_line) return -1; char *body_start = blank_line + 4; size_t header_len = (size_t)(body_start - buf); /* The last header's terminating "\r\n" is the FIRST "\r\n" of the * "\r\n\r\n" sequence — so it's at blank_line..+1. To process every * header line including the last one, walk byte-range * [buf .. blank_line + 2). The "+2" includes the last header's "\r\n". */ char *header_end = blank_line + 2; /* Method + path */ char *line_end = strstr(buf, "\r\n"); if (!line_end || line_end >= header_end) return -1; char first_line[1024]; size_t fl_len = (size_t)(line_end - buf); if (fl_len >= sizeof first_line) fl_len = sizeof first_line - 1; memcpy(first_line, buf, fl_len); first_line[fl_len] = '\0'; char path_buf[256] = {0}; if (sscanf(first_line, "%15s %255s", req->method, path_buf) != 2) return -1; memcpy(req->path, path_buf, sizeof req->path); req->path[sizeof req->path - 1] = '\0'; /* Headers */ req->auth = NULL; req->content_type = NULL; long content_length = 0; char *p = line_end + 2; while (p < header_end) { /* find next \r\n strictly within the header range */ char *nl = NULL; for (char *q = p; q + 1 < header_end; ++q) { if (q[0] == '\r' && q[1] == '\n') { nl = q; break; } } if (!nl) break; size_t llen = (size_t)(nl - p); char line[2048]; if (llen >= sizeof line) llen = sizeof line - 1; memcpy(line, p, llen); line[llen] = '\0'; if (header_match(line, "Authorization:") == 0) { char *v = line + 14; while (*v == ' ') v++; req->auth = strdup(v); } else if (header_match(line, "Content-Type:") == 0) { char *v = line + 13; while (*v == ' ') v++; req->content_type = strdup(v); } else if (header_match(line, "Content-Length:") == 0) { content_length = strtol(line + 15, NULL, 10); } p = nl + 2; } /* Body */ req->body = NULL; req->body_len = 0; if (content_length > 0) { size_t already = off - header_len; char *body = (char *)malloc((size_t)content_length + 1); if (!body) return -1; if (already > (size_t)content_length) already = (size_t)content_length; memcpy(body, body_start, already); size_t got = already; while (got < (size_t)content_length) { ssize_t n = read(fd, body + got, (size_t)content_length - got); if (n <= 0) { free(body); return -1; } got += (size_t)n; } body[content_length] = '\0'; req->body = body; req->body_len = (size_t)content_length; } return 0; } static void write_response(int fd, const mock_response_t *resp) { char header[512]; int n = snprintf(header, sizeof header, "HTTP/1.1 %d %s\r\n" "Content-Type: %s\r\n" "Content-Length: %zu\r\n" "Connection: close\r\n" "\r\n", resp->status, resp->status == 200 ? "OK" : resp->status == 401 ? "Unauthorized" : resp->status == 404 ? "Not Found" : resp->status == 502 ? "Bad Gateway" : "Other", resp->content_type ? resp->content_type : "application/json", resp->body ? strlen(resp->body) : 0); if (n > 0) { ssize_t wrote = write(fd, header, (size_t)n); (void)wrote; if (resp->body && *resp->body) { wrote = write(fd, resp->body, strlen(resp->body)); (void)wrote; } } } static void *server_loop(void *arg) { (void)arg; while (!srv_stop) { struct sockaddr_in addr; socklen_t alen = sizeof addr; int cfd = accept(srv_fd, (struct sockaddr *)&addr, &alen); if (cfd < 0) { if (errno == EINTR || errno == EAGAIN) continue; break; } mock_request_t req; memset(&req, 0, sizeof req); if (read_request(cfd, &req) == 0) { capture(&req); mock_response_t resp; if (dequeue_response(&resp) == 0) { write_response(cfd, &resp); free(resp.content_type); free(resp.body); } else { /* No script left — return 500. */ mock_response_t fb = {500, strdup("text/plain"), strdup("no scripted response")}; write_response(cfd, &fb); free(fb.content_type); free(fb.body); } } close(cfd); } return NULL; } static int start_server(void) { srv_fd = socket(AF_INET, SOCK_STREAM, 0); if (srv_fd < 0) return -1; int yes = 1; setsockopt(srv_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes); /* Bind to a random port on loopback. */ struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = 0; if (bind(srv_fd, (struct sockaddr *)&addr, sizeof addr) != 0) return -1; socklen_t alen = sizeof addr; if (getsockname(srv_fd, (struct sockaddr *)&addr, &alen) != 0) return -1; srv_port = ntohs(addr.sin_port); if (listen(srv_fd, 16) != 0) return -1; /* Make accept interruptible by close. */ int flags = fcntl(srv_fd, F_GETFL, 0); (void)flags; if (pthread_create(&srv_thread, NULL, server_loop, NULL) != 0) return -1; return 0; } static void stop_server(void) { srv_stop = 1; /* Kick accept by connecting + closing. */ int kicker = socket(AF_INET, SOCK_STREAM, 0); if (kicker >= 0) { struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = htons(srv_port); connect(kicker, (struct sockaddr *)&addr, sizeof addr); close(kicker); } pthread_join(srv_thread, NULL); if (srv_fd >= 0) close(srv_fd); /* Drain any residual queue + captures. */ pthread_mutex_lock(&q_mu); while (q_resp_head != q_resp_tail) { free(q_resp[q_resp_head].content_type); free(q_resp[q_resp_head].body); q_resp_head = (q_resp_head + 1) % MAX_RESPONSES; } for (int i = 0; i < captured_count; ++i) { free(captured[i].auth); free(captured[i].content_type); free(captured[i].body); } captured_count = 0; pthread_mutex_unlock(&q_mu); } static char *make_base_url(void) { static char buf[64]; snprintf(buf, sizeof buf, "http://127.0.0.1:%d", srv_port); return buf; } static void reset_captures(void) { pthread_mutex_lock(&q_mu); for (int i = 0; i < captured_count; ++i) { free(captured[i].auth); free(captured[i].content_type); free(captured[i].body); } captured_count = 0; pthread_mutex_unlock(&q_mu); } /* ------------------------------------------------------------------ */ /* tests */ /* ------------------------------------------------------------------ */ static void t_version(void) { TEST("version"); CHECK_STR_EQ(cf_version(), "0.1.0"); CHECK_STR_EQ(cf_status_str(CF_OK), "CF_OK"); CHECK_STR_EQ(cf_status_str(CF_ERR_PARSE), "CF_ERR_PARSE"); } static void t_client_lifecycle(void) { TEST("client_lifecycle"); cf_client_t *c = cf_client_new("http://127.0.0.1:1/", "tok"); CHECK(c != NULL); cf_client_set_timeout_secs(c, 30); CHECK(cf_client_set_admin_token(c, "admintok") == CF_OK); cf_client_free(c); /* NULL base_url rejected. */ CHECK(cf_client_new(NULL, "x") == NULL); CHECK(cf_client_new("", "x") == NULL); } static void t_healthz_ok(void) { TEST("healthz_ok"); reset_captures(); enqueue_response(200, "application/json", "{\"ok\":true,\"claude_present\":true,\"claude_version\":\"1.2.3\"}"); cf_client_t *c = cf_client_new(make_base_url(), "tok"); cf_client_set_timeout_secs(c, 5); cf_healthz_t h = {0}; cf_error_t err = {0}; cf_status_t s = cf_healthz(c, &h, &err); CHECK(s == CF_OK); CHECK(h.ok == 1); CHECK(h.claude_present == 1); CHECK_STR_EQ(h.claude_version, "1.2.3"); cf_healthz_free(&h); cf_error_free(&err); cf_client_free(c); /* /healthz must NOT have an Auth header — server says it doesn't * require one and the client doesn't promise one. (Our code does * inject the bearer token when set; that's also fine — server * ignores. Just verify the path was correct.) */ CHECK_STR_EQ(captured[0].method, "GET"); CHECK_STR_EQ(captured[0].path, "/healthz"); } static void t_healthz_transport_fail(void) { TEST("healthz_transport_fail"); cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok"); cf_client_set_timeout_secs(c, 2); cf_healthz_t h = {0}; cf_error_t err = {0}; cf_status_t s = cf_healthz(c, &h, &err); CHECK(s == CF_ERR_TRANSPORT); CHECK(err.message && err.message[0] != '\0'); cf_error_free(&err); cf_client_free(c); } static void t_run_success_object(void) { TEST("run_success_object"); reset_captures(); enqueue_response(200, "application/json", "{\"ok\":true," "\"result\":{\"hello\":\"world\",\"n\":7}," "\"duration_ms\":1234," "\"stop_reason\":\"end_turn\"}"); cf_client_t *c = cf_client_new(make_base_url(), "cf_abc"); cf_run_request_t req = { .prompt = "Reply with JSON", .model = "sonnet", .timeout_secs = 30, }; cf_run_result_t res = {0}; cf_error_t err = {0}; cf_status_t s = cf_run(c, &req, &res, &err); CHECK(s == CF_OK); CHECK(res.duration_ms == 1234); CHECK(res.result_is_string == 0); CHECK_STR_EQ(res.stop_reason, "end_turn"); CHECK(res.result_json != NULL); /* result_json should be parseable by cJSON. */ cJSON *parsed = (cJSON *)cf_run_result_as_cjson(&res); CHECK(parsed != NULL); cJSON *hello = cJSON_GetObjectItemCaseSensitive(parsed, "hello"); CHECK(cJSON_IsString(hello)); if (cJSON_IsString(hello)) CHECK_STR_EQ(hello->valuestring, "world"); cJSON_Delete(parsed); /* Verify request body sent the right shape. */ CHECK_STR_EQ(captured[0].method, "POST"); CHECK_STR_EQ(captured[0].path, "/run"); CHECK(captured[0].auth != NULL); CHECK_STR_EQ(captured[0].auth, "Bearer cf_abc"); cJSON *sent = cJSON_Parse(captured[0].body); CHECK(sent != NULL); if (sent) { cJSON *prompt = cJSON_GetObjectItemCaseSensitive(sent, "prompt"); cJSON *model = cJSON_GetObjectItemCaseSensitive(sent, "model"); cJSON *to = cJSON_GetObjectItemCaseSensitive(sent, "timeout_secs"); CHECK(cJSON_IsString(prompt)); if (cJSON_IsString(prompt)) CHECK_STR_EQ(prompt->valuestring, "Reply with JSON"); CHECK(cJSON_IsString(model)); if (cJSON_IsString(model)) CHECK_STR_EQ(model->valuestring, "sonnet"); CHECK(cJSON_IsNumber(to)); if (cJSON_IsNumber(to)) CHECK((int)to->valuedouble == 30); cJSON_Delete(sent); } cf_run_result_free(&res); cf_error_free(&err); cf_client_free(c); } static void t_run_success_string(void) { TEST("run_success_string"); reset_captures(); enqueue_response(200, "application/json", "{\"ok\":true," "\"result\":\"plain text reply\"," "\"duration_ms\":42," "\"stop_reason\":\"end_turn\"}"); cf_client_t *c = cf_client_new(make_base_url(), "tok"); cf_run_request_t req = { .prompt = "hi" }; cf_run_result_t res = {0}; cf_error_t err = {0}; CHECK(cf_run(c, &req, &res, &err) == CF_OK); CHECK(res.result_is_string == 1); /* result_json is the JSON-encoded string, including quotes. */ CHECK_STR_EQ(res.result_json, "\"plain text reply\""); cf_run_result_free(&res); cf_error_free(&err); cf_client_free(c); } static void t_run_files_passed(void) { TEST("run_files_passed"); reset_captures(); enqueue_response(200, "application/json", "{\"ok\":true,\"result\":\"ok\",\"duration_ms\":1,\"stop_reason\":\"end_turn\"}"); const char *files[] = { "ff_aaa", "ff_bbb" }; cf_client_t *c = cf_client_new(make_base_url(), "tok"); cf_run_request_t req = { .prompt = "extract", .files = files, .files_count = 2, }; cf_run_result_t res = {0}; cf_error_t err = {0}; CHECK(cf_run(c, &req, &res, &err) == CF_OK); cJSON *sent = cJSON_Parse(captured[0].body); CHECK(sent != NULL); if (sent) { cJSON *arr = cJSON_GetObjectItemCaseSensitive(sent, "files"); CHECK(cJSON_IsArray(arr)); if (cJSON_IsArray(arr)) { CHECK(cJSON_GetArraySize(arr) == 2); cJSON *e0 = cJSON_GetArrayItem(arr, 0); cJSON *e1 = cJSON_GetArrayItem(arr, 1); CHECK(cJSON_IsString(e0)); CHECK(cJSON_IsString(e1)); if (cJSON_IsString(e0)) CHECK_STR_EQ(e0->valuestring, "ff_aaa"); if (cJSON_IsString(e1)) CHECK_STR_EQ(e1->valuestring, "ff_bbb"); } cJSON_Delete(sent); } cf_run_result_free(&res); cf_error_free(&err); cf_client_free(c); } static void t_run_502_envelope(void) { TEST("run_502_envelope"); reset_captures(); enqueue_response(502, "application/json", "{\"ok\":false,\"error\":\"claude exited with status 1\"," "\"stderr\":\"some stderr\",\"duration_ms\":2000,\"stop_reason\":null}"); cf_client_t *c = cf_client_new(make_base_url(), "tok"); cf_run_request_t req = { .prompt = "x" }; cf_run_result_t res = {0}; cf_error_t err = {0}; cf_status_t s = cf_run(c, &req, &res, &err); CHECK(s == CF_ERR_API); CHECK(err.http_status == 502); CHECK(err.message != NULL); CHECK(strstr(err.message, "claude exited") != NULL); cf_run_result_free(&res); cf_error_free(&err); cf_client_free(c); } static void t_run_401_auth(void) { TEST("run_401_auth"); reset_captures(); enqueue_response(401, "application/json", "{\"detail\":\"invalid token\"}"); cf_client_t *c = cf_client_new(make_base_url(), "wrong"); cf_run_request_t req = { .prompt = "x" }; cf_run_result_t res = {0}; cf_error_t err = {0}; cf_status_t s = cf_run(c, &req, &res, &err); CHECK(s == CF_ERR_AUTH); CHECK(err.http_status == 401); CHECK(err.message != NULL); CHECK(strstr(err.message, "invalid token") != NULL); cf_run_result_free(&res); cf_error_free(&err); cf_client_free(c); } static void t_run_usage_errors(void) { TEST("run_usage_errors"); cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok"); cf_run_request_t req = { .prompt = "" }; cf_run_result_t res = {0}; cf_error_t err = {0}; cf_status_t s = cf_run(c, &req, &res, &err); CHECK(s == CF_ERR_USAGE); CHECK(err.message != NULL); cf_error_free(&err); /* NULL request */ s = cf_run(c, NULL, &res, &err); CHECK(s == CF_ERR_USAGE); cf_error_free(&err); cf_client_free(c); } static void t_upload_file(void) { TEST("upload_file"); reset_captures(); enqueue_response(200, "application/json", "{\"file_token\":\"ff_xyz\",\"ttl_secs\":600,\"size\":11}"); /* Write a tmp file. */ char tmpl[] = "/tmp/cf-c-test-XXXXXX"; int fd = mkstemp(tmpl); CHECK(fd >= 0); if (fd >= 0) { ssize_t w = write(fd, "hello world", 11); (void)w; close(fd); } cf_client_t *c = cf_client_new(make_base_url(), "tok"); cf_file_token_t ft = {0}; cf_error_t err = {0}; cf_status_t s = cf_upload_file(c, tmpl, 600, &ft, &err); CHECK(s == CF_OK); CHECK_STR_EQ(ft.file_token, "ff_xyz"); CHECK(ft.ttl_secs == 600); CHECK(ft.size == 11); /* Verify request shape: POST /files, multipart, contains file content. */ CHECK_STR_EQ(captured[0].method, "POST"); CHECK_STR_EQ(captured[0].path, "/files"); CHECK(captured[0].content_type != NULL); if (captured[0].content_type) { CHECK(strstr(captured[0].content_type, "multipart/form-data") != NULL); } CHECK(captured[0].body != NULL); if (captured[0].body) { CHECK(find_bytes(captured[0].body, captured[0].body_len, "hello world", 11) != NULL); CHECK(find_bytes(captured[0].body, captured[0].body_len, "ttl_secs", 8) != NULL); } cf_file_token_free(&ft); cf_error_free(&err); cf_client_free(c); unlink(tmpl); } static void t_admin_create_token(void) { TEST("admin_create_token"); reset_captures(); enqueue_response(200, "application/json", "{\"name\":\"cauldron\",\"token\":\"cf_secret_xxx\"," "\"ip_cidrs\":[\"172.24.0.0/16\",\"10.0.0.0/8\"]}"); cf_client_t *c = cf_client_new(make_base_url(), NULL); CHECK(cf_client_set_admin_token(c, "admin_bootstrap_token") == CF_OK); const char *cidrs[] = { "172.24.0.0/16", "10.0.0.0/8" }; cf_admin_token_t out = {0}; cf_error_t err = {0}; cf_status_t s = cf_admin_create_token(c, "cauldron", cidrs, 2, &out, &err); CHECK(s == CF_OK); CHECK_STR_EQ(out.name, "cauldron"); CHECK_STR_EQ(out.token, "cf_secret_xxx"); CHECK(out.ip_cidrs_count == 2); if (out.ip_cidrs_count == 2) { CHECK_STR_EQ(out.ip_cidrs[0], "172.24.0.0/16"); CHECK_STR_EQ(out.ip_cidrs[1], "10.0.0.0/8"); } /* Verify auth was the admin token. */ CHECK_STR_EQ(captured[0].auth, "Bearer admin_bootstrap_token"); cf_admin_token_free(&out); cf_error_free(&err); cf_client_free(c); } static void t_admin_list_revoke(void) { TEST("admin_list_revoke"); reset_captures(); enqueue_response(200, "application/json", "{\"tokens\":[" "{\"name\":\"a\",\"ip_cidrs\":[\"127.0.0.1/32\"],\"created_at\":1700000000,\"last_used_at\":1700000100}," "{\"name\":\"b\",\"ip_cidrs\":[],\"created_at\":1700000200}" "]}"); cf_client_t *c = cf_client_new(make_base_url(), NULL); cf_client_set_admin_token(c, "admin"); cf_admin_token_list_t list = {0}; cf_error_t err = {0}; CHECK(cf_admin_list_tokens(c, &list, &err) == CF_OK); CHECK(list.count == 2); if (list.count == 2) { CHECK_STR_EQ(list.items[0].name, "a"); CHECK(list.items[0].ip_cidrs_count == 1); if (list.items[0].ip_cidrs_count == 1) { CHECK_STR_EQ(list.items[0].ip_cidrs[0], "127.0.0.1/32"); } CHECK(list.items[0].created_at == 1700000000); CHECK(list.items[0].last_used_at == 1700000100); CHECK_STR_EQ(list.items[1].name, "b"); CHECK(list.items[1].ip_cidrs_count == 0); } cf_admin_token_list_free(&list); cf_error_free(&err); /* Revoke. */ enqueue_response(200, "application/json", "{\"ok\":true}"); CHECK(cf_admin_revoke_token(c, "a", &err) == CF_OK); CHECK_STR_EQ(captured[1].method, "DELETE"); CHECK_STR_EQ(captured[1].path, "/admin/tokens/a"); /* Revoke missing -> 404 */ enqueue_response(404, "application/json", "{\"detail\":\"no such token\"}"); cf_status_t s = cf_admin_revoke_token(c, "nope", &err); CHECK(s == CF_ERR_API); CHECK(err.http_status == 404); CHECK(err.message && strstr(err.message, "no such token") != NULL); cf_error_free(&err); cf_client_free(c); } static void t_admin_requires_token(void) { TEST("admin_requires_token"); cf_client_t *c = cf_client_new("http://127.0.0.1:1", NULL); cf_admin_token_list_t list = {0}; cf_error_t err = {0}; cf_status_t s = cf_admin_list_tokens(c, &list, &err); CHECK(s == CF_ERR_USAGE); CHECK(err.message != NULL); cf_error_free(&err); cf_client_free(c); } static void t_url_normalisation(void) { TEST("url_normalisation"); reset_captures(); enqueue_response(200, "application/json", "{\"ok\":true,\"claude_present\":false}"); /* Trailing slash should be stripped. */ char base_with_slash[64]; snprintf(base_with_slash, sizeof base_with_slash, "%s/", make_base_url()); cf_client_t *c = cf_client_new(base_with_slash, "tok"); cf_healthz_t h = {0}; cf_error_t err = {0}; CHECK(cf_healthz(c, &h, &err) == CF_OK); CHECK_STR_EQ(captured[0].path, "/healthz"); cf_healthz_free(&h); cf_error_free(&err); cf_client_free(c); } /* ------------------------------------------------------------------ */ /* main */ /* ------------------------------------------------------------------ */ int main(void) { /* Don't die on SIGPIPE if a peer hangs up. */ signal(SIGPIPE, SIG_IGN); if (start_server() != 0) { fprintf(stderr, "FATAL: could not start mock server\n"); return 2; } t_version(); t_client_lifecycle(); t_healthz_ok(); t_healthz_transport_fail(); t_run_success_object(); t_run_success_string(); t_run_files_passed(); t_run_502_envelope(); t_run_401_auth(); t_run_usage_errors(); t_upload_file(); t_admin_create_token(); t_admin_list_revoke(); t_admin_requires_token(); t_url_normalisation(); stop_server(); fprintf(stderr, "\n%d tests, %d failures\n", g_tests, g_failures); return g_failures == 0 ? 0 : 1; }