From 081433e2b785ebdec6fd2eef1fc9785a4dca4da6 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 9 Oct 2026 15:08:01 -0700 Subject: [PATCH 1/4] test(runtime): measure all suites and cover protocol boundaries Signed-off-by: Thomas Vincent --- .github/workflows/regressions.yml | 2 +- Makefile.am | 13 +++ Makefile.in | 13 +++ src/ping/icmp6.c | 2 +- src/ping/icmp_shared.c | 2 +- tests/contracts/test_spine_contracts.c | 50 ++++++++ .../contracts/test_spine_settings_contracts.c | 78 +++++++++++++ tests/regression/test_spine_regressions.c | 46 ++++++++ tests/tools/run_coverage.sh | 28 ++++- tests/unit/test_linked.c | 18 ++- tests/unit/test_ping_icmp.c | 98 ++++++++++++++++ tests/unit/test_snmpv3_session.c | 107 ++++++++++++++++++ 12 files changed, 445 insertions(+), 12 deletions(-) diff --git a/.github/workflows/regressions.yml b/.github/workflows/regressions.yml index 2172a8a4..a44761d4 100644 --- a/.github/workflows/regressions.yml +++ b/.github/workflows/regressions.yml @@ -32,7 +32,7 @@ jobs: run: | set -eu sudo apt-get update - sudo apt-get install -y autoconf automake libtool help2man libmariadb-dev mariadb-client libsnmp-dev snmpd libssl-dev libcap2-bin lcov + sudo apt-get install -y autoconf automake libtool help2man libmariadb-dev libcmocka-dev mariadb-client libsnmp-dev snmpd libssl-dev libcap2-bin lcov - name: Build and test production code run: | set -eu diff --git a/Makefile.am b/Makefile.am index 475e0513..4d393bc8 100644 --- a/Makefile.am +++ b/Makefile.am @@ -121,6 +121,19 @@ coverage-fault-source-list: # Production and linked tests share runtime objects. Report real object names. coverage-object-list: @printf '%s\n' $(spine_OBJECTS) $(test_spine_regressions_OBJECTS) $(test_spine_faults_OBJECTS) | LC_ALL=C sort -u + +# The cmocka suites exercise shipped code, including socket/error paths via +# syscall interception. Keep their measured producers separate from the +# unmodified production-linked regression/fault producers above. +.PHONY: coverage-unit-program-list coverage-unit-object-list +coverage-unit-program-list: + @printf '%s\n' $(filter tests/unit/%,$(check_PROGRAMS)) + +# spine_runtime.c contains only test globals and generates no executable +# coverage profile. All production sources remain in coverage-object-list. +coverage-unit-object-list: + @printf '%s\n' $(filter-out tests/support/spine_runtime.o tests/support/%-spine_runtime.o, \ + $(foreach program,$(filter tests/unit/%,$(check_PROGRAMS)),$($(subst /,_,$(program))_OBJECTS))) | LC_ALL=C sort -u # Docker targets — require Dockerfile and docker/dev.Dockerfile (from PR #401) .PHONY: docker docker-dev verify cppcheck diff --git a/Makefile.in b/Makefile.in index fb44074c..3d5e9fbc 100644 --- a/Makefile.in +++ b/Makefile.in @@ -4661,6 +4661,19 @@ coverage-fault-source-list: # Production and linked tests share runtime objects. Report real object names. coverage-object-list: @printf '%s\n' $(spine_OBJECTS) $(test_spine_regressions_OBJECTS) $(test_spine_faults_OBJECTS) | LC_ALL=C sort -u + +# The cmocka suites exercise shipped code, including socket/error paths via +# syscall interception. Keep their measured producers separate from the +# unmodified production-linked regression/fault producers above. +.PHONY: coverage-unit-program-list coverage-unit-object-list +coverage-unit-program-list: + @printf '%s\n' $(filter tests/unit/%,$(check_PROGRAMS)) + +# spine_runtime.c contains only test globals and generates no executable +# coverage profile. All production sources remain in coverage-object-list. +coverage-unit-object-list: + @printf '%s\n' $(filter-out tests/support/spine_runtime.o tests/support/%-spine_runtime.o, \ + $(foreach program,$(filter tests/unit/%,$(check_PROGRAMS)),$($(subst /,_,$(program))_OBJECTS))) | LC_ALL=C sort -u # Docker targets — require Dockerfile and docker/dev.Dockerfile (from PR #401) .PHONY: docker docker-dev verify cppcheck diff --git a/src/ping/icmp6.c b/src/ping/icmp6.c index c2a23fcc..c85e3642 100644 --- a/src/ping/icmp6.c +++ b/src/ping/icmp6.c @@ -405,7 +405,7 @@ int ping_icmp_ipv6(const host_t *host, ping_t *ping) { } } else { /* the kernel does not match the source address for us */ - if (memcmp(&fromname.sin6_addr, &recvname.sin6_addr, sizeof(struct in6_addr)) != 0) { + if (memcmp(fromname.sin6_addr.s6_addr, recvname.sin6_addr.s6_addr, sizeof(fromname.sin6_addr.s6_addr)) != 0) { /* another host responded */ goto keep_listening_ipv6; } diff --git a/src/ping/icmp_shared.c b/src/ping/icmp_shared.c index ddc9e331..710f4194 100644 --- a/src/ping/icmp_shared.c +++ b/src/ping/icmp_shared.c @@ -352,7 +352,7 @@ static void icmp_shared_dispatch(int family, const unsigned char *reply, ssize_t else { const struct sockaddr_in6 *source = (const struct sockaddr_in6 *) from; - if (memcmp(&source->sin6_addr, &waiter->peer6, sizeof(waiter->peer6)) != 0 || + if (memcmp(source->sin6_addr.s6_addr, waiter->peer6.s6_addr, sizeof(waiter->peer6.s6_addr)) != 0 || !icmp6_reply_matches(reply, len, waiter->id, waiter->seq, TRUE)) { continue; } diff --git a/tests/contracts/test_spine_contracts.c b/tests/contracts/test_spine_contracts.c index 1c18a2ea..d63ae1cf 100644 --- a/tests/contracts/test_spine_contracts.c +++ b/tests/contracts/test_spine_contracts.c @@ -7,6 +7,7 @@ */ #include "internal/common.h" #include "app/spine.h" +#include "app/startup_internal.h" #include #include @@ -253,6 +254,54 @@ static void test_fatal_signal_contracts(void) { assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); } +static void test_startup_output_contracts(void) { + config_t previous = set; + char output[DBL_BUFSIZE]; + + set.logging.log_destination = LOGDEST_STDOUT; + set.poller.threads = 3; + for (int debug = 0; debug <= 1; debug++) { + set.logging.log_level = debug ? POLLER_VERBOSITY_DEBUG : POLLER_VERBOSITY_LOW; + for (int hidden = 0; hidden <= 1; hidden++) { + set.console.stdout_notty = hidden; + for (int remote = 0; remote <= 1; remote++) { + for (int secondary = 0; secondary <= 1; secondary++) { + FILE *capture = tmpfile(); + int saved_stdout; + size_t count; + + assert(capture != NULL && fflush(stdout) == 0); + saved_stdout = dup(STDOUT_FILENO); + assert(saved_stdout >= 0 && dup2(fileno(capture), STDOUT_FILENO) == STDOUT_FILENO); + set.poller.poller_id = secondary ? 2 : 1; + set.hosts.has_device_0 = secondary; + report_startup(remote ? REMOTE : LOCAL); + report_poll_statistics(0.0, 7); + assert(fflush(stdout) == 0 && dup2(saved_stdout, STDOUT_FILENO) == STDOUT_FILENO); + assert(close(saved_stdout) == 0); + rewind(capture); + count = fread(output, 1, sizeof(output) - 1, capture); + assert(count < sizeof(output) - 1 && !ferror(capture)); + output[count] = '\0'; + assert(fclose(capture) == 0); + assert((strstr(output, "Version " VERSION " starting") != NULL) == (debug || !hidden)); + assert((strstr(output, "Threads: 3, Devices: 7") != NULL) == (debug || !hidden)); + assert((strstr(output, "Sending entries to ") != NULL) == (secondary && (debug || !hidden))); + if (secondary && (debug || !hidden)) { + assert(strstr(output, remote ? "remote database in 'online' mode" : "local database in 'offline', or 'recovery' mode") != NULL); + } + if (debug) { + assert(strstr(output, secondary ? "Device 0 Poller Items found." : "No Device 0 Poller Items found.") != NULL); + assert(strstr(output, mysql_thread_safe() ? "MySQL is Thread Safe!" : "MySQL is NOT Thread Safe!") != NULL); + } + } + } + } + } + set = previous; + puts("production startup and statistics output contracts passed"); +} + void test_additional_contracts(void) { test_keyword_roundtrips(); test_lock_contracts(); @@ -260,6 +309,7 @@ void test_additional_contracts(void) { test_legacy_ip_predicate(); test_multipart_boundaries(); test_fatal_signal_contracts(); + test_startup_output_contracts(); puts("production additional contracts passed"); } diff --git a/tests/contracts/test_spine_settings_contracts.c b/tests/contracts/test_spine_settings_contracts.c index 16537273..e8d95c5b 100644 --- a/tests/contracts/test_spine_settings_contracts.c +++ b/tests/contracts/test_spine_settings_contracts.c @@ -52,3 +52,81 @@ void test_settings_write_contracts(MYSQL *mysql) { set = previous; puts("production settings write outcome regressions passed"); } + +/* Only the disposable fixture settings/poller tables are changed. Keep exact + * copies because read_config_options() opens its own connection and therefore + * cannot see a transaction on this test's connection. */ +void test_config_option_boundaries(MYSQL *mysql) { + config_t previous = set; + char query[LRG_BUFSIZE]; + + assert(db_insert(mysql, LOCAL, "CREATE TEMPORARY TABLE spine_saved_option_settings AS SELECT name,value FROM settings")); + assert(db_insert(mysql, LOCAL, "CREATE TEMPORARY TABLE spine_saved_option_poller AS SELECT id,threads FROM poller WHERE id=1")); + set.poller.poller_id = 1; + set.poller.threads_set = FALSE; + set.logging.log_destination = LOGDEST_STDOUT; + set.logging.log_level = POLLER_VERBOSITY_NONE; + set.hosts.host_id_list[0] = '\0'; + for (int high = 0; high <= 1; high++) { + assert(db_insert(mysql, LOCAL, "DELETE FROM settings")); + spine_snprintf(query, sizeof(query), + "INSERT INTO settings(name,value) VALUES " + "('log_verbosity','%d'),('log_destination','STDOUT')," + "('path_webroot','%s'),('path_cactilog','%s'),('path_php_binary','/fixture/php')," + "('default_datechar','%d'),('default_date_format','%d')," + "('availability_method','%d'),('ping_recovery_count','2'),('ping_failure_count','3')," + "('ping_method','%d'),('ping_retries','4'),('snmp_retries','2')," + "('poller_interval','%d'),('concurrent_processes','2'),('script_timeout','%d')," + "('selective_device_debug','42'),('spine_log_level','1'),('php_servers','%d')," + "('active_profiles','%d'),('total_snmp_ports','%d'),('max_get_size','%d')," + "('log_perror','on'),('log_pwarn','on'),('log_pstats','on')," + "('boost_redirect','on'),('boost_rrd_update_enable','on')", + POLLER_VERBOSITY_DEBUG, high ? "/fixture/web" : "", high ? "/fixture/log" : "", + high ? GDC_MAX + 1 : GDC_MIN - 1, high ? GD_MAX + 1 : GD_MIN - 1, + AVAIL_NONE, PING_TCP, high ? 60 : 0, high ? 30 : 2, + high ? MAX_PHP_SERVERS + 1 : 0, high ? 2 : -1, high ? 2 : -1, + high ? 999 : 16); + assert(db_insert(mysql, LOCAL, query)); + spine_snprintf(query, sizeof(query), "UPDATE poller SET threads=%d WHERE id=1", high ? MAX_THREADS + 1 : 3); + assert(db_insert(mysql, LOCAL, query)); + read_config_options(); + assert(set.logging.log_level == POLLER_VERBOSITY_DEBUG); + assert(set.logging.log_destination == LOGDEST_STDOUT); + assert(strcmp(set.logging.path_logfile, high ? "/fixture/log" : "") == 0); + assert(set.logging.log_datetime_separator == GDC_DEFAULT && set.logging.log_datetime_format == GD_DEFAULT); + assert(strcmp(set.php.path_php, "/fixture/php") == 0); + assert(set.availability.availability_method == AVAIL_NONE && set.availability.ping_method == PING_TCP); + assert(set.availability.ping_recovery_count == 2 && set.availability.ping_failure_count == 3); + assert(set.availability.ping_retries == 4 && set.snmp.snmp_retries == 2); + assert(set.poller.threads == (high ? MAX_THREADS : 3)); + assert(set.poller.poller_interval == (high ? 60 : 0) && set.poller.num_parent_processes == 2); + assert(set.php.script_timeout == (high ? 30 : 5)); + assert(set.php.php_servers == (high ? MAX_PHP_SERVERS : 1)); + assert(set.poller.active_profiles == (high ? 2 : 0) && set.snmp.total_snmp_ports == (high ? 2 : 0)); + assert(set.snmp.snmp_max_get_size == (high ? 128 : 16)); + assert(set.logging.log_perror && set.logging.log_pwarn && set.logging.log_pstats); + assert(set.boost.boost_enabled && set.boost.boost_redirect); + assert(strcmp(set.logging.selective_device_debug, "42") == 0 && set.logging.spine_log_level == 1); + assert(!set.php.php_required); /* The fixture has no script-server items. */ + /* The preceding CLI override remains authoritative across reloads. */ + assert(set.availability.ping_timeout == 777); + } + assert(db_insert(mysql, LOCAL, "DELETE FROM settings")); + assert(db_insert(mysql, LOCAL, "INSERT INTO settings(name,value) VALUES('log_destination','STDOUT')")); + read_config_options(); + /* getsetting() represents absent rows as owned empty strings. Preserve + * that contract: numeric reads become zero before their existing clamps. */ + assert(set.php.script_timeout == 5 && set.php.php_servers == 1); + assert(set.snmp.snmp_max_get_size == 0 && set.snmp.snmp_retries == 0); + assert(set.poller.active_profiles == 0 && set.snmp.total_snmp_ports == 0); + assert(set.poller.poller_interval == 0 && set.poller.num_parent_processes == 0); + assert(!set.logging.log_perror && !set.logging.log_pwarn && !set.logging.log_pstats); + assert(!set.boost.boost_enabled && !set.boost.boost_redirect); + assert(set.availability.ping_timeout == 777); + assert(db_insert(mysql, LOCAL, "DELETE FROM settings")); + assert(db_insert(mysql, LOCAL, "INSERT INTO settings(name,value) SELECT name,value FROM spine_saved_option_settings")); + assert(db_insert(mysql, LOCAL, "UPDATE poller p JOIN spine_saved_option_poller s ON p.id=s.id SET p.threads=s.threads")); + assert(db_insert(mysql, LOCAL, "DROP TEMPORARY TABLE spine_saved_option_settings,spine_saved_option_poller")); + set = previous; + puts("production configuration option bounds and defaults passed"); +} diff --git a/tests/regression/test_spine_regressions.c b/tests/regression/test_spine_regressions.c index 1a3e85c1..ff62b334 100644 --- a/tests/regression/test_spine_regressions.c +++ b/tests/regression/test_spine_regressions.c @@ -22,6 +22,7 @@ extern int run_script_stream_fixture(const char *scenario); extern void test_script_stream_contracts(void); extern void test_additional_database_contracts(MYSQL *mysql); extern void test_settings_write_contracts(MYSQL *mysql); +extern void test_config_option_boundaries(MYSQL *mysql); extern void test_additional_reindex_contracts(MYSQL *mysql); extern void test_reindex_result_contracts(MYSQL *mysql); extern void test_additional_snmp_session_boundaries(void); @@ -2447,6 +2448,7 @@ static void test_database_configuration(void) { read_config_options(); assert(set.availability.ping_timeout == 777); db_connect(LOCAL, &mysql); + test_config_option_boundaries(&mysql); test_settings_write_contracts(&mysql); test_additional_database_contracts(&mysql); test_poller_queries(&mysql); @@ -2752,6 +2754,50 @@ static void test_host_status_transitions(void) { assert(no_snmp.statistics.cur_time == 0); update_host_status(HOST_DOWN, &no_snmp, &ping, AVAIL_SNMP); assert(strcmp(no_snmp.state.status_last_error, "Device does not require SNMP") == 0); + /* Public transition/output matrix, including devices that do not require + * SNMP. A failed poll never contributes a response-time sample. */ + set.logging.log_level = POLLER_VERBOSITY_HIGH; + set.logging.log_destination = LOGDEST_STDOUT; + const int states[] = {HOST_UNKNOWN, HOST_UP, HOST_DOWN, HOST_RECOVERING}; + for (int threshold = 1; threshold <= 2; threshold++) { + set.availability.ping_failure_count = threshold; + set.availability.ping_recovery_count = threshold; + for (size_t method = 0; method < sizeof(methods) / sizeof(methods[0]); method++) { + for (size_t state = 0; state < sizeof(states) / sizeof(states[0]); state++) { + for (int snmp = 0; snmp <= 1; snmp++) { + for (int up = 0; up <= 1; up++) { + host_t sample = {0}; + int expected_status; + double expected_time = expected[method]; + + sample.state.status = states[state]; + sample.snmp.profile.version = snmp ? 3 : 2; + sample.statistics.min_time = 1000; + if (!snmp && methods[method] == AVAIL_SNMP) expected_time = 0; + if (!snmp && methods[method] == AVAIL_SNMP_AND_PING) expected_time = 4; + update_host_status(up ? HOST_UP : HOST_DOWN, &sample, &ping, methods[method]); + if (up) { + expected_status = threshold == 2 && (states[state] == HOST_DOWN || states[state] == HOST_RECOVERING) ? HOST_RECOVERING : HOST_UP; + assert(sample.statistics.failed_polls == 0 && sample.statistics.availability == 100); + assert(sample.statistics.cur_time == expected_time && sample.statistics.avg_time == expected_time); + assert(sample.statistics.min_time == expected_time && sample.statistics.max_time == expected_time); + } else { + expected_status = states[state] == HOST_UP && threshold == 2 ? HOST_UP : HOST_DOWN; + assert(sample.statistics.failed_polls == 1 && sample.statistics.availability == 0); + assert(sample.statistics.cur_time == 0 && sample.statistics.avg_time == 0); + if (methods[method] == AVAIL_SNMP) { + assert(strcmp(sample.state.status_last_error, snmp ? "SNMP unavailable" : "Device does not require SNMP") == 0); + } else { + const char *message = snmp && (methods[method] == AVAIL_SNMP_AND_PING || methods[method] == AVAIL_SNMP_OR_PING) ? "SNMP unavailable, network unavailable" : "network unavailable"; + assert(strcmp(sample.state.status_last_error, message) == 0); + } + } + assert(sample.statistics.total_polls == 1 && sample.state.status == expected_status); + } + } + } + } + } set = previous; } diff --git a/tests/tools/run_coverage.sh b/tests/tools/run_coverage.sh index a2e4cc92..14b47d6f 100755 --- a/tests/tools/run_coverage.sh +++ b/tests/tools/run_coverage.sh @@ -92,19 +92,31 @@ for source in "${fault_sources[@]}"; do done producer_units=() make --no-print-directory coverage-object-list > "$coverage_dir/object-manifest.txt" +make --no-print-directory coverage-unit-program-list > "$coverage_dir/unit-programs.txt" +make --no-print-directory coverage-unit-object-list > "$coverage_dir/unit-object-manifest.txt" +mapfile -t unit_programs < "$coverage_dir/unit-programs.txt" +if [[ "${#unit_programs[@]}" -eq 0 || -z "${unit_programs[0]}" ]]; then + printf 'Coverage requires the cmocka unit suites; install its development package.\n' >&2 + exit 1 +fi +for program in "${unit_programs[@]}"; do + case "$program" in tests/unit/test_*) ;; *) printf 'Unsupported unit program: %s\n' "$program" >&2; exit 1;; esac +done +cat "$coverage_dir/object-manifest.txt" "$coverage_dir/unit-object-manifest.txt" | LC_ALL=C sort -u > "$coverage_dir/all-object-manifest.txt" while IFS= read -r object; do case "$object" in *.o) producer_units+=("${object%.o}");; *) exit 1;; esac -done < "$coverage_dir/object-manifest.txt" +done < "$coverage_dir/all-object-manifest.txt" [[ "${#producer_units[@]}" -gt 0 ]] printf '%s\n' "${producer_units[@]}" > "$coverage_dir/producers.txt" [[ "$(sort -u "$coverage_dir/producers.txt" | wc -l)" -eq "${#producer_units[@]}" ]] make clean >> "$coverage_dir/build.log" 2>&1 find . -path "$coverage_find_path" -prune -o -name '*.gcda' -type f -exec rm -f {} + -make -j2 spine test_spine_regressions test_spine_faults >> "$coverage_dir/build.log" 2>&1 +make -j2 spine test_spine_regressions test_spine_faults "${unit_programs[@]}" >> "$coverage_dir/build.log" 2>&1 sha256sum config/config.h Makefile.in > "$coverage_dir/generated-config.sha256" cp config/config.h "$coverage_dir/generated-config.h" cp Makefile.in "$coverage_dir/generated-Makefile.in" sha256sum spine test_spine_regressions test_spine_faults > "$coverage_dir/binaries.sha256" +sha256sum "${unit_programs[@]}" >> "$coverage_dir/binaries.sha256" cp config.log "$coverage_dir/config.log" notes=() for producer in "${producer_units[@]}"; do [[ -s "$producer.gcno" ]]; notes+=("$producer.gcno"); done @@ -112,6 +124,11 @@ tests/tools/verify_coverage.sh --producers "$source_dir" "$coverage_dir/producer sha256sum "${notes[@]}" > "$coverage_dir/notes.sha256" ./spine --version > "$coverage_dir/entry-version.log" 2>&1 ./spine --help > "$coverage_dir/entry-help.log" 2>&1 +mkdir -p "$coverage_dir/unit-logs" +for program in "${unit_programs[@]}"; do + "$program" > "$coverage_dir/unit-logs/${program##*/}.log" 2>&1 +done +(cd "$coverage_dir" && sha256sum unit-logs/*.log) > "$coverage_dir/unit-logs.sha256" ./test_spine_regressions > "$coverage_dir/default.log" 2>&1 grep -Fq 'production regression tests passed' "$coverage_dir/default.log" grep -Fq 'production SNMPv3 key and timeout boundary regressions passed' "$coverage_dir/default.log" @@ -120,8 +137,10 @@ grep -Fq 'production script stream regressions passed' "$coverage_dir/default.lo grep -Fq 'production config structure bindings passed' "$coverage_dir/default.log" grep -Fq 'production numeric error-list boundary regressions passed' "$coverage_dir/default.log" grep -Fq 'production additional contracts passed' "$coverage_dir/default.log" +grep -Fq 'production startup and statistics output contracts passed' "$coverage_dir/default.log" ./test_spine_regressions --database > "$coverage_dir/database.log" 2>&1 grep -Fq 'production settings write outcome regressions passed' "$coverage_dir/database.log" +grep -Fq 'production configuration option bounds and defaults passed' "$coverage_dir/database.log" grep -Fq 'production database configuration regressions passed' "$coverage_dir/database.log" grep -Fq 'production output write failure and retry regressions passed' "$coverage_dir/database.log" grep -Fq 'production output recollection retains incomplete historical samples passed' "$coverage_dir/database.log" @@ -178,6 +197,7 @@ sha256sum "${profiles[@]}" > "$coverage_dir/profiles.sha256" mkdir -p "$coverage_dir/profiles" "$coverage_dir/bin" cp --parents -- "${notes[@]}" "${profiles[@]}" "$coverage_dir/profiles/" cp -- spine test_spine_regressions test_spine_faults "$coverage_dir/bin/" +cp -- "${unit_programs[@]}" "$coverage_dir/bin/" (cd "$coverage_dir" && find profiles bin -type f -print0 | sort -z | xargs -0 sha256sum) > "$coverage_dir/saved-producers.sha256" { sha256sum --check "$coverage_dir/source.sha256" @@ -185,7 +205,7 @@ cp -- spine test_spine_regressions test_spine_faults "$coverage_dir/bin/" sha256sum --check "$coverage_dir/binaries.sha256" sha256sum --check "$coverage_dir/notes.sha256" } > "$coverage_dir/source-verification.log" -printf '%s\n' default numeric-error-boundaries config-bindings script-streams cli-aliases additional-contracts database settings-write-outcome output-write-failure-retry output-recollection-new-timestamp simultaneous-output-failure-ordering remote-output-destination-failure-recollection output-sql-batch-boundary nullable-snmp-profile live-reindex snmp local-silent-udp-snmp-multi-timeout snmpv3-key-timeout snmpv3-live fault-default fault-logger fault-process fault-database-retry fault-ping-only-session worker-launch-admitted worker-launch-rejected worker-launch-eagain-retry icmp-denied icmp-capability > "$coverage_dir/scenarios.txt" -(cd "$coverage_dir" && sha256sum default.log database.log snmp.log snmpv3.log snmpv3-agent.log fault-default.log fault-database.log icmp-denied.log icmp-capability.log scenarios.txt production.info summary.txt source.sha256 generated-config.sha256 binaries.sha256 notes.sha256 profiles.sha256 production-sources.txt test-sources.txt fault-sources.txt producers.txt revision.txt worktree-status.txt compiler.txt dependencies.txt gcov.txt lcov.txt object-manifest.txt entry-help.log entry-version.log entry-gcov.json entry-verifier-self-test.log source-Makefile.in generated-Makefile.in generated-config.h source-inputs.nul source-inputs.tar.gz saved-producers.sha256) > "$coverage_dir/evidence.sha256" +printf '%s\n' default cmocka-unit-suites numeric-error-boundaries config-bindings script-streams cli-aliases additional-contracts database settings-write-outcome output-write-failure-retry output-recollection-new-timestamp simultaneous-output-failure-ordering remote-output-destination-failure-recollection output-sql-batch-boundary nullable-snmp-profile live-reindex snmp local-silent-udp-snmp-multi-timeout snmpv3-key-timeout snmpv3-live fault-default fault-logger fault-process fault-database-retry fault-ping-only-session worker-launch-admitted worker-launch-rejected worker-launch-eagain-retry icmp-denied icmp-capability > "$coverage_dir/scenarios.txt" +(cd "$coverage_dir" && sha256sum default.log database.log snmp.log snmpv3.log snmpv3-agent.log fault-default.log fault-database.log icmp-denied.log icmp-capability.log scenarios.txt production.info summary.txt source.sha256 generated-config.sha256 binaries.sha256 notes.sha256 profiles.sha256 production-sources.txt test-sources.txt fault-sources.txt producers.txt revision.txt worktree-status.txt compiler.txt dependencies.txt gcov.txt lcov.txt object-manifest.txt unit-programs.txt unit-object-manifest.txt all-object-manifest.txt unit-logs.sha256 unit-logs/*.log entry-help.log entry-version.log entry-gcov.json entry-verifier-self-test.log source-Makefile.in generated-Makefile.in generated-config.h source-inputs.nul source-inputs.tar.gz saved-producers.sha256) > "$coverage_dir/evidence.sha256" cat "$coverage_dir/summary.txt" coverage_complete=1 diff --git a/tests/unit/test_linked.c b/tests/unit/test_linked.c index 57670310..e8d93fbe 100644 --- a/tests/unit/test_linked.c +++ b/tests/unit/test_linked.c @@ -507,11 +507,19 @@ static void test_read_spine_config_rejects_a_missing_file(void **state) { } static void test_read_spine_config_reads_settings(void **state) { - const char *path = "/tmp/spine_test.conf"; + char path[] = "/tmp/spine-linked-config-XXXXXX"; + int descriptor; + int read_status; FILE *fp; (void) state; - fp = fopen(path, "wb"); + descriptor = mkstemp(path); + assert_true(descriptor >= 0); + fp = fdopen(descriptor, "wb"); + if (fp == NULL) { + close(descriptor); + unlink(path); + } assert_non_null(fp); fputs("DB_Host testhost\n", fp); fputs("DB_Database testdb\n", fp); @@ -523,14 +531,14 @@ static void test_read_spine_config_reads_settings(void **state) { fclose(fp); config_defaults(); - assert_int_equal(read_spine_config(path), 0); + read_status = read_spine_config(path); + assert_int_equal(unlink(path), 0); + assert_int_equal(read_status, 0); assert_string_equal(set.database.host, "testhost"); assert_string_equal(set.database.database, "testdb"); assert_string_equal(set.database.user, "testuser"); assert_int_equal(set.database.port, 3399); - - remove(path); } /* --- get_date_format(): cached storage, rebuilt by set_date_format() ------ */ diff --git a/tests/unit/test_ping_icmp.c b/tests/unit/test_ping_icmp.c index bbb79de8..8aa4629f 100644 --- a/tests/unit/test_ping_icmp.c +++ b/tests/unit/test_ping_icmp.c @@ -66,6 +66,7 @@ static uint16_t reply_seq; static int recvfrom_calls; static int recvfrom_eintr_once; static int controlled_reply_v6; +static int wrong_v6_source_octet; static int socket_cloexec_einval; static int fcntl_fails; @@ -188,6 +189,9 @@ static ssize_t test_recvfrom(int fd, void *buffer, size_t length, int flags, memset(source6, 0, sizeof(*source6)); source6->sin6_family = AF_INET6; source6->sin6_addr = in6addr_loopback; + if (wrong_v6_source_octet >= 0 && (size_t) wrong_v6_source_octet < sizeof(source6->sin6_addr.s6_addr)) { + source6->sin6_addr.s6_addr[wrong_v6_source_octet++] ^= 0x80; + } *address_len = sizeof(*source6); } return (ssize_t) length6; @@ -346,6 +350,7 @@ static int ping_reset(void **state) { icmp6_shared.reading = FALSE; recvfrom_eintr_once = 0; controlled_reply_v6 = 0; + wrong_v6_source_octet = -1; socket_cloexec_einval = 0; fcntl_fails = 0; icmp_waiters = NULL; @@ -894,6 +899,17 @@ static void test_shared_dispatch_ipv6(void **state) { icmp_shared_dispatch(AF_INET, reply, length, &from); assert_false(waiter.answered); source->sin6_addr = in6addr_loopback; + header->icmp6_seq = htons(5); + icmp_shared_dispatch(AF_INET6, reply, length, &from); + assert_false(waiter.answered); + header->icmp6_seq = htons(4); + /* Every address octet participates in peer identity; struct padding does not. */ + for (size_t octet = 0; octet < sizeof(source->sin6_addr.s6_addr); octet++) { + source->sin6_addr.s6_addr[octet] ^= 0x80; + icmp_shared_dispatch(AF_INET6, reply, length, &from); + assert_false(waiter.answered); + source->sin6_addr.s6_addr[octet] ^= 0x80; + } icmp_shared_dispatch(AF_INET6, reply, length, &from); assert_true(waiter.answered); thread_mutex_unlock(LOCK_ICMP); @@ -1012,6 +1028,84 @@ static void test_ipv6_datagram_socket_answers(void **state) { assert_int_equal(controlled_socket_closed, 1); } +static void test_ipv6_datagram_rejects_wrong_peer_addresses(void **state) { + host_t host; + ping_t ping = {0}; + + (void) state; + use_owned_controlled_socket(); + controlled_reply = 1; + controlled_reply_v6 = 1; + wrong_v6_source_octet = 0; + make_host6(&host); + assert_int_equal(ping_icmp_ipv6(&host, &ping), HOST_UP); + assert_int_equal(recvfrom_calls, sizeof(in6addr_loopback.s6_addr) + 1); + assert_string_equal(ping.ping_response, "ICMPv6: Device is Alive"); + assert_int_equal(controlled_socket_closed, 1); +} + +static void test_ipv6_invalid_input_releases_resources(void **state) { + (void) state; + for (int mode = 0; mode <= 2; mode++) { + host_t host; + ping_t ping = {0}; + + assert_int_equal(ping_reset(NULL), 0); + use_owned_controlled_socket(); + track_packet = 1; + packet_size = sizeof(struct icmp6_hdr) + sizeof(icmp6_payload) - 1; + resolver_mode = mode; + make_host(&host, mode == 0 ? "" : "::1"); + pi_debug_table[0] = host.id; + assert_int_equal(ping_icmp_ipv6(&host, &ping), HOST_DOWN); + assert_string_equal(ping.ping_response, mode == 0 ? "ICMPv6: Destination address not specified" : "ICMPv6: Destination hostname invalid"); + assert_string_equal(ping.ping_status, "down"); + assert_int_equal(resolver_calls, mode == 0 ? 0 : mode == 1 ? 1 + : 4); + assert_int_equal(freeaddrinfo_calls, 0); + assert_int_equal(packet_released, 1); + assert_int_equal(controlled_socket_closed, 1); + assert_int_equal(ping_teardown(NULL), 0); + controlled_pair[0] = controlled_pair[1] = -1; + } +} + +static void test_ipv6_fd_boundary_releases_resources(void **state) { + host_t host; + ping_t ping = {0}; + + (void) state; + use_controlled_socket = 1; + controlled_socket_fd = FD_SETSIZE; + controlled_reply = 1; + controlled_reply_v6 = 1; + track_packet = 1; + packet_size = sizeof(struct icmp6_hdr) + sizeof(icmp6_payload) - 1; + make_host6(&host); + pi_debug_table[0] = host.id; + assert_int_equal(ping_icmp_ipv6(&host, &ping), HOST_DOWN); + assert_non_null(strstr(ping.ping_response, "FD_SETSIZE")); + assert_int_equal(packet_released, 1); + assert_int_equal(controlled_socket_closed, 1); +} + +static void test_ipv6_socket_failure_budget_is_bounded(void **state) { + host_t host; + ping_t ping = {0}; + + (void) state; + /* The datagram attempt and all five raw attempts fail deterministically. */ + socket_failures_remaining = 6; + set.logging.log_level = POLLER_VERBOSITY_DEBUG; + make_host6(&host); + pi_debug_table[0] = host.id; + assert_int_equal(ping_icmp_ipv6(&host, &ping), HOST_DOWN); + assert_non_null(strstr(ping.ping_response, "unable to create ICMPv6 Socket")); + assert_int_equal(socket_calls, 6); + assert_int_equal(controlled_socket_closed, 0); + assert_null(packet_allocation); +} + int main(void) { const struct CMUnitTest tests[] = { cmocka_unit_test_setup_teardown(test_fd_setsize_guard_releases_the_packet, ping_reset, ping_teardown), @@ -1040,6 +1134,10 @@ int main(void) { cmocka_unit_test_setup_teardown(test_ipv6_shared_socket_answers, ping_reset, ping_teardown), cmocka_unit_test_setup_teardown(test_ipv6_shared_socket_timeout, ping_reset, ping_teardown), cmocka_unit_test_setup_teardown(test_ipv6_datagram_socket_answers, ping_reset, ping_teardown), + cmocka_unit_test_setup_teardown(test_ipv6_datagram_rejects_wrong_peer_addresses, ping_reset, ping_teardown), + cmocka_unit_test_setup_teardown(test_ipv6_invalid_input_releases_resources, ping_reset, ping_teardown), + cmocka_unit_test_setup_teardown(test_ipv6_fd_boundary_releases_resources, ping_reset, ping_teardown), + cmocka_unit_test_setup_teardown(test_ipv6_socket_failure_budget_is_bounded, ping_reset, ping_teardown), }; return cmocka_run_group_tests(tests, NULL, NULL); diff --git a/tests/unit/test_snmpv3_session.c b/tests/unit/test_snmpv3_session.c index 33ab00ca..bfafeb5b 100644 --- a/tests/unit/test_snmpv3_session.c +++ b/tests/unit/test_snmpv3_session.c @@ -37,14 +37,68 @@ static unsigned char captured_auth_key[USM_AUTH_KU_LEN]; static unsigned char captured_priv_key[USM_PRIV_KU_LEN]; static oid captured_auth_proto[MAX_OID_LEN]; static oid captured_priv_proto[MAX_OID_LEN]; +typedef enum { + REPLY_NONE, + REPLY_EMPTY, + REPLY_SCALAR, + REPLY_AGENT_ERROR, + REPLY_NAMELESS, + REPLY_NOSUCH_INSTANCE, + REPLY_END_OF_MIB +} test_reply_kind_t; + +static int reply_status; +static test_reply_kind_t reply_kind; +static int request_calls; +static int request_command; static void *capture_snmp_sess_open(struct snmp_session *session); +static int capture_synch_response(void *session, netsnmp_pdu *request, netsnmp_pdu **response); #define snmp_sess_open capture_snmp_sess_open +#define snmp_sess_synch_response capture_synch_response #include "../../src/snmp/session.c" #include "../../src/snmp/requests.c" #include "../../src/snmp/response.c" #undef snmp_sess_open +#undef snmp_sess_synch_response + +static int capture_synch_response(void *session, netsnmp_pdu *request, netsnmp_pdu **response) { + static const oid name[] = {1, 3, 6, 1, 2, 1, 1, 1, 0}; + long value = 42; + + assert_ptr_equal(session, (void *) (uintptr_t) 1); + assert_non_null(request); + assert_non_null(request->variables); + request_command = request->command; + request_calls++; + /* Match Net-SNMP ownership, including a failed/timeout send. */ + snmp_free_pdu(request); + *response = NULL; + if (reply_kind == REPLY_NONE) return reply_status; + *response = snmp_pdu_create(SNMP_MSG_RESPONSE); + assert_non_null(*response); + (*response)->errstat = SNMP_ERR_NOERROR; + (*response)->errindex = 0; + if (reply_kind == REPLY_SCALAR || reply_kind == REPLY_NAMELESS || reply_kind == REPLY_NOSUCH_INSTANCE || reply_kind == REPLY_END_OF_MIB) { + u_char type = reply_kind == REPLY_NOSUCH_INSTANCE ? SNMP_NOSUCHINSTANCE : reply_kind == REPLY_END_OF_MIB ? SNMP_ENDOFMIBVIEW + : ASN_INTEGER; + const u_char *contents = type == ASN_INTEGER ? (const u_char *) &value : NULL; + size_t length = type == ASN_INTEGER ? sizeof(value) : 0; + + assert_non_null(snmp_pdu_add_variable(*response, name, sizeof(name) / sizeof(name[0]), type, contents, length)); + if (reply_kind == REPLY_NAMELESS) { + /* Short OIDs use the varbind's inline name storage. */ + if ((*response)->variables->name != (*response)->variables->name_loc) { + SNMP_FREE((*response)->variables->name); + } + (*response)->variables->name = NULL; + (*response)->variables->name_length = 0; + } + } + if (reply_kind == REPLY_AGENT_ERROR) (*response)->errstat = SNMP_ERR_GENERR; + return reply_status; +} static void *capture_snmp_sess_open(struct snmp_session *session) { captured_security_level = session->securityLevel; @@ -85,6 +139,10 @@ static int session_reset(void **state) { captured_priv_key_len = 0; captured_auth_proto_len = 0; captured_priv_proto_len = 0; + reply_status = STAT_SUCCESS; + reply_kind = REPLY_NONE; + request_calls = 0; + request_command = 0; memset(captured_auth_key, 0, sizeof(captured_auth_key)); memset(captured_priv_key, 0, sizeof(captured_priv_key)); memset(captured_auth_proto, 0, sizeof(captured_auth_proto)); @@ -486,6 +544,54 @@ static void test_multi_get_refuses_a_missing_session(void **state) { assert_true(IS_UNDEFINED(request.result)); } +static void test_single_request_transport_and_response_contracts(void **state) { + typedef struct { + int status; + test_reply_kind_t kind; + const char *get_value; + const char *optional_value; + const char *next_value; + bool get_failed; + bool next_failed; + } response_case_t; + static const response_case_t cases[] = { + {STAT_SUCCESS, REPLY_NONE, "U", "U", "U", TRUE, TRUE}, + {STAT_TIMEOUT, REPLY_NONE, "U", "", "U", TRUE, TRUE}, + {STAT_TIMEOUT, REPLY_SCALAR, "U", "U", "U", TRUE, TRUE}, + {STAT_SUCCESS, REPLY_EMPTY, "", "", "U", FALSE, TRUE}, + {STAT_SUCCESS, REPLY_AGENT_ERROR, "", "", "", FALSE, FALSE}, + {STAT_SUCCESS, REPLY_SCALAR, "42", "42", "42", FALSE, FALSE}, + {STAT_SUCCESS, REPLY_NAMELESS, "", "", "U", FALSE, TRUE}, + {STAT_SUCCESS, REPLY_NOSUCH_INSTANCE, "U", "U", "U", TRUE, TRUE}, + {STAT_SUCCESS, REPLY_END_OF_MIB, "U", "U", "U", TRUE, TRUE}}; + char text_oid[] = ".1.3.6.1.2.1.1.1.0"; + + (void) state; + for (size_t index = 0; index < sizeof(cases) / sizeof(cases[0]); index++) { + for (int operation = 0; operation < 3; operation++) { + host_t host = {0}; + char *result; + const char *expected = operation == 0 ? cases[index].get_value : operation == 1 ? cases[index].optional_value + : cases[index].next_value; + + host.snmp.session = (void *) (uintptr_t) 1; + reply_status = cases[index].status; + reply_kind = cases[index].kind; + request_calls = 0; + print_message("transport status=%d response kind=%d operation=%d\n", reply_status, reply_kind, operation); + result = operation == 2 ? snmp_getnext(&host, text_oid) : snmp_get_base(&host, text_oid, operation == 0); + assert_non_null(result); + assert_string_equal(result, expected); + assert_int_equal(host.snmp.status, cases[index].status); + assert_int_equal(host.ignore_host, operation == 0 ? cases[index].get_failed : operation == 1 ? FALSE + : cases[index].next_failed); + assert_int_equal(request_calls, 1); + assert_int_equal(request_command, operation == 2 ? SNMP_MSG_GETNEXT : SNMP_MSG_GET); + free(result); + } + } +} + int main(void) { const struct CMUnitTest tests[] = { cmocka_unit_test_setup(test_value_presence_contract, session_reset), @@ -510,6 +616,7 @@ int main(void) { cmocka_unit_test_setup(test_session_construction_does_not_modify_caller_passphrases, session_reset), cmocka_unit_test_setup(test_authnopriv_does_not_modify_caller_password, session_reset), cmocka_unit_test_setup(test_multi_get_refuses_a_missing_session, session_reset), + cmocka_unit_test_setup(test_single_request_transport_and_response_contracts, session_reset), }; return cmocka_run_group_tests(tests, NULL, NULL); From a44feab7ed12b91cb74f800949bb2a32f2f4a784 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 9 Oct 2026 16:44:24 -0700 Subject: [PATCH 2/4] test(runtime): cover decoded SNMP status and completion failures Signed-off-by: Thomas Vincent --- tests/regression/test_spine_faults.c | 79 +++++++++++++++++++++++++++- tests/unit/test_snmpv3_session.c | 22 ++++---- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/tests/regression/test_spine_faults.c b/tests/regression/test_spine_faults.c index 373680f3..8d7e8095 100644 --- a/tests/regression/test_spine_faults.c +++ b/tests/regression/test_spine_faults.c @@ -75,6 +75,13 @@ static const char *fake_fail_prefix; static int fake_item_rows; static char *fake_item_row[21]; static int fake_queries; +static int fake_schedule_writes; +static int fake_item_selects; +static bool fake_device_row; +static bool fake_device_row_pending; +static char *fake_host_row[37]; +static poller_thread_t *fake_completion_peer; +static void *fake_poll_worker(void *argument); static int fake_result_storage; #define FAKE_RESULT ((MYSQL_RES *) (void *) &fake_result_storage) static int interrupted_queries; @@ -187,7 +194,22 @@ size_t __wrap_strftime(char *output, size_t capacity, const char *format, const int __wrap_mysql_query(MYSQL *mysql, const char *query) { if (fake_database) { fake_queries++; + if (strncmp(query, "UPDATE poller_item", strlen("UPDATE poller_item")) == 0) fake_schedule_writes++; + if (strncmp(query, "SELECT ", 7) == 0 && strstr(query, " FROM poller_item ") != NULL) fake_item_selects++; + /* Pause the first partition's error write while another partition + * finishes. Joining keeps this scripted server's state serialized. */ + if (fake_completion_peer != NULL && strncmp(query, "INSERT INTO host_errors", strlen("INSERT INTO host_errors")) == 0) { + poller_thread_t *peer = fake_completion_peer; + pthread_t worker; + fake_completion_peer = NULL; + fake_rows = 0; + assert(pthread_create(&worker, NULL, fake_poll_worker, peer) == 0); + assert(pthread_join(worker, NULL) == 0); + assert(fake_schedule_writes == 0 && !details[peer->device_counter]->complete); + } + fake_device_row_pending = fake_device_row && strncmp(query, "SELECT SQL_NO_CACHE id,", strlen("SELECT SQL_NO_CACHE id,")) == 0; fake_rows_current = fake_rows_prefix != NULL && strncmp(query, fake_rows_prefix, strlen(fake_rows_prefix)) == 0 ? fake_rows_matched : fake_rows; + if (fake_device_row_pending) fake_rows_current = 1; if (strncmp(query, "SET SESSION sql_mode", strlen("SET SESSION sql_mode")) == 0) { fake_errno_value = fake_session_mode_fails ? 1146 : 0; } else if (fake_fail_prefix != NULL && strncmp(query, fake_fail_prefix, strlen(fake_fail_prefix)) == 0) { @@ -286,6 +308,10 @@ my_ulonglong __wrap_mysql_num_rows(MYSQL_RES *result) { MYSQL_ROW __wrap_mysql_fetch_row(MYSQL_RES *result) { if (result == FAKE_RESULT) { + if (fake_device_row_pending) { + fake_device_row_pending = FALSE; + return fake_host_row; + } if (fake_item_rows <= 0) return NULL; fake_item_rows--; return fake_item_row; @@ -870,6 +896,11 @@ static void fake_script(const int *errors, int count) { fake_fail_connect_number = 0; fake_connects = 0; fake_queries = 0; + fake_schedule_writes = 0; + fake_item_selects = 0; + fake_device_row = FALSE; + fake_device_row_pending = FALSE; + fake_completion_peer = NULL; fake_session_mode_fails = FALSE; fake_ping_reconnects = FALSE; fake_rows = 0; @@ -1072,15 +1103,18 @@ static void test_fake_database_poll_host(void) { details = device_list; fake_database = TRUE; set.logging.log_destination = 0; - set.poller.threads = 1; + set.poller.threads = 2; set.poller.poller_id = 1; set.poller.active_profiles = 2; set.poller.poller_interval = 5; set.availability.ping_only = FALSE; - db_pool_local = calloc(1, sizeof(*db_pool_local)); + db_pool_local = calloc(2, sizeof(*db_pool_local)); assert(db_pool_local != NULL); fake_script(NULL, 0); assert(db_connect(LOCAL, &db_pool_local[0].mysql)); + assert(db_connect(LOCAL, &db_pool_local[1].mysql)); + db_pool_local[1].id = 1; + db_pool_local[1].free = FALSE; db_pool_local[0].free = FALSE; run_fake_poll(&device, 0); assert(device.poll_failed && !device.complete && set.exit.exit_code == EXIT_FAILURE); @@ -1105,6 +1139,17 @@ static void test_fake_database_poll_host(void) { assert_fake_poll_failed(&device); assert(fake_queries == 1); + /* A failed reindex read stops collection and keeps the device due. */ + fake_script(NULL, 0); + fake_device_row = TRUE; + fake_host_row[0] = "902"; + fake_host_row[3] = "0"; + fake_host_row[14] = "0"; /* AVAIL_NONE: no network access. */ + fake_fail_prefix = "SELECT SQL_NO_CACHE data_query_id"; + run_fake_poll(&device, 902); + assert_fake_poll_failed(&device); + assert(fake_item_selects == 0 && fake_schedule_writes == 0); + /* A device deleted since selection returns quietly, uncounted. */ fake_script(NULL, 0); memset(&device, 0, sizeof(device)); @@ -1152,9 +1197,39 @@ static void test_fake_database_poll_host(void) { fprintf(stderr, "completion write '%s' failing: complete=%d exit=%d queries=%d\n", completion_writes[failing], device.complete, set.exit.exit_code, fake_queries); assert(!device.complete && set.exit.exit_code == EXIT_FAILURE && db_pool_local[0].free); + assert(fake_schedule_writes == (failing == 0 ? 1 : 0)); } + db_pool_local[1].free = TRUE; + /* The final partition must wait for the first partition's pending + * bookkeeping outcome before it can advance the shared schedule. */ + for (int fail_write = 0; fail_write < 2; fail_write++) { + poller_thread_t first = {0}; + poller_thread_t second = {0}; + pthread_t worker; + fake_script(NULL, 0); + fake_rows = 1; + fake_item_rows = 1; + fake_fail_prefix = fail_write ? "INSERT INTO host_errors" : NULL; + memset(&device, 0, sizeof(device)); + device.host_threads = 2; + first.host_thread = 1; + second.host_thread = 2; + STRNCOPY(first.host_time, "1791244800"); + STRNCOPY(second.host_time, "1791244800"); + set.exit.exit_code = EXIT_SUCCESS; + fake_completion_peer = &second; + assert(pthread_create(&worker, NULL, fake_poll_worker, &first) == 0); + assert(pthread_join(worker, NULL) == 0); + assert(fake_completion_peer == NULL && device.threads_complete == 2); + assert(device.complete == !fail_write && device.output_failed == fail_write); + assert(fake_schedule_writes == !fail_write); + assert(set.exit.exit_code == (fail_write ? EXIT_FAILURE : EXIT_SUCCESS)); + assert(db_pool_local[0].free && db_pool_local[1].free); + } + assert(spine_permits_destroy(&available_scripts) == 0); + db_disconnect(&db_pool_local[1].mysql); db_disconnect(&db_pool_local[0].mysql); free(db_pool_local); fake_database = FALSE; diff --git a/tests/unit/test_snmpv3_session.c b/tests/unit/test_snmpv3_session.c index bfafeb5b..72a9c75b 100644 --- a/tests/unit/test_snmpv3_session.c +++ b/tests/unit/test_snmpv3_session.c @@ -553,17 +553,19 @@ static void test_single_request_transport_and_response_contracts(void **state) { const char *next_value; bool get_failed; bool next_failed; + int get_status; + int next_status; } response_case_t; static const response_case_t cases[] = { - {STAT_SUCCESS, REPLY_NONE, "U", "U", "U", TRUE, TRUE}, - {STAT_TIMEOUT, REPLY_NONE, "U", "", "U", TRUE, TRUE}, - {STAT_TIMEOUT, REPLY_SCALAR, "U", "U", "U", TRUE, TRUE}, - {STAT_SUCCESS, REPLY_EMPTY, "", "", "U", FALSE, TRUE}, - {STAT_SUCCESS, REPLY_AGENT_ERROR, "", "", "", FALSE, FALSE}, - {STAT_SUCCESS, REPLY_SCALAR, "42", "42", "42", FALSE, FALSE}, - {STAT_SUCCESS, REPLY_NAMELESS, "", "", "U", FALSE, TRUE}, - {STAT_SUCCESS, REPLY_NOSUCH_INSTANCE, "U", "U", "U", TRUE, TRUE}, - {STAT_SUCCESS, REPLY_END_OF_MIB, "U", "U", "U", TRUE, TRUE}}; + {STAT_SUCCESS, REPLY_NONE, "U", "U", "U", TRUE, TRUE, STAT_ERROR, STAT_ERROR}, + {STAT_TIMEOUT, REPLY_NONE, "U", "", "U", TRUE, TRUE, STAT_TIMEOUT, STAT_TIMEOUT}, + {STAT_TIMEOUT, REPLY_SCALAR, "U", "U", "U", TRUE, TRUE, STAT_ERROR, STAT_TIMEOUT}, + {STAT_SUCCESS, REPLY_EMPTY, "", "", "U", FALSE, TRUE, STAT_SUCCESS, STAT_ERROR}, + {STAT_SUCCESS, REPLY_AGENT_ERROR, "", "", "", FALSE, FALSE, STAT_SUCCESS, STAT_SUCCESS}, + {STAT_SUCCESS, REPLY_SCALAR, "42", "42", "42", FALSE, FALSE, STAT_SUCCESS, STAT_SUCCESS}, + {STAT_SUCCESS, REPLY_NAMELESS, "", "", "U", FALSE, TRUE, STAT_SUCCESS, STAT_ERROR}, + {STAT_SUCCESS, REPLY_NOSUCH_INSTANCE, "U", "U", "U", TRUE, TRUE, STAT_ERROR, STAT_ERROR}, + {STAT_SUCCESS, REPLY_END_OF_MIB, "U", "U", "U", TRUE, TRUE, STAT_ERROR, STAT_ERROR}}; char text_oid[] = ".1.3.6.1.2.1.1.1.0"; (void) state; @@ -582,7 +584,7 @@ static void test_single_request_transport_and_response_contracts(void **state) { result = operation == 2 ? snmp_getnext(&host, text_oid) : snmp_get_base(&host, text_oid, operation == 0); assert_non_null(result); assert_string_equal(result, expected); - assert_int_equal(host.snmp.status, cases[index].status); + assert_int_equal(host.snmp.status, operation == 2 ? cases[index].next_status : cases[index].get_status); assert_int_equal(host.ignore_host, operation == 0 ? cases[index].get_failed : operation == 1 ? FALSE : cases[index].next_failed); assert_int_equal(request_calls, 1); From 88963e02324ad7566670e49ee6a3adb83e97a9a0 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 9 Oct 2026 17:14:42 -0700 Subject: [PATCH 3/4] test(poller): use production stack sizing for interleaved workers Signed-off-by: Thomas Vincent --- tests/regression/test_spine_faults.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/regression/test_spine_faults.c b/tests/regression/test_spine_faults.c index 8d7e8095..343b0d5a 100644 --- a/tests/regression/test_spine_faults.c +++ b/tests/regression/test_spine_faults.c @@ -82,6 +82,7 @@ static bool fake_device_row_pending; static char *fake_host_row[37]; static poller_thread_t *fake_completion_peer; static void *fake_poll_worker(void *argument); +static void start_test_worker(pthread_t *worker, void *(*start)(void *), void *argument); static int fake_result_storage; #define FAKE_RESULT ((MYSQL_RES *) (void *) &fake_result_storage) static int interrupted_queries; @@ -203,7 +204,7 @@ int __wrap_mysql_query(MYSQL *mysql, const char *query) { pthread_t worker; fake_completion_peer = NULL; fake_rows = 0; - assert(pthread_create(&worker, NULL, fake_poll_worker, peer) == 0); + start_test_worker(&worker, fake_poll_worker, peer); assert(pthread_join(worker, NULL) == 0); assert(fake_schedule_writes == 0 && !details[peer->device_counter]->complete); } @@ -1218,7 +1219,7 @@ static void test_fake_database_poll_host(void) { STRNCOPY(second.host_time, "1791244800"); set.exit.exit_code = EXIT_SUCCESS; fake_completion_peer = &second; - assert(pthread_create(&worker, NULL, fake_poll_worker, &first) == 0); + start_test_worker(&worker, fake_poll_worker, &first); assert(pthread_join(worker, NULL) == 0); assert(fake_completion_peer == NULL && device.threads_complete == 2); assert(device.complete == !fail_write && device.output_failed == fail_write); From d0a7e1e853135295673198207bd91537b63d20f3 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 11 Oct 2026 19:28:36 -0700 Subject: [PATCH 4/4] fix(coverage): exclude generated test logs from source hashes Signed-off-by: Thomas Vincent --- tests/tools/run_coverage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tools/run_coverage.sh b/tests/tools/run_coverage.sh index 560cf824..6f454d56 100755 --- a/tests/tools/run_coverage.sh +++ b/tests/tools/run_coverage.sh @@ -37,7 +37,7 @@ case "$coverage_base" in "$source_dir"/*) coverage_find_path="./${coverage_base#"$source_dir"/}";; *) ;; esac -find . -path "$coverage_find_path" -prune -o -type f \( -name '*.c' -o -name '*.h' -o -name 'Makefile.am' -o -name 'configure.ac' -o -path './scripts/copyright_year.sh' -o -path './tests/fixtures/*' -o -path './tests/tools/*' -o -path './.github/workflows/*.yml' \) -not -path './.git/*' -not -path './config/config.h' -print0 | sort -z > "$coverage_dir/source-inputs.nul" +find . -path "$coverage_find_path" -prune -o -type f \( -name '*.c' -o -name '*.h' -o -name 'Makefile.am' -o -name 'configure.ac' -o -path './scripts/copyright_year.sh' -o -path './tests/fixtures/*' -o -path './tests/tools/*' -o -path './.github/workflows/*.yml' \) -not -path './.git/*' -not -path './config/config.h' -not -name '*.log' -not -name '*.trs' -print0 | sort -z > "$coverage_dir/source-inputs.nul" xargs -0 sha256sum < "$coverage_dir/source-inputs.nul" > "$coverage_dir/source.sha256" tar -czf "$coverage_dir/source-inputs.tar.gz" --null -T "$coverage_dir/source-inputs.nul" # Autoreconf replaces this tracked template. Preserve its original bytes and