- 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
1600 lines
55 KiB
C
1600 lines
55 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 <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;
|
||
}
|