/* test_ha_vote_gate.c — Unity tests for Task 3.2: role-based vote-deny gate * in handle_request_vote(). * * Tests verify (Req 6.1, 6.2, 6.4): * Req 6.1: CTRL_PRIMARY → deny all vote requests (return 0) * Req 6.1: CTRL_PRIMARY + fresher same-term challenger → still deny (return 0) * Req 6.2: CTRL_STANDBY + fresh heartbeat → deny (return 0) * Req 6.4: CTRL_STANDBY + stale heartbeat → evaluate Raft gates (not denied by role gate) * Req 6.4: CTRL_ELECTING → evaluate Raft gates (not denied by role gate) * * Implementation gated on: * - ha_heartbeat_is_fresh(ha): returns 1 if elapsed < heartbeat_timeout_ms * - handle_request_vote() prepends role-based deny before existing Raft gates */ #define _GNU_SOURCE #include #include #include #include #include #include #include #include "controller.capnp.h" #include "ha.h" /* Functions under test — declared here to match their signatures in ha.c */ int handle_request_vote(ha_state_t *ha, uint64_t term, const char *candidate_id, uint64_t config_tag, uint64_t last_lsn, uint64_t my_config_tag, uint64_t my_last_lsn, int in_catch_up_flag); /* ── Shared config for vote gate tests ──────────────────────────────────── */ static ha_config_t g_gate_cfg = { .heartbeat_interval_ms = 3000, .heartbeat_timeout_ms = 10000, /* 10 s — used for ha_heartbeat_is_fresh */ .election_timeout_ms = 2000, .probe_connect_timeout_ms = 500, .max_promotion_retries = 5, .election_jitter_max_ms = 0, .election_priority_step_ms = 500, }; /* ── Helper: set ha->last_activity to "now - offset_ms milliseconds ago" ── * * last_activity is the production field written by ha_on_heartbeat_received * (whole CLOCK_MONOTONIC seconds); offset_ms is rounded down to seconds. */ static void set_last_activity_age(ha_state_t *ha, long offset_ms) { struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); ha->last_activity = now.tv_sec - offset_ms / 1000; } /* ── Req 6.1: CTRL_PRIMARY → deny regardless of candidate freshness ────── */ static void test_primary_role_denies_vote_request(void) { /* When the receiving node is CTRL_PRIMARY it must deny all vote requests * without evaluating term, config_tag, or LSN. The candidate is perfectly * fresh (same config_tag, same LSN, same term) — still deny. */ ha_state_t ha; ha_init(&ha, &g_gate_cfg, 0); atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY, memory_order_release); atomic_store_explicit(&ha.term, 5ULL, memory_order_release); ha.voted_for_term = 0; int granted = handle_request_vote(&ha, 5ULL, /* same term — would pass term check */ "ctrl-b", 100ULL, /* same config_tag as mine */ 50ULL, /* same LSN as mine */ 100ULL, /* my config_tag */ 50ULL, /* my last_lsn */ 0 /* not in catch_up */); TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted, "CTRL_PRIMARY must deny all incoming vote requests (Req 6.1)"); ha_fini(&ha); printf("test_primary_role_denies_vote_request: PASS\n"); } /* ── Req 6.1: CTRL_PRIMARY denies a fresher same-term challenger ─────────── */ static void test_primary_role_denies_fresher_same_term_challenger(void) { /* The vote-deny contract that a transitional COMMITTING role used to carry * now lives entirely on CTRL_PRIMARY (single leader role). An elected * primary must deny a same-term challenger even when the challenger looks * FRESHER (higher config_tag / LSN): the role gate fires before any * freshness evaluation, protecting the live primary from displacement. */ ha_state_t ha; ha_init(&ha, &g_gate_cfg, 0); atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY, memory_order_release); atomic_store_explicit(&ha.term, 3ULL, memory_order_release); ha.voted_for_term = 0; int granted = handle_request_vote(&ha, 3ULL, /* same term */ "ctrl-c", 200ULL, /* candidate is fresher */ 100ULL, 100ULL, /* my config_tag */ 50ULL, 0); TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted, "CTRL_PRIMARY must deny a fresher same-term challenger (Req 6.1)"); ha_fini(&ha); printf("test_primary_role_denies_fresher_same_term_challenger: PASS\n"); } /* ── Req 6.2: CTRL_STANDBY + fresh heartbeat → deny ────────────────────── */ static void test_standby_fresh_heartbeat_denies_vote_request(void) { /* When the receiving node is CTRL_STANDBY and received a heartbeat recently * (within heartbeat_timeout_ms = 10 s), ha_heartbeat_is_fresh() returns 1 * and the function must deny the vote — my primary is alive. */ ha_state_t ha; ha_init(&ha, &g_gate_cfg, 0); atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release); atomic_store_explicit(&ha.term, 2ULL, memory_order_release); ha.voted_for_term = 0; /* Active primary control channel (handle_ctrlhello sets this on a real * standby; the deny gate requires a live connection AND a fresh heartbeat). * ha_fini does not close the fd, so any non-negative value is safe. */ ha.primary_ctrl_fd = 999; /* last_activity = 500 ms ago → rounds to "this second" (< 10 s → fresh) */ set_last_activity_age(&ha, 500L); int granted = handle_request_vote(&ha, 2ULL, "ctrl-b", 100ULL, 50ULL, 100ULL, 50ULL, 0); TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted, "CTRL_STANDBY with fresh heartbeat must deny vote requests (Req 6.2)"); ha_fini(&ha); printf("test_standby_fresh_heartbeat_denies_vote_request: PASS\n"); } /* ── Req 6.4: CTRL_STANDBY + stale heartbeat → evaluate Raft gates ──────── */ static void test_standby_stale_heartbeat_evaluates_vote(void) { /* When the receiving node is CTRL_STANDBY but the heartbeat is older than * heartbeat_timeout_ms (10 s), ha_heartbeat_is_fresh() returns 0. * The role gate must NOT deny — fall through to evaluate Raft gates. * * We set up a candidate with the same config_tag and LSN (freshness tie → * grant) and no prior vote — the Raft logic should grant the vote. */ ha_state_t ha; ha_init(&ha, &g_gate_cfg, 0); atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release); atomic_store_explicit(&ha.term, 1ULL, memory_order_release); ha.voted_for_term = 0; /* Active primary control channel — staleness alone must open the gate */ ha.primary_ctrl_fd = 999; /* last_activity = 15 000 ms ago (> 10 000 ms timeout → stale) */ set_last_activity_age(&ha, 15000L); int granted = handle_request_vote(&ha, 1ULL, "ctrl-a", 100ULL, /* same config_tag — freshness tie */ 50ULL, /* same LSN — freshness tie */ 100ULL, 50ULL, 0); /* With stale heartbeat the role gate does not deny; Raft gates evaluate * and should grant (equal freshness, no prior vote). */ TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted, "CTRL_STANDBY with stale heartbeat must evaluate Raft gates (Req 6.4)"); ha_fini(&ha); printf("test_standby_stale_heartbeat_evaluates_vote: PASS\n"); } /* ── Req 6.4: CTRL_ELECTING → evaluate Raft gates ───────────────────────── */ static void test_electing_role_evaluates_vote(void) { /* When the receiving node is CTRL_ELECTING (it is itself campaigning), * the role gate must not deny — fall through to Raft term/freshness gates. * * A valid candidate (same term, fresh config_tag, no prior vote) must be * granted, exercising the full Raft evaluation path. */ ha_state_t ha; ha_init(&ha, &g_gate_cfg, 0); atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release); atomic_store_explicit(&ha.term, 4ULL, memory_order_release); ha.voted_for_term = 0; int granted = handle_request_vote(&ha, 4ULL, "ctrl-c", 300ULL, /* candidate fresher (higher tag) */ 200ULL, 100ULL, /* my config_tag (lower) */ 50ULL, 0); /* Role gate does not fire; Raft gates evaluate; candidate is fresh → grant */ TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted, "CTRL_ELECTING must evaluate Raft gates, not deny via role gate (Req 6.4)"); ha_fini(&ha); printf("test_electing_role_evaluates_vote: PASS\n"); } /* ── Test suite entry point ───────────────────────────────────────────────── */ void test_ha_vote_gate(void) { /* Req 6.1 — PRIMARY denies */ RUN_TEST(test_primary_role_denies_vote_request); /* Req 6.1 — PRIMARY denies a fresher same-term challenger */ RUN_TEST(test_primary_role_denies_fresher_same_term_challenger); /* Req 6.2 — STANDBY + fresh heartbeat denies */ RUN_TEST(test_standby_fresh_heartbeat_denies_vote_request); /* Req 6.4 — STANDBY + stale heartbeat evaluates */ RUN_TEST(test_standby_stale_heartbeat_evaluates_vote); /* Req 6.4 — ELECTING evaluates */ RUN_TEST(test_electing_role_evaluates_vote); }