Files
codebase-memory-mcp/tests/test_httpd.c
T
Martin Vogel 50a6a67b89 Streamline local agent controls and preserve proactive chat
Separate agent configuration from chat visibility, keep chat compact, and
resume selection explanations after manual questions. Restore project
conversations locally for 24 hours with bounded storage and clear history.

Move daemon configuration into System and report persisted index metadata
and live watcher registration alongside its configuration control.

Validated with 303 focused frontend tests, TypeScript/Vite production build,
160 native watcher/HTTP tests, regression checks, and local browser checks.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-01 20:24:06 +02:00

3487 lines
136 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/platform.h"
#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 <yyjson/yyjson.h>
#include "ui/http_server.h"
#include <store/store.h>
#include <watcher/watcher.h>
#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[512];
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);
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, 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_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"));
free(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);
free(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);
free(reply);
ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200);
ASSERT_NOT_NULL(strstr(reply, "\"accepted\":0"));
ASSERT_NOT_NULL(strstr(reply, "\"duplicates\":1"));
free(reply);
reply = cbm_activity_agents(db, "fixture-a", 0, 10);
ASSERT_NOT_NULL(strstr(reply, "\"events\":[]"));
ASSERT_NOT_NULL(strstr(reply, "\"truncated\":true"));
free(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"));
free(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"));
free(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"));
free(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"));
free(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"));
free(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\""));
free(page);
page = cbm_activity_logs(db, -1, 1, 2);
ASSERT_NOT_NULL(strstr(page, "\"total\":605"));
ASSERT_NOT_NULL(strstr(page, "\"scope\":\"daemon\""));
free(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"));
free(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"));
free(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"));
free(json);
json = cbm_activity_logs_filtered(db, 1, 1, 3, "fixture-a", false, "BROKEN");
ASSERT_NOT_NULL(strstr(json, "\"cursor\":2"));
free(json);
json = cbm_activity_logs_filtered(db, -1, 200, 3, "fixture-b", false, "10%_");
ASSERT_NOT_NULL(strstr(json, "\"total\":0"));
free(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);
free(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"));
free(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"));
free(reply);
ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200);
ASSERT_NOT_NULL(strstr(reply, "\"duplicates\":2"));
free(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);
free(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"));
free(page);
page = cbm_activity_agents(db, "other", 0, 10);
ASSERT_NOT_NULL(strstr(page, "\"events\":[]"));
free(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"));
free(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"));
free(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();
}
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_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_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, "@"));
free(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 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 is
* 1024 bytes and the value comes straight from POST /api/index) 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();
}
/* ── Suite ────────────────────────────────────────────────────── */
SUITE(httpd) {
RUN_TEST(ui_server_configuration_is_atomic_and_origin_protected);
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);
/* 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_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_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_allows_only_ui_read_tools);
RUN_TEST(ui_server_atlas_routes_are_wired);
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);
}