clawdforge/clients/c/tests/test_client.c
Kayos a69e924592 clients/c: initial C SDK for clawdforge
Synchronous client over libcurl + vendored cJSON. Single public
header (include/clawdforge.h) with an opaque cf_client_t and the
full surface: /healthz, /run, /files, /admin/tokens.

- C11, no GNU extensions; -Wall -Wextra -Wpedantic clean
- Hidden visibility on the shared lib + CF_API export attribute
- Static + shared lib via CMake; relocatable pkg-config (${pcfiledir})
- Errors via out-param cf_error_t; every output struct has a _free()
- Multipart upload streams from disk via curl_mime_filedata
- 15 in-process socket-loop tests; valgrind + ASan clean
2026-04-28 23:01:52 -07:00

797 lines
26 KiB
C

/* 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 <arpa/inet.h>
#include <ctype.h>
#include <netinet/in.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <clawdforge.h>
#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;
}