Files
codebase-memory-mcp/tests/test_httpd.c
2026-10-11 20:16:34 +02:00

4157 lines
164 KiB
C

/*
* test_httpd.c — Tests for the first-party graph-UI HTTP server.
*
* Two layers:
* 1. Parser/helper unit tests against httpd.h's pure functions
* (no sockets): request-line parsing, strict CRLF, Content-Length
* edge cases, chunked rejection, NUL/percent-decode rules,
* query-param decoding, route pattern matching.
* 2. Live-socket integration tests against the full UI server
* (http_server.c) on an ephemeral port: routing, CORS policy,
* RPC dispatch, transport limits, receive deadline, clean shutdown.
*/
#include "../src/foundation/compat.h"
#if defined(__APPLE__)
#include <mach-o/dyld.h> /* _NSGetExecutablePath — deleted-self driver */
#endif
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/compat_thread.h"
#include "../src/foundation/log.h"
#include "../src/foundation/mem_core.h"
#include "../src/foundation/platform.h"
#include "../src/foundation/str_util.h" /* cbm_json_escape: expected index-status fragment */
#include "../src/cli/cli.h"
#include "../src/daemon/host_internal.h"
#include "../src/git/git_context.h" /* #798 follow-up: live-socket git-resolve repro */
#include "../src/ui/http_server.h"
#include "test_framework.h"
#include "test_helpers.h"
#include "ui/httpd.h"
#include "ui/activity.h"
#include "ui/atlas.h"
#include "ui/layout3d.h"
#include <yyjson/yyjson.h>
#include "ui/http_server.h"
#include <store/store.h>
#include <watcher/watcher.h>
#include <yyjson/yyjson.h> /* the index replies are asserted as parsed JSON */
#include <limits.h> /* PATH_MAX: which platforms resolve a root past 1 KB */
#include <stdio.h>
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
#ifndef CBM_VERSION
#define CBM_VERSION "dev"
#endif
#ifndef _WIN32
#include <sys/stat.h>
#endif
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h> /* #798 follow-up: CreateThread/WaitForSingleObject watchdog */
typedef SOCKET th_sock_t;
#define th_sock_close closesocket
#define th_sock_shutdown(s) shutdown((s), SD_BOTH)
#define TH_SOCK_BAD INVALID_SOCKET
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h> /* struct timeval for the SO_RCVTIMEO watchdog (#798 follow-up) */
#include <sys/wait.h> /* fork/waitpid crash-isolation for the browse overflow guard */
#include <unistd.h>
typedef int th_sock_t;
#define th_sock_close close
#define th_sock_shutdown(s) shutdown((s), SHUT_RDWR)
#define TH_SOCK_BAD (-1)
#endif
static char httpd_log_buf[8192];
static void httpd_capture_log(const char *line) {
size_t used = strlen(httpd_log_buf);
size_t avail = sizeof(httpd_log_buf) - used;
if (avail <= 1)
return;
int n = snprintf(httpd_log_buf + used, avail, "%s\n", line ? line : "");
if (n < 0 || (size_t)n >= avail)
httpd_log_buf[sizeof(httpd_log_buf) - 1] = '\0';
}
/* ── Raw-socket test client ───────────────────────────────────── */
static th_sock_t th_connect_with_recv_buffer(int port, int recv_buffer) {
#ifdef _WIN32
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa); /* refcounted; cleanup not needed in tests */
#endif
th_sock_t s = socket(AF_INET, SOCK_STREAM, 0);
if (s == TH_SOCK_BAD)
return TH_SOCK_BAD;
if (recv_buffer > 0 && setsockopt(s, SOL_SOCKET, SO_RCVBUF, (const char *)&recv_buffer,
sizeof(recv_buffer)) != 0) {
th_sock_close(s);
return TH_SOCK_BAD;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((unsigned short)port);
addr.sin_addr.s_addr = htonl(0x7F000001); /* 127.0.0.1 */
if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
th_sock_close(s);
return TH_SOCK_BAD;
}
return s;
}
static th_sock_t th_connect(int port) {
return th_connect_with_recv_buffer(port, 0);
}
static int th_send_all(th_sock_t s, const char *data, size_t len) {
size_t off = 0;
while (off < len) {
#ifdef _WIN32
int n = send(s, data + off, (int)(len - off), 0);
#else
ssize_t n = send(s, data + off, len - off, 0);
#endif
if (n <= 0)
return -1;
off += (size_t)n;
}
return 0;
}
/* Read until the server closes the connection (Connection: close model). */
static int th_recv_until_close(th_sock_t s, char *buf, size_t bufsz) {
size_t off = 0;
for (;;) {
#ifdef _WIN32
int n = recv(s, buf + off, (int)(bufsz - 1 - off), 0);
#else
ssize_t n = recv(s, buf + off, bufsz - 1 - off, 0);
#endif
if (n <= 0)
break;
off += (size_t)n;
if (off >= bufsz - 1)
break;
}
buf[off] = '\0';
return (int)off;
}
/* One-shot raw HTTP exchange. Returns response length, 0 on connect failure. */
static int th_http_raw(int port, const char *request, char *resp, size_t respsz) {
th_sock_t s = th_connect(port);
if (s == TH_SOCK_BAD)
return 0;
if (th_send_all(s, request, strlen(request)) != 0) {
th_sock_close(s);
return 0;
}
int n = th_recv_until_close(s, resp, respsz);
th_sock_close(s);
return n;
}
/* Existing route tests focus on endpoint behavior. Add the loopback Host and
* JSON mutation header the browser supplies. Security tests use th_http_raw()
* to exercise missing/hostile headers without this convenience layer. */
static char *th_request_with_ui_headers(int port, const char *request) {
const char *head_end = strstr(request, "\r\n\r\n");
if (!head_end)
return strdup(request);
const char *target = strchr(request, ' ');
target = target ? target + 1 : NULL;
bool protected_route =
target && (strncmp(target, "/api/", 5) == 0 || strncmp(target, "/rpc ", 5) == 0 ||
strncmp(target, "/rpc?", 5) == 0);
bool mutation = strncmp(request, "POST ", 5) == 0;
bool have_host = strstr(request, "\r\nHost:") != NULL;
bool have_content_type = strstr(request, "\r\nContent-Type:") != NULL;
size_t request_len = strlen(request);
size_t capacity = request_len + 256;
char *result = malloc(capacity);
if (!result)
return NULL;
size_t head_len = (size_t)(head_end - request);
memcpy(result, request, head_len);
size_t pos = head_len;
if (!have_host)
pos += (size_t)snprintf(result + pos, capacity - pos, "\r\nHost: 127.0.0.1:%d", port);
if (protected_route && mutation && !have_content_type) {
pos += (size_t)snprintf(result + pos, capacity - pos, "\r\nContent-Type: application/json");
}
(void)snprintf(result + pos, capacity - pos, "\r\n\r\n%s", head_end + 4);
return result;
}
static int th_http(int port, const char *request, char *resp, size_t respsz) {
char *prepared = th_request_with_ui_headers(port, request);
if (!prepared)
return 0;
int result = th_http_raw(port, prepared, resp, respsz);
free(prepared);
return result;
}
/* HTTP status code from a raw response ("HTTP/1.1 404 ..."), or -1. */
static int th_status(const char *resp) {
if (strncmp(resp, "HTTP/1.1 ", 9) != 0)
return -1;
return atoi(resp + 9);
}
/* ── Parser unit tests ────────────────────────────────────────── */
TEST(httpd_parse_simple_get) {
const char *raw = "GET /api/logs?lines=5 HTTP/1.1\r\n"
"Host: 127.0.0.1\r\n"
"Origin: http://localhost:5173\r\n"
"\r\n";
cbm_http_req_t req;
size_t body_off = 0, clen = 99;
int rc = cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(req.method, "GET");
ASSERT_STR_EQ(req.path, "/api/logs");
ASSERT_STR_EQ(req.query, "lines=5");
ASSERT_EQ(req.http_minor, 1);
ASSERT_STR_EQ(req.origin, "http://localhost:5173");
ASSERT_EQ((int)clen, 0);
ASSERT_EQ((int)body_off, (int)strlen(raw));
PASS();
}
TEST(httpd_parse_security_headers_and_rejects_duplicates) {
const char *raw = "POST /rpc HTTP/1.1\r\n"
"Host: 127.0.0.1:9749\r\n"
"Content-Type: application/json\r\n"
"Origin: http://127.0.0.1:9749\r\n"
"Content-Length: 0\r\n\r\n";
cbm_http_req_t req;
size_t body_off = 0, content_length = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &content_length), 0);
ASSERT_STR_EQ(req.host, "127.0.0.1:9749");
ASSERT_STR_EQ(req.content_type, "application/json");
static const char *duplicates[] = {
"GET / HTTP/1.1\r\nHost: localhost\r\nHost: localhost\r\n\r\n",
"GET / HTTP/1.1\r\nOrigin: http://localhost\r\nOrigin: http://localhost\r\n\r\n",
("POST / HTTP/1.1\r\nContent-Type: application/json\r\nContent-Type: "
"application/json\r\n\r\n"),
};
for (size_t i = 0; i < sizeof(duplicates) / sizeof(duplicates[0]); i++) {
ASSERT_EQ(cbm_http_parse_head(duplicates[i], strlen(duplicates[i]), &req, &body_off,
&content_length),
400);
}
PASS();
}
TEST(httpd_parse_post_with_body_offset) {
const char *raw = "POST /rpc HTTP/1.1\r\n"
"Content-Length: 7\r\n"
"Content-Type: application/json\r\n"
"\r\n"
"{\"a\":1}";
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
int rc = cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(req.method, "POST");
ASSERT_STR_EQ(req.path, "/rpc");
ASSERT_STR_EQ(req.query, "");
ASSERT_EQ((int)clen, 7);
ASSERT_STR_EQ(raw + body_off, "{\"a\":1}");
PASS();
}
TEST(httpd_parse_origin_case_insensitive) {
const char *raw = "GET / HTTP/1.1\r\n"
"origin: http://127.0.0.1:9749\r\n"
"\r\n";
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 0);
ASSERT_STR_EQ(req.origin, "http://127.0.0.1:9749");
PASS();
}
TEST(httpd_parse_rejects_bare_lf) {
const char *raw = "GET / HTTP/1.1\nHost: x\n\n";
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 400);
PASS();
}
TEST(httpd_parse_rejects_chunked) {
const char *raw = "POST /rpc HTTP/1.1\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n";
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 411);
PASS();
}
TEST(httpd_parse_rejects_oversized_content_length) {
char raw[256];
snprintf(raw, sizeof(raw), "POST /rpc HTTP/1.1\r\nContent-Length: %d\r\n\r\n",
CBM_HTTP_MAX_BODY + 1);
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 413);
PASS();
}
TEST(httpd_parse_rejects_garbage_content_length) {
const char *raw = "POST /rpc HTTP/1.1\r\nContent-Length: abc\r\n\r\n";
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 400);
const char *neg = "POST /rpc HTTP/1.1\r\nContent-Length: -5\r\n\r\n";
ASSERT_EQ(cbm_http_parse_head(neg, strlen(neg), &req, &body_off, &clen), 400);
PASS();
}
TEST(httpd_parse_rejects_percent00_in_target) {
const char *raw = "GET /a%00b HTTP/1.1\r\n\r\n";
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 400);
/* %00 hidden in the query string is rejected too */
const char *q = "GET /ok?x=%00 HTTP/1.1\r\n\r\n";
ASSERT_EQ(cbm_http_parse_head(q, strlen(q), &req, &body_off, &clen), 400);
PASS();
}
TEST(httpd_parse_rejects_raw_nul_in_head) {
char raw[64] = "GET /a";
size_t len = 6;
raw[len++] = '\0';
memcpy(raw + len, " HTTP/1.1\r\n\r\n", 13);
len += 13;
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, len, &req, &body_off, &clen), 400);
PASS();
}
TEST(httpd_parse_incomplete_head_needs_more) {
const char *raw = "GET /api/logs HTTP/1.1\r\nHost: x\r\n"; /* no CRLFCRLF yet */
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), CBM_HTTP_NEED_MORE);
PASS();
}
TEST(httpd_parse_rejects_missing_version) {
const char *raw = "GET /\r\n\r\n";
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 400);
const char *v2 = "GET / HTTP/2\r\n\r\n";
ASSERT_EQ(cbm_http_parse_head(v2, strlen(v2), &req, &body_off, &clen), 400);
PASS();
}
TEST(httpd_parse_rejects_oversized_head) {
/* A head that exceeds CBM_HTTP_MAX_HEAD without terminating → 431 */
size_t big = CBM_HTTP_MAX_HEAD + 1024;
char *raw = malloc(big);
ASSERT_NOT_NULL(raw);
memcpy(raw, "GET / HTTP/1.1\r\nX-Junk: ", 24);
memset(raw + 24, 'A', big - 24);
cbm_http_req_t req;
size_t body_off = 0, clen = 0;
int rc = cbm_http_parse_head(raw, big, &req, &body_off, &clen);
free(raw);
ASSERT_EQ(rc, 431);
PASS();
}
TEST(httpd_query_param_decode) {
char buf[64];
ASSERT_TRUE(cbm_http_query_param("a=hello+world&b=%2Ffoo%2F", "a", buf, (int)sizeof(buf)));
ASSERT_STR_EQ(buf, "hello world");
ASSERT_TRUE(cbm_http_query_param("a=hello+world&b=%2Ffoo%2F", "b", buf, (int)sizeof(buf)));
ASSERT_STR_EQ(buf, "/foo/");
/* uppercase + lowercase hex */
ASSERT_TRUE(cbm_http_query_param("p=%2fTmp%2F", "p", buf, (int)sizeof(buf)));
ASSERT_STR_EQ(buf, "/Tmp/");
PASS();
}
TEST(httpd_query_param_edge_cases) {
char buf[8];
/* missing param */
ASSERT_FALSE(cbm_http_query_param("a=1", "b", buf, (int)sizeof(buf)));
/* empty value (current server treats it as absent) */
ASSERT_FALSE(cbm_http_query_param("a=&b=2", "a", buf, (int)sizeof(buf)));
/* value too large for buf */
ASSERT_FALSE(cbm_http_query_param("a=123456789", "a", buf, (int)sizeof(buf)));
/* decoded NUL rejected */
char big[32];
ASSERT_FALSE(cbm_http_query_param("a=x%00y", "a", big, (int)sizeof(big)));
/* name is a prefix of another name — must not match */
ASSERT_FALSE(cbm_http_query_param("abc=1", "ab", buf, (int)sizeof(buf)));
/* truncated percent escape */
ASSERT_FALSE(cbm_http_query_param("a=%2", "a", buf, (int)sizeof(buf)));
PASS();
}
TEST(httpd_path_match_matrix) {
/* exact */
ASSERT_TRUE(cbm_http_path_match("/", "/"));
ASSERT_FALSE(cbm_http_path_match("/x", "/"));
ASSERT_TRUE(cbm_http_path_match("/rpc", "/rpc"));
ASSERT_FALSE(cbm_http_path_match("/rpc2", "/rpc"));
/* trailing-* prefix */
ASSERT_TRUE(cbm_http_path_match("/api/layout", "/api/layout*"));
ASSERT_TRUE(cbm_http_path_match("/assets/index-abc.js", "/assets/*"));
ASSERT_FALSE(cbm_http_path_match("/api/browse", "/api/layout*"));
/* raw path is matched — percent-encoded slash must NOT route */
ASSERT_FALSE(cbm_http_path_match("/api%2Fbrowse", "/api/browse*"));
ASSERT_FALSE(cbm_http_path_match("/api%2fbrowse", "/api/browse*"));
/* CORS origin patterns */
ASSERT_TRUE(cbm_http_path_match("http://localhost:5173", "http://localhost:*"));
ASSERT_TRUE(cbm_http_path_match("http://127.0.0.1:9749", "http://127.0.0.1:*"));
ASSERT_FALSE(cbm_http_path_match("http://evil.com", "http://localhost:*"));
ASSERT_FALSE(cbm_http_path_match("https://localhost:5173", "http://localhost:*"));
ASSERT_FALSE(cbm_http_path_match("http://localhost.evil.com:80", "http://localhost:*"));
PASS();
}
TEST(httpd_resolves_bare_binary_path_from_path) {
#ifdef _WIN32
PASS();
#else
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_bin_XXXXXX");
char *td = cbm_mkdtemp(tmpdir);
ASSERT_NOT_NULL(td);
char exe[512];
snprintf(exe, sizeof(exe), "%s/codebase-memory-mcp", td);
FILE *f = fopen(exe, "w");
ASSERT_NOT_NULL(f);
fputs("#!/bin/sh\nexit 0\n", f);
fclose(f);
ASSERT_EQ(chmod(exe, 0755), 0);
char *old_path = getenv("PATH") ? strdup(getenv("PATH")) : NULL;
cbm_setenv("PATH", td, 1);
char resolved[1024];
ASSERT_TRUE(
cbm_http_server_resolve_binary_path("codebase-memory-mcp", resolved, sizeof(resolved)));
ASSERT_STR_EQ(resolved, exe);
if (old_path) {
cbm_setenv("PATH", old_path, 1);
free(old_path);
} else {
cbm_unsetenv("PATH");
}
PASS();
#endif
}
TEST(httpd_deleted_self_spawn_path_follows_platform_ruling) {
#if defined(__linux__) || defined(__APPLE__)
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_deleted_self_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir));
char current[1024];
#if defined(__linux__)
ssize_t current_len = readlink("/proc/self/exe", current, sizeof(current) - 1);
ASSERT_GT(current_len, 0);
ASSERT_LT(current_len, (ssize_t)(sizeof(current) - 1));
current[current_len] = '\0';
#else
uint32_t current_sz = sizeof(current);
ASSERT_EQ(_NSGetExecutablePath(current, &current_sz), 0);
#endif
char launch_path[512];
char replacement_path[512];
snprintf(launch_path, sizeof(launch_path), "%s/test-runner", tmpdir);
snprintf(replacement_path, sizeof(replacement_path), "%s/test-runner.new", tmpdir);
ASSERT_EQ(cbm_copy_file(current, launch_path), 0);
ASSERT_EQ(chmod(launch_path, 0755), 0);
int ready_pipe[2];
int continue_pipe[2];
ASSERT_EQ(pipe(ready_pipe), 0);
ASSERT_EQ(pipe(continue_pipe), 0);
pid_t child = fork();
if (child == 0) {
close(ready_pipe[0]);
close(continue_pipe[1]);
char ready_fd[32];
char continue_fd[32];
snprintf(ready_fd, sizeof(ready_fd), "%d", ready_pipe[1]);
snprintf(continue_fd, sizeof(continue_fd), "%d", continue_pipe[0]);
execl(launch_path, launch_path, "__cbm_deleted_self_probe", ready_fd, continue_fd,
launch_path, (char *)NULL);
_exit(127);
}
ASSERT_GT(child, 0);
close(ready_pipe[1]);
close(continue_pipe[0]);
char ready = '\0';
ASSERT_EQ(read(ready_pipe[0], &ready, 1), 1);
ASSERT_EQ(ready, 'R');
#if defined(__linux__)
/* Atomic rename-over: the installer's real move. /proc/self/exe now reads
* "(deleted)" in the child, which is the state the magic-link contract
* covers. */
ASSERT_EQ(cbm_copy_file(current, replacement_path), 0);
ASSERT_EQ(chmod(replacement_path, 0755), 0);
ASSERT_EQ(rename(replacement_path, launch_path), 0);
#else
/* macOS: a rename-over leaves a VALID executable at the launch path, which
* resolve is right to return (the worker's build-fingerprint gate is the
* layer that refuses a mismatched build — covered by its own tests). The
* fail-closed branch guards the image-GONE case, so remove the image. */
(void)replacement_path;
ASSERT_EQ(unlink(launch_path), 0);
#endif
ASSERT_EQ(write(continue_pipe[1], "G", 1), 1);
close(ready_pipe[0]);
close(continue_pipe[1]);
int status = 0;
ASSERT_EQ(waitpid(child, &status, 0), child);
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
unlink(launch_path);
rmdir(tmpdir);
PASS();
#else
PASS();
#endif
}
/* ── Transport integration (listener only) ────────────────────── */
TEST(httpd_listen_ephemeral_port) {
cbm_httpd_t *d = cbm_httpd_listen(0);
ASSERT_NOT_NULL(d);
int port = cbm_httpd_port(d);
ASSERT_GT(port, 0);
/* accept with a short timeout and no client → NULL, promptly */
cbm_http_conn_t *c = cbm_httpd_accept(d, 50);
ASSERT_NULL(c);
ASSERT_TRUE(cbm_httpd_close(d));
PASS();
}
TEST(httpd_listen_port_collision_returns_null) {
cbm_httpd_t *d1 = cbm_httpd_listen(0);
ASSERT_NOT_NULL(d1);
cbm_httpd_t *d2 = cbm_httpd_listen(cbm_httpd_port(d1));
ASSERT_NULL(d2);
ASSERT_TRUE(cbm_httpd_close(d1));
PASS();
}
TEST(httpd_close_refuses_while_connection_owns_listener) {
cbm_httpd_t *listener = cbm_httpd_listen(0);
ASSERT_NOT_NULL(listener);
th_sock_t client = th_connect(cbm_httpd_port(listener));
ASSERT_TRUE(client != TH_SOCK_BAD);
cbm_http_conn_t *connection = cbm_httpd_accept(listener, 1000);
ASSERT_NOT_NULL(connection);
ASSERT_FALSE(cbm_httpd_close(listener));
cbm_httpd_conn_close(connection);
th_sock_close(client);
ASSERT_TRUE(cbm_httpd_close(listener));
PASS();
}
/* ── Full UI server integration ───────────────────────────────── */
typedef struct {
cbm_http_server_t *srv;
cbm_thread_t tid;
} th_server_t;
static void *th_server_thread(void *arg) {
cbm_http_server_run((cbm_http_server_t *)arg);
return NULL;
}
static int th_server_thread_start(cbm_thread_t *thread, cbm_http_server_t *server) {
if (!cbm_http_server_schedule_run(server))
return -1;
int rc = cbm_thread_create(thread, 0, th_server_thread, server);
if (rc != 0 && !cbm_http_server_cancel_scheduled_run(server))
return -1;
return rc;
}
static int th_server_start(th_server_t *ts) {
ts->srv = cbm_http_server_new(0);
if (!ts->srv)
return -1;
if (th_server_thread_start(&ts->tid, ts->srv) != 0) {
(void)cbm_http_server_free(ts->srv);
return -1;
}
return 0;
}
static int th_server_start_with_watcher(th_server_t *ts, cbm_watcher_t *watcher) {
ts->srv = cbm_http_server_new(0);
if (!ts->srv)
return -1;
cbm_http_server_set_watcher(ts->srv, watcher);
if (th_server_thread_start(&ts->tid, ts->srv) != 0) {
(void)cbm_http_server_free(ts->srv);
return -1;
}
return 0;
}
typedef struct {
atomic_int calls;
char root_path[4096]; /* the route resolves into a 4 KB buffer; record it whole */
char project_name[256];
} th_ui_index_executor_t;
typedef struct {
atomic_int calls;
atomic_int release;
} th_ui_blocking_index_executor_t;
static int th_ui_index_executor(void *opaque, const char *root_path, const char *project_name) {
th_ui_index_executor_t *executor = opaque;
snprintf(executor->root_path, sizeof(executor->root_path), "%s", root_path);
snprintf(executor->project_name, sizeof(executor->project_name), "%s",
project_name ? project_name : "");
atomic_fetch_add(&executor->calls, 1);
return 0;
}
static int th_ui_blocking_index_executor(void *opaque, const char *root_path,
const char *project_name) {
(void)root_path;
(void)project_name;
th_ui_blocking_index_executor_t *executor = opaque;
atomic_fetch_add(&executor->calls, 1);
while (!atomic_load(&executor->release))
cbm_usleep(1000);
return 0;
}
static bool th_wait_atomic_int(atomic_int *value, int expected, uint32_t timeout_ms) {
uint64_t deadline = cbm_now_ms() + timeout_ms;
while (cbm_now_ms() < deadline) {
if (atomic_load(value) == expected) {
return true;
}
cbm_usleep(1000);
}
return atomic_load(value) == expected;
}
static bool th_wait_http_server_activity(cbm_http_server_t *server, cbm_httpd_activity_t expected,
uint32_t timeout_ms) {
uint64_t deadline = cbm_now_ms() + timeout_ms;
while (cbm_now_ms() < deadline) {
if (cbm_http_server_activity_for_test(server) == expected)
return true;
cbm_usleep(1000);
}
return cbm_http_server_activity_for_test(server) == expected;
}
static bool th_wait_httpd_activity(cbm_httpd_t *listener, cbm_httpd_activity_t expected,
uint32_t timeout_ms) {
uint64_t deadline = cbm_now_ms() + timeout_ms;
while (cbm_now_ms() < deadline) {
if (cbm_httpd_activity_for_test(listener) == expected)
return true;
cbm_usleep(1000);
}
return cbm_httpd_activity_for_test(listener) == expected;
}
typedef struct {
atomic_int begin_calls;
atomic_int end_calls;
bool allow;
char begin_project[256];
char end_project[256];
} th_ui_mutation_guard_t;
static void th_ui_mutation_guard_init(th_ui_mutation_guard_t *guard, bool allow) {
memset(guard, 0, sizeof(*guard));
atomic_init(&guard->begin_calls, 0);
atomic_init(&guard->end_calls, 0);
guard->allow = allow;
}
static bool th_ui_mutation_begin(void *opaque, const char *project) {
th_ui_mutation_guard_t *guard = opaque;
snprintf(guard->begin_project, sizeof(guard->begin_project), "%s", project ? project : "");
atomic_fetch_add(&guard->begin_calls, 1);
return guard->allow;
}
static void th_ui_mutation_end(void *opaque, const char *project) {
th_ui_mutation_guard_t *guard = opaque;
snprintf(guard->end_project, sizeof(guard->end_project), "%s", project ? project : "");
atomic_fetch_add(&guard->end_calls, 1);
}
static int th_server_start_with_mutation_guard(th_server_t *ts, cbm_watcher_t *watcher,
th_ui_mutation_guard_t *guard) {
ts->srv = cbm_http_server_new(0);
if (!ts->srv)
return -1;
if (watcher)
cbm_http_server_set_watcher(ts->srv, watcher);
cbm_http_server_set_project_mutation_guard(ts->srv, th_ui_mutation_begin, th_ui_mutation_end,
guard);
if (th_server_thread_start(&ts->tid, ts->srv) != 0) {
(void)cbm_http_server_free(ts->srv);
return -1;
}
return 0;
}
static void th_server_stop(th_server_t *ts) {
cbm_http_server_stop(ts->srv);
(void)cbm_thread_join(&ts->tid);
(void)cbm_http_server_free(ts->srv);
}
typedef struct {
char tmpdir[256];
char cache_dir[512];
char root_dir[512];
char *saved_cache_dir;
cbm_store_t *store;
cbm_watcher_t *watcher;
} ui_delete_fixture_t;
static int ui_delete_fixture_init(ui_delete_fixture_t *fx) {
memset(fx, 0, sizeof(*fx));
char *tmp = th_mktempdir("cbm_httpd_delete");
if (!tmp)
return -1;
snprintf(fx->tmpdir, sizeof(fx->tmpdir), "%s", tmp);
snprintf(fx->cache_dir, sizeof(fx->cache_dir), "%s/cache", fx->tmpdir);
snprintf(fx->root_dir, sizeof(fx->root_dir), "%s/root", fx->tmpdir);
const char *saved = getenv("CBM_CACHE_DIR");
fx->saved_cache_dir = saved ? strdup(saved) : NULL;
if (th_mkdir_p(fx->cache_dir) != 0 || th_mkdir_p(fx->root_dir) != 0) {
return -1;
}
cbm_setenv("CBM_CACHE_DIR", fx->cache_dir, 1);
fx->store = cbm_store_open_memory();
fx->watcher = cbm_watcher_new(fx->store, NULL, NULL);
return fx->store && fx->watcher ? 0 : -1;
}
static void ui_delete_fixture_cleanup(ui_delete_fixture_t *fx) {
if (fx->watcher)
cbm_watcher_free(fx->watcher);
if (fx->store)
cbm_store_close(fx->store);
if (fx->saved_cache_dir) {
cbm_setenv("CBM_CACHE_DIR", fx->saved_cache_dir, 1);
free(fx->saved_cache_dir);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
th_cleanup(fx->tmpdir);
}
static void ui_delete_db_path(const ui_delete_fixture_t *fx, const char *project, char *out,
size_t outsz) {
snprintf(out, outsz, "%s/%s.db", fx->cache_dir, project);
}
static int ui_delete_make_db_file(const ui_delete_fixture_t *fx, const char *project) {
char path[1024];
ui_delete_db_path(fx, project, path, sizeof(path));
return th_write_file(path, "test db");
}
static int ui_delete_make_sidecars(const ui_delete_fixture_t *fx, const char *project) {
char path[1024];
ui_delete_db_path(fx, project, path, sizeof(path));
char wal[1040], shm[1040];
snprintf(wal, sizeof(wal), "%s-wal", path);
snprintf(shm, sizeof(shm), "%s-shm", path);
return th_write_file(wal, "wal") == 0 && th_write_file(shm, "shm") == 0 ? 0 : -1;
}
static int ui_delete_request(th_server_t *ts, const char *target, char *resp, size_t respsz) {
char req[512];
snprintf(req, sizeof(req), "DELETE %s HTTP/1.1\r\n\r\n", target);
return th_http(cbm_http_server_port(ts->srv), req, resp, respsz);
}
static int ui_adr_post_request(th_server_t *ts, const char *project, const char *content,
char *resp, size_t respsz) {
char body[2048];
int body_len =
snprintf(body, sizeof(body), "{\"project\":\"%s\",\"content\":\"%s\"}", project, content);
if (body_len < 0 || (size_t)body_len >= sizeof(body))
return 0;
char req[2304];
int req_len = snprintf(req, sizeof(req),
"POST /api/adr HTTP/1.1\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %d\r\n\r\n%s",
body_len, body);
if (req_len < 0 || (size_t)req_len >= sizeof(req))
return 0;
return th_http(cbm_http_server_port(ts->srv), req, resp, respsz);
}
static int ui_adr_get_request(th_server_t *ts, const char *project, char *resp, size_t respsz) {
char req[512];
int req_len = snprintf(req, sizeof(req), "GET /api/adr?project=%s HTTP/1.1\r\n\r\n", project);
if (req_len < 0 || (size_t)req_len >= sizeof(req))
return 0;
return th_http(cbm_http_server_port(ts->srv), req, resp, respsz);
}
static int ui_adr_seed(const ui_delete_fixture_t *fx, const char *project, const char *content) {
char db_path[1024];
ui_delete_db_path(fx, project, db_path, sizeof(db_path));
cbm_store_t *store = cbm_store_open_path(db_path);
if (!store)
return CBM_STORE_ERR;
int rc = cbm_store_adr_store(store, project, content);
cbm_store_close(store);
return rc;
}
static bool ui_adr_equals(const ui_delete_fixture_t *fx, const char *project,
const char *expected) {
char db_path[1024];
ui_delete_db_path(fx, project, db_path, sizeof(db_path));
cbm_store_t *store = cbm_store_open_path_query(db_path);
if (!store)
return false;
cbm_adr_t adr = {0};
int rc = cbm_store_adr_get(store, project, &adr);
bool equal = rc == CBM_STORE_OK && adr.content && strcmp(adr.content, expected) == 0;
if (rc == CBM_STORE_OK)
cbm_store_adr_free(&adr);
cbm_store_close(store);
return equal;
}
TEST(ui_server_readiness_proof_is_exact_and_generation_bound) {
static const char challenge[] =
"202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f";
static const char expected[] =
"62215de7bddcea7e2c4047ff6bb94f8d18262fc8b3f3648134bb7d44158ff84d";
uint8_t secret[CBM_SHA256_DIGEST_LEN];
for (size_t index = 0; index < sizeof(secret); index++) {
secret[index] = (uint8_t)index;
}
th_server_t without_secret;
ASSERT_EQ(th_server_start(&without_secret), 0);
char request[512];
ASSERT_GT(snprintf(request, sizeof(request),
"GET /__cbm/ui-readiness?challenge=%s HTTP/1.1\r\n\r\n", challenge),
0);
char response[4096];
int response_length =
th_http(cbm_http_server_port(without_secret.srv), request, response, sizeof(response));
int missing_secret_status = response_length > 0 ? th_status(response) : -1;
th_server_stop(&without_secret);
th_server_t server = {0};
server.srv = cbm_http_server_new(0);
ASSERT_NOT_NULL(server.srv);
cbm_http_server_set_readiness_secret(server.srv, secret);
ASSERT_EQ(th_server_thread_start(&server.tid, server.srv), 0);
int port = cbm_http_server_port(server.srv);
response_length = th_http(port, request, response, sizeof(response));
char *body = response_length > 0 ? strstr(response, "\r\n\r\n") : NULL;
body = body ? body + 4 : NULL;
bool exact_proof = response_length > 0 && th_status(response) == 200 && body &&
strcmp(body, expected) == 0 &&
strstr(response, "Content-Type: text/plain; charset=utf-8") != NULL &&
strstr(response, "Cache-Control: no-store") != NULL;
ASSERT_GT(snprintf(request, sizeof(request),
"GET /__cbm/ui-readiness?challenge="
"202122232425262728292A2b2c2d2e2f303132333435363738393a3b3c3d3e3f "
"HTTP/1.1\r\n\r\n"),
0);
response_length = th_http(port, request, response, sizeof(response));
int uppercase_status = response_length > 0 ? th_status(response) : -1;
ASSERT_GT(snprintf(request, sizeof(request),
"GET /__cbm/ui-readiness?challenge=%s&extra=1 HTTP/1.1\r\n\r\n", challenge),
0);
response_length = th_http(port, request, response, sizeof(response));
int extra_parameter_status = response_length > 0 ? th_status(response) : -1;
response_length =
th_http(port, "GET /__cbm/ui-readiness HTTP/1.1\r\n\r\n", response, sizeof(response));
int missing_challenge_status = response_length > 0 ? th_status(response) : -1;
th_server_stop(&server);
ASSERT_EQ(missing_secret_status, 503);
ASSERT_TRUE(exact_proof);
ASSERT_EQ(uppercase_status, 400);
ASSERT_EQ(extra_parameter_status, 400);
ASSERT_EQ(missing_challenge_status, 400);
PASS();
}
TEST(ui_server_unknown_path_404) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char resp[4096];
int n = th_http(port, "GET /definitely/not/here HTTP/1.1\r\n\r\n", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 404);
/* every response is explicit-length + close */
ASSERT_NOT_NULL(strstr(resp, "Connection: close"));
ASSERT_NOT_NULL(strstr(resp, "Content-Length:"));
th_server_stop(&ts);
PASS();
}
/* Security regression: process IDs are not cancellation capabilities. The UI
* must never expose an endpoint that accepts an arbitrary PID and reaches an
* OS process-termination API (the former Windows path accepted every PID but
* self). Daemon-owned jobs are cancelled by opaque, owner-bound subscription
* handles instead, so this legacy route must be completely absent. */
TEST(ui_server_process_kill_route_is_unavailable) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
static const char body[] = "{\"pid\":2147483646}";
char request[512];
snprintf(request, sizeof(request),
"POST /api/process-kill HTTP/1.1\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
char resp[4096];
int n = th_http(port, request, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 404);
ASSERT_NULL(strstr(resp, "\"killed\""));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_process_measurements_declare_platform_semantics) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[16384];
int n = th_http(cbm_http_server_port(ts.srv), "GET /api/processes HTTP/1.1\r\n\r\n", resp,
sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
#ifdef _WIN32
ASSERT_NOT_NULL(strstr(resp, "\"cpu_unit\":\"seconds\""));
ASSERT_NOT_NULL(strstr(resp, "\"memory_kind\":\"working_set\""));
ASSERT_NOT_NULL(strstr(resp, "\"self_memory_kind\":\"working_set\""));
#else
ASSERT_NOT_NULL(strstr(resp, "\"cpu_unit\":\"percent\""));
ASSERT_NOT_NULL(strstr(resp, "\"memory_kind\":\"resident\""));
ASSERT_NOT_NULL(strstr(resp, "\"self_memory_kind\":\"peak_resident\""));
#endif
ASSERT_NOT_NULL(strstr(resp, "\"self_memory_available\":"));
ASSERT_NOT_NULL(strstr(resp, "\"self_pid\":"));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_routes_indexing_through_joinable_daemon_executor) {
char *root = th_mktempdir("cbm_httpd_daemon_index");
ASSERT_NOT_NULL(root);
/* The executor receives the root in resolved form (the string the route
* checked), which on macOS differs from the /tmp spelling. */
char resolved_root[4096];
ASSERT_TRUE(cbm_canonical_path(root, resolved_root, sizeof(resolved_root)));
th_ui_index_executor_t executor = {0};
atomic_init(&executor.calls, 0);
th_server_t ts;
ts.srv = cbm_http_server_new(0);
ASSERT_NOT_NULL(ts.srv);
cbm_http_server_set_index_executor(ts.srv, th_ui_index_executor, &executor);
ASSERT_EQ(th_server_thread_start(&ts.tid, ts.srv), 0);
char body[1024];
snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"ui-project\"}", root);
char request[1400];
snprintf(request, sizeof(request),
"POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
char response[4096];
int response_length =
th_http(cbm_http_server_port(ts.srv), request, response, sizeof(response));
bool called = th_wait_atomic_int(&executor.calls, 1, 2000);
th_server_stop(&ts);
ASSERT_GT(response_length, 0);
ASSERT_EQ(th_status(response), 202);
ASSERT_TRUE(called);
ASSERT_STR_EQ(executor.root_path, resolved_root);
ASSERT_STR_EQ(executor.project_name, "ui-project");
th_cleanup(root);
PASS();
}
TEST(ui_server_free_never_joins_active_index_worker) {
char *root = th_mktempdir("cbm_httpd_active_index");
ASSERT_NOT_NULL(root);
th_ui_blocking_index_executor_t executor = {0};
atomic_init(&executor.calls, 0);
atomic_init(&executor.release, 0);
th_server_t ts;
ts.srv = cbm_http_server_new(0);
ASSERT_NOT_NULL(ts.srv);
cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor);
ASSERT_EQ(th_server_thread_start(&ts.tid, ts.srv), 0);
char body[1024];
snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"blocked\"}", root);
char request[1400];
snprintf(request, sizeof(request),
"POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
char response[4096];
ASSERT_GT(th_http(cbm_http_server_port(ts.srv), request, response, sizeof(response)), 0);
ASSERT_EQ(th_status(response), 202);
ASSERT_TRUE(th_wait_atomic_int(&executor.calls, 1, 2000));
cbm_http_server_stop(ts.srv);
ASSERT_EQ(cbm_thread_join(&ts.tid), 0);
uint64_t started = cbm_now_ms();
ASSERT_FALSE(cbm_http_server_free(ts.srv));
ASSERT_LT(cbm_now_ms() - started, 1000);
atomic_store(&executor.release, 1);
bool freed = false;
uint64_t deadline = cbm_now_ms() + 2000;
while (!freed && cbm_now_ms() < deadline) {
freed = cbm_http_server_free(ts.srv);
if (!freed)
cbm_usleep(1000);
}
ASSERT_TRUE(freed);
th_cleanup(root);
PASS();
}
/* ── Frontend log file (POST/GET /api/ui-log) ─────────────────── */
static int ui_log_read_file(const char *path, char *out, size_t outsz) {
FILE *f = cbm_fopen(path, "rb");
if (!f)
return -1;
size_t n = fread(out, 1, outsz - 1, f);
fclose(f);
out[n] = '\0';
return (int)n;
}
static int ui_log_count_char(const char *s, char ch) {
int n = 0;
for (; *s; s++) {
if (*s == ch)
n++;
}
return n;
}
static int ui_log_post(int port, const char *body, char *resp, size_t respsz) {
size_t cap = strlen(body) + 512;
char *req = malloc(cap);
if (!req)
return 0;
snprintf(req, cap,
"POST /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Content-Type: application/json\r\nContent-Length: %zu\r\n\r\n%s",
port, strlen(body), body);
int n = th_http(port, req, resp, respsz);
free(req);
return n;
}
static int activity_interrupt_rows(unsigned kind, void *database, void *statement, void *unused) {
(void)kind;
(void)unused;
const char *sql = sqlite3_sql(statement);
if (sql && (strstr(sql, "SELECT id,ts,level,source,message") ||
strstr(sql, "SELECT id,payload FROM agent_events")))
sqlite3_interrupt(database);
return 0;
}
TEST(activity_reply_memory_core_ownership) {
sqlite3 *db = cbm_activity_open(":memory:");
ASSERT_NOT_NULL(db);
size_t before_blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
size_t before_bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER);
char *reply = cbm_activity_logs(db, -1, 10, 0);
ASSERT_NOT_NULL(reply);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks + 1);
cbm_free(CBM_MEM_CLASS_OTHER, reply);
reply = NULL;
ASSERT_EQ(cbm_activity_ingest(db, "{", &reply), 400);
ASSERT_NULL(reply);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks);
reply = NULL;
ASSERT_EQ(cbm_activity_ingest(db, "{\"project\":\"memory-test\",\"events\":[]}", &reply), 200);
ASSERT_NOT_NULL(reply);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks + 1);
cbm_free(CBM_MEM_CLASS_OTHER, reply);
reply = cbm_activity_agents(db, "memory-test", 0, 10);
ASSERT_NOT_NULL(reply);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks + 1);
cbm_free(CBM_MEM_CLASS_OTHER, reply);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks);
ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), before_bytes);
sqlite3_close(db);
PASS();
}
TEST(activity_preserves_severity_and_reports_interrupted_reads) {
sqlite3 *db = cbm_activity_open(":memory:");
ASSERT_NOT_NULL(db);
ASSERT_TRUE(cbm_activity_log(
db, "{\"level\":\"info\",\"msg\":\"read.file\",\"path\":\"notes/level=error.md\"}"));
ASSERT_TRUE(cbm_activity_log(db, "level=info msg=read.file path=notes/level=error.md"));
char *json = cbm_activity_logs(db, -1, 10, 2);
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"total\":0"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
ASSERT_EQ(sqlite3_trace_v2(db, SQLITE_TRACE_STMT, activity_interrupt_rows, db), SQLITE_OK);
ASSERT_NULL(cbm_activity_logs(db, -1, 10, 0));
ASSERT_NULL(cbm_activity_agents(db, "test", 0, 10));
sqlite3_trace_v2(db, 0, NULL, NULL);
sqlite3_close(db);
PASS();
}
TEST(activity_pruned_start_events_do_not_resurrect_on_retry) {
sqlite3 *db = cbm_activity_open(":memory:");
ASSERT_NOT_NULL(db);
const char *batch =
"{\"project\":\"fixture-a\",\"events\":[{\"run\":\"r1\",\"agent\":\"fixture\",\"tool\":"
"\"Read\",\"seq\":1,\"ts\":1000,\"phase\":\"start\"}]}";
char *reply = NULL;
ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200);
cbm_free(CBM_MEM_CLASS_OTHER, reply);
ASSERT_EQ(
sqlite3_exec(
db,
"WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<10001) INSERT "
"INTO agent_events(project,run,seq,payload) SELECT 'fixture-b','filler',v,'{}' FROM n",
NULL, NULL, NULL),
SQLITE_OK);
ASSERT_EQ(cbm_activity_ingest(db, "{\"project\":\"fixture-b\",\"events\":[]}", &reply), 200);
cbm_free(CBM_MEM_CLASS_OTHER, reply);
ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200);
ASSERT_NOT_NULL(strstr(reply, "\"accepted\":0"));
ASSERT_NOT_NULL(strstr(reply, "\"duplicates\":1"));
cbm_free(CBM_MEM_CLASS_OTHER, reply);
reply = cbm_activity_agents(db, "fixture-a", 0, 10);
ASSERT_NOT_NULL(strstr(reply, "\"events\":[]"));
ASSERT_NOT_NULL(strstr(reply, "\"truncated\":true"));
cbm_free(CBM_MEM_CLASS_OTHER, reply);
sqlite3_close(db);
PASS();
}
TEST(activity_migration_preserves_v1_logs_and_rejects_future_schema) {
char *dir = th_mktempdir("cbm_activity_migration");
ASSERT_NOT_NULL(dir);
char path[1024];
snprintf(path, sizeof(path), "%s/activity.db", dir);
sqlite3 *db = NULL;
ASSERT_EQ(sqlite3_open(path, &db), SQLITE_OK);
ASSERT_EQ(
sqlite3_exec(db,
"CREATE TABLE daemon_logs(id INTEGER PRIMARY KEY AUTOINCREMENT,ts TEXT NOT "
"NULL,level INTEGER NOT NULL,source TEXT NOT NULL,message TEXT NOT NULL);"
"INSERT INTO daemon_logs(ts,level,source,message) "
"VALUES('2026-09-09T00:00:00Z',3,'migration fixture','preserved "
"failure');PRAGMA user_version=1",
NULL, NULL, NULL),
SQLITE_OK);
sqlite3_close(db);
db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
char *json = cbm_activity_logs(db, -1, 10, 2);
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "preserved failure"));
ASSERT_NOT_NULL(strstr(json, "\"total\":1"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
const char *ui = "{\"session\":\"migration-fixture\",\"seq\":1,\"level\":\"error\",\"source\":"
"\"frontend\",\"message\":\"retry-safe\"}";
ASSERT_TRUE(cbm_activity_log(db, ui));
ASSERT_TRUE(cbm_activity_log(db, ui));
json = cbm_activity_logs(db, -1, 10, 2);
ASSERT_NOT_NULL(strstr(json, "\"total\":2"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
ASSERT_EQ(sqlite3_exec(db, "PRAGMA user_version=99", NULL, NULL, NULL), SQLITE_OK);
sqlite3_close(db);
ASSERT_NULL(cbm_activity_open(path));
th_cleanup(dir);
PASS();
}
TEST(activity_log_project_scope_preserves_unknown_v2_history_and_filters_before_limit) {
char *dir = th_mktempdir("cbm_activity_project_scope");
ASSERT_NOT_NULL(dir);
char path[1024];
snprintf(path, sizeof(path), "%s/activity.db", dir);
sqlite3 *db = NULL;
ASSERT_EQ(sqlite3_open(path, &db), SQLITE_OK);
ASSERT_EQ(
sqlite3_exec(
db,
"CREATE TABLE daemon_logs(id INTEGER PRIMARY KEY AUTOINCREMENT,ts TEXT NOT NULL,"
"level INTEGER NOT NULL,source TEXT NOT NULL,message TEXT NOT NULL,event_key TEXT);"
"INSERT INTO daemon_logs VALUES(1,'2026-09-09',3,'old-worker',"
"'legacy project=fixture-a path=repo-a/broken.c',NULL);PRAGMA user_version=2",
NULL, NULL, NULL),
SQLITE_OK);
sqlite3_close(db);
db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
ASSERT_TRUE(cbm_activity_log(db, "{\"level\":\"error\",\"source\":\"fixture\","
"\"project\":\"fixture-a\",\"message\":\"first-a\"}"));
ASSERT_TRUE(
cbm_activity_log(db, "level=error msg=index.worker project=fixture-a record=last-a"));
ASSERT_TRUE(cbm_activity_log(
db, "{\"level\":\"error\",\"source\":\"unknown\","
"\"message\":\" project=fixture-a \",\"page\":\"/?project=fixture-a\"}"));
ASSERT_TRUE(cbm_activity_log(db, "level=error msg=unknown record=project=fixture-a"));
ASSERT_EQ(
sqlite3_exec(db,
"WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<600) "
"INSERT INTO daemon_logs(ts,level,source,message,project) "
"SELECT '2026-09-09',3,'fixture-b','unrelated flood','fixture-b' FROM n",
NULL, NULL, NULL),
SQLITE_OK);
char *page = cbm_activity_logs_scoped(db, -1, 1, 2, "fixture-a", false);
ASSERT_NOT_NULL(page);
ASSERT_NOT_NULL(strstr(page, "last-a"));
ASSERT_NOT_NULL(strstr(page, "\"total\":2"));
ASSERT_NOT_NULL(strstr(page, "\"scope\":\"project\""));
ASSERT_NULL(strstr(page, "unrelated flood"));
ASSERT_NULL(strstr(page, "legacy"));
cbm_free(CBM_MEM_CLASS_OTHER, page);
page = cbm_activity_logs_scoped(db, 0, 1, 2, "fixture-a", false);
ASSERT_NOT_NULL(strstr(page, "first-a"));
ASSERT_NOT_NULL(strstr(page, "\"cursor\":2"));
ASSERT_NOT_NULL(strstr(page, "\"has_more\":true"));
cbm_free(CBM_MEM_CLASS_OTHER, page);
sqlite3_close(db);
db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
page = cbm_activity_logs_scoped(db, 2, 1, 2, "fixture-a", false);
ASSERT_NOT_NULL(strstr(page, "last-a"));
ASSERT_NOT_NULL(strstr(page, "\"cursor\":3"));
ASSERT_NOT_NULL(strstr(page, "\"has_more\":false"));
cbm_free(CBM_MEM_CLASS_OTHER, page);
page = cbm_activity_logs_scoped(db, -1, 10, 2, NULL, true);
ASSERT_NOT_NULL(strstr(page, "legacy project=fixture-a"));
ASSERT_NOT_NULL(strstr(page, "\"total\":3"));
ASSERT_NOT_NULL(strstr(page, "\"project\":null"));
ASSERT_NOT_NULL(strstr(page, "\"scope\":\"unattributed\""));
cbm_free(CBM_MEM_CLASS_OTHER, page);
page = cbm_activity_logs(db, -1, 1, 2);
ASSERT_NOT_NULL(strstr(page, "\"total\":605"));
ASSERT_NOT_NULL(strstr(page, "\"scope\":\"daemon\""));
cbm_free(CBM_MEM_CLASS_OTHER, page);
sqlite3_close(db);
th_cleanup(dir);
PASS();
}
TEST(ui_log_scopes_use_explicit_entry_ownership_and_survive_server_restart) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
const char *body =
"{\"project\":\"fixture-a\",\"page\":\"/?project=wrong\","
"\"session\":\"project-scope-fixture\",\"entries\":["
"{\"seq\":1,\"level\":\"error\",\"message\":\"owned-a\"},"
"{\"seq\":2,\"level\":\"error\",\"project\":\"fixture-b\",\"message\":\"owned-b\"},"
"{\"seq\":3,\"level\":\"error\",\"project\":null,\"message\":\"unknown-owner\"}]}";
char response[8192];
ASSERT_GT(ui_log_post(port, body, response, sizeof(response)), 0);
ASSERT_EQ(th_status(response), 200);
th_server_stop(&ts);
ASSERT_EQ(th_server_start(&ts), 0);
port = cbm_http_server_port(ts.srv);
ASSERT_GT(th_http(port,
"GET /api/logs?project=fixture-a&lines=1&min_level=error HTTP/1.1\r\n\r\n",
response, sizeof(response)),
0);
ASSERT_EQ(th_status(response), 200);
ASSERT_NOT_NULL(strstr(response, "owned-a"));
ASSERT_NULL(strstr(response, "owned-b"));
ASSERT_NULL(strstr(response, "unknown-owner"));
ASSERT_NOT_NULL(strstr(response, "\"total\":1"));
ASSERT_GT(th_http(port, "GET /api/logs?project=fixture-b&lines=1 HTTP/1.1\r\n\r\n", response,
sizeof(response)),
0);
ASSERT_NOT_NULL(strstr(response, "owned-b"));
ASSERT_GT(th_http(port, "GET /api/logs?scope=unattributed&lines=10 HTTP/1.1\r\n\r\n", response,
sizeof(response)),
0);
ASSERT_NOT_NULL(strstr(response, "unknown-owner"));
ASSERT_NULL(strstr(response, "owned-a"));
ASSERT_NULL(strstr(response, "owned-b"));
ASSERT_GT(th_http(port, "GET /api/logs?project= HTTP/1.1\r\n\r\n", response, sizeof(response)),
0);
ASSERT_EQ(th_status(response), 400);
ASSERT_GT(th_http(port, "GET /api/logs?project=fixture-a&scope=unattributed HTTP/1.1\r\n\r\n",
response, sizeof(response)),
0);
ASSERT_EQ(th_status(response), 400);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(activity_log_search_and_severity_apply_before_limit_and_survive_reopen) {
char *dir = th_mktempdir("cbm_log_filter_fixture");
ASSERT_NOT_NULL(dir);
char path[1024];
snprintf(path, sizeof(path), "%s/activity.db", dir);
sqlite3 *db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
ASSERT_EQ(
sqlite3_exec(db,
"WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<49) "
"INSERT INTO daemon_logs(ts,level,source,message,project) "
"SELECT '2026-09-09',3,'parser','src/10%_broken.c','fixture-a' FROM n;"
"WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<600) "
"INSERT INTO daemon_logs(ts,level,source,message,project) "
"SELECT '2026-09-09',1,'http','routine','fixture-a' FROM n",
NULL, NULL, NULL),
SQLITE_OK);
char *json = cbm_activity_logs(db, -1, 200, 0);
ASSERT_NOT_NULL(json);
ASSERT_NULL(strstr(json, "10%_broken"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
json = cbm_activity_logs_filtered(db, -1, 200, 3, "fixture-a", false, "10%_");
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"total\":49"));
ASSERT_NOT_NULL(strstr(json, "\"query\":\"10%_\""));
ASSERT_NOT_NULL(strstr(json, "10%_broken.c"));
ASSERT_NULL(strstr(json, "routine"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
sqlite3_close(db);
db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
json = cbm_activity_logs_filtered(db, 0, 1, 3, "fixture-a", false, "BROKEN");
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"cursor\":1"));
ASSERT_NOT_NULL(strstr(json, "\"has_more\":true"));
ASSERT_NOT_NULL(strstr(json, "\"total\":49"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
json = cbm_activity_logs_filtered(db, 1, 1, 3, "fixture-a", false, "BROKEN");
ASSERT_NOT_NULL(strstr(json, "\"cursor\":2"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
json = cbm_activity_logs_filtered(db, -1, 200, 3, "fixture-b", false, "10%_");
ASSERT_NOT_NULL(strstr(json, "\"total\":0"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
sqlite3_close(db);
th_cleanup(dir);
PASS();
}
TEST(ui_log_retention_prevents_expired_retry_and_preserves_full_json) {
char *dir = th_mktempdir("cbm_ui_retention_fixture");
ASSERT_NOT_NULL(dir);
char path[1024];
snprintf(path, sizeof(path), "%s/activity.db", dir);
sqlite3 *db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
char message[12000];
memset(message, 'x', sizeof(message));
const char *prefix =
"{\"received\":\"2026-01-01T00:00:00Z\",\"page\":\"/\","
"\"session\":\"retention-fixture\",\"seq\":1,\"level\":\"info\",\"message\":\"";
memcpy(message, prefix, strlen(prefix));
memcpy(message + sizeof(message) - 3, "\"}", 3);
ASSERT_EQ(cbm_activity_ui_log(db, message, false), 1);
char *json = cbm_activity_ui_logs(db, 200, path, "", NULL);
ASSERT_NOT_NULL(json);
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
ASSERT_NOT_NULL(doc);
const char *line =
yyjson_get_str(yyjson_arr_get(yyjson_obj_get(yyjson_doc_get_root(doc), "lines"), 0));
ASSERT_NOT_NULL(line);
ASSERT_EQ(strlen(line), strlen(message));
yyjson_doc *full = yyjson_read(line, strlen(line), 0);
ASSERT_NOT_NULL(full);
yyjson_doc_free(full);
yyjson_doc_free(doc);
cbm_free(CBM_MEM_CLASS_OTHER, json);
ASSERT_EQ(
sqlite3_exec(db,
"WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<3000) "
"INSERT INTO daemon_logs(ts,level,source,message) SELECT "
"'2026-09-09',1,'fixture','flood' FROM n",
NULL, NULL, NULL),
SQLITE_OK);
ASSERT_TRUE(cbm_activity_log(db, "level=info msg=retention.trigger"));
ASSERT_EQ(cbm_activity_ui_log(db, message, false), 0);
sqlite3_close(db);
db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
ASSERT_EQ(cbm_activity_ui_log(db, message, false), 0);
json = cbm_activity_ui_logs(db, 200, path, "", NULL);
ASSERT_NOT_NULL(strstr(json, "\"total\":0"));
cbm_free(CBM_MEM_CLASS_OTHER, json);
sqlite3_close(db);
th_cleanup(dir);
PASS();
}
TEST(ui_log_journal_is_canonical_for_info_retry_and_restart) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char response[16384];
const char *body =
"{\"page\":\"/\",\"session\":\"canonical-fixture\",\"entries\":["
"{\"seq\":1,\"level\":\"info\",\"source\":\"console\",\"project\":\"fixture-a\","
"\"message\":\"ordinary retained 10%_detail\"}]}";
ASSERT_GT(ui_log_post(port, body, response, sizeof(response)), 0);
ASSERT_EQ(th_status(response), 200);
ASSERT_NOT_NULL(strstr(response, "\"accepted\":1"));
ASSERT_GT(ui_log_post(port, body, response, sizeof(response)), 0);
ASSERT_NOT_NULL(strstr(response, "\"accepted\":0"));
ASSERT_NOT_NULL(strstr(response, "\"duplicates\":1"));
char path[1024];
snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir);
char content[4096];
ASSERT_GT(ui_log_read_file(path, content, sizeof(content)), 0);
ASSERT_EQ(ui_log_count_char(content, '\n'), 1);
FILE *file = cbm_fopen(path, "wb");
ASSERT_NOT_NULL(file);
fputs("unrelated export mutation\n", file);
fclose(file);
th_server_stop(&ts);
ASSERT_EQ(th_server_start(&ts), 0);
port = cbm_http_server_port(ts.srv);
ASSERT_GT(th_http(port, "GET /api/ui-log HTTP/1.1\r\n\r\n", response, sizeof(response)), 0);
ASSERT_EQ(th_status(response), 200);
ASSERT_NOT_NULL(strstr(response, "ordinary retained 10%_detail"));
ASSERT_NOT_NULL(strstr(response, "\"source\":\"sqlite\""));
ASSERT_NOT_NULL(strstr(response, "activity.db"));
ASSERT_NOT_NULL(strstr(response, "\"total\":1"));
ASSERT_NULL(strstr(response, "unrelated export mutation"));
ASSERT_GT(th_http(port,
"GET /api/logs?project=fixture-a&min_level=info&q=10%25_ HTTP/1.1\r\n\r\n",
response, sizeof(response)),
0);
ASSERT_EQ(th_status(response), 200);
ASSERT_NOT_NULL(strstr(response, "ordinary retained 10%_detail"));
ASSERT_NOT_NULL(strstr(response, "\"total\":1"));
ASSERT_GT(th_http(port, "GET /api/logs?q= HTTP/1.1\r\n\r\n", response, sizeof(response)), 0);
ASSERT_EQ(th_status(response), 400);
ASSERT_GT(ui_log_post(port, body, response, sizeof(response)), 0);
ASSERT_NOT_NULL(strstr(response, "\"duplicates\":1"));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_log_legacy_import_is_once_and_preserves_unknown_provenance_and_time) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
char path[1024];
snprintf(path, sizeof(path), "%s/logs", fx.cache_dir);
ASSERT_TRUE(cbm_mkdir_p(path, 0700));
snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir);
FILE *file = cbm_fopen(path, "wb");
ASSERT_NOT_NULL(file);
fputs("{\"received\":\"2026-01-01T00:00:00Z\",\"page\":\"/?project=wrong\","
"\"session\":\"\",\"level\":\"info\",\"source\":\"old-ui\","
"\"project\":\"not-current\",\"message\":\"legacy info fixture\"}\n",
file);
fclose(file);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char response[16384];
ASSERT_GT(th_http(port, "GET /api/ui-log HTTP/1.1\r\n\r\n", response, sizeof(response)), 0);
ASSERT_EQ(th_status(response), 200);
ASSERT_NOT_NULL(strstr(response, "legacy info fixture"));
ASSERT_NOT_NULL(strstr(response, "\"total\":1"));
th_server_stop(&ts);
ASSERT_EQ(th_server_start(&ts), 0);
port = cbm_http_server_port(ts.srv);
ASSERT_GT(th_http(port, "GET /api/ui-log HTTP/1.1\r\n\r\n", response, sizeof(response)), 0);
ASSERT_NOT_NULL(strstr(response, "\"total\":1"));
ASSERT_GT(th_http(port, "GET /api/logs?scope=unattributed&q=legacy HTTP/1.1\r\n\r\n", response,
sizeof(response)),
0);
ASSERT_NOT_NULL(strstr(response, "\"project\":null"));
ASSERT_NOT_NULL(strstr(response, "\"ts\":\"2026-01-01T00:00:00Z\""));
ASSERT_GT(th_http(port, "GET /api/logs?project=not-current&q=legacy HTTP/1.1\r\n\r\n", response,
sizeof(response)),
0);
ASSERT_NOT_NULL(strstr(response, "\"total\":0"));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(activity_journal_reopen_cursor_dedup_and_project_isolation) {
char *dir = th_mktempdir("cbm_activity");
ASSERT_NOT_NULL(dir);
char path[1024];
snprintf(path, sizeof(path), "%s/activity.db", dir);
sqlite3 *db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
const char *batch = "{\"project\":\"demo\",\"events\":["
"{\"ts\":1000,\"agent\":\"test\",\"run\":\"run\",\"seq\":2,\"phase\":"
"\"end\",\"tool\":\"Read\",\"response\":\"never-store-this\"},"
"{\"ts\":999,\"agent\":\"test\",\"run\":\"run\",\"seq\":1,\"phase\":"
"\"end\",\"tool\":\"Read\"}]}";
char *reply = NULL;
ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200);
ASSERT_NOT_NULL(strstr(reply, "\"accepted\":2"));
cbm_free(CBM_MEM_CLASS_OTHER, reply);
ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200);
ASSERT_NOT_NULL(strstr(reply, "\"duplicates\":2"));
cbm_free(CBM_MEM_CLASS_OTHER, reply);
char *page = cbm_activity_agents(db, "demo", 0, 1);
ASSERT_NOT_NULL(page);
ASSERT_NULL(strstr(page, "never-store-this"));
ASSERT_NOT_NULL(strstr(page, "\"has_more\":true"));
yyjson_doc *parsed = yyjson_read(page, strlen(page), 0);
int64_t cursor = yyjson_get_sint(yyjson_obj_get(yyjson_doc_get_root(parsed), "cursor"));
yyjson_doc_free(parsed);
cbm_free(CBM_MEM_CLASS_OTHER, page);
sqlite3_close(db);
db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
page = cbm_activity_agents(db, "demo", cursor, 10);
ASSERT_NOT_NULL(strstr(page, "\"seq\":1"));
ASSERT_NULL(strstr(page, "\"seq\":2"));
cbm_free(CBM_MEM_CLASS_OTHER, page);
page = cbm_activity_agents(db, "other", 0, 10);
ASSERT_NOT_NULL(strstr(page, "\"events\":[]"));
cbm_free(CBM_MEM_CLASS_OTHER, page);
ASSERT_EQ(cbm_activity_ingest(db, "{\"events\":[{\"run\":\"bad\"}]}", &reply), 400);
sqlite3_close(db);
th_cleanup(dir);
PASS();
}
TEST(activity_errors_survive_routine_log_retention_and_reopen) {
char *dir = th_mktempdir("cbm_activity_logs");
ASSERT_NOT_NULL(dir);
char path[1024];
snprintf(path, sizeof(path), "%s/activity.db", dir);
sqlite3 *db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
ASSERT_TRUE(cbm_activity_log(db, "level=error msg=index.parse.failed path=src/broken.c"));
for (int i = 0; i < 3010; i++)
ASSERT_TRUE(cbm_activity_log(db, "level=info msg=http.request"));
sqlite3 *reader = cbm_activity_open(path);
ASSERT_NOT_NULL(reader);
char *page = cbm_activity_logs(reader, -1, 10, 2);
ASSERT_NOT_NULL(strstr(page, "index.parse.failed"));
ASSERT_NOT_NULL(strstr(page, "\"level\":\"error\""));
ASSERT_NOT_NULL(strstr(page, "\"total\":1"));
ASSERT_NULL(strstr(page, "http.request"));
cbm_free(CBM_MEM_CLASS_OTHER, page);
sqlite3_close(reader);
sqlite3_close(db);
db = cbm_activity_open(path);
ASSERT_NOT_NULL(db);
page = cbm_activity_logs(db, -1, 10, 0);
ASSERT_NOT_NULL(strstr(page, "\"total\":3001"));
cbm_free(CBM_MEM_CLASS_OTHER, page);
sqlite3_close(db);
th_cleanup(dir);
PASS();
}
TEST(ui_agent_events_and_errors_use_daemon_port_and_survive_restart) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
const char *body =
"{\"project\":\"demo\",\"events\":[{\"ts\":1000,\"agent\":\"test "
"fixture\",\"run\":\"restart-fixture\",\"seq\":1,\"phase\":\"end\",\"tool\":\"Read\"}]}";
char request[2048], resp[16384];
snprintf(request, sizeof(request),
"POST /api/agent-events HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: "
"%zu\r\n\r\n%s",
strlen(body), body);
ASSERT_GT(th_http(cbm_http_server_port(ts.srv), request, resp, sizeof(resp)), 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"accepted\":1"));
cbm_ui_log_append("level=error msg=index.failure.fixture path=test-fixture/broken.c");
th_server_stop(&ts);
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
ASSERT_GT(
th_http(port, "GET /api/agent-events?project=demo HTTP/1.1\r\n\r\n", resp, sizeof(resp)),
0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "restart-fixture"));
ASSERT_GT(th_http(port, request, resp, sizeof(resp)), 0);
ASSERT_NOT_NULL(strstr(resp, "\"duplicates\":1"));
ASSERT_GT(th_http(port, "GET /api/logs?min_level=warn HTTP/1.1\r\n\r\n", resp, sizeof(resp)),
0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "index.failure.fixture"));
ASSERT_NOT_NULL(strstr(resp, "\"persistent\":true"));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
/* One post, two entries: both are durable in SQLite and mirrored as JSONL
* for compatibility. Both readers use the journal, including ordinary logs. */
TEST(ui_log_post_writes_jsonl_and_tail_reads_it) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
const char *body =
"{\"page\":\"/?project=demo\",\"session\":\"s1\",\"entries\":["
"{\"ts\":\"2026-09-08T10:00:00.000Z\",\"seq\":1,\"level\":\"error\",\"source\":\"rpc\","
"\"message\":\"get_code_snippet returned no source\",\"detail\":\"HTTP 200\","
"\"stack\":\"Error: x\\n at y\"},"
"{\"ts\":\"2026-09-08T10:00:01.000Z\",\"seq\":2,\"level\":\"log\",\"source\":\"console\","
"\"message\":\"galaxy \\\"ready\\\"\"}]}";
char resp[16384];
ASSERT_TRUE(ui_log_post(port, body, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"accepted\":2"));
ASSERT_NOT_NULL(strstr(resp, "\"dropped\":0"));
ASSERT_NOT_NULL(strstr(resp, "\"file_error\":false"));
ASSERT_NOT_NULL(strstr(resp, "/logs/ui.log\""));
char path[1024];
snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir);
char content[8192];
ASSERT_TRUE(ui_log_read_file(path, content, sizeof(content)) > 0);
ASSERT_EQ(ui_log_count_char(content, '\n'), 2);
ASSERT_TRUE(strncmp(content, "{\"received\":\"", 13) == 0);
ASSERT_NOT_NULL(strstr(content, "\"page\":\"/?project=demo\",\"session\":\"s1\",\"seq\":1"));
ASSERT_NOT_NULL(strstr(content, "\"level\":\"error\",\"source\":\"rpc\","
"\"message\":\"get_code_snippet returned no source\","
"\"detail\":\"HTTP 200\",\"stack\":\"Error: x\\n at y\"}"));
ASSERT_NOT_NULL(strstr(content,
"\"seq\":2,\"ts\":\"2026-09-08T10:00:01.000Z\",\"level\":\"log\","
"\"source\":\"console\",\"message\":\"galaxy \\\"ready\\\"\"}"));
/* The tail, asked for one line: the newest, and it says it is partial. */
char req[256];
snprintf(req, sizeof(req), "GET /api/ui-log?lines=1 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"partial\":true"));
ASSERT_NOT_NULL(strstr(resp, "\"total\":2"));
ASSERT_NOT_NULL(strstr(resp, "galaxy"));
ASSERT_TRUE(strstr(resp, "get_code_snippet") == NULL);
ASSERT_NOT_NULL(strstr(resp, "\"size_bytes\":"));
ASSERT_TRUE(strstr(resp, "previous_path") == NULL);
/* Asked for more than there is: both, complete. */
snprintf(req, sizeof(req), "GET /api/ui-log?lines=10 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"partial\":false"));
ASSERT_NOT_NULL(strstr(resp, "get_code_snippet"));
ASSERT_NOT_NULL(strstr(resp, "galaxy"));
/* The same journal carries both the error and the ordinary info line. */
snprintf(req, sizeof(req), "GET /api/logs?lines=50 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "ui.error rpc: get_code_snippet returned no source"));
ASSERT_NOT_NULL(strstr(resp, "ui.info console: galaxy"));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
/* Refused bodies never create the file: empty, not JSON, no entries[], and a
* post above the 64 KiB cap. Entries that are not objects are counted as
* dropped rather than refused, so one bad entry does not cost the rest. */
TEST(ui_log_post_refuses_bad_bodies) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char resp[4096];
char req[512];
snprintf(req, sizeof(req),
"POST /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Content-Type: application/json\r\nContent-Length: 0\r\n\r\n",
port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 400);
ASSERT_TRUE(ui_log_post(port, "not json", resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 400);
ASSERT_TRUE(ui_log_post(port, "{\"page\":\"/\"}", resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 400);
size_t big_len = 70000;
char *big = malloc(big_len + 64);
ASSERT_NOT_NULL(big);
strcpy(big, "{\"entries\":[{\"message\":\"");
size_t at = strlen(big);
memset(big + at, 'a', big_len);
strcpy(big + at + big_len, "\"}]}");
ASSERT_TRUE(ui_log_post(port, big, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 413);
free(big);
char path[1024];
snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir);
ASSERT_TRUE(!cbm_file_exists(path));
ASSERT_TRUE(ui_log_post(port, "{\"entries\":[42,{\"message\":\"kept\"}]}", resp, sizeof(resp)) >
0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"accepted\":1,\"dropped\":1"));
ASSERT_TRUE(cbm_file_exists(path));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
/* The file rotates once to ui.log.1 when it has reached the configured size
* at the time of a post; the tail names the previous file. */
TEST(ui_log_rotates_at_configured_size) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
cbm_setenv("CBM_UI_LOG_ROTATE_BYTES", "300", 1);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char resp[8192];
const char *names[] = {"first", "second", "third"};
for (int i = 0; i < 3; i++) {
char body[1024];
char pad[320];
memset(pad, 'p', sizeof(pad) - 1);
pad[sizeof(pad) - 1] = '\0';
snprintf(body, sizeof(body), "{\"entries\":[{\"level\":\"info\",\"message\":\"%s %s\"}]}",
names[i], pad);
ASSERT_TRUE(ui_log_post(port, body, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
}
char path[1024], previous[1040];
snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir);
snprintf(previous, sizeof(previous), "%s.1", path);
char content[4096];
ASSERT_TRUE(ui_log_read_file(path, content, sizeof(content)) > 0);
ASSERT_NOT_NULL(strstr(content, "\"message\":\"third "));
ASSERT_TRUE(strstr(content, "second") == NULL);
ASSERT_TRUE(ui_log_read_file(previous, content, sizeof(content)) > 0);
ASSERT_NOT_NULL(strstr(content, "\"message\":\"second "));
ASSERT_TRUE(strstr(content, "first") == NULL);
char req[256];
snprintf(req, sizeof(req), "GET /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"previous_path\":\""));
ASSERT_NOT_NULL(strstr(resp, "ui.log.1\""));
ASSERT_NOT_NULL(strstr(resp, "third "));
th_server_stop(&ts);
cbm_unsetenv("CBM_UI_LOG_ROTATE_BYTES");
ui_delete_fixture_cleanup(&fx);
PASS();
}
/* The UI connections have a fixed allowlist: one optional local inference service and the
* model host plus its verified download CDN. Browser consent controls model
* downloads; script sources remain local, with no unrestricted CDN or eval
* allowance. */
TEST(ui_csp_connect_src_has_explicit_download_hosts) {
const char *csp = CBM_UI_CSP_VALUE;
ASSERT_NOT_NULL(strstr(csp, "connect-src 'self' http://127.0.0.1:4141 "
"https://huggingface.co https://us.aws.cdn.hf.co;"));
ASSERT_NOT_NULL(strstr(csp, "script-src 'self' 'wasm-unsafe-eval';"));
ASSERT_NOT_NULL(strstr(csp, "worker-src 'self' blob:;"));
ASSERT_TRUE(strstr(csp, "*") == NULL);
ASSERT_TRUE(strstr(csp, "'unsafe-eval'") == NULL);
ASSERT_TRUE(strstr(csp, "ws://") == NULL);
ASSERT_TRUE(strstr(csp, "wss://") == NULL);
int hosts = 0;
const char *p = csp;
while ((p = strstr(p, "http://")) != NULL) {
ASSERT_TRUE(strncmp(p, "http://127.0.0.1:4141", 21) == 0);
hosts++;
p += 7;
}
ASSERT_EQ(hosts, 1);
int secure_hosts = 0;
p = csp;
while ((p = strstr(p, "https://")) != NULL) {
secure_hosts++;
p += 8;
}
ASSERT_EQ(secure_hosts, 2);
ASSERT_NOT_NULL(strstr(csp, "frame-ancestors 'none'"));
ASSERT_NOT_NULL(strstr(csp, "object-src 'none'"));
PASS();
}
/* ── POST /api/index carries the resolved root ────────────────── */
/* The route resolves the request's root_path, runs the workspace check on the
* resolved form, and the job must then carry exactly that string: what was
* checked is what the daemon receives. These cases drive the real endpoint
* with the recording executor and read every place the stored root surfaces
* (the executor argument, the 202 reply, /api/index-status). */
typedef struct {
bool was_set;
char value[4096];
} th_allowed_root_saved_t;
/* Set (or, with NULL, clear) CBM_ALLOWED_ROOT and remember the previous value
* so the test can put it back before asserting — a failed assertion returns
* early, and a boundary left behind would leak into every later test. */
static void th_allowed_root_set(th_allowed_root_saved_t *saved, const char *value) {
const char *current = getenv("CBM_ALLOWED_ROOT");
saved->was_set = current != NULL;
snprintf(saved->value, sizeof(saved->value), "%s", current ? current : "");
if (value) {
cbm_setenv("CBM_ALLOWED_ROOT", value, 1);
} else {
cbm_unsetenv("CBM_ALLOWED_ROOT");
}
}
static void th_allowed_root_restore(const th_allowed_root_saved_t *saved) {
if (saved->was_set) {
cbm_setenv("CBM_ALLOWED_ROOT", saved->value, 1);
} else {
cbm_unsetenv("CBM_ALLOWED_ROOT");
}
}
typedef struct {
th_ui_index_executor_t executor;
int status; /* HTTP status of the POST, -1 when no reply came */
bool executor_called; /* only waited for after a 202 */
char response[8192]; /* the raw POST reply */
char index_status[16384]; /* GET /api/index-status, read after the POST */
} th_ui_index_probe_t;
/* Response body: everything after the header block, or "" when absent. */
static const char *th_response_body(const char *response) {
const char *body = strstr(response, "\r\n\r\n");
return body ? body + 4 : "";
}
/* Boot a server with the recording executor, POST one /api/index request,
* wait for the worker only when the route accepted the job, read
* /api/index-status and stop the server. Everything observed lands in the
* probe so the caller asserts after teardown. */
static void th_ui_index_probe(th_ui_index_probe_t *probe, const char *root_path,
const char *project_name) {
memset(probe, 0, sizeof(*probe));
atomic_init(&probe->executor.calls, 0);
probe->status = -1;
th_server_t ts;
ts.srv = cbm_http_server_new(0);
if (!ts.srv) {
return;
}
cbm_http_server_set_index_executor(ts.srv, th_ui_index_executor, &probe->executor);
if (th_server_thread_start(&ts.tid, ts.srv) != 0) {
(void)cbm_http_server_free(ts.srv);
return;
}
int port = cbm_http_server_port(ts.srv);
char body[8192];
snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"%s\"}", root_path,
project_name);
char request[8192 + 256];
snprintf(request, sizeof(request),
"POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
if (th_http(port, request, probe->response, sizeof(probe->response)) > 0) {
probe->status = th_status(probe->response);
}
if (probe->status == 202) {
probe->executor_called = th_wait_atomic_int(&probe->executor.calls, 1, 2000);
}
char status_request[128];
snprintf(status_request, sizeof(status_request),
"GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port);
(void)th_http(port, status_request, probe->index_status, sizeof(probe->index_status));
th_server_stop(&ts);
}
TEST(ui_server_index_start_refuses_root_outside_allowed) {
char *tmp = th_mktempdir("cbm_httpd_index_bound");
ASSERT_NOT_NULL(tmp);
char base[256];
snprintf(base, sizeof(base), "%s", tmp);
char allowed[512];
char outside[512];
snprintf(allowed, sizeof(allowed), "%s/allowed", base);
snprintf(outside, sizeof(outside), "%s/outside", base);
ASSERT_EQ(th_mkdir_p(allowed), 0);
ASSERT_EQ(th_mkdir_p(outside), 0);
/* The boundary is declared in resolved form, as allow-root records it. */
char canonical_base[4096];
ASSERT_TRUE(cbm_canonical_path(base, canonical_base, sizeof(canonical_base)));
char allowed_root[4096 + 16];
snprintf(allowed_root, sizeof(allowed_root), "%s/allowed", canonical_base);
/* Spelled under the allowed root, resolving beside it: the verdict has to
* come from the resolved form. */
char request_path[600];
snprintf(request_path, sizeof(request_path), "%s/allowed/../outside", base);
th_allowed_root_saved_t saved;
th_allowed_root_set(&saved, allowed_root);
th_ui_index_probe_t probe;
th_ui_index_probe(&probe, request_path, "bound");
th_allowed_root_restore(&saved);
th_cleanup(base);
ASSERT_EQ(probe.status, 403);
ASSERT_NOT_NULL(strstr(probe.response, "outside the allowed root"));
ASSERT_EQ(atomic_load(&probe.executor.calls), 0);
ASSERT_STR_EQ(th_response_body(probe.index_status), "[]");
PASS();
}
TEST(ui_server_index_start_stores_resolved_root) {
char *tmp = th_mktempdir("cbm_httpd_index_root");
ASSERT_NOT_NULL(tmp);
char base[256];
snprintf(base, sizeof(base), "%s", tmp);
char inner[512];
snprintf(inner, sizeof(inner), "%s/allowed/inner", base);
ASSERT_EQ(th_mkdir_p(inner), 0);
/* The resolved form of the allowed directory itself, as the server
* computes it. A "/allowed" suffix on a resolved parent is not that form
* on Windows, where the resolved path uses backslashes throughout. */
char allowed_dir[512];
snprintf(allowed_dir, sizeof(allowed_dir), "%s/allowed", base);
char allowed_root[4096];
ASSERT_TRUE(cbm_canonical_path(allowed_dir, allowed_root, sizeof(allowed_root)));
/* The request names the allowed root through a ".." segment, so its
* spelling is not the resolved form (on macOS /tmp is itself a link to
* /private/tmp, so the prefix differs as well). */
char request_path[600];
snprintf(request_path, sizeof(request_path), "%s/allowed/inner/..", base);
ASSERT_TRUE(strcmp(request_path, allowed_root) != 0);
th_allowed_root_saved_t saved;
th_allowed_root_set(&saved, allowed_root);
th_ui_index_probe_t probe;
th_ui_index_probe(&probe, request_path, "resolved");
th_allowed_root_restore(&saved);
th_cleanup(base);
ASSERT_EQ(probe.status, 202);
ASSERT_TRUE(probe.executor_called);
/* The daemon receives the string the check ran on, not the request
* spelling. */
ASSERT_STR_EQ(probe.executor.root_path, allowed_root);
ASSERT_STR_EQ(probe.executor.project_name, "resolved");
/* The 202 reply and the status listing report that same stored root,
* JSON-escaped (a Windows path carries backslashes). */
char escaped[8192];
cbm_json_escape(escaped, (int)sizeof(escaped), allowed_root);
char expected[8192 + 64];
snprintf(expected, sizeof(expected), "\"path\":\"%s\"}", escaped);
ASSERT_NOT_NULL(strstr(probe.response, expected));
snprintf(expected, sizeof(expected), "\"path\":\"%s\",\"error\"", escaped);
ASSERT_NOT_NULL(strstr(probe.index_status, expected));
PASS();
}
TEST(ui_server_index_start_long_root_not_truncated) {
char *tmp = th_mktempdir("cbm_httpd_index_long");
ASSERT_NOT_NULL(tmp);
char base[256];
snprintf(base, sizeof(base), "%s", tmp);
/* A root well past 1 KB, the capacity the job used to hold, and well
* inside the 4 KB the route resolves; 200-byte components keep every
* component under NAME_MAX. */
char component[201];
memset(component, 'd', sizeof(component) - 1);
component[sizeof(component) - 1] = '\0';
char deep[2048];
int n = snprintf(deep, sizeof(deep), "%s", base);
while (n < 1400) {
n += snprintf(deep + n, sizeof(deep) - (size_t)n, "/%s", component);
}
bool created = th_mkdir_p(deep) == 0;
char canonical[4096];
bool resolved = created && cbm_canonical_path(deep, canonical, sizeof(canonical)) != 0;
/* No boundary here: the question is the length the job keeps. */
th_allowed_root_saved_t saved;
th_allowed_root_set(&saved, NULL);
th_ui_index_probe_t probe;
th_ui_index_probe(&probe, deep, "long");
th_allowed_root_restore(&saved);
th_cleanup(base);
if (probe.status == 202) {
/* The platform resolved the path, so the worker has to receive it
* whole — the same string the route checked. */
ASSERT_TRUE(resolved);
ASSERT_TRUE(probe.executor_called);
ASSERT_TRUE(strlen(probe.executor.root_path) > 1023);
ASSERT_STR_EQ(probe.executor.root_path, canonical);
} else {
/* The platform cannot resolve a path this long (macOS: PATH_MAX is
* 1024, so mkdir and stat refuse it before the route sees it); the
* route answers 400 and keeps nothing. */
ASSERT_EQ(probe.status, 400);
ASSERT_EQ(atomic_load(&probe.executor.calls), 0);
ASSERT_STR_EQ(th_response_body(probe.index_status), "[]");
}
#if defined(PATH_MAX) && PATH_MAX >= 2048
/* Linux resolves it (PATH_MAX 4096), and that is the one platform where a
* cut-off could reach the worker; the accepted branch is the only honest
* outcome there, so the refusal branch may not stand in for it. */
ASSERT_TRUE(created);
ASSERT_EQ(probe.status, 202);
#endif
PASS();
}
/* The 202 reply names the stored root; a quote or backslash in it has to
* travel escaped or the reply is not JSON. */
TEST(ui_server_index_start_reply_escapes_path) {
char *tmp = th_mktempdir("cbm_httpd_index_quote");
ASSERT_NOT_NULL(tmp);
char base[256];
snprintf(base, sizeof(base), "%s", tmp);
char root[512];
#ifdef _WIN32
/* A quote is not a legal file-name byte on Windows; the separators of the
* resolved path already need escaping there. */
snprintf(root, sizeof(root), "%s\\plain", base);
#else
snprintf(root, sizeof(root), "%s/q\"uote", base);
#endif
ASSERT_EQ(th_mkdir_p(root), 0);
char canonical[4096];
ASSERT_TRUE(cbm_canonical_path(root, canonical, sizeof(canonical)));
/* The request body is JSON too, so the root goes in escaped. */
char request_path[1024];
cbm_json_escape(request_path, (int)sizeof(request_path), root);
th_ui_index_probe_t probe;
th_ui_index_probe(&probe, request_path, "quoted");
th_cleanup(base);
bool parsed = false;
int slot = -1;
char reply_path[4096] = "";
const char *body = th_response_body(probe.response);
yyjson_doc *doc = yyjson_read(body, strlen(body), 0);
if (doc) {
yyjson_val *obj = yyjson_doc_get_root(doc);
yyjson_val *path = yyjson_obj_get(obj, "path");
yyjson_val *slot_val = yyjson_obj_get(obj, "slot");
parsed = yyjson_is_obj(obj);
if (yyjson_is_str(path)) {
snprintf(reply_path, sizeof(reply_path), "%s", yyjson_get_str(path));
}
if (yyjson_is_int(slot_val)) {
slot = yyjson_get_int(slot_val);
}
yyjson_doc_free(doc);
}
ASSERT_EQ(probe.status, 202);
ASSERT_TRUE(probe.executor_called);
ASSERT_STR_EQ(probe.executor.root_path, canonical);
ASSERT_TRUE(parsed);
ASSERT_STR_EQ(reply_path, canonical);
ASSERT_TRUE(slot >= 0);
PASS();
}
TEST(ui_server_root_without_embedded_assets_is_not_found) {
/* The frontend is linked into the image, so its availability is decided at
* build time, not warmed at runtime: a binary built without --with-ui has
* no index.html and never will. That is a permanent 404, NOT a retryable
* 503 -- promising a retry for a condition that cannot change would make
* every client poll forever. */
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv), "GET / HTTP/1.1\r\n\r\n", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 404);
ASSERT_NOT_NULL(strstr(resp, "no frontend embedded"));
ASSERT_NULL(strstr(resp, "Retry-After"));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_same_origin_request_is_allowed) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char req[512];
snprintf(req, sizeof(req),
"OPTIONS /rpc HTTP/1.1\r\n"
"Host: 127.0.0.1:%d\r\n"
"Origin: http://127.0.0.1:%d\r\n\r\n",
port, port);
char resp[4096];
int n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 204);
char expected_origin[128];
snprintf(expected_origin, sizeof(expected_origin),
"Access-Control-Allow-Origin: http://127.0.0.1:%d", port);
ASSERT_NOT_NULL(strstr(resp, expected_origin));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_rejects_foreign_and_null_origins) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char resp[4096];
char req[768];
snprintf(req, sizeof(req),
"OPTIONS /rpc HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Origin: http://evil.example.com\r\n\r\n",
port);
int n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
ASSERT_NULL(strstr(resp, "Access-Control-Allow-Origin"));
snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: localhost:%d\r\nOrigin: null\r\n\r\n",
port);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
snprintf(req, sizeof(req),
"GET / HTTP/1.1\r\nHost: localhost:%d\r\n"
"Origin: http://127.0.0.1:%d\r\n\r\n",
port, port);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
static const char body[] = "{\"project\":\"victim\",\"content\":\"poison\"}";
snprintf(req, sizeof(req),
"POST /api/adr HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Origin: http://evil.example.com\r\nContent-Type: text/plain\r\n"
"Content-Length: %zu\r\n\r\n%s",
port, strlen(body), body);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
th_server_stop(&ts);
PASS();
}
TEST(ui_server_mutations_require_json_content_type) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}";
char req[1024];
char resp[8192];
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: %zu\r\n\r\n%s",
port, strlen(body), body);
int n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 415);
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Content-Length: %zu\r\n\r\n%s",
port, strlen(body), body);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 415);
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Content-Type: application/json; charset=utf-8\r\n"
"Content-Length: %zu\r\n\r\n%s",
port, strlen(body), body);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
th_server_stop(&ts);
PASS();
}
TEST(ui_server_rpc_rejects_duplicate_adr_arguments) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
static const struct {
const char *params;
int expected_status;
} cases[] = {
{"{\"name\":\"manage_adr\"}", 200},
{"{\"name\":\"manage_adr\",\"arguments\":{\"mode\":\"update\"},"
"\"arguments\":{\"mode\":\"get\"}}",
403},
{"{\"name\":\"manage_adr\",\"arguments\":{\"mode\":\"get\"},"
"\"arguments\":{\"mode\":\"update\"}}",
403},
};
int statuses[sizeof(cases) / sizeof(cases[0])] = {0};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
char body[512], request[1024], response[8192];
snprintf(body, sizeof(body),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":%s}",
cases[i].params);
snprintf(request, sizeof(request),
"POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
int received = th_http(cbm_http_server_port(ts.srv), request, response, sizeof(response));
statuses[i] = received > 0 ? th_status(response) : 0;
}
th_server_stop(&ts);
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
ASSERT_EQ(statuses[i], cases[i].expected_status);
}
PASS();
}
TEST(ui_server_rpc_allows_only_ui_read_tools) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
/* CBM Atlas reads the graph through /rpc: every read-only query tool must
* pass the allowlist (a 403 on get_graph_schema is #1663). HTTP 200 means
* "allowed and dispatched" — tool-level argument errors still ride inside
* a 200 JSON-RPC envelope. */
static const char *allowed_tools[] = {
"list_projects", "get_code_snippet", "get_graph_schema", "search_graph",
"search_code", "trace_path", "trace_call_path", "get_architecture",
"query_graph", "detect_changes", "check_index_coverage", "index_status",
};
char req[1024];
char resp[8192];
int n = 0;
for (size_t i = 0; i < sizeof(allowed_tools) / sizeof(allowed_tools[0]); i++) {
char body[512];
snprintf(body, sizeof(body),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"%s\",\"arguments\":{}}}",
allowed_tools[i]);
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\""));
}
static const char *blocked_tools[] = {"delete_project", "ingest_traces", "index_repository"};
for (size_t i = 0; i < sizeof(blocked_tools) / sizeof(blocked_tools[0]); i++) {
char blocked_body[512];
snprintf(blocked_body, sizeof(blocked_body),
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","
"\"params\":{\"name\":\"%s\",\"arguments\":{}}}",
blocked_tools[i]);
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(blocked_body), blocked_body);
n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
}
/* manage_adr: the read modes pass, everything that writes is refused. */
static const struct {
const char *arguments;
int expected_status;
} adr_cases[] = {
{"{}", 200}, /* no mode → default "get" */
{"{\"mode\":\"get\"}", 200}, /* explicit read */
{"{\"mode\":\"sections\"}", 200}, /* header listing */
{"{\"mode\":\"update\"}", 403}, /* write */
{"{\"mode\":\"store\"}", 403}, /* legacy write alias */
{"{\"mode\":1}", 403}, /* non-string mode */
{"{\"mode\":\"get\",\"mode\":\"update\"}", 403}, /* smuggled duplicate */
};
for (size_t i = 0; i < sizeof(adr_cases) / sizeof(adr_cases[0]); i++) {
char adr_body[512];
snprintf(adr_body, sizeof(adr_body),
"{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\","
"\"params\":{\"name\":\"manage_adr\",\"arguments\":%s}}",
adr_cases[i].arguments);
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(adr_body), adr_body);
n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), adr_cases[i].expected_status);
}
const char *initialize = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\","
"\"params\":{}}";
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(initialize), initialize);
n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
const char *ambiguous = "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\",\"name\":\"delete_project\","
"\"arguments\":{}}}";
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(ambiguous), ambiguous);
n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
th_server_stop(&ts);
PASS();
}
TEST(ui_server_atlas_routes_are_wired) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
/* A wired route with a missing ?project answers 400; an unwired path
* falls through to the static handler's 404. This pins the /api/flow
* (singular) route, which once used a "?*" pattern that never matched
* because req->path carries no query string. */
static const char *const routes[] = {
"/api/tree", "/api/symbol", "/api/flows", "/api/flow", "/api/metrics", "/api/trace",
"/api/scent", "/api/bridges", "/api/blast", "/api/handout", "/api/why"};
char req[256];
char resp[4096];
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) {
snprintf(req, sizeof(req), "GET %s HTTP/1.1\r\n\r\n", routes[i]);
int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 400);
}
th_server_stop(&ts);
PASS();
}
TEST(ui_server_retained_cache_memory_is_flushed_before_thread_exit) {
cbm_atlas_metrics_cache_clear();
cbm_atlas_flows_cache_clear();
cbm_layout_regions_cache_clear();
ui_delete_fixture_t fixture;
ASSERT_EQ(ui_delete_fixture_init(&fixture), 0);
char path[1024];
ui_delete_db_path(&fixture, "http-memory", path, sizeof(path));
cbm_store_t *store = cbm_store_open_path(path);
ASSERT_NOT_NULL(store);
ASSERT_EQ(cbm_store_upsert_project(store, "http-memory", ""), CBM_STORE_OK);
/* Settle the independent history cache on the caller before measuring the
* HTTP worker. An empty source root does not retain history-scan buffers. */
char *warm = cbm_atlas_metrics_json(store, "http-memory");
ASSERT_NOT_NULL(warm);
cbm_free(CBM_MEM_CLASS_OTHER, warm);
cbm_atlas_metrics_cache_clear();
cbm_layout_regions_cache_clear();
cbm_store_close(store);
void *sentinel = cbm_alloc(CBM_MEM_CLASS_OTHER, 64);
ASSERT_NOT_NULL(sentinel);
th_server_t server;
server.srv = cbm_http_server_new(0);
ASSERT_NOT_NULL(server.srv);
size_t before_blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
size_t before_bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER);
ASSERT_EQ(th_server_thread_start(&server.tid, server.srv), 0);
char response[16384];
int length = th_http(cbm_http_server_port(server.srv),
"GET /api/metrics?project=http-memory HTTP/1.1\r\n\r\n", response,
sizeof(response));
bool metrics_ok = length > 0 && th_status(response) == 200;
/* Receiving a second reply proves the first request reached its flush
* boundary. It avoids asserting the listener's transient idle state. */
length = th_http(cbm_http_server_port(server.srv), "GET /missing-memory-probe HTTP/1.1\r\n\r\n",
response, sizeof(response));
bool fence_ok = length > 0 && th_status(response) == 404;
size_t retained_blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
cbm_http_server_stop(server.srv);
int joined = cbm_thread_join(&server.tid);
cbm_atlas_metrics_cache_clear();
cbm_atlas_flows_cache_clear();
cbm_layout_regions_cache_clear();
size_t after_blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
size_t after_bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER);
bool freed = cbm_http_server_free(server.srv);
cbm_free(CBM_MEM_CLASS_OTHER, sentinel);
ui_delete_fixture_cleanup(&fixture);
ASSERT_TRUE(metrics_ok);
ASSERT_TRUE(fence_ok);
ASSERT_EQ(joined, 0);
ASSERT_TRUE(freed);
ASSERT_GT(retained_blocks, before_blocks);
ASSERT_EQ(after_blocks, before_blocks);
ASSERT_EQ(after_bytes, before_bytes);
PASS();
}
TEST(ui_server_oversized_body_rejected) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char req[256];
snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nContent-Length: %d\r\n\r\n",
CBM_HTTP_MAX_BODY + 1);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 413);
th_server_stop(&ts);
PASS();
}
TEST(ui_server_encoded_slash_not_routed) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv), "GET /api%2Fbrowse?path=/tmp HTTP/1.1\r\n\r\n",
resp, sizeof(resp));
ASSERT_GT(n, 0);
/* must fall through to 404 — NOT the browse handler */
ASSERT_EQ(th_status(resp), 404);
ASSERT_NULL(strstr(resp, "\"dirs\""));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_nul_in_target_rejected) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n =
th_http(cbm_http_server_port(ts.srv), "GET /a%00b HTTP/1.1\r\n\r\n", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 400);
th_server_stop(&ts);
PASS();
}
TEST(ui_server_browse_traversal_probe) {
/* Percent-encoded traversal in the QUERY VALUE is decoded (that is the
* documented contract) and then hits the same directory checks as any
* other path. The server must answer with a well-formed JSON error or
* listing — never crash, never echo raw unescaped input. */
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[65536];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /api/browse?path=%2Ftmp%2F..%2F..%2Fprivate HTTP/1.1\r\n\r\n", resp,
sizeof(resp));
ASSERT_GT(n, 0);
int st = th_status(resp);
ASSERT_TRUE(st == 200 || st == 400 || st == 403);
const char *json = strstr(resp, "\r\n\r\n");
ASSERT_NOT_NULL(json);
ASSERT_EQ(json[4], '{');
th_server_stop(&ts);
PASS();
}
TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr) {
static const char *project = "ui-guard-adr-busy";
static const char *original = "original architecture";
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
ASSERT_EQ(ui_adr_seed(&fx, project, original), CBM_STORE_OK);
th_ui_mutation_guard_t guard;
th_ui_mutation_guard_init(&guard, false);
th_server_t ts;
ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0);
char resp[4096];
int n = ui_adr_post_request(&ts, project, "replacement architecture", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 423);
ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing"));
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
ASSERT_EQ(atomic_load(&guard.end_calls), 0);
ASSERT_STR_EQ(guard.begin_project, project);
/* A rejected mutation must leave the published DB queryable and unchanged.
* GET is a query operation and therefore must not enter the mutation guard. */
n = ui_adr_get_request(&ts, project, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"has_adr\":true"));
ASSERT_NOT_NULL(strstr(resp, original));
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
ASSERT_EQ(atomic_load(&guard.end_calls), 0);
ASSERT_TRUE(ui_adr_equals(&fx, project, original));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_adr_mutation_guard_balances_success) {
static const char *project = "ui-guard-adr-success";
static const char *content = "coordinated architecture";
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
th_ui_mutation_guard_t guard;
th_ui_mutation_guard_init(&guard, true);
th_server_t ts;
ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0);
char resp[4096];
int n = ui_adr_post_request(&ts, project, content, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "{\"saved\":true}"));
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
ASSERT_EQ(atomic_load(&guard.end_calls), 1);
ASSERT_STR_EQ(guard.begin_project, project);
ASSERT_STR_EQ(guard.end_project, project);
ASSERT_TRUE(ui_adr_equals(&fx, project, content));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_delete_mutation_guard_busy_preserves_project) {
static const char *project = "ui-guard-delete-busy";
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
ASSERT_EQ(ui_delete_make_db_file(&fx, project), 0);
ASSERT_EQ(ui_delete_make_sidecars(&fx, project), 0);
cbm_watcher_watch(fx.watcher, project, fx.root_dir);
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_ui_mutation_guard_t guard;
th_ui_mutation_guard_init(&guard, false);
th_server_t ts;
ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0);
char resp[4096];
int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-busy", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 423);
ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing"));
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
ASSERT_EQ(atomic_load(&guard.end_calls), 0);
ASSERT_STR_EQ(guard.begin_project, project);
char db_path[1024], wal_path[1040], shm_path[1040];
ui_delete_db_path(&fx, project, db_path, sizeof(db_path));
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path);
ASSERT_TRUE(cbm_file_exists(db_path));
ASSERT_TRUE(cbm_file_exists(wal_path));
ASSERT_TRUE(cbm_file_exists(shm_path));
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_delete_mutation_guard_balances_success) {
static const char *project = "ui-guard-delete-success";
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
ASSERT_EQ(ui_delete_make_db_file(&fx, project), 0);
cbm_watcher_watch(fx.watcher, project, fx.root_dir);
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_ui_mutation_guard_t guard;
th_ui_mutation_guard_init(&guard, true);
th_server_t ts;
ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0);
char resp[4096];
int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-success", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "{\"deleted\":true}"));
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
ASSERT_EQ(atomic_load(&guard.end_calls), 1);
ASSERT_STR_EQ(guard.begin_project, project);
ASSERT_STR_EQ(guard.end_project, project);
char db_path[1024];
ui_delete_db_path(&fx, project, db_path, sizeof(db_path));
ASSERT_FALSE(cbm_file_exists(db_path));
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 0);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_delete_project_unwatches_after_delete) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
ASSERT_EQ(ui_delete_make_db_file(&fx, "ui-delete-watch"), 0);
ASSERT_EQ(ui_delete_make_sidecars(&fx, "ui-delete-watch"), 0);
cbm_watcher_watch(fx.watcher, "ui-delete-watch", fx.root_dir);
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_t ts;
ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0);
char resp[4096];
int n = ui_delete_request(&ts, "/api/project?name=ui-delete-watch", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "{\"deleted\":true}"));
char db[1024], wal[1040], shm[1040];
ui_delete_db_path(&fx, "ui-delete-watch", db, sizeof(db));
snprintf(wal, sizeof(wal), "%s-wal", db);
snprintf(shm, sizeof(shm), "%s-shm", db);
ASSERT_FALSE(cbm_file_exists(db));
ASSERT_FALSE(cbm_file_exists(wal));
ASSERT_FALSE(cbm_file_exists(shm));
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 0);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_delete_project_unwatches_missing_db) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
cbm_watcher_watch(fx.watcher, "ui-delete-missing", fx.root_dir);
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_t ts;
ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0);
char resp[4096];
int n = ui_delete_request(&ts, "/api/project?name=ui-delete-missing", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 404);
ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"project not found\"}"));
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 0);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_delete_project_no_watcher_still_deletes) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
ASSERT_EQ(ui_delete_make_db_file(&fx, "ui-delete-no-watcher"), 0);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = ui_delete_request(&ts, "/api/project?name=ui-delete-no-watcher", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
char db[1024];
ui_delete_db_path(&fx, "ui-delete-no-watcher", db, sizeof(db));
ASSERT_FALSE(cbm_file_exists(db));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_delete_project_missing_name_keeps_watch) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
cbm_watcher_watch(fx.watcher, "ui-delete-still-watched", fx.root_dir);
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_t ts;
ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0);
char resp[4096];
int n = ui_delete_request(&ts, "/api/project", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 400);
ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"missing name\"}"));
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_delete_project_invalid_name_keeps_watch) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
cbm_watcher_watch(fx.watcher, "bad/name", fx.root_dir);
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_t ts;
ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0);
char resp[4096];
int n = ui_delete_request(&ts, "/api/project?name=bad%2Fname", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 404);
ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"project not found\"}"));
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
TEST(ui_server_delete_project_unlink_failure_keeps_watch) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
char db[1024];
ui_delete_db_path(&fx, "ui-delete-unlink-fails", db, sizeof(db));
ASSERT_EQ(th_mkdir_p(db), 0);
cbm_watcher_watch(fx.watcher, "ui-delete-unlink-fails", fx.root_dir);
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_t ts;
ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0);
char resp[4096];
int n = ui_delete_request(&ts, "/api/project?name=ui-delete-unlink-fails", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 500);
ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"failed to delete\"}"));
ASSERT_TRUE(cbm_file_exists(db));
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
static bool ui_health_matches(th_server_t *server, const char *project, const char *status,
const char *indexed_at, bool registered, bool running) {
char request[512], response[4096];
snprintf(request, sizeof(request), "GET /api/project-health?name=%s HTTP/1.1\r\n\r\n", project);
int length = th_http(cbm_http_server_port(server->srv), request, response, sizeof(response));
if (length <= 0 || th_status(response) != 200)
return false;
const char *body = strstr(response, "\r\n\r\n");
if (!body)
return false;
body += 4;
yyjson_doc *doc = yyjson_read(body, strlen(body), 0);
if (!doc)
return false;
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *actual_status = yyjson_obj_get(root, "status");
yyjson_val *timestamp = yyjson_obj_get(root, "indexed_at");
yyjson_val *watch = yyjson_obj_get(root, "watch_registered");
yyjson_val *loop = yyjson_obj_get(root, "watcher_running");
bool matches = yyjson_is_str(actual_status) &&
strcmp(yyjson_get_str(actual_status), status) == 0 && yyjson_is_bool(watch) &&
yyjson_get_bool(watch) == registered && yyjson_is_bool(loop) &&
yyjson_get_bool(loop) == running;
matches = matches && (indexed_at ? yyjson_is_str(timestamp) &&
strcmp(yyjson_get_str(timestamp), indexed_at) == 0
: yyjson_is_null(timestamp));
if (strcmp(status, "healthy") == 0) {
matches = matches && yyjson_is_int(yyjson_obj_get(root, "nodes")) &&
yyjson_get_int(yyjson_obj_get(root, "nodes")) == 0 &&
yyjson_is_int(yyjson_obj_get(root, "edges")) &&
yyjson_get_int(yyjson_obj_get(root, "edges")) == 0 &&
yyjson_get_int(yyjson_obj_get(root, "size_bytes")) > 0;
}
yyjson_doc_free(doc);
return matches;
}
TEST(ui_server_project_health_timestamp_and_no_watcher) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
char db_path[1024];
ui_delete_db_path(&fx, "health-indexed", db_path, sizeof(db_path));
cbm_store_t *store = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(store);
ASSERT_EQ(cbm_store_upsert_project(store, "health-indexed", fx.root_dir), CBM_STORE_OK);
cbm_project_t project = {0};
ASSERT_EQ(cbm_store_get_project(store, "health-indexed", &project), CBM_STORE_OK);
ASSERT_NOT_NULL(project.indexed_at);
ASSERT_TRUE(project.indexed_at[0] != '\0');
cbm_store_close(store);
/* This endpoint's corrupt branch means "cannot open". A text file is not
* sufficient: the store's immutable fallback opens lazily and can return
* a handle before discovering invalid contents. An existing directory
* makes both open attempts fail, independently of file permissions. */
ui_delete_db_path(&fx, "health-unreadable", db_path, sizeof(db_path));
ASSERT_EQ(th_mkdir_p(db_path), 0);
ASSERT_TRUE(cbm_file_exists(db_path));
ASSERT_NULL(cbm_store_open_path_query(db_path));
th_server_t server;
ASSERT_EQ(th_server_start(&server), 0);
bool healthy =
ui_health_matches(&server, "health-indexed", "healthy", project.indexed_at, false, false);
bool missing = ui_health_matches(&server, "health-missing", "missing", NULL, false, false);
bool corrupt = ui_health_matches(&server, "health-unreadable", "corrupt", NULL, false, false);
th_server_stop(&server);
cbm_project_free_fields(&project);
ui_delete_fixture_cleanup(&fx);
ASSERT_TRUE(healthy);
ASSERT_TRUE(missing);
ASSERT_TRUE(corrupt);
PASS();
}
static void *ui_health_watcher_run(void *opaque) {
(void)cbm_watcher_run(opaque, 10);
return NULL;
}
TEST(ui_server_project_health_live_watcher_state) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
/* Missing root is owned by this fixture and prevents any Git subprocess. */
char missing_root[1024];
snprintf(missing_root, sizeof(missing_root), "%s/absent", fx.root_dir);
ASSERT_TRUE(cbm_watcher_watch(fx.watcher, "health-watched", missing_root));
th_server_t server;
ASSERT_EQ(th_server_start_with_watcher(&server, fx.watcher), 0);
bool registered_idle =
ui_health_matches(&server, "health-watched", "missing", NULL, true, false);
bool unregistered_idle =
ui_health_matches(&server, "health-other", "missing", NULL, false, false);
cbm_thread_t thread;
ASSERT_EQ(cbm_thread_create(&thread, 0, ui_health_watcher_run, fx.watcher), 0);
uint64_t deadline = cbm_now_ms() + 5000;
while (!cbm_watcher_project_status(fx.watcher, NULL).running && cbm_now_ms() < deadline)
cbm_usleep(1000);
bool registered_running =
ui_health_matches(&server, "health-watched", "missing", NULL, true, true);
bool unregistered_running =
ui_health_matches(&server, "health-other", "missing", NULL, false, true);
cbm_watcher_stop(fx.watcher);
bool stopped = ui_health_matches(&server, "health-watched", "missing", NULL, true, false);
int joined = cbm_thread_join(&thread);
cbm_watcher_unwatch(fx.watcher, "health-watched");
bool removed = ui_health_matches(&server, "health-watched", "missing", NULL, false, false);
th_server_stop(&server);
ui_delete_fixture_cleanup(&fx);
ASSERT_TRUE(registered_idle);
ASSERT_TRUE(unregistered_idle);
ASSERT_TRUE(registered_running);
ASSERT_TRUE(unregistered_running);
ASSERT_TRUE(stopped);
ASSERT_EQ(joined, 0);
ASSERT_TRUE(removed);
PASS();
}
TEST(ui_server_ui_config_detects_zh_accept_language) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /api/ui-config HTTP/1.1\r\n"
"Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n"
"\r\n",
resp, sizeof(resp));
ASSERT_TRUE(n > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_ui_config_includes_serving_version_issue1820) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv), "GET /api/ui-config HTTP/1.1\r\n\r\n", resp,
sizeof(resp));
ASSERT_TRUE(n > 0);
ASSERT_EQ(th_status(resp), 200);
char expected_version[128];
snprintf(expected_version, sizeof(expected_version), "\"version\":\"%s\"", CBM_VERSION);
ASSERT_NOT_NULL(strstr(resp, expected_version));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_ui_config_prefers_config_lang) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX");
char *td = cbm_mkdtemp(tmpdir);
ASSERT_NOT_NULL(td);
char *old_home = getenv("HOME") ? strdup(getenv("HOME")) : NULL;
cbm_setenv("HOME", td, 1);
char cache_dir[1024];
snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
cbm_config_t *cfg = cbm_config_open(cache_dir);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_UI_LANG, "zh"), 0);
cbm_config_close(cfg);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /api/ui-config HTTP/1.1\r\n"
"Accept-Language: en-US,en;q=0.9\r\n"
"\r\n",
resp, sizeof(resp));
ASSERT_TRUE(n > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
th_server_stop(&ts);
if (old_home) {
cbm_setenv("HOME", old_home, 1);
free(old_home);
}
PASS();
}
TEST(ui_server_slow_request_hits_deadline) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
/* Shorten the deadline so the test is fast */
cbm_http_server_set_recv_deadline_ms(ts.srv, 300);
int port = cbm_http_server_port(ts.srv);
th_sock_t s = th_connect(port);
ASSERT_TRUE(s != TH_SOCK_BAD);
ASSERT_EQ(th_send_all(s, "GET /api", 8), 0); /* partial request, then stall */
char resp[1024];
int n = th_recv_until_close(s, resp, sizeof(resp)); /* server must give up */
th_sock_close(s);
/* Either a 408 or a bare close is acceptable — the loop must move on */
if (n > 0) {
ASSERT_EQ(th_status(resp), 408);
}
/* …and the server must still answer the next request */
char resp2[4096];
int n2 = th_http(port, "GET /definitely/not/here HTTP/1.1\r\n\r\n", resp2, sizeof(resp2));
ASSERT_GT(n2, 0);
ASSERT_EQ(th_status(resp2), 404);
th_server_stop(&ts);
PASS();
}
TEST(ui_server_access_log_redacts_query) {
httpd_log_buf[0] = '\0';
CBMLogLevel prev_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_sink_ex(httpd_capture_log, CBM_LOG_SINK_REPLACE);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /definitely/not/here?token=secret HTTP/1.1\r\n\r\n", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 404);
th_server_stop(&ts);
cbm_log_set_sink(NULL);
cbm_log_set_level(prev_level);
ASSERT_NOT_NULL(strstr(httpd_log_buf, "msg=http.request"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "component=graph_ui"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "method=GET"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "path=/definitely/not/here"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "status=404"));
ASSERT_NULL(strstr(httpd_log_buf, "token"));
ASSERT_NULL(strstr(httpd_log_buf, "secret"));
PASS();
}
TEST(ui_server_stop_joins_cleanly) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
/* no requests at all — stop must unblock the accept wait promptly */
th_server_stop(&ts);
PASS();
}
TEST(ui_server_free_refuses_active_loop) {
cbm_http_server_t *server = cbm_http_server_new(0);
ASSERT_NOT_NULL(server);
cbm_thread_t thread;
ASSERT_EQ(th_server_thread_start(&thread, server), 0);
char response[512];
ASSERT_GT(
th_http(cbm_http_server_port(server), "GET / HTTP/1.1\r\n\r\n", response, sizeof(response)),
0);
ASSERT_FALSE(cbm_http_server_free(server));
cbm_http_server_stop(server);
ASSERT_EQ(cbm_thread_join(&thread), 0);
ASSERT_TRUE(cbm_http_server_free(server));
PASS();
}
TEST(ui_server_free_refuses_scheduled_run_before_child_starts) {
cbm_http_server_t *server = cbm_http_server_new(0);
ASSERT_NOT_NULL(server);
ASSERT_TRUE(cbm_http_server_schedule_run(server));
ASSERT_FALSE(cbm_http_server_free(server));
ASSERT_TRUE(cbm_http_server_cancel_scheduled_run(server));
ASSERT_TRUE(cbm_http_server_free(server));
PASS();
}
TEST(daemon_host_http_thread_create_failure_cancels_scheduled_run) {
ASSERT_TRUE(cbm_daemon_host_http_thread_create_failure_lifecycle_for_test());
PASS();
}
typedef struct {
th_sock_t socket;
atomic_int *operation_finished;
atomic_int stop_finished;
atomic_int watchdog_fired;
} th_http_stop_watchdog_t;
static void *th_http_stop_watchdog(void *opaque) {
th_http_stop_watchdog_t *watchdog = opaque;
/* Hang detector, not a latency assertion: sized for the slowest
* sanitizer/loaded-runner tail (see AGENTS.md, CI determinism). */
for (int elapsed_ms = 0; elapsed_ms < 10000; elapsed_ms += 10) {
if (atomic_load(&watchdog->stop_finished) ||
(watchdog->operation_finished && atomic_load(watchdog->operation_finished)))
return NULL;
cbm_usleep(10 * 1000);
}
atomic_store(&watchdog->watchdog_fired, 1);
(void)th_sock_shutdown(watchdog->socket);
return NULL;
}
typedef struct {
cbm_httpd_t *listener;
atomic_int accepted;
atomic_int finished;
int send_deadline_ms;
} th_httpd_large_reply_t;
static void *th_httpd_large_reply(void *opaque) {
th_httpd_large_reply_t *reply = opaque;
cbm_http_conn_t *connection = cbm_httpd_accept(reply->listener, 3000);
if (!connection) {
atomic_store(&reply->finished, 1);
return NULL;
}
atomic_store(&reply->accepted, 1);
if (reply->send_deadline_ms)
cbm_http_conn_set_send_deadline_ms(connection, reply->send_deadline_ms);
size_t response_size = 8U * 1024U * 1024U;
char *response = malloc(response_size);
if (response) {
memset(response, 'R', response_size);
cbm_http_reply_buf(connection, 200, "Content-Type: application/octet-stream\r\n", response,
response_size);
free(response);
}
cbm_httpd_conn_close(connection);
atomic_store(&reply->finished, 1);
return NULL;
}
TEST(httpd_interrupt_unblocks_nonreading_large_response_within_one_second) {
cbm_httpd_t *listener = cbm_httpd_listen(0);
ASSERT_NOT_NULL(listener);
cbm_httpd_set_send_buffer_for_test(listener, 64 * 1024);
/* Deadline pinned far out of reach: the only remaining way the blocked
* 8 MB send can end is the interrupt, so the join itself proves
* interrupt causality — no wall-clock discrimination needed (the old
* elapsed<500ms check was a lottery under sanitizer slowdown). */
cbm_httpd_set_send_deadline_for_test(listener, 60 * 1000);
th_httpd_large_reply_t reply = {.listener = listener};
atomic_init(&reply.accepted, 0);
atomic_init(&reply.finished, 0);
th_sock_t socket = th_connect_with_recv_buffer(cbm_httpd_port(listener), 1024);
ASSERT_TRUE(socket != TH_SOCK_BAD);
cbm_thread_t reply_thread;
ASSERT_EQ(cbm_thread_create(&reply_thread, 0, th_httpd_large_reply, &reply), 0);
ASSERT_TRUE(th_wait_httpd_activity(listener, CBM_HTTPD_ACTIVITY_RESPONDING, 1000));
ASSERT_EQ(atomic_load(&reply.accepted), 1);
ASSERT_EQ(atomic_load(&reply.finished), 0);
th_http_stop_watchdog_t watchdog = {
.socket = socket,
.operation_finished = &reply.finished,
};
atomic_init(&watchdog.stop_finished, 0);
atomic_init(&watchdog.watchdog_fired, 0);
cbm_thread_t watchdog_thread;
ASSERT_EQ(cbm_thread_create(&watchdog_thread, 0, th_http_stop_watchdog, &watchdog), 0);
cbm_httpd_interrupt(listener);
ASSERT_EQ(cbm_thread_join(&reply_thread), 0);
atomic_store(&watchdog.stop_finished, 1);
ASSERT_EQ(cbm_thread_join(&watchdog_thread), 0);
(void)th_sock_shutdown(socket);
th_sock_close(socket);
ASSERT_TRUE(cbm_httpd_close(listener));
/* The 60 s deadline cannot have fired, so the join above is the proof
* of interrupt delivery; the watchdog is purely a hang detector. */
ASSERT_EQ(atomic_load(&watchdog.watchdog_fired), 0);
PASS();
}
TEST(httpd_large_response_opt_in_survives_a_busy_reader_without_changing_default) {
cbm_httpd_t *listener = cbm_httpd_listen(0);
ASSERT_NOT_NULL(listener);
cbm_httpd_set_send_buffer_for_test(listener, 64 * 1024);
cbm_httpd_set_send_deadline_for_test(listener, 100);
th_httpd_large_reply_t reply = {.listener = listener, .send_deadline_ms = 5000};
atomic_init(&reply.accepted, 0);
atomic_init(&reply.finished, 0);
th_sock_t socket = th_connect_with_recv_buffer(cbm_httpd_port(listener), 64 * 1024);
ASSERT_TRUE(socket != TH_SOCK_BAD);
cbm_thread_t reply_thread;
ASSERT_EQ(cbm_thread_create(&reply_thread, 0, th_httpd_large_reply, &reply), 0);
ASSERT_TRUE(th_wait_httpd_activity(listener, CBM_HTTPD_ACTIVITY_RESPONDING, 1000));
/* Deterministic backpressure spans the ordinary test deadline. The new
* per-response opt-in must preserve the complete promised Content-Length. */
cbm_usleep(300 * 1000);
size_t size = 8U * 1024U * 1024U;
char *response = malloc(size + 1024);
ASSERT_NOT_NULL(response);
int got = th_recv_until_close(socket, response, size + 1024);
ASSERT_EQ(cbm_thread_join(&reply_thread), 0);
th_sock_close(socket);
ASSERT_TRUE(cbm_httpd_close(listener));
ASSERT_GT(got, (int)size);
char *body = strstr(response, "\r\n\r\n");
ASSERT_NOT_NULL(body);
ASSERT_EQ((size_t)got - (size_t)(body + 4 - response), size);
ASSERT_EQ(response[got - 1], 'R');
free(response);
PASS();
}
TEST(httpd_nonreading_large_response_hits_send_deadline_without_interrupt) {
cbm_httpd_t *listener = cbm_httpd_listen(0);
ASSERT_NOT_NULL(listener);
cbm_httpd_set_send_buffer_for_test(listener, 64 * 1024);
th_httpd_large_reply_t reply = {.listener = listener};
atomic_init(&reply.accepted, 0);
atomic_init(&reply.finished, 0);
th_sock_t socket = th_connect_with_recv_buffer(cbm_httpd_port(listener), 1024);
ASSERT_TRUE(socket != TH_SOCK_BAD);
cbm_thread_t reply_thread;
ASSERT_EQ(cbm_thread_create(&reply_thread, 0, th_httpd_large_reply, &reply), 0);
ASSERT_TRUE(th_wait_httpd_activity(listener, CBM_HTTPD_ACTIVITY_RESPONDING, 1000));
ASSERT_EQ(atomic_load(&reply.accepted), 1);
ASSERT_EQ(atomic_load(&reply.finished), 0);
uint64_t started = cbm_now_ms();
th_http_stop_watchdog_t watchdog = {
.socket = socket,
.operation_finished = &reply.finished,
};
atomic_init(&watchdog.stop_finished, 0);
atomic_init(&watchdog.watchdog_fired, 0);
cbm_thread_t watchdog_thread;
ASSERT_EQ(cbm_thread_create(&watchdog_thread, 0, th_http_stop_watchdog, &watchdog), 0);
ASSERT_EQ(cbm_thread_join(&reply_thread), 0);
uint64_t elapsed = cbm_now_ms() - started;
atomic_store(&watchdog.stop_finished, 1);
ASSERT_EQ(cbm_thread_join(&watchdog_thread), 0);
th_sock_close(socket);
ASSERT_TRUE(cbm_httpd_close(listener));
ASSERT_GTE(elapsed, 500);
ASSERT_EQ(atomic_load(&watchdog.watchdog_fired), 0);
PASS();
}
TEST(ui_server_stop_interrupts_partial_request_within_one_second) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
cbm_http_server_set_recv_deadline_ms(ts.srv, 3000);
int port = cbm_http_server_port(ts.srv);
th_sock_t socket = th_connect(port);
ASSERT_TRUE(socket != TH_SOCK_BAD);
char partial[256];
snprintf(partial, sizeof(partial), "GET /api/logs HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n", port);
ASSERT_EQ(th_send_all(socket, partial, strlen(partial)), 0);
ASSERT_TRUE(th_wait_http_server_activity(ts.srv, CBM_HTTPD_ACTIVITY_READING_REQUEST, 1000));
th_http_stop_watchdog_t watchdog = {.socket = socket};
atomic_init(&watchdog.stop_finished, 0);
atomic_init(&watchdog.watchdog_fired, 0);
cbm_thread_t watchdog_thread;
ASSERT_EQ(cbm_thread_create(&watchdog_thread, 0, th_http_stop_watchdog, &watchdog), 0);
uint64_t started = cbm_now_ms();
cbm_http_server_stop(ts.srv);
ASSERT_EQ(cbm_thread_join(&ts.tid), 0);
uint64_t elapsed = cbm_now_ms() - started;
atomic_store(&watchdog.stop_finished, 1);
ASSERT_EQ(cbm_thread_join(&watchdog_thread), 0);
(void)th_sock_shutdown(socket);
th_sock_close(socket);
ASSERT_TRUE(cbm_http_server_free(ts.srv));
ASSERT_LT(elapsed, 1000);
ASSERT_EQ(atomic_load(&watchdog.watchdog_fired), 0);
PASS();
}
/* ── /api/repo-info git-remote URL helpers (distilled from PR #789) ── */
/* The web base must always be https (deep-links can't be downgraded) and must
* never carry embedded credentials, across scp / ssh / https remote shapes. */
TEST(repo_info_web_base_normalizes_to_https) {
struct {
const char *in;
const char *want;
} cases[] = {
{"git@github.com:org/repo.git", "https://github.com/org/repo"},
{"git@github.com:org/repo", "https://github.com/org/repo"},
{"https://github.com/org/repo.git", "https://github.com/org/repo"},
{"ssh://git@github.com/org/repo.git", "https://github.com/org/repo"},
{"https://user:token@github.com/org/repo.git", "https://github.com/org/repo"},
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
char *got = cbm_ui_git_web_base(cases[i].in);
ASSERT_NOT_NULL(got);
ASSERT_STR_EQ(got, cases[i].want);
/* Never leak credentials into the web base. */
ASSERT_NULL(strstr(got, "token"));
ASSERT_NULL(strstr(got, "@"));
cbm_free(CBM_MEM_CLASS_OTHER, got);
}
/* Unrecognized shapes yield NULL, not a bogus link. */
ASSERT_NULL(cbm_ui_git_web_base(""));
ASSERT_NULL(cbm_ui_git_web_base("not-a-url"));
PASS();
}
/* The remote_url field echoed to the client must have any user:pass@ stripped. */
TEST(repo_info_strips_credentials_from_remote) {
char *safe = cbm_ui_git_strip_credentials("https://alice:s3cr3t@github.com/org/repo.git");
ASSERT_NOT_NULL(safe);
ASSERT_STR_EQ(safe, "https://github.com/org/repo.git");
ASSERT_NULL(strstr(safe, "s3cr3t"));
ASSERT_NULL(strstr(safe, "alice"));
free(safe);
/* Credential-free URLs pass through unchanged. */
char *plain = cbm_ui_git_strip_credentials("https://github.com/org/repo.git");
ASSERT_NOT_NULL(plain);
ASSERT_STR_EQ(plain, "https://github.com/org/repo.git");
free(plain);
/* An '@' in the path (not the authority) must not be treated as creds. */
char *pathat = cbm_ui_git_strip_credentials("https://github.com/org/repo/@scope");
ASSERT_NOT_NULL(pathat);
ASSERT_STR_EQ(pathat, "https://github.com/org/repo/@scope");
free(pathat);
/* scp-style carries no secret and is left intact. */
char *scp = cbm_ui_git_strip_credentials("git@github.com:org/repo.git");
ASSERT_NOT_NULL(scp);
ASSERT_STR_EQ(scp, "git@github.com:org/repo.git");
free(scp);
ASSERT_NULL(cbm_ui_git_strip_credentials(NULL));
PASS();
}
/* ── #798 follow-up: full UI-mode hang repro (live sockets) ───── */
/* Like th_http but arms a client-side receive-timeout watchdog. If the
* single-threaded server wedges, recv() returns instead of blocking forever, so
* the test FAILs deterministically rather than hanging CI. 0 on connect/timeout. */
static int th_http_deadline(int port, const char *request, char *resp, size_t respsz,
int timeout_ms) {
char *prepared = th_request_with_ui_headers(port, request);
if (!prepared)
return 0;
th_sock_t s = th_connect(port);
if (s == TH_SOCK_BAD) {
free(prepared);
return 0;
}
#ifdef _WIN32
DWORD tv = (DWORD)timeout_ms;
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char *)&tv, sizeof(tv));
#else
struct timeval tv;
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
#endif
if (th_send_all(s, prepared, strlen(prepared)) != 0) {
free(prepared);
th_sock_close(s);
return 0;
}
free(prepared);
int n = th_recv_until_close(s, resp, respsz);
th_sock_close(s);
return n;
}
/* #798 was a single-threaded-server wedge: list_projects never returned and the
* whole UI stopped answering. Assert the running server answers list_projects
* within a hard deadline while it holds live listening sockets. The client
* receive-timeout is the watchdog: a wedge → no 200 → FAIL, never a CI hang. */
TEST(ui_server_list_projects_responds_under_watchdog) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}";
char req[512];
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %d\r\n\r\n%s",
(int)strlen(body), body);
char resp[8192];
int n = th_http_deadline(cbm_http_server_port(ts.srv), req, resp, sizeof(resp), 15000);
th_server_stop(&ts);
ASSERT_GT(n, 0); /* a response arrived before the watchdog fired */
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\""));
PASS();
}
#ifdef _WIN32
typedef struct {
char path[512];
int resolved_ok;
} th_gitctx_probe_t;
static DWORD WINAPI th_gitctx_probe_thread(LPVOID arg) {
th_gitctx_probe_t *p = (th_gitctx_probe_t *)arg;
cbm_git_context_t ctx;
memset(&ctx, 0, sizeof(ctx));
int rc = cbm_git_context_resolve(p->path, &ctx);
p->resolved_ok = (rc == 0 && ctx.is_git) ? 1 : 0;
cbm_git_context_free(&ctx);
return 0;
}
#endif
/* The load-bearing end-to-end repro of #798: while the single-threaded UI server
* holds LIVE listening/AFD socket handles in this process, cbm_git_context_resolve
* — the exact path list_projects runs (add_git_context_json → resolve →
* cbm_popen(git)) — must not hang. Under a raw-_popen regression git inherits
* those sockets and its MSYS2 runtime deadlocks in NtQueryObject; the watchdog
* turns that into a hard FAIL instead of an infinite hang. */
TEST(git_context_resolve_no_hang_under_live_ui_sockets) {
#ifndef _WIN32
SKIP_PLATFORM("Windows-only: #798 UI listening-socket handle inheritance");
#else
char *tmp = th_mktempdir("cbm_798repro");
if (!tmp)
FAIL("th_mktempdir returned NULL");
char cmd[1024];
snprintf(cmd, sizeof(cmd),
"git -C \"%s\" init -q && git -C \"%s\" -c user.email=t@t -c user.name=t "
"commit -q --allow-empty -m init",
tmp, tmp);
if (system(cmd) != 0) {
th_rmtree(tmp);
SKIP_PLATFORM("git not available to init a repo");
}
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
th_gitctx_probe_t *probe = (th_gitctx_probe_t *)calloc(1, sizeof(*probe));
ASSERT_NOT_NULL(probe);
snprintf(probe->path, sizeof(probe->path), "%s", tmp);
HANDLE h = CreateThread(NULL, 0, th_gitctx_probe_thread, probe, 0, NULL);
ASSERT_NOT_NULL(h);
DWORD w = WaitForSingleObject(h, 30000);
if (w != WAIT_OBJECT_0) {
/* Wedged on the inherited-socket NtQueryObject walk. Deliberately leak
* the heap probe + thread (a late wake must not touch freed memory);
* process exit reaps them. Fail loudly rather than hang CI. */
th_server_stop(&ts);
FAIL("cbm_git_context_resolve hung under live UI sockets (#798 regression)");
}
CloseHandle(h);
th_server_stop(&ts);
int ok = probe->resolved_ok;
free(probe);
th_rmtree(tmp);
ASSERT_EQ(ok, 1);
PASS();
#endif
}
/* Host is part of the authority boundary: HTTP/1.1 requires exactly one, and
* the optional port must be the actual bound port. This blocks DNS rebinding
* and prevents a foreign localhost service from manufacturing same-origin
* requests for this daemon. */
TEST(ui_server_rejects_non_loopback_host) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char resp[4096];
int n = th_http(port, "GET / HTTP/1.1\r\nHost: evil.example.com\r\n\r\n", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
n = th_http_raw(port, "GET / HTTP/1.1\r\n\r\n", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 400);
static const char *bare_loopback_authorities[] = {
"127.0.0.1",
"localhost",
"[::1]",
};
char req[256];
for (size_t i = 0; i < sizeof(bare_loopback_authorities) / sizeof(bare_loopback_authorities[0]);
i++) {
snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: %s\r\n\r\n",
bare_loopback_authorities[i]);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
}
snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port + 1);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_NEQ(th_status(resp), 400);
ASSERT_NEQ(th_status(resp), 403);
static const char *bare_origins[] = {
"http://127.0.0.1",
"http://localhost",
"http://[::1]",
};
for (size_t i = 0; i < sizeof(bare_origins) / sizeof(bare_origins[0]); i++) {
snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nOrigin: %s\r\n\r\n",
port, bare_origins[i]);
n = th_http_raw(port, req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 403);
}
th_server_stop(&ts);
PASS();
}
/* The browser sends UTF-8 repository paths in a percent-encoded query. On
* Windows, the handler's narrow opendir/readdir calls accepted the ASCII parent
* but could neither list nor open a non-ASCII child, even though cbm_is_dir's
* wide-path check had already accepted it. Exercise both user-visible steps:
* discover the Unicode directory from its parent, then browse into it. */
TEST(ui_server_browse_non_ascii_directory) {
static const char utf8_name[] = "\xE9\x81\x93\xE5\x85\xB7\xE7\xAE\xB1"; /* toolbox */
char *created = th_mktempdir("cbm_browse_utf8");
if (!created)
FAIL("mktempdir");
char base[512];
snprintf(base, sizeof(base), "%s", created);
cbm_normalize_path_sep(base);
char unicode_dir[768];
char child[1024];
int unicode_len = snprintf(unicode_dir, sizeof(unicode_dir), "%s/%s", base, utf8_name);
int child_len = snprintf(child, sizeof(child), "%s/Percy", unicode_dir);
if (unicode_len <= 0 || (size_t)unicode_len >= sizeof(unicode_dir) || child_len <= 0 ||
(size_t)child_len >= sizeof(child) || th_mkdir_p(child) != 0) {
th_cleanup(base);
FAIL("failed to create non-ASCII browse fixture");
}
th_server_t ts;
if (th_server_start(&ts) != 0) {
th_cleanup(base);
FAIL("server start");
}
int port = cbm_http_server_port(ts.srv);
char request[1536];
char response[8192];
snprintf(request, sizeof(request),
"GET /api/browse?path=%s/%%E9%%81%%93%%E5%%85%%B7%%E7%%AE%%B1 HTTP/1.1\r\n\r\n", base);
int n = th_http(port, request, response, sizeof(response));
bool child_browse_ok =
n > 0 && th_status(response) == 200 && strstr(response, "\"Percy\"") != NULL;
snprintf(request, sizeof(request), "GET /api/browse?path=%s HTTP/1.1\r\n\r\n", base);
n = th_http(port, request, response, sizeof(response));
bool parent_lists_unicode =
n > 0 && th_status(response) == 200 && strstr(response, utf8_name) != NULL;
th_server_stop(&ts);
th_cleanup(base);
ASSERT_TRUE(parent_lists_unicode);
ASSERT_TRUE(child_browse_ok);
PASS();
}
/* The directory browser formats readdir() entries into a fixed 32 KB response
* buffer. The per-entry loop is clamped, but the trailing "parent"/"roots"
* appends were not — once the entries filled the buffer, pos ran past the end
* and the next size argument wrapped, writing out of bounds. Fill the buffer
* with many long-named subdirectories and browse it in a forked child so an
* overflow surfaces as a killing signal (ASan abort) rather than a clean run. */
TEST(ui_server_browse_wide_dir_no_overflow) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic");
#else
char *dir = th_mktempdir("cbm_browse");
if (!dir) {
FAIL("mktempdir");
}
char longname[240];
memset(longname, 'a', sizeof(longname) - 1);
longname[sizeof(longname) - 1] = '\0';
for (int i = 0; i < 250; i++) { /* 250 * ~220 chars overflows the 32 KB buffer */
char sub[600];
snprintf(sub, sizeof(sub), "%s/%s%03d", dir, longname, i);
th_mkdir_p(sub);
}
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
th_server_t ts;
if (th_server_start(&ts) != 0) {
_exit(2);
}
char req[512];
int port = cbm_http_server_port(ts.srv);
snprintf(req, sizeof(req), "GET /api/browse?path=%s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
dir, port);
char *resp = malloc(262144);
int n = resp ? th_http(port, req, resp, 262144) : 0;
int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL);
free(resp);
th_server_stop(&ts);
_exit(ok ? 0 : 3);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
char rm[600];
snprintf(rm, sizeof(rm), "rm -rf '%s'", dir);
(void)system(rm);
if (WIFSIGNALED(status)) {
char m[96];
snprintf(m, sizeof(m), "browse killed by signal %d — response buffer overflow",
WTERMSIG(status));
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
PASS();
#endif
}
/* The log endpoint serialises the ring into one heap buffer budgeted at
* LOG_LINE_MAX + 10 bytes per line. JSON escaping doubles every '"' and '\\',
* so an escape-dense line needs ~2x LOG_LINE_MAX. The inner escape loop stops
* at buf_size - 10, but the per-line framing writes (separator comma, opening
* quote, closing quote) were raw indexes, so every line past saturation wrote
* three bytes beyond the allocation. Fill the ring with escape-dense lines and
* read it back in a forked child, so the overflow surfaces as a killing signal
* (ASan abort) instead of silent heap corruption. */
TEST(ui_server_logs_escape_dense_no_overflow) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic");
#else
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
/* Worst case the ring can hold: every byte escapes to two bytes. */
char dense[512];
for (size_t i = 0; i < sizeof(dense) - 1; i++) {
dense[i] = (i % 2 == 0) ? '"' : '\\';
}
dense[sizeof(dense) - 1] = '\0';
for (int i = 0; i < 500; i++) { /* fills the whole 500-entry ring */
cbm_ui_log_append(dense);
}
th_server_t ts;
if (th_server_start(&ts) != 0) {
_exit(2);
}
char req[256];
int port = cbm_http_server_port(ts.srv);
snprintf(req, sizeof(req), "GET /api/logs?lines=500 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
size_t cap = 4u * 1024u * 1024u;
char *resp = malloc(cap);
int n = resp ? th_http(port, req, resp, (int)cap) : 0;
int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL);
free(resp);
th_server_stop(&ts);
_exit(ok ? 0 : 3);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
char m[96];
snprintf(m, sizeof(m), "logs killed by signal %d — response buffer overflow",
WTERMSIG(status));
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
PASS();
#endif
}
/* The index-status endpoint renders every active job into a fixed 2 KB stack
* buffer. http_appendf clamps its own writes, but the separator and the closing
* bracket were raw indexes, so two jobs holding ~1 KB root paths (the field
* was 1024 bytes and now holds the 4 KB resolved root) pushed pos to
* the clamp and the close then wrote past the buffer. Drive it through the real
* endpoint with the index executor stubbed out, in a forked child so the
* overflow surfaces as a killing signal. */
#define MAX_TEST_INDEX_JOBS 4
TEST(ui_server_index_status_long_paths_no_overflow) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic");
#else
char *base = th_mktempdir("cbm_status");
if (!base) {
FAIL("mktempdir");
}
/* Two real directories whose paths are long enough that two rendered job
* entries exceed the 2 KB response buffer. */
/* Four slots x ~520 chars overflows the 2 KB buffer while every path stays
* well inside PATH_MAX — a single pair of ~1 KB paths would reach the
* buffer too, but mkdir refuses them once the temp-dir prefix is added. */
char comp[200];
memset(comp, 'd', sizeof(comp) - 1);
comp[sizeof(comp) - 1] = '\0';
char deep[MAX_TEST_INDEX_JOBS][800];
for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) {
int n = snprintf(deep[j], sizeof(deep[j]), "%s/%d", base, j);
while (n < 520) {
n += snprintf(deep[j] + n, sizeof(deep[j]) - (size_t)n, "/%s", comp);
}
th_mkdir_p(deep[j]);
}
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
/* The blocking executor holds every job open so all four slots are
* occupied at once. With the non-blocking stub each job finishes
* immediately and handle_index_start recycles the same slot, leaving a
* single entry to render — far short of the buffer. */
th_ui_blocking_index_executor_t executor = {0};
atomic_init(&executor.calls, 0);
atomic_init(&executor.release, 0);
th_server_t ts;
ts.srv = cbm_http_server_new(0);
if (!ts.srv) {
_exit(2);
}
cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor);
if (th_server_thread_start(&ts.tid, ts.srv) != 0) {
_exit(2);
}
int port = cbm_http_server_port(ts.srv);
for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) {
char body[1200];
snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"p%d\"}", deep[j],
j);
char request[1500];
snprintf(request, sizeof(request),
"POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
char response[4096];
int rn = th_http(port, request, response, sizeof(response));
/* A rejected POST would leave the slot empty and the endpoint would
* render nothing — a vacuous pass. Fail loudly instead. */
if (rn <= 0 || th_status(response) != 202) {
_exit(4);
}
}
/* Wait for the state the assertion depends on — all four jobs actually
* running — rather than for a duration. */
if (!th_wait_atomic_int(&executor.calls, MAX_TEST_INDEX_JOBS, 5000)) {
atomic_store(&executor.release, 1);
_exit(5);
}
char req[256];
snprintf(req, sizeof(req), "GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
char resp[8192];
int n = th_http(port, req, resp, sizeof(resp));
int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL);
atomic_store(&executor.release, 1);
th_server_stop(&ts);
_exit(ok ? 0 : 3);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
th_cleanup(base);
if (WIFSIGNALED(status)) {
char m[96];
snprintf(m, sizeof(m), "index-status killed by signal %d — response buffer overflow",
WTERMSIG(status));
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
PASS();
#endif
}
TEST(ui_server_configuration_is_atomic_and_origin_protected) {
char directory[256];
snprintf(directory, sizeof(directory), "%s/cbm-http-settings-XXXXXX", cbm_tmpdir());
ASSERT_NOT_NULL(cbm_mkdtemp(directory));
char *previous = getenv("CBM_CACHE_DIR") ? strdup(getenv("CBM_CACHE_DIR")) : NULL;
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", directory, 1), 0);
th_server_t server;
ASSERT_EQ(th_server_start(&server), 0);
int port = cbm_http_server_port(server.srv);
char *response = malloc(131072);
ASSERT_NOT_NULL(response);
ASSERT_GT(th_http(port, "GET /api/config HTTP/1.1\r\n\r\n", response, 131072), 0);
ASSERT_EQ(th_status(response), 200);
ASSERT_NOT_NULL(strstr(response, "Cache-Control: no-store"));
char *body = strstr(response, "\r\n\r\n");
ASSERT_NOT_NULL(body);
body += 4;
yyjson_doc *document = yyjson_read(body, strlen(body), 0);
ASSERT_NOT_NULL(document);
const char *revision =
yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(document), "revision"));
ASSERT_NOT_NULL(revision);
char payload[256];
snprintf(payload, sizeof(payload),
"{\"revision\":\"%s\",\"changes\":{\"auto_index\":\"true\"}}", revision);
yyjson_doc_free(document);
char request[1024];
snprintf(
request, sizeof(request),
"POST /api/config HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nOrigin: "
"http://foreign.example\r\nContent-Type: application/json\r\nContent-Length: %zu\r\n\r\n%s",
port, strlen(payload), payload);
ASSERT_GT(th_http_raw(port, request, response, 131072), 0);
ASSERT_EQ(th_status(response), 403);
snprintf(request, sizeof(request),
"POST /api/config HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Type: "
"text/plain\r\nContent-Length: %zu\r\n\r\n%s",
port, strlen(payload), payload);
ASSERT_GT(th_http_raw(port, request, response, 131072), 0);
ASSERT_EQ(th_status(response), 415);
snprintf(request, sizeof(request), "POST /api/config HTTP/1.1\r\nContent-Length: %zu\r\n\r\n%s",
strlen(payload), payload);
ASSERT_GT(th_http(port, request, response, 131072), 0);
ASSERT_EQ(th_status(response), 200);
ASSERT_GT(th_http(port, request, response, 131072), 0);
ASSERT_EQ(th_status(response), 409);
ASSERT_GT(th_http(port, "GET /api/config-extra HTTP/1.1\r\n\r\n", response, 131072), 0);
ASSERT_EQ(th_status(response), 404);
th_server_stop(&server);
free(response);
if (previous) {
cbm_setenv("CBM_CACHE_DIR", previous, 1);
free(previous);
} else
cbm_unsetenv("CBM_CACHE_DIR");
char path[512];
snprintf(path, sizeof(path), "%s/_config.db", directory);
remove(path);
cbm_rmdir(directory);
PASS();
}
/* Grow path in place to exactly target bytes with 'd' components, none
* longer than 200 so every component stays under NAME_MAX. */
static void th_pad_path_to(char *path, size_t cap, int target) {
char fill[201];
memset(fill, 'd', sizeof(fill) - 1);
fill[sizeof(fill) - 1] = '\0';
int n = (int)strlen(path);
while (n < target && (size_t)n + 2 < cap) {
int room = target - n - 1; /* after the '/' */
int len = room > 200 ? 200 : room;
if (len <= 0) {
break;
}
n += snprintf(path + n, cap - (size_t)n, "/%.*s", len, fill);
}
}
/* Parse an index-status body and compare each entry's "path" with the root
* expected for its slot. Results go to out-params so the caller can tear
* down before asserting. */
static void th_index_status_check(const char *body, int expected_count, char expected[][4096],
bool *parsed, int *count, bool *paths_whole) {
*parsed = false;
*count = 0;
*paths_whole = false;
yyjson_doc *doc = yyjson_read(body, strlen(body), 0);
if (!doc) {
return;
}
yyjson_val *arr = yyjson_doc_get_root(doc);
if (yyjson_is_arr(arr)) {
*parsed = true;
*count = (int)yyjson_arr_size(arr);
bool whole = *count == expected_count;
size_t idx;
size_t max_entries;
yyjson_val *entry;
yyjson_arr_foreach(arr, idx, max_entries, entry) {
yyjson_val *slot = yyjson_obj_get(entry, "slot");
yyjson_val *path = yyjson_obj_get(entry, "path");
int s = yyjson_is_int(slot) ? yyjson_get_int(slot) : -1;
whole = whole && s >= 0 && s < expected_count && yyjson_is_str(path) &&
strcmp(yyjson_get_str(path), expected[s]) == 0;
}
*paths_whole = whole;
}
yyjson_doc_free(doc);
}
/* The listing is sized for what the job table holds. Every slot takes a root
* as long as the platform resolves; the body has to parse as JSON and carry
* each root whole, first with one job and then with the table full. Roots
* enter the table the way the clamp test's do: real directories through the
* route, held open by the blocking executor so every slot stays occupied. */
TEST(ui_server_index_status_long_roots_render_whole) {
char *tmp = th_mktempdir("cbm_status_whole");
ASSERT_NOT_NULL(tmp);
char base[256];
snprintf(base, sizeof(base), "%s", tmp);
/* Linux: just under the 4 KB field; the 4 KB request body still wraps it. */
int target = 4096 - 160;
#if defined(_WIN32)
/* MAX_PATH: long roots are not characterised there; the listing is still
* checked for shape and content with every slot occupied. */
target = 200;
#elif defined(PATH_MAX) && PATH_MAX < 4096
/* macOS: the longest root the route can resolve; four still exceed 2 KB. */
target = PATH_MAX - 96;
#endif
char deep[MAX_TEST_INDEX_JOBS][4096];
char expected[MAX_TEST_INDEX_JOBS][4096];
for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) {
snprintf(deep[j], sizeof(deep[j]), "%s/%d", base, j);
th_pad_path_to(deep[j], sizeof(deep[j]), target);
ASSERT_EQ((int)strlen(deep[j]), target);
ASSERT_EQ(th_mkdir_p(deep[j]), 0);
ASSERT_TRUE(cbm_canonical_path(deep[j], expected[j], sizeof(expected[j])));
}
th_ui_blocking_index_executor_t executor = {0};
atomic_init(&executor.calls, 0);
atomic_init(&executor.release, 0);
th_server_t ts;
ts.srv = cbm_http_server_new(0);
ASSERT_NOT_NULL(ts.srv);
cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor);
ASSERT_EQ(th_server_thread_start(&ts.tid, ts.srv), 0);
int port = cbm_http_server_port(ts.srv);
char status_request[128];
snprintf(status_request, sizeof(status_request),
"GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port);
int accepted = 0;
bool started = true;
bool one_parsed = false;
bool one_whole = false;
bool all_parsed = false;
bool all_whole = false;
int one_count = 0;
int all_count = 0;
size_t one_body_len = 0;
size_t all_body_len = 0;
char listing[65536];
for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) {
char body[4600];
snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"p%d\"}", deep[j], j);
char request[4900];
snprintf(request, sizeof(request),
"POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
char response[8192];
int rn = th_http(port, request, response, sizeof(response));
if (rn > 0 && th_status(response) == 202) {
accepted++;
}
/* Wait for the state the listing depends on: this job running. */
started = started && th_wait_atomic_int(&executor.calls, j + 1, 5000);
if (j == 0 || j == MAX_TEST_INDEX_JOBS - 1) {
int n = th_http(port, status_request, listing, sizeof(listing));
const char *status_body = n > 0 ? th_response_body(listing) : "";
if (j == 0) {
one_body_len = strlen(status_body);
th_index_status_check(status_body, 1, expected, &one_parsed, &one_count,
&one_whole);
} else {
all_body_len = strlen(status_body);
th_index_status_check(status_body, MAX_TEST_INDEX_JOBS, expected, &all_parsed,
&all_count, &all_whole);
}
}
}
/* Release the workers and tear down; free refuses until each worker has
* flagged completion, the same way the active-worker test waits. */
atomic_store(&executor.release, 1);
cbm_http_server_stop(ts.srv);
ASSERT_EQ(cbm_thread_join(&ts.tid), 0);
bool freed = false;
uint64_t deadline = cbm_now_ms() + 2000;
while (!freed && cbm_now_ms() < deadline) {
freed = cbm_http_server_free(ts.srv);
if (!freed)
cbm_usleep(1000);
}
th_cleanup(base);
ASSERT_TRUE(freed);
ASSERT_EQ(accepted, MAX_TEST_INDEX_JOBS);
ASSERT_TRUE(started);
if (!one_parsed) {
char m[128];
snprintf(m, sizeof(m), "one-job index-status body of %zu bytes is not JSON", one_body_len);
FAIL(m);
}
ASSERT_EQ(one_count, 1);
ASSERT_TRUE(one_whole);
if (!all_parsed) {
char m[128];
snprintf(m, sizeof(m), "full-table index-status body of %zu bytes is not JSON",
all_body_len);
FAIL(m);
}
ASSERT_EQ(all_count, MAX_TEST_INDEX_JOBS);
ASSERT_TRUE(all_whole);
PASS();
}
/* ── Suite ────────────────────────────────────────────────────── */
SUITE(httpd) {
RUN_TEST(ui_server_configuration_is_atomic_and_origin_protected);
RUN_TEST(ui_server_browse_non_ascii_directory);
RUN_TEST(ui_server_browse_wide_dir_no_overflow);
RUN_TEST(ui_server_logs_escape_dense_no_overflow);
RUN_TEST(ui_server_index_status_long_paths_no_overflow);
RUN_TEST(ui_server_index_status_long_roots_render_whole);
/* Parser / helpers */
RUN_TEST(httpd_parse_simple_get);
RUN_TEST(httpd_parse_security_headers_and_rejects_duplicates);
RUN_TEST(httpd_parse_post_with_body_offset);
RUN_TEST(httpd_parse_origin_case_insensitive);
RUN_TEST(httpd_parse_rejects_bare_lf);
RUN_TEST(httpd_parse_rejects_chunked);
RUN_TEST(httpd_parse_rejects_oversized_content_length);
RUN_TEST(httpd_parse_rejects_garbage_content_length);
RUN_TEST(httpd_parse_rejects_percent00_in_target);
RUN_TEST(httpd_parse_rejects_raw_nul_in_head);
RUN_TEST(httpd_parse_incomplete_head_needs_more);
RUN_TEST(httpd_parse_rejects_missing_version);
RUN_TEST(httpd_parse_rejects_oversized_head);
RUN_TEST(httpd_query_param_decode);
RUN_TEST(httpd_query_param_edge_cases);
RUN_TEST(httpd_path_match_matrix);
RUN_TEST(httpd_resolves_bare_binary_path_from_path);
RUN_TEST(httpd_deleted_self_spawn_path_follows_platform_ruling);
RUN_TEST(repo_info_web_base_normalizes_to_https);
RUN_TEST(repo_info_strips_credentials_from_remote);
/* Transport */
RUN_TEST(httpd_listen_ephemeral_port);
RUN_TEST(httpd_listen_port_collision_returns_null);
RUN_TEST(httpd_close_refuses_while_connection_owns_listener);
/* Full UI server */
RUN_TEST(ui_server_readiness_proof_is_exact_and_generation_bound);
RUN_TEST(ui_server_rejects_non_loopback_host);
RUN_TEST(ui_server_unknown_path_404);
RUN_TEST(ui_server_process_kill_route_is_unavailable);
RUN_TEST(ui_server_process_measurements_declare_platform_semantics);
RUN_TEST(ui_server_routes_indexing_through_joinable_daemon_executor);
RUN_TEST(ui_server_free_never_joins_active_index_worker);
RUN_TEST(ui_csp_connect_src_has_explicit_download_hosts);
RUN_TEST(activity_preserves_severity_and_reports_interrupted_reads);
RUN_TEST(activity_reply_memory_core_ownership);
RUN_TEST(activity_pruned_start_events_do_not_resurrect_on_retry);
RUN_TEST(activity_migration_preserves_v1_logs_and_rejects_future_schema);
RUN_TEST(activity_log_project_scope_preserves_unknown_v2_history_and_filters_before_limit);
RUN_TEST(ui_log_scopes_use_explicit_entry_ownership_and_survive_server_restart);
RUN_TEST(activity_log_search_and_severity_apply_before_limit_and_survive_reopen);
RUN_TEST(ui_log_retention_prevents_expired_retry_and_preserves_full_json);
RUN_TEST(ui_log_journal_is_canonical_for_info_retry_and_restart);
RUN_TEST(ui_log_legacy_import_is_once_and_preserves_unknown_provenance_and_time);
RUN_TEST(activity_journal_reopen_cursor_dedup_and_project_isolation);
RUN_TEST(activity_errors_survive_routine_log_retention_and_reopen);
RUN_TEST(ui_agent_events_and_errors_use_daemon_port_and_survive_restart);
RUN_TEST(ui_log_post_writes_jsonl_and_tail_reads_it);
RUN_TEST(ui_log_post_refuses_bad_bodies);
RUN_TEST(ui_log_rotates_at_configured_size);
RUN_TEST(ui_server_index_start_refuses_root_outside_allowed);
RUN_TEST(ui_server_index_start_stores_resolved_root);
RUN_TEST(ui_server_index_start_long_root_not_truncated);
RUN_TEST(ui_server_index_start_reply_escapes_path);
RUN_TEST(ui_server_root_without_embedded_assets_is_not_found);
RUN_TEST(ui_server_same_origin_request_is_allowed);
RUN_TEST(ui_server_rejects_foreign_and_null_origins);
RUN_TEST(ui_server_mutations_require_json_content_type);
RUN_TEST(ui_server_rpc_rejects_duplicate_adr_arguments);
RUN_TEST(ui_server_rpc_allows_only_ui_read_tools);
RUN_TEST(ui_server_atlas_routes_are_wired);
RUN_TEST(ui_server_retained_cache_memory_is_flushed_before_thread_exit);
RUN_TEST(ui_server_oversized_body_rejected);
RUN_TEST(ui_server_encoded_slash_not_routed);
RUN_TEST(ui_server_nul_in_target_rejected);
RUN_TEST(ui_server_browse_traversal_probe);
RUN_TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr);
RUN_TEST(ui_server_adr_mutation_guard_balances_success);
RUN_TEST(ui_server_delete_mutation_guard_busy_preserves_project);
RUN_TEST(ui_server_delete_mutation_guard_balances_success);
RUN_TEST(ui_server_delete_project_unwatches_after_delete);
RUN_TEST(ui_server_delete_project_unwatches_missing_db);
RUN_TEST(ui_server_delete_project_no_watcher_still_deletes);
RUN_TEST(ui_server_delete_project_missing_name_keeps_watch);
RUN_TEST(ui_server_delete_project_invalid_name_keeps_watch);
RUN_TEST(ui_server_delete_project_unlink_failure_keeps_watch);
RUN_TEST(ui_server_project_health_timestamp_and_no_watcher);
RUN_TEST(ui_server_project_health_live_watcher_state);
RUN_TEST(ui_server_ui_config_detects_zh_accept_language);
RUN_TEST(ui_server_ui_config_includes_serving_version_issue1820);
RUN_TEST(ui_server_ui_config_prefers_config_lang);
RUN_TEST(ui_server_slow_request_hits_deadline);
RUN_TEST(ui_server_access_log_redacts_query);
RUN_TEST(ui_server_stop_joins_cleanly);
RUN_TEST(ui_server_free_refuses_active_loop);
RUN_TEST(ui_server_free_refuses_scheduled_run_before_child_starts);
RUN_TEST(daemon_host_http_thread_create_failure_cancels_scheduled_run);
RUN_TEST(httpd_interrupt_unblocks_nonreading_large_response_within_one_second);
RUN_TEST(httpd_large_response_opt_in_survives_a_busy_reader_without_changing_default);
RUN_TEST(httpd_nonreading_large_response_hits_send_deadline_without_interrupt);
RUN_TEST(ui_server_stop_interrupts_partial_request_within_one_second);
/* #798 follow-up: full UI-mode hang repro under live sockets */
RUN_TEST(ui_server_list_projects_responds_under_watchdog);
RUN_TEST(git_context_resolve_no_hang_under_live_ui_sockets);
}