clawdforge/clients/c/tests/test_client.c
Kayos 22e57e3dad clients/c: v0.2 multi-turn Session API
- cf_session_t (opaque), cf_session_options_t, cf_turn_options_t,
  cf_turn_event_t, cf_turn_result_t, cf_session_state_t, cf_session_list_t
- cf_session_new / _turn / _close (idempotent) / _free, _state_get,
  _list_get, plus cf_session_id / _agent accessors
- cf_turn_result_text concatenates type=="text" events into a single
  string, lazily cached, OWNED by the result (caller must not free)
- Session-id allow-list ([A-Za-z0-9_-]+) validated client-side everywhere
  it lands in the URL path — same pattern as cf_admin_revoke_token, no
  reverse-proxy traversal foothold
- Bearer hygiene preserved: regression test covers cf_session_new /
  state_get / list_get and asserts the bearer never lands in
  cf_error_t.message
- v0.1 surface unchanged. Existing types (cf_client_t, cf_run_*, etc.)
  are byte-identical. cJSON 1.7.18 preserved.

Tests (21 → 34, +13):
- session_turn_round_trip — full create + turn + close, event parsing
- session_close_idempotent — close × 3, DELETE on the wire ONCE
- session_turn_after_close_returns_error — CF_ERR_USAGE, no HTTP
- session_cross_token_returns_404 — CF_ERR_API + http_status==404
- session_validates_id_traversal — "a/../healthz" rejected pre-network
- session_list_get — 2-row mock parsed; null last_turn_at → -1
- session_state_get — full state shape parsed
- turn_result_text_concatenates — skips non-text + empty content
- session_free_idempotent — NULL-safe across all v0.2 freers
- turn_result_memory_clean — valgrind regression guard
- session_turn_with_files — files + timeout_secs serialised
- session_bearer_never_leaks — 3 fallible paths, bearer stays out
- session_null_arg_defenses — every entry point rejects NULLs

Verification:
- cmake --build build (Release, -Werror -Wall -Wextra -Wpedantic
  -Wshadow): clean
- ctest --test-dir build: 34/34 pass
- valgrind --leak-check=full: 12,678 allocs == 12,678 frees, 0 errors,
  0 leaks
- ASan build: 34/34 pass clean
- UBSan build (-fsanitize=undefined -fno-sanitize-recover=all): 34/34
  pass clean

README adds "Multi-turn / Sessions (v0.2)" section: lifecycle example,
idempotent-close note, event/text ownership rules, state + listing,
memory ownership table, what's not in v0.2.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 07:08:50 -07:00

1600 lines
55 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <clawdforge.h>
#include "cJSON.h"
/* Internal symbols used by the buffer-overflow guard test. The static
* library exports them; these are not part of the public ABI. */
typedef struct cf_buf {
char *data;
size_t len;
size_t cap;
} cf_buf_t_test;
extern void cf_buf_init(cf_buf_t_test *b);
extern int cf_buf_append(cf_buf_t_test *b, const void *src, size_t n);
extern void cf_buf_free(cf_buf_t_test *b);
/* ------------------------------------------------------------------ */
/* 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;
/* When stream_bytes > 0, the response body is `stream_bytes` ASCII
* 'x' characters generated on the fly from a small fixed buffer
* (no big malloc). Used to exercise the response-body cap without
* allocating tens of megabytes server-side. */
long stream_bytes;
} 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("");
r->stream_bytes = 0;
q_resp_tail = (q_resp_tail + 1) % MAX_RESPONSES;
pthread_mutex_unlock(&q_mu);
}
static void enqueue_stream_response(int status, long bytes) {
pthread_mutex_lock(&q_mu);
mock_response_t *r = &q_resp[q_resp_tail];
r->status = status;
r->content_type = strdup("application/octet-stream");
r->body = NULL;
r->stream_bytes = bytes;
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) {
size_t body_len = resp->stream_bytes > 0
? (size_t)resp->stream_bytes
: (resp->body ? strlen(resp->body) : 0);
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",
body_len);
if (n > 0) {
ssize_t wrote = write(fd, header, (size_t)n);
(void)wrote;
if (resp->stream_bytes > 0) {
/* Stream `stream_bytes` ASCII 'x' from a small chunk buffer.
* Stops early on EPIPE — clients that abort write_cb hang up
* mid-transfer, which is exactly what we want to test. */
char chunk[8192];
memset(chunk, 'x', sizeof chunk);
size_t remaining = (size_t)resp->stream_bytes;
while (remaining > 0) {
size_t step = remaining < sizeof chunk ? remaining : sizeof chunk;
ssize_t w = write(fd, chunk, step);
if (w <= 0) break;
remaining -= (size_t)w;
}
} else 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"), 0};
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.2.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[80];
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);
}
/* ------------------------------------------------------------------ */
/* New tests added by audit-fix pass */
/* ------------------------------------------------------------------ */
/* H3 follow-up: cf_admin_revoke_token must reject names that are
* not [A-Za-z0-9_-]+ before it interpolates into the URL. */
static void t_revoke_token_validates_name(void) {
TEST("revoke_token_validates_name");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", NULL);
cf_client_set_admin_token(c, "admin");
cf_error_t err = {0};
/* Path-traversal attempt: must be rejected client-side. */
cf_status_t s = cf_admin_revoke_token(c, "a/../healthz", &err);
CHECK(s == CF_ERR_USAGE);
CHECK(err.message != NULL);
if (err.message) CHECK(strstr(err.message, "invalid token name") != NULL);
cf_error_free(&err);
/* Empty name */
s = cf_admin_revoke_token(c, "", &err);
CHECK(s == CF_ERR_USAGE);
cf_error_free(&err);
/* URL-encoded byte still rejected — raw byte not in allow-list. */
s = cf_admin_revoke_token(c, "a%2F..%2Fhealthz", &err);
CHECK(s == CF_ERR_USAGE);
cf_error_free(&err);
/* A valid name still proceeds to a network call (which fails because
* no server is listening at :1, surfacing CF_ERR_TRANSPORT — not
* CF_ERR_USAGE — proving the validator let it through). */
s = cf_admin_revoke_token(c, "valid-name_123", &err);
CHECK(s == CF_ERR_TRANSPORT);
cf_error_free(&err);
cf_client_free(c);
}
/* M1: cf_buf_append guards both the additive overflow (n + len + 1) and
* the doubling overflow (newcap *= 2). The guards must fire on the size
* computation alone — neither realloc nor memcpy is reached on either
* synthetic input, so data may safely stay NULL. */
static void t_buf_append_overflow_guards(void) {
TEST("buf_append_overflow_guards");
cf_buf_t_test b;
cf_buf_init(&b);
/* (a) additive overflow: n + len + 1 wraps. */
b.data = NULL;
b.cap = 0;
b.len = SIZE_MAX - 4;
int rc = cf_buf_append(&b, "abcd", 5); /* 5 > SIZE_MAX - (SIZE_MAX-4) - 1 = 3 */
CHECK(rc == -1);
/* (b) doubling overflow: cap is already past SIZE_MAX/2, and n
* forces need > cap so the loop runs. The first iteration must
* trip the `newcap > SIZE_MAX/2` guard. */
b.data = NULL;
b.len = 0;
b.cap = (SIZE_MAX / 2) + 100;
rc = cf_buf_append(&b, "x", SIZE_MAX - 200);
CHECK(rc == -1);
/* Reset to a clean state for the sanity check / free. */
b.data = NULL;
b.len = 0;
b.cap = 0;
/* (c) sanity: a normal small append still works. */
rc = cf_buf_append(&b, "hello", 5);
CHECK(rc == 0);
CHECK(b.len == 5);
CHECK(b.data != NULL);
if (b.data) CHECK(strcmp(b.data, "hello") == 0);
cf_buf_free(&b);
}
/* M2: a server response larger than CF_MAX_RESPONSE_BYTES must abort
* mid-transfer, not silently consume the heap. */
static void t_response_body_size_cap(void) {
TEST("response_body_size_cap");
reset_captures();
/* CF_MAX_RESPONSE_BYTES is 64 MiB. Stream 65 MiB so we cross the cap. */
long over_cap = (long)CF_MAX_RESPONSE_BYTES + (1L << 20);
enqueue_stream_response(200, over_cap);
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_client_set_timeout_secs(c, 30);
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 != NULL);
cf_healthz_free(&h);
cf_error_free(&err);
cf_client_free(c);
}
/* M3: with no CONNECTTIMEOUT set, libcurl waits ~300s. We set 10s,
* so a connect to 10.255.255.1 (RFC1918 unroutable) must fail well
* before the test-suite timeout (60s). */
static void t_connect_timeout(void) {
TEST("connect_timeout");
cf_client_t *c = cf_client_new("http://10.255.255.1:80", "tok");
/* Total timeout high so we know the connect-timeout is what trips. */
cf_client_set_timeout_secs(c, 30);
cf_healthz_t h = {0};
cf_error_t err = {0};
struct timeval t0, t1;
gettimeofday(&t0, NULL);
cf_status_t s = cf_healthz(c, &h, &err);
gettimeofday(&t1, NULL);
double elapsed = (t1.tv_sec - t0.tv_sec) +
(t1.tv_usec - t0.tv_usec) / 1e6;
CHECK(s == CF_ERR_TRANSPORT);
/* Allow 18s slack for slow CI; the libcurl default of 300s would
* blow the test-suite timeout long before this. */
CHECK(elapsed < 18.0);
cf_healthz_free(&h);
cf_error_free(&err);
cf_client_free(c);
}
/* M4: g_curl_init_count must survive concurrent cf_client_new /
* cf_client_free across threads without leaking, double-cleaning, or
* tearing down libcurl while another thread is mid-request. */
typedef struct {
int iters;
} init_thread_arg_t;
static void *init_worker(void *arg) {
init_thread_arg_t *a = (init_thread_arg_t *)arg;
for (int i = 0; i < a->iters; ++i) {
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
if (c) {
cf_client_set_timeout_secs(c, 1);
cf_client_free(c);
}
}
return NULL;
}
static void t_concurrent_client_init(void) {
TEST("concurrent_client_init");
enum { N = 4 };
pthread_t th[N];
init_thread_arg_t args[N];
for (int i = 0; i < N; ++i) {
args[i].iters = 50;
int rc = pthread_create(&th[i], NULL, init_worker, &args[i]);
CHECK(rc == 0);
}
for (int i = 0; i < N; ++i) {
pthread_join(th[i], NULL);
}
/* If we're still running with no crash and no SIGSEGV, the atomic
* refcount held. valgrind/asan will catch any heap corruption. */
CHECK(1);
}
/* CVE-2024-31755: prior to cJSON 1.7.18, calling cJSON_SetValuestring
* with a NULL `valuestring` argument on a string node would deref NULL
* inside strlen. The fix is a NULL check that returns NULL safely. */
static void t_cjson_bump(void) {
TEST("cjson_bump");
cJSON *node = cJSON_CreateString("seed");
CHECK(node != NULL);
if (node) {
/* Pre-fix: this call crashed. Post-fix: returns NULL. */
char *r = cJSON_SetValuestring(node, NULL);
CHECK(r == NULL);
/* The original valuestring must be untouched. */
CHECK(cJSON_IsString(node));
if (cJSON_IsString(node)) {
CHECK_STR_EQ(node->valuestring, "seed");
}
cJSON_Delete(node);
}
/* Belt-and-braces: parsing crafted JSON also returns sanely (NULL
* or a node), never crashes. */
const char *crafted = "{\"a\":[1,2,{\"b\":\"c\"}],\"d\":null}";
cJSON *parsed = cJSON_Parse(crafted);
CHECK(parsed != NULL);
if (parsed) cJSON_Delete(parsed);
/* And a malformed input: must return NULL, not crash. */
cJSON *bad = cJSON_Parse("{\"a\":");
CHECK(bad == NULL);
}
/* ------------------------------------------------------------------ */
/* v0.2 — multi-turn / Sessions */
/* ------------------------------------------------------------------ */
/* Helper — JSON body for "POST /sessions" success. */
static const char *SESS_CREATE_BODY =
"{\"ok\":true,"
"\"session_id\":\"sess_abc\","
"\"agent\":\"claude\","
"\"created_at\":1735000000,"
"\"cwd\":\"/tmp/sess_abc\"}";
static const char *SESS_CLOSE_BODY = "{\"ok\":true}";
/* Test — full create + turn round trip. */
static void t_session_turn_round_trip(void) {
TEST("session_turn_round_trip");
reset_captures();
/* 1) POST /sessions response */
enqueue_response(200, "application/json", SESS_CREATE_BODY);
/* 2) POST /sessions/<id>/turn response with structured events */
enqueue_response(200, "application/json",
"{\"ok\":true,"
"\"session_id\":\"sess_abc\","
"\"turn_index\":1,"
"\"events\":["
"{\"type\":\"thinking\",\"content\":\"hmm...\"},"
"{\"type\":\"tool_call\",\"name\":\"Read\",\"args\":{\"path\":\"README.md\"},\"result\":{\"chars\":42}},"
"{\"type\":\"text\",\"content\":\"hello, \"},"
"{\"type\":\"text\",\"content\":\"world\"}"
"],"
"\"stop_reason\":\"end_turn\","
"\"duration_ms\":4321}");
cf_client_t *c = cf_client_new(make_base_url(), "cf_apptok");
cf_session_t *s = NULL;
cf_error_t err = {0};
cf_session_options_t sopts = { .agent = "claude" };
cf_status_t rc = cf_session_new(c, &sopts, &s, &err);
CHECK(rc == CF_OK);
CHECK(s != NULL);
if (s) {
CHECK_STR_EQ(cf_session_id(s), "sess_abc");
CHECK_STR_EQ(cf_session_agent(s), "claude");
}
/* Verify the create POST */
CHECK_STR_EQ(captured[0].method, "POST");
CHECK_STR_EQ(captured[0].path, "/sessions");
CHECK_STR_EQ(captured[0].auth, "Bearer cf_apptok");
cJSON *sent = cJSON_Parse(captured[0].body);
CHECK(sent != NULL);
if (sent) {
cJSON *jagent = cJSON_GetObjectItemCaseSensitive(sent, "agent");
CHECK(cJSON_IsString(jagent));
if (cJSON_IsString(jagent)) CHECK_STR_EQ(jagent->valuestring, "claude");
cJSON_Delete(sent);
}
/* Now run a turn */
cf_turn_options_t topts = {0};
cf_turn_result_t *r = NULL;
rc = cf_session_turn(s, "Read README.md and summarize", &topts, &r, &err);
CHECK(rc == CF_OK);
CHECK(r != NULL);
if (r) {
CHECK(r->ok == 1);
CHECK_STR_EQ(r->session_id, "sess_abc");
CHECK(r->turn_index == 1);
CHECK_STR_EQ(r->stop_reason, "end_turn");
CHECK(r->duration_ms == 4321);
CHECK(r->events_count == 4);
if (r->events_count == 4) {
CHECK_STR_EQ(r->events[0].type, "thinking");
CHECK_STR_EQ(r->events[0].content, "hmm...");
CHECK_STR_EQ(r->events[1].type, "tool_call");
CHECK_STR_EQ(r->events[1].name, "Read");
CHECK(r->events[1].args_json != NULL);
CHECK(r->events[1].result_json != NULL);
if (r->events[1].args_json) {
CHECK(strstr(r->events[1].args_json, "README.md") != NULL);
}
CHECK_STR_EQ(r->events[2].type, "text");
CHECK_STR_EQ(r->events[3].type, "text");
}
/* cf_turn_result_text should concatenate text events. */
const char *text = cf_turn_result_text(r);
CHECK_STR_EQ(text, "hello, world");
/* Second call — must reuse the cached buffer. */
const char *text2 = cf_turn_result_text(r);
CHECK(text == text2);
cf_turn_result_free(r);
}
/* Verify the turn POST */
CHECK_STR_EQ(captured[1].method, "POST");
CHECK_STR_EQ(captured[1].path, "/sessions/sess_abc/turn");
CHECK_STR_EQ(captured[1].auth, "Bearer cf_apptok");
sent = cJSON_Parse(captured[1].body);
CHECK(sent != NULL);
if (sent) {
cJSON *jp = cJSON_GetObjectItemCaseSensitive(sent, "prompt");
CHECK(cJSON_IsString(jp));
if (cJSON_IsString(jp)) CHECK_STR_EQ(jp->valuestring, "Read README.md and summarize");
cJSON_Delete(sent);
}
/* Now close the session — DELETE /sessions/<id> */
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
rc = cf_session_close(s, &err);
CHECK(rc == CF_OK);
CHECK_STR_EQ(captured[2].method, "DELETE");
CHECK_STR_EQ(captured[2].path, "/sessions/sess_abc");
cf_session_free(s); /* already closed; no extra HTTP traffic */
cf_error_free(&err);
cf_client_free(c);
}
/* Test — close × 2 only hits DELETE once. */
static void t_session_close_idempotent(void) {
TEST("session_close_idempotent");
reset_captures();
enqueue_response(200, "application/json", SESS_CREATE_BODY);
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
/* Note: NOT enqueueing a second DELETE response — the second close
* should short-circuit before hitting the wire. If it did go to
* the wire, the test server's fallback 500 would surface as a
* non-CF_OK from the second close. */
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_session_t *s = NULL;
cf_error_t err = {0};
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
CHECK(cf_session_close(s, &err) == CF_OK);
CHECK(cf_session_close(s, &err) == CF_OK); /* idempotent — no HTTP */
CHECK(cf_session_close(s, &err) == CF_OK);
/* Captured: POST /sessions, then exactly ONE DELETE. */
CHECK(captured_count == 2);
if (captured_count >= 2) {
CHECK_STR_EQ(captured[0].method, "POST");
CHECK_STR_EQ(captured[1].method, "DELETE");
}
cf_session_free(s);
cf_error_free(&err);
cf_client_free(c);
}
/* Test — turn after close → CF_ERR_USAGE, no HTTP traffic. */
static void t_session_turn_after_close_returns_error(void) {
TEST("session_turn_after_close_returns_error");
reset_captures();
enqueue_response(200, "application/json", SESS_CREATE_BODY);
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_session_t *s = NULL;
cf_error_t err = {0};
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
CHECK(cf_session_close(s, &err) == CF_OK);
cf_turn_options_t topts = {0};
cf_turn_result_t *r = NULL;
cf_status_t rc = cf_session_turn(s, "hi", &topts, &r, &err);
CHECK(rc == CF_ERR_USAGE);
CHECK(r == NULL);
CHECK(err.message && strstr(err.message, "closed") != NULL);
/* And NO third HTTP request was made. */
CHECK(captured_count == 2);
cf_error_free(&err);
cf_session_free(s);
cf_client_free(c);
}
/* Test — server 404 on cross-token access surfaces as CF_ERR_API + 404. */
static void t_session_cross_token_returns_404(void) {
TEST("session_cross_token_returns_404");
reset_captures();
enqueue_response(404, "application/json", "{\"detail\":\"session not found\"}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_session_state_t *state = NULL;
cf_error_t err = {0};
cf_status_t rc = cf_session_state_get(c, "sess_other", &state, &err);
CHECK(rc == CF_ERR_API);
CHECK(err.http_status == 404);
CHECK(state == NULL);
CHECK(err.message && strstr(err.message, "session not found") != NULL);
/* Bearer must NOT leak into the error. */
CHECK(err.message && strstr(err.message, "tok") == NULL);
CHECK(err.message && strstr(err.message, "Bearer") == NULL);
cf_error_free(&err);
cf_client_free(c);
}
/* Test — session_id with traversal characters → CF_ERR_USAGE pre-network. */
static void t_session_validates_id_traversal(void) {
TEST("session_validates_id_traversal");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
cf_error_t err = {0};
cf_session_state_t *state = NULL;
cf_status_t rc = cf_session_state_get(c, "a/../healthz", &state, &err);
CHECK(rc == CF_ERR_USAGE);
CHECK(state == NULL);
cf_error_free(&err);
rc = cf_session_state_get(c, "../etc/passwd", &state, &err);
CHECK(rc == CF_ERR_USAGE);
cf_error_free(&err);
rc = cf_session_state_get(c, "", &state, &err);
CHECK(rc == CF_ERR_USAGE);
cf_error_free(&err);
/* A valid id continues to the network and surfaces CF_ERR_TRANSPORT
* (no server listening at :1) — proving the validator passed. */
rc = cf_session_state_get(c, "valid_id-123", &state, &err);
CHECK(rc == CF_ERR_TRANSPORT);
cf_error_free(&err);
cf_client_free(c);
}
/* Test — cf_session_list_get parses 2 rows and surfaces fields. */
static void t_session_list_get(void) {
TEST("session_list_get");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,\"count\":2,\"sessions\":["
"{\"session_id\":\"sess_a\",\"app_name\":\"app1\",\"agent\":\"claude\","
"\"created_at\":1700000000,\"last_turn_at\":1700000100,"
"\"turn_count\":3,\"closed_at\":null},"
"{\"session_id\":\"sess_b\",\"app_name\":\"app1\",\"agent\":\"claude\","
"\"created_at\":1700000200,\"last_turn_at\":null,"
"\"turn_count\":0,\"closed_at\":1700000300}"
"]}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_session_list_t *list = NULL;
cf_error_t err = {0};
cf_status_t rc = cf_session_list_get(c, &list, &err);
CHECK(rc == CF_OK);
CHECK(list != NULL);
if (list) {
CHECK(list->items_count == 2);
if (list->items_count == 2) {
CHECK_STR_EQ(list->items[0].session_id, "sess_a");
CHECK_STR_EQ(list->items[0].app_name, "app1");
CHECK_STR_EQ(list->items[0].agent, "claude");
CHECK(list->items[0].created_at == 1700000000);
CHECK(list->items[0].last_turn_at == 1700000100);
CHECK(list->items[0].turn_count == 3);
CHECK(list->items[0].closed_at == -1);
CHECK_STR_EQ(list->items[1].session_id, "sess_b");
CHECK(list->items[1].last_turn_at == -1); /* null */
CHECK(list->items[1].closed_at == 1700000300);
}
cf_session_list_free(list);
}
CHECK_STR_EQ(captured[0].method, "GET");
CHECK_STR_EQ(captured[0].path, "/sessions");
cf_error_free(&err);
cf_client_free(c);
}
/* Test — cf_session_state_get returns a populated state struct. */
static void t_session_state_get(void) {
TEST("session_state_get");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,\"session_id\":\"sess_a\",\"app_name\":\"app1\","
"\"agent\":\"claude\",\"created_at\":1700000000,"
"\"last_turn_at\":1700000100,\"turn_count\":5,\"closed_at\":null}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_session_state_t *state = NULL;
cf_error_t err = {0};
cf_status_t rc = cf_session_state_get(c, "sess_a", &state, &err);
CHECK(rc == CF_OK);
CHECK(state != NULL);
if (state) {
CHECK_STR_EQ(state->session_id, "sess_a");
CHECK_STR_EQ(state->app_name, "app1");
CHECK_STR_EQ(state->agent, "claude");
CHECK(state->created_at == 1700000000);
CHECK(state->last_turn_at == 1700000100);
CHECK(state->turn_count == 5);
CHECK(state->closed_at == -1); /* null */
cf_session_state_free(state);
}
CHECK_STR_EQ(captured[0].method, "GET");
CHECK_STR_EQ(captured[0].path, "/sessions/sess_a");
cf_error_free(&err);
cf_client_free(c);
}
/* Test — text concatenation skips non-text events; cached on second call. */
static void t_turn_result_text_concatenates(void) {
TEST("turn_result_text_concatenates");
reset_captures();
enqueue_response(200, "application/json", SESS_CREATE_BODY);
enqueue_response(200, "application/json",
"{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":0,"
"\"events\":["
"{\"type\":\"text\",\"content\":\"alpha \"},"
"{\"type\":\"thinking\",\"content\":\"ignored\"},"
"{\"type\":\"text\",\"content\":\"beta\"},"
"{\"type\":\"text\"}," /* no content — skipped */
"{\"type\":\"text\",\"content\":\"\"}," /* empty — appends nothing */
"{\"type\":\"text\",\"content\":\" gamma\"}"
"],"
"\"stop_reason\":\"end_turn\",\"duration_ms\":1}");
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_session_t *s = NULL;
cf_error_t err = {0};
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
cf_turn_result_t *r = NULL;
cf_turn_options_t topts = {0};
CHECK(cf_session_turn(s, "go", &topts, &r, &err) == CF_OK);
if (r) {
CHECK_STR_EQ(cf_turn_result_text(r), "alpha beta gamma");
/* Empty-text edge case is handled. */
cf_turn_result_free(r);
}
cf_session_close(s, &err);
cf_session_free(s);
cf_error_free(&err);
cf_client_free(c);
}
/* Test — cf_session_free(NULL) and double-free pattern is safe. */
static void t_session_free_idempotent(void) {
TEST("session_free_idempotent");
cf_session_free(NULL); /* must not crash */
/* For an open session, free should attempt close. With no server
* listening, the close fails internally but cf_session_free swallows
* it — must not crash, must not leak. */
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
/* Verify the other v0.2 freers are also NULL-safe. */
cf_turn_result_free(NULL);
cf_session_state_free(NULL);
cf_session_list_free(NULL);
cf_client_free(c);
CHECK(1); /* survival = pass */
}
/* Test — turn-result memory is fully released (valgrind catches leaks). */
static void t_turn_result_memory_clean(void) {
TEST("turn_result_memory_clean");
reset_captures();
enqueue_response(200, "application/json", SESS_CREATE_BODY);
enqueue_response(200, "application/json",
"{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":7,"
"\"events\":["
"{\"type\":\"text\",\"content\":\"a\"},"
"{\"type\":\"tool_call\",\"name\":\"Bash\","
"\"args\":{\"cmd\":\"ls\"},\"result\":{\"out\":\"foo\\nbar\"}},"
"{\"type\":\"thinking\",\"content\":\"...\"}"
"],"
"\"stop_reason\":\"end_turn\",\"duration_ms\":99}");
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_session_t *s = NULL;
cf_error_t err = {0};
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
cf_turn_options_t topts = {0};
cf_turn_result_t *r = NULL;
CHECK(cf_session_turn(s, "x", &topts, &r, &err) == CF_OK);
/* Touch text twice to populate + reuse the cache. */
CHECK_STR_EQ(cf_turn_result_text(r), "a");
CHECK_STR_EQ(cf_turn_result_text(r), "a");
cf_turn_result_free(r);
cf_session_close(s, &err);
cf_session_free(s);
cf_error_free(&err);
cf_client_free(c);
}
/* Test — turn with files passes the array. */
static void t_session_turn_with_files(void) {
TEST("session_turn_with_files");
reset_captures();
enqueue_response(200, "application/json", SESS_CREATE_BODY);
enqueue_response(200, "application/json",
"{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":0,"
"\"events\":[],\"stop_reason\":\"end_turn\",\"duration_ms\":1}");
enqueue_response(200, "application/json", SESS_CLOSE_BODY);
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_session_t *s = NULL;
cf_error_t err = {0};
CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK);
const char *files[] = { "ff_aaa", "ff_bbb" };
cf_turn_options_t topts = {
.files = files,
.files_count = 2,
.timeout_secs = 45,
};
cf_turn_result_t *r = NULL;
CHECK(cf_session_turn(s, "process these", &topts, &r, &err) == CF_OK);
cJSON *sent = cJSON_Parse(captured[1].body);
CHECK(sent != NULL);
if (sent) {
cJSON *farr = cJSON_GetObjectItemCaseSensitive(sent, "files");
CHECK(cJSON_IsArray(farr));
if (cJSON_IsArray(farr)) {
CHECK(cJSON_GetArraySize(farr) == 2);
}
cJSON *jto = cJSON_GetObjectItemCaseSensitive(sent, "timeout_secs");
CHECK(cJSON_IsNumber(jto));
if (cJSON_IsNumber(jto)) CHECK((int)jto->valuedouble == 45);
cJSON_Delete(sent);
}
cf_turn_result_free(r);
cf_session_close(s, &err);
cf_session_free(s);
cf_error_free(&err);
cf_client_free(c);
}
/* Test — bearer token never appears in any cf_error_t.message. */
static void t_session_bearer_never_leaks(void) {
TEST("session_bearer_never_leaks");
/* Guard against a regression where a future refactor concatenates
* the auth header into a transport / parse error. We try every
* fallible session entry point with a distinctive bearer string
* and confirm the bearer never appears in the resulting message. */
static const char *SECRET = "BEARERMARKER_zzz_9999";
cf_client_t *c = cf_client_new("http://127.0.0.1:1", SECRET);
cf_error_t err = {0};
/* (a) cf_session_new — server unreachable */
cf_session_t *s = NULL;
cf_session_options_t sopts = { .agent = "claude" };
cf_session_new(c, &sopts, &s, &err);
CHECK(err.message != NULL);
if (err.message) CHECK(strstr(err.message, SECRET) == NULL);
cf_error_free(&err);
/* (b) cf_session_state_get — server returns 404 with detail field */
reset_captures();
/* Reset client to point at the mock server. */
cf_client_free(c);
c = cf_client_new(make_base_url(), SECRET);
enqueue_response(404, "application/json",
"{\"detail\":\"session not found\"}");
cf_session_state_t *state = NULL;
cf_session_state_get(c, "sess_xyz", &state, &err);
CHECK(err.message != NULL);
if (err.message) {
CHECK(strstr(err.message, SECRET) == NULL);
CHECK(strstr(err.message, "Bearer") == NULL);
}
cf_error_free(&err);
/* (c) cf_session_list_get — 401 with detail */
enqueue_response(401, "application/json",
"{\"detail\":\"invalid token\"}");
cf_session_list_t *list = NULL;
cf_session_list_get(c, &list, &err);
CHECK(err.message != NULL);
if (err.message) CHECK(strstr(err.message, SECRET) == NULL);
cf_error_free(&err);
cf_client_free(c);
}
/* Test — null-arg defenses on every session entry point. */
static void t_session_null_arg_defenses(void) {
TEST("session_null_arg_defenses");
cf_error_t err = {0};
cf_session_t *s = NULL;
cf_turn_result_t *r = NULL;
cf_session_state_t *state = NULL;
CHECK(cf_session_new(NULL, NULL, &s, &err) == CF_ERR_USAGE);
cf_error_free(&err);
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
CHECK(cf_session_new(c, NULL, NULL, &err) == CF_ERR_USAGE);
cf_error_free(&err);
CHECK(cf_session_turn(NULL, "p", NULL, &r, &err) == CF_ERR_USAGE);
cf_error_free(&err);
CHECK(cf_session_close(NULL, &err) == CF_ERR_USAGE);
cf_error_free(&err);
CHECK(cf_session_state_get(c, NULL, &state, &err) == CF_ERR_USAGE);
cf_error_free(&err);
CHECK(cf_session_list_get(c, NULL, &err) == CF_ERR_USAGE);
cf_error_free(&err);
/* accessors safe on NULL */
CHECK(cf_session_id(NULL) == NULL);
CHECK(cf_session_agent(NULL) == NULL);
CHECK(strcmp(cf_turn_result_text(NULL), "") == 0);
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();
/* Audit-fix tests */
t_revoke_token_validates_name();
t_buf_append_overflow_guards();
t_response_body_size_cap();
t_connect_timeout();
t_concurrent_client_init();
t_cjson_bump();
/* v0.2 — multi-turn / sessions */
t_session_turn_round_trip();
t_session_close_idempotent();
t_session_turn_after_close_returns_error();
t_session_cross_token_returns_404();
t_session_validates_id_traversal();
t_session_list_get();
t_session_state_get();
t_turn_result_text_concatenates();
t_session_free_idempotent();
t_turn_result_memory_clean();
t_session_turn_with_files();
t_session_bearer_never_leaks();
t_session_null_arg_defenses();
stop_server();
fprintf(stderr, "\n%d tests, %d failures\n", g_tests, g_failures);
return g_failures == 0 ? 0 : 1;
}