From 722d7824c46ceca6302c25ea79ae08d20e5b1432 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Thu, 28 May 2026 10:11:56 +0000 Subject: [PATCH] test(ha-config-bootstrap): RED-phase tests for ha-term.bin durability (task 6.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 Unity tests covering persist (req 9.1), restore (req 9.2 RED), missing-file zero-init (req 9.3), CRC-mismatch safe-zero, and write+rename atomicity. Build fails with 3 undefined-reference linker errors on ha_restore_term() — expected RED phase proof. --- .kiro/specs/ha-config-bootstrap/tasks.md | 2 +- src/controller/meson.build | 1 + src/controller/testsuite/controller_test.c | 2 + src/controller/testsuite/test_ha_durability.c | 402 ++++++++++++++++++ 4 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 src/controller/testsuite/test_ha_durability.c diff --git a/.kiro/specs/ha-config-bootstrap/tasks.md b/.kiro/specs/ha-config-bootstrap/tasks.md index 4a15bfd..f37a9a1 100644 --- a/.kiro/specs/ha-config-bootstrap/tasks.md +++ b/.kiro/specs/ha-config-bootstrap/tasks.md @@ -135,7 +135,7 @@ Dependency order: startup validation (D) unblocks vtaskmgr before election (A); - _Boundary: Config Bootstrap (config_repl.c, myapp.c)_ - [ ] 6. Change 9: Election state durability (ha-term.bin) -- [ ] 6.1 Write failing tests for ha-term.bin persist and restore +- [x] 6.1 Write failing tests for ha-term.bin persist and restore - In `src/controller/testsuite/test_ha_durability.c`: test `ha_persist_term()` writes file; test `ha_init()` restores term and voted_for from file; test CRC mismatch → zeroes state safely with WARN log; test missing file → term=0, no prior vote; test write+rename atomicity (partial write leaves old file intact) - `make test` fails - _Requirements: 9.1, 9.2, 9.3_ diff --git a/src/controller/meson.build b/src/controller/meson.build index a3a3229..c0d8ac2 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -105,6 +105,7 @@ controller_unit_test_src = [ 'testsuite/test_ha_election.c', 'testsuite/test_ha_bootstrap.c', 'testsuite/test_ha_bootstrap_support.c', + 'testsuite/test_ha_durability.c', 'startup_validation.c', 'worker.c', 'persist.c', diff --git a/src/controller/testsuite/controller_test.c b/src/controller/testsuite/controller_test.c index 9665acd..1e3b823 100644 --- a/src/controller/testsuite/controller_test.c +++ b/src/controller/testsuite/controller_test.c @@ -37,6 +37,7 @@ extern void test_worker_join(void); extern void test_ha_startup(void); extern void test_ha_election(void); extern void test_ha_bootstrap(void); +extern void test_ha_durability(void); void setUp(void) { return; } @@ -143,6 +144,7 @@ int main(int argc, char* argv[]) TEST(test_ha_startup); TEST(test_ha_election); TEST(test_ha_bootstrap); + TEST(test_ha_durability); return UNITY_END(); } diff --git a/src/controller/testsuite/test_ha_durability.c b/src/controller/testsuite/test_ha_durability.c new file mode 100644 index 0000000..2027145 --- /dev/null +++ b/src/controller/testsuite/test_ha_durability.c @@ -0,0 +1,402 @@ +/* test_ha_durability.c — Unity RED-phase tests for ha-term.bin persist/restore. + * + * Tests verify: + * Req 9.1: ha_persist_term() writes ha-term.bin with correct magic, term, + * voted_for_term, voted_for_name, and valid CRC32. + * Req 9.2: ha_restore_term() reads ha-term.bin and restores term and voted_for + * into ha_state_t (RED: ha_restore_term does not exist yet). + * Req 9.3: Missing ha-term.bin → term=0, voted_for_name empty (safe first-boot). + * Req 9.2: CRC mismatch in ha-term.bin → zeroes state safely (no stale data). + * Req 9.1: write+rename atomicity — .tmp file cleaned up; ha-term.bin consistent. + * + * Functions under test: + * ha_persist_term() — already implemented in ha.c (task 4.2) + * ha_restore_term() — NOT YET IMPLEMENTED; declared below to force linker error + * + * RED phase proof: ha_restore_term() does not exist in ha.c. The linker will + * fail with "undefined reference to ha_restore_term" — this is the expected + * RED-phase outcome for task 6.1. Task 6.2 adds the implementation. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ha.h" +#include "config.h" /* XCE_MAXNAME_LEN */ + +/* ── On-disk format (mirrors ha.c internals, duplicated here for white-box tests) ─ + * + * This struct matches ha_term_file_t in ha.c. It is intentionally duplicated + * here (not exported via ha.h) so the tests can construct and inspect raw file + * contents without coupling ha.h to internal storage details. + */ +#define HA_TERM_MAGIC_TEST 0xDEADBEEFCAFEBABEULL + +typedef struct { + uint64_t magic; + uint64_t term; + uint64_t voted_for_term; + char voted_for_name[XCE_MAXNAME_LEN]; + uint32_t crc32; +} ha_term_file_test_t; + +/* ── RED phase declaration ──────────────────────────────────────────────────── + * + * ha_restore_term: read {workdir}/ha-term.bin and restore ha->term and + * ha->voted_for_* if the file exists and passes CRC verification. + * + * On missing file: no-op (term stays 0, voted_for_name stays empty). + * On CRC mismatch: log WARN, zero ha->term and ha->voted_for_* for safety. + * On valid file: atomically store ha->term; copy voted_for_term/name. + * + * Returns 0 on success (including "file not found" and "CRC bad → zeroed"), + * -errno on unexpected I/O errors. + * + * This declaration exists only to make the tests compile; the linker will + * fail with "undefined reference to ha_restore_term" until task 6.2 lands. + */ +int ha_restore_term(ha_state_t *ha, const char *workdir); + +/* ── Shared minimal ha_config_t ───────────────────────────────────────────── */ + +static ha_config_t g_durability_cfg = { + .heartbeat_interval_ms = 3000, + .heartbeat_timeout_ms = 10000, + .election_timeout_ms = 2000, + .probe_connect_timeout_ms = 500, + .max_promotion_retries = 5, +}; + +/* ── Helper: compute CRC32 the same way ha_persist_term() does ────────────── + * + * CRC covers all bytes before the crc32 field (i.e. offsetof(…, crc32)). + */ +static uint32_t compute_term_crc(const ha_term_file_test_t *f) +{ + size_t crc_len = offsetof(ha_term_file_test_t, crc32); + return (uint32_t)crc32(0L, (const Bytef *)f, (uInt)crc_len); +} + +/* ── Helper: write an ha_term_file_test_t directly to {dir}/ha-term.bin ──── */ + +static void write_term_file(const char *dir, const ha_term_file_test_t *f) +{ + char path[1024]; + snprintf(path, sizeof(path), "%s/ha-term.bin", dir); + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + TEST_ASSERT_MESSAGE(fd >= 0, "Failed to open ha-term.bin for writing"); + ssize_t written = write(fd, f, sizeof(*f)); + close(fd); + TEST_ASSERT_EQUAL_size_t(sizeof(*f), (size_t)written); +} + +/* ── Test 1: ha_persist_term() writes ha-term.bin with correct content ────── + * + * Req 9.1: persist must be called before reporting a vote grant or term increment. + * We set up an ha_state_t, call ha_persist_term(), then read back and verify: + * - file exists + * - magic == HA_TERM_MAGIC + * - term and voted_for fields match what was in ha_state_t + * - CRC32 is valid + */ +static void test_persist_term_writes_file(void) +{ + char tmpdir[] = "/tmp/test_ha_durXXXXXX"; + char *dir = mkdtemp(tmpdir); + TEST_ASSERT_NOT_NULL_MESSAGE(dir, "mkdtemp failed"); + + ha_state_t ha; + ha_init(&ha, &g_durability_cfg, 0); + + /* Set up some state to persist */ + atomic_store_explicit(&ha.term, 7ULL, memory_order_release); + ha.voted_for_term = 7; + strncpy(ha.voted_for_name, "ctrl-node-2", sizeof(ha.voted_for_name) - 1); + ha.voted_for_name[sizeof(ha.voted_for_name) - 1] = '\0'; + + ha_persist_term(&ha, dir); + + /* Verify the file exists */ + char path[1024]; + snprintf(path, sizeof(path), "%s/ha-term.bin", dir); + struct stat st; + int stat_rc = stat(path, &st); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, stat_rc, "ha-term.bin should exist after persist"); + TEST_ASSERT_EQUAL_size_t(sizeof(ha_term_file_test_t), (size_t)st.st_size); + + /* Read back and verify content */ + ha_term_file_test_t f; + memset(&f, 0, sizeof(f)); + int fd = open(path, O_RDONLY); + TEST_ASSERT_MESSAGE(fd >= 0, "Cannot open ha-term.bin for reading"); + ssize_t n = read(fd, &f, sizeof(f)); + close(fd); + TEST_ASSERT_EQUAL_size_t(sizeof(f), (size_t)n); + + TEST_ASSERT_EQUAL_UINT64(HA_TERM_MAGIC_TEST, f.magic); + TEST_ASSERT_EQUAL_UINT64(7ULL, f.term); + TEST_ASSERT_EQUAL_UINT64(7ULL, f.voted_for_term); + TEST_ASSERT_EQUAL_STRING("ctrl-node-2", f.voted_for_name); + + /* CRC must be valid */ + uint32_t expected_crc = compute_term_crc(&f); + TEST_ASSERT_EQUAL_UINT32(expected_crc, f.crc32); + + /* tmp file must NOT exist (rename succeeded) */ + char tmp_path[1040]; + snprintf(tmp_path, sizeof(tmp_path), "%s/ha-term.bin.tmp", dir); + TEST_ASSERT_EQUAL_INT(-1, stat(tmp_path, &st)); + + ha_fini(&ha); + + /* cleanup */ + unlink(path); + rmdir(dir); + + printf("test_persist_term_writes_file: PASS\n"); +} + +/* ── Test 2: ha_restore_term() restores term and voted_for from valid file ── + * + * Req 9.2: startup must restore the most recently persisted term and voted_for + * record before participating in any election. + * + * This test writes a valid ha_term_file_test_t directly to disk (bypassing + * ha_persist_term — this exercises the read path in isolation), then calls + * ha_restore_term(). Verification: ha->term and ha->voted_for_name match + * what was written. + * + * RED PHASE: this test will FAIL at link time because ha_restore_term() + * does not exist in ha.c. This is intentional — linker error proves RED. + */ +static void test_ha_restore_term_from_file(void) +{ + char tmpdir[] = "/tmp/test_ha_durXXXXXX"; + char *dir = mkdtemp(tmpdir); + TEST_ASSERT_NOT_NULL_MESSAGE(dir, "mkdtemp failed"); + + /* Build a valid ha_term_file_test_t and write it directly */ + ha_term_file_test_t f; + memset(&f, 0, sizeof(f)); + f.magic = HA_TERM_MAGIC_TEST; + f.term = 42ULL; + f.voted_for_term = 42ULL; + strncpy(f.voted_for_name, "ctrl-node-1", sizeof(f.voted_for_name) - 1); + f.voted_for_name[sizeof(f.voted_for_name) - 1] = '\0'; + f.crc32 = compute_term_crc(&f); + write_term_file(dir, &f); + + /* Init an ha_state_t (starts at term=0) then restore */ + ha_state_t ha; + ha_init(&ha, &g_durability_cfg, 0); + + /* Req 9.2: ha_restore_term() should set ha.term and ha.voted_for_* */ + int rc = ha_restore_term(&ha, dir); /* <-- undefined reference: RED proof */ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "ha_restore_term must return 0 on valid file"); + + /* After restore, term and voted_for must match the file */ + uint64_t restored_term = atomic_load_explicit(&ha.term, memory_order_acquire); + TEST_ASSERT_EQUAL_UINT64(42ULL, restored_term); + TEST_ASSERT_EQUAL_UINT64(42ULL, ha.voted_for_term); + TEST_ASSERT_EQUAL_STRING("ctrl-node-1", ha.voted_for_name); + + ha_fini(&ha); + + /* cleanup */ + char path[1024]; + snprintf(path, sizeof(path), "%s/ha-term.bin", dir); + unlink(path); + rmdir(dir); + + printf("test_ha_restore_term_from_file: PASS\n"); +} + +/* ── Test 3: Missing ha-term.bin → term=0, voted_for empty (first-boot) ───── + * + * Req 9.3: on first boot where no ha-term.bin exists, the system shall + * initialize the term to zero with no prior vote recorded. + * + * This test verifies the "file not found" branch of ha_restore_term(). + * It may compile to a link error (RED) even before we can test the logic. + */ +static void test_ha_restore_term_zero_on_missing_file(void) +{ + /* Use a tmpdir with no ha-term.bin in it */ + char tmpdir[] = "/tmp/test_ha_durXXXXXX"; + char *dir = mkdtemp(tmpdir); + TEST_ASSERT_NOT_NULL_MESSAGE(dir, "mkdtemp failed"); + + ha_state_t ha; + ha_init(&ha, &g_durability_cfg, 0); + + /* No file written — restore should be a safe no-op */ + int rc = ha_restore_term(&ha, dir); /* <-- undefined reference: RED proof */ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "ha_restore_term must return 0 on missing file"); + + /* term must be 0, voted_for_name must be empty */ + uint64_t term = atomic_load_explicit(&ha.term, memory_order_acquire); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(0ULL, term, "term must be 0 on first boot"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, ha.voted_for_name[0], + "voted_for_name must be empty on first boot"); + + ha_fini(&ha); + rmdir(dir); + + printf("test_ha_restore_term_zero_on_missing_file: PASS\n"); +} + +/* ── Test 4: CRC mismatch → zeroes state safely ───────────────────────────── + * + * Req 9.2: a corrupted ha-term.bin must not restore stale state. The system + * must log WARN and zero ha->term and ha->voted_for_*. + * + * We write a file with term=99 but deliberately corrupt the CRC field. + * After ha_restore_term(), term must be 0 and voted_for_name must be empty. + */ +static void test_ha_restore_crc_mismatch_zeroes_state(void) +{ + char tmpdir[] = "/tmp/test_ha_durXXXXXX"; + char *dir = mkdtemp(tmpdir); + TEST_ASSERT_NOT_NULL_MESSAGE(dir, "mkdtemp failed"); + + /* Build a file with valid structure but wrong CRC */ + ha_term_file_test_t f; + memset(&f, 0, sizeof(f)); + f.magic = HA_TERM_MAGIC_TEST; + f.term = 99ULL; + f.voted_for_term = 99ULL; + strncpy(f.voted_for_name, "ctrl-node-3", sizeof(f.voted_for_name) - 1); + f.voted_for_name[sizeof(f.voted_for_name) - 1] = '\0'; + f.crc32 = 0xDEAD1234U; /* deliberately wrong CRC */ + write_term_file(dir, &f); + + ha_state_t ha; + ha_init(&ha, &g_durability_cfg, 0); + + /* ha_restore_term must detect CRC mismatch and zero state */ + int rc = ha_restore_term(&ha, dir); /* <-- undefined reference: RED proof */ + /* Return value 0 is acceptable: "handled gracefully, no fatal error" */ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "ha_restore_term must return 0 on CRC mismatch"); + + /* State must be zeroed — no stale data restored */ + uint64_t term = atomic_load_explicit(&ha.term, memory_order_acquire); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(0ULL, term, + "term must be 0 after CRC mismatch"); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, ha.voted_for_name[0], + "voted_for_name must be empty after CRC mismatch"); + + ha_fini(&ha); + + char path[1024]; + snprintf(path, sizeof(path), "%s/ha-term.bin", dir); + unlink(path); + rmdir(dir); + + printf("test_ha_restore_crc_mismatch_zeroes_state: PASS\n"); +} + +/* ── Test 5: write+rename atomicity — .tmp cleaned up; final file consistent ─ + * + * Req 9.1: ha_persist_term uses write-to-tmp + rename for atomic durability. + * Verify: + * a) After writing term=1, ha-term.bin contains term=1. + * b) After writing term=2 over it, ha-term.bin contains term=2. + * c) The .tmp file does not exist after either call (rename succeeded). + * + * This test does NOT simulate a partial write (that would require filesystem + * hooks not available in unit tests). Instead it verifies the postcondition: + * the final ha-term.bin is consistent and no .tmp artifact is left behind. + */ +static void test_persist_atomicity_tmp_cleaned_up(void) +{ + char tmpdir[] = "/tmp/test_ha_durXXXXXX"; + char *dir = mkdtemp(tmpdir); + TEST_ASSERT_NOT_NULL_MESSAGE(dir, "mkdtemp failed"); + + ha_state_t ha; + ha_init(&ha, &g_durability_cfg, 0); + + /* -- Round 1: persist term=1 -- */ + atomic_store_explicit(&ha.term, 1ULL, memory_order_release); + ha.voted_for_term = 1; + strncpy(ha.voted_for_name, "ctrl-node-a", sizeof(ha.voted_for_name) - 1); + ha.voted_for_name[sizeof(ha.voted_for_name) - 1] = '\0'; + ha_persist_term(&ha, dir); + + char path[1024]; + snprintf(path, sizeof(path), "%s/ha-term.bin", dir); + char tmp_path[1040]; + snprintf(tmp_path, sizeof(tmp_path), "%s/ha-term.bin.tmp", dir); + + /* ha-term.bin must exist and contain term=1 */ + ha_term_file_test_t f1; + int fd = open(path, O_RDONLY); + TEST_ASSERT_MESSAGE(fd >= 0, "ha-term.bin should exist after first persist"); + read(fd, &f1, sizeof(f1)); + close(fd); + TEST_ASSERT_EQUAL_UINT64(1ULL, f1.term); + TEST_ASSERT_EQUAL_UINT32(compute_term_crc(&f1), f1.crc32); + + /* .tmp must not exist */ + struct stat st; + TEST_ASSERT_EQUAL_INT_MESSAGE(-1, stat(tmp_path, &st), + "ha-term.bin.tmp must not exist after successful persist"); + + /* -- Round 2: persist term=2 over existing file -- */ + atomic_store_explicit(&ha.term, 2ULL, memory_order_release); + ha.voted_for_term = 2; + strncpy(ha.voted_for_name, "ctrl-node-b", sizeof(ha.voted_for_name) - 1); + ha.voted_for_name[sizeof(ha.voted_for_name) - 1] = '\0'; + ha_persist_term(&ha, dir); + + /* ha-term.bin must now contain term=2 */ + ha_term_file_test_t f2; + fd = open(path, O_RDONLY); + TEST_ASSERT_MESSAGE(fd >= 0, "ha-term.bin should still exist after second persist"); + read(fd, &f2, sizeof(f2)); + close(fd); + TEST_ASSERT_EQUAL_UINT64(2ULL, f2.term); + TEST_ASSERT_EQUAL_STRING("ctrl-node-b", f2.voted_for_name); + TEST_ASSERT_EQUAL_UINT32(compute_term_crc(&f2), f2.crc32); + + /* .tmp still must not exist */ + TEST_ASSERT_EQUAL_INT_MESSAGE(-1, stat(tmp_path, &st), + "ha-term.bin.tmp must not exist after second persist"); + + ha_fini(&ha); + unlink(path); + rmdir(dir); + + printf("test_persist_atomicity_tmp_cleaned_up: PASS\n"); +} + +/* ── Test suite entry point ───────────────────────────────────────────────── */ + +void test_ha_durability(void) +{ + /* Req 9.1: persist writes a correct, CRC-valid file */ + RUN_TEST(test_persist_term_writes_file); + + /* Req 9.2: restore reads back term and voted_for — RED: linker error expected */ + RUN_TEST(test_ha_restore_term_from_file); + + /* Req 9.3: missing file → safe zero init */ + RUN_TEST(test_ha_restore_term_zero_on_missing_file); + + /* Req 9.2: CRC mismatch → safe zero init (no stale state) */ + RUN_TEST(test_ha_restore_crc_mismatch_zeroes_state); + + /* Req 9.1: write+rename atomicity — no .tmp artifact left */ + RUN_TEST(test_persist_atomicity_tmp_cleaned_up); +}