diff --git a/.kiro/specs/ha-config-bootstrap/tasks.md b/.kiro/specs/ha-config-bootstrap/tasks.md index 70cb1159..c1cef4bc 100644 --- a/.kiro/specs/ha-config-bootstrap/tasks.md +++ b/.kiro/specs/ha-config-bootstrap/tasks.md @@ -31,7 +31,7 @@ Dependency order: startup validation (D) unblocks vtaskmgr before election (A); - _Boundary: Controller FSM (ha.h)_ - [ ] 2. Change D: Startup validation and ASAN fix -- [ ] 2.1 Write failing tests for startup validation +- [x] 2.1 Write failing tests for startup validation - In `src/controller/testsuite/test_ha_startup.c`: test even-N peer count returns `-EINVAL`; test cert missing returns `-ENOENT` with full path in message; test `--vcpus 0` returns `-EINVAL`; test `vcpus` key in config file logs WARN but does not fail - `make test` fails (functions under test do not exist yet) - _Requirements: 4.1, 4.2, 4.3, 4.4_ diff --git a/src/controller/meson.build b/src/controller/meson.build index 3ef1cf83..e9abadb5 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -100,6 +100,7 @@ controller_unit_test_src = [ 'testsuite/test_ctrl_metrics.c', 'testsuite/test_config_repl.c', 'testsuite/test_worker_join.c', + 'testsuite/test_ha_startup.c', 'worker.c', 'persist.c', 'ha.c', diff --git a/src/controller/testsuite/controller_test.c b/src/controller/testsuite/controller_test.c index aac74c4f..4254d92e 100644 --- a/src/controller/testsuite/controller_test.c +++ b/src/controller/testsuite/controller_test.c @@ -34,6 +34,7 @@ extern void test_p3_manual_failover(void); extern void test_ctrl_metrics(void); extern void test_config_repl(void); extern void test_worker_join(void); +extern void test_ha_startup(void); void setUp(void) { return; } @@ -137,6 +138,7 @@ int main(int argc, char* argv[]) TEST(test_ctrl_metrics); TEST(test_config_repl); TEST(test_worker_join); + TEST(test_ha_startup); return UNITY_END(); } diff --git a/src/controller/testsuite/test_ha_startup.c b/src/controller/testsuite/test_ha_startup.c new file mode 100644 index 00000000..02d76e8a --- /dev/null +++ b/src/controller/testsuite/test_ha_startup.c @@ -0,0 +1,244 @@ +/* test_ha_startup.c — Unity RED-phase tests for startup validation + * + * Tests verify: even-N peer count rejection, cert pre-flight -ENOENT with path + * in error message, --vcpus 0 rejection, and vcpus key in config logs WARN + * (via caller-supplied warn_buf) without failing. + * + * Requirements: 4.1, 4.2, 4.3, 4.4 + * Functions under test: validate_peer_count(), validate_cert_files(), + * validate_vcpus(), validate_startup_config() + * These functions do NOT exist yet — this file establishes the contract + * for tasks 2.2 and 2.3. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/* Functions under test — declared here; implementations come in tasks 2.2/2.3 */ +int validate_peer_count(int n_peers); +int validate_cert_files(const char *cert_path, const char *key_path, + char *err_buf, size_t err_len); +int validate_vcpus(int vcpus); +int validate_startup_config(const char *config_path, + char *warn_buf, size_t warn_len); + +/* ── helpers ──────────────────────────────────────────────────────────── */ + +static char g_tmpdir[256]; + +static void startup_setup(void) +{ + snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/test_ha_startup_%d_%ld", + (int)getpid(), (long)time(NULL)); + mkdir(g_tmpdir, 0700); +} + +static void startup_teardown(void) +{ + char cmd[512]; + snprintf(cmd, sizeof(cmd), "rm -rf %s", g_tmpdir); + system(cmd); + g_tmpdir[0] = '\0'; +} + +/* write a tiny file inside g_tmpdir and return its path in out_path */ +static void write_tmp_file(const char *name, const char *content, + char *out_path, size_t out_len) +{ + snprintf(out_path, out_len, "%s/%s", g_tmpdir, name); + FILE *f = fopen(out_path, "w"); + if (f) { + fputs(content, f); + fclose(f); + } +} + +/* ── Req 4.1: even-N peer count rejection ─────────────────────────────── */ + +static void test_peer_count_2_is_invalid(void) +{ + /* 2 peers is even (> 1) — must reject with -EINVAL. + * Raft quorum: a 2-node cluster cannot form a majority partition safely + * (1-of-2 means any single failure loses quorum), so n_peers=2 is invalid. */ + TEST_ASSERT_EQUAL_INT(-EINVAL, validate_peer_count(2)); + printf("test_peer_count_2_is_invalid: PASS\n"); +} + +static void test_peer_count_4_is_invalid(void) +{ + /* 4 peers is even — must reject with -EINVAL */ + TEST_ASSERT_EQUAL_INT(-EINVAL, validate_peer_count(4)); + printf("test_peer_count_4_is_invalid: PASS\n"); +} + +static void test_peer_count_1_is_valid(void) +{ + /* 1 peer (single-node mode) must be accepted */ + TEST_ASSERT_EQUAL_INT(0, validate_peer_count(1)); + printf("test_peer_count_1_is_valid: PASS\n"); +} + +static void test_peer_count_3_is_valid(void) +{ + /* 3 peers is odd — majority quorum possible; must be accepted */ + TEST_ASSERT_EQUAL_INT(0, validate_peer_count(3)); + printf("test_peer_count_3_is_valid: PASS\n"); +} + +/* ── Req 4.2: cert pre-flight — missing cert returns -ENOENT with path ── */ + +static void test_cert_missing_returns_enoent_with_path(void) +{ + startup_setup(); + + const char *cert_path = "/tmp/test_ha_startup_nonexistent_cert.pem"; + const char *key_path = "/tmp/test_ha_startup_nonexistent_key.pem"; + + char err_buf[256] = {0}; + + /* Neither file exists — must return -ENOENT and include cert_path in msg */ + int rc = validate_cert_files(cert_path, key_path, err_buf, sizeof(err_buf)); + TEST_ASSERT_EQUAL_INT(-ENOENT, rc); + TEST_ASSERT_NOT_NULL(strstr(err_buf, cert_path)); + + startup_teardown(); + printf("test_cert_missing_returns_enoent_with_path: PASS\n"); +} + +static void test_cert_present_key_missing_returns_enoent_with_path(void) +{ + startup_setup(); + + /* Write a real cert file but leave key absent */ + char cert_path[512]; + write_tmp_file("ctrl.pem", "-----BEGIN CERTIFICATE-----\n", cert_path, + sizeof(cert_path)); + + const char *key_path = "/tmp/test_ha_startup_absent_key.pem"; + + char err_buf[256] = {0}; + + int rc = validate_cert_files(cert_path, key_path, err_buf, sizeof(err_buf)); + TEST_ASSERT_EQUAL_INT(-ENOENT, rc); + /* Key is missing — error message must contain the key path */ + TEST_ASSERT_NOT_NULL(strstr(err_buf, key_path)); + + startup_teardown(); + printf("test_cert_present_key_missing_returns_enoent_with_path: PASS\n"); +} + +static void test_cert_and_key_present_returns_zero(void) +{ + startup_setup(); + + char cert_path[512], key_path[512]; + write_tmp_file("ctrl.pem", "-----BEGIN CERTIFICATE-----\n", cert_path, + sizeof(cert_path)); + write_tmp_file("ctrl.key", "-----BEGIN PRIVATE KEY-----\n", key_path, + sizeof(key_path)); + + char err_buf[256] = {0}; + + int rc = validate_cert_files(cert_path, key_path, err_buf, sizeof(err_buf)); + TEST_ASSERT_EQUAL_INT(0, rc); + + startup_teardown(); + printf("test_cert_and_key_present_returns_zero: PASS\n"); +} + +/* ── Req 4.3: --vcpus validation ─────────────────────────────────────── */ + +static void test_vcpus_zero_is_invalid(void) +{ + /* --vcpus 0 must be rejected with -EINVAL */ + TEST_ASSERT_EQUAL_INT(-EINVAL, validate_vcpus(0)); + printf("test_vcpus_zero_is_invalid: PASS\n"); +} + +static void test_vcpus_negative_is_invalid(void) +{ + TEST_ASSERT_EQUAL_INT(-EINVAL, validate_vcpus(-1)); + printf("test_vcpus_negative_is_invalid: PASS\n"); +} + +static void test_vcpus_positive_is_valid(void) +{ + /* --vcpus 4 is a normal value — must return 0 */ + TEST_ASSERT_EQUAL_INT(0, validate_vcpus(4)); + printf("test_vcpus_positive_is_valid: PASS\n"); +} + +/* ── Req 4.4: vcpus key in config file — WARN only, no failure ────────── */ + +static void test_vcpus_key_in_config_warns_but_does_not_fail(void) +{ + /* Create a temp config file with vcpus key using mkstemp */ + char tmppath[] = "/tmp/test_config_XXXXXX"; + int fd = mkstemp(tmppath); + const char *content = "vcpus = 4\nqueue_depth = 100\n"; + write(fd, content, strlen(content)); + close(fd); + + char warn_buf[256] = {0}; + int result = validate_startup_config(tmppath, warn_buf, sizeof(warn_buf)); + + /* vcpus key found — must NOT fail (return 0) */ + TEST_ASSERT_EQUAL_INT(0, result); + /* warn_buf must contain a deprecation message mentioning "vcpus" */ + TEST_ASSERT_NOT_NULL(strstr(warn_buf, "vcpus")); + + unlink(tmppath); + printf("test_vcpus_key_in_config_warns_but_does_not_fail: PASS\n"); +} + +static void test_config_without_vcpus_key_has_no_warning(void) +{ + char tmppath[] = "/tmp/test_config2_XXXXXX"; + int fd = mkstemp(tmppath); + const char *content = "heartbeat_interval_ms = 3000\nqueue_depth = 100\n"; + write(fd, content, strlen(content)); + close(fd); + + char warn_buf[256] = {0}; + int result = validate_startup_config(tmppath, warn_buf, sizeof(warn_buf)); + + /* no vcpus key — must succeed with no warning */ + TEST_ASSERT_EQUAL_INT(0, result); + TEST_ASSERT_EQUAL_INT(0, warn_buf[0]); /* warn_buf empty — no warning */ + + unlink(tmppath); + printf("test_config_without_vcpus_key_has_no_warning: PASS\n"); +} + +/* ── Test suite entry point ───────────────────────────────────────────── */ + +void test_ha_startup(void) +{ + /* Req 4.1 — even-N peer count */ + RUN_TEST(test_peer_count_2_is_invalid); + RUN_TEST(test_peer_count_4_is_invalid); + RUN_TEST(test_peer_count_1_is_valid); + RUN_TEST(test_peer_count_3_is_valid); + + /* Req 4.2 — cert pre-flight with full path in error message */ + RUN_TEST(test_cert_missing_returns_enoent_with_path); + RUN_TEST(test_cert_present_key_missing_returns_enoent_with_path); + RUN_TEST(test_cert_and_key_present_returns_zero); + + /* Req 4.3 — vcpus validation */ + RUN_TEST(test_vcpus_zero_is_invalid); + RUN_TEST(test_vcpus_negative_is_invalid); + RUN_TEST(test_vcpus_positive_is_valid); + + /* Req 4.4 — vcpus key WARN via warn_buf; no failure */ + RUN_TEST(test_vcpus_key_in_config_warns_but_does_not_fail); + RUN_TEST(test_config_without_vcpus_key_has_no_warning); +}