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
797 lines
26 KiB
C
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;
|
|
}
|