From a05dc886bd756982dfa335f0ff1701fafc53731a Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 31 Oct 2026 10:02:48 -0400 Subject: [PATCH 1/2] fix(web): preserve floor-plan backgrounds during publication failures Hold original and uploaded background identities in a private directory, publish without replacing a competing destination, and restore only verified entries without overwriting intervening files. Preserve original, uploaded and competing data when publication or metadata rollback cannot safely finish. Create uploads relative to the held private directory. Add 24 direct-handler regressions with private SQLite/filesystem fixtures and existing CMake/integration/sanitizer registration. On origin/main 8927bc8a the suite fails 17/24; the isolated fix passes 24/24 under Debug ASan/UBSan. No application service or shared legacy fixture was used. Uncertain recovery artifacts are retained rather than recursively removed. --- .github/workflows/integration-test.yml | 3 +- .github/workflows/sanitizer.yml | 2 +- src/web/api_handlers_operator_floor_plans.c | 286 ++++++++--- tests/unit/CMakeLists.txt | 3 + .../test_floor_plan_background_publication.c | 456 ++++++++++++++++++ 5 files changed, 688 insertions(+), 62 deletions(-) create mode 100644 tests/unit/test_floor_plan_background_publication.c diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index f4f825289..094cbe662 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -93,6 +93,7 @@ jobs: test_db_storage_policies \ test_api_handlers_storage_targets \ test_api_handlers_storage_policies \ + test_floor_plan_background_publication \ test_config \ test_logger \ test_strings \ @@ -145,7 +146,7 @@ jobs: run: | cd build ctest --output-on-failure -V \ - -R "test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_camera_favorites|test_api_handlers_camera_favorites|test_db_backup|test_mp4_failed_open_memory|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_faac_encoder|test_audio_transcoder|test_mp4_audio_recovery|test_mp4_forced_reconnect|test_mp4_recovery_watchdog|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_hls_cleanup|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils" + -R "test_floor_plan_background_publication|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_camera_favorites|test_api_handlers_camera_favorites|test_db_backup|test_mp4_failed_open_memory|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_faac_encoder|test_audio_transcoder|test_mp4_audio_recovery|test_mp4_forced_reconnect|test_mp4_recovery_watchdog|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_hls_cleanup|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils" - name: Generate coverage report if: always() diff --git a/.github/workflows/sanitizer.yml b/.github/workflows/sanitizer.yml index 94e6ba7a1..8c42491d8 100644 --- a/.github/workflows/sanitizer.yml +++ b/.github/workflows/sanitizer.yml @@ -60,7 +60,7 @@ jobs: cd build echo "Running tests with ASan + UBSan enabled..." ctest --output-on-failure -V \ - -R "test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_faac_encoder|test_audio_transcoder|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_audit_log|test_audit_summary" + -R "test_floor_plan_background_publication|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_faac_encoder|test_audio_transcoder|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_audit_log|test_audit_summary" - name: Smoke test - start and stop service run: | diff --git a/src/web/api_handlers_operator_floor_plans.c b/src/web/api_handlers_operator_floor_plans.c index a2a1fe436..81d36437f 100644 --- a/src/web/api_handlers_operator_floor_plans.c +++ b/src/web/api_handlers_operator_floor_plans.c @@ -5,12 +5,18 @@ * This file is part of LightNVR . */ +#define _GNU_SOURCE #define _POSIX_C_SOURCE 200809L #include "web/api_handlers_operator_floor_plans.h" #include #include +#include +#include +#ifdef __linux__ +#include +#endif #include #include #include @@ -24,6 +30,7 @@ #include "core/authorization.h" #include "core/config.h" +#include "core/logger.h" #include "core/path_utils.h" #include "database/db_auth.h" #include "database/db_fleet_query.h" @@ -867,6 +874,159 @@ static bool background_matches_mime(const char *mime, // floor_plans/. suffix from ever truncating. #define BACKGROUND_PATH_SIZE (MAX_PATH_LENGTH + 128) +typedef struct { + char directory[BACKGROUND_PATH_SIZE + 32]; + char path[BACKGROUND_PATH_SIZE + 48]; + int descriptor; + int parent; + int old_file; + int upload_file; + char upload_name[64]; + struct stat upload_identity; + struct stat parent_identity; + bool created; + bool validated; + struct stat identity; +} background_backup_t; + +static bool create_background_backup(const char *destination, background_backup_t *backup) { + int length = snprintf(backup->directory, sizeof(backup->directory), "%s.backup.XXXXXX", destination); + if (length < 0 || (size_t)length >= sizeof(backup->directory) || !mkdtemp(backup->directory)) return false; + backup->created = true; + backup->descriptor = open(backup->directory, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + struct stat named; + if (backup->descriptor < 0 || fstat(backup->descriptor, &backup->identity) != 0 || + lstat(backup->directory, &named) != 0 || !S_ISDIR(backup->identity.st_mode) || + backup->identity.st_uid != geteuid() || (backup->identity.st_mode & 0777) != 0700 || + named.st_dev != backup->identity.st_dev || named.st_ino != backup->identity.st_ino) return false; + backup->validated = true; + length = snprintf(backup->path, sizeof(backup->path), "%s/original", backup->directory); + return length > 0 && (size_t)length < sizeof(backup->path); +} + +static void finish_background_backup(background_backup_t *backup) { + if (backup->created) { + struct stat current; + /* Never recurse. Failed recovery leaves the original in this private + * directory for inspection; empty-directory retirement is best effort. */ + if (!backup->validated || backup->descriptor < 0 || lstat(backup->directory, ¤t) != 0 || + current.st_dev != backup->identity.st_dev || current.st_ino != backup->identity.st_ino || + rmdir(backup->directory) != 0) + log_warn("Floor-plan backup directory retained for inspection: %s", backup->directory); + } + if (backup->descriptor >= 0) close(backup->descriptor); + backup->descriptor = -1; + if (backup->parent >= 0) close(backup->parent); + if (backup->old_file >= 0) close(backup->old_file); + if (backup->upload_file >= 0) close(backup->upload_file); + backup->parent = backup->old_file = backup->upload_file = -1; +} + +static bool background_same_inode(const struct stat *a, const struct stat *b) { + return a->st_dev == b->st_dev && a->st_ino == b->st_ino; +} + +static int background_move_noreplace(int from, const char *source, int to, const char *destination) { +#if defined(__linux__) && defined(SYS_renameat2) + return (int)syscall(SYS_renameat2, from, source, to, destination, 1U); +#else + (void)from; (void)source; (void)to; (void)destination; + errno = ENOSYS; + return -1; +#endif +} + +/* 1 means a bound original, 0 means absent, -1 rejects without metadata change. */ +static int bind_background_original(const char *destination, const char *directory, + background_backup_t *backup) { + const char *leaf = strrchr(destination, '/'); + leaf = leaf ? leaf + 1 : destination; + if (backup->parent < 0) backup->parent = open(directory, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + struct stat parent_identity, named, original; + if (backup->parent < 0 || fstat(backup->parent, &parent_identity) != 0) return -1; +#ifdef O_PATH + backup->old_file = openat(backup->parent, leaf, O_PATH | O_NOFOLLOW | O_CLOEXEC); +#else + errno = ENOTSUP; + return -1; /* No safe special-file pinning facility on this platform. */ +#endif + if (backup->old_file < 0) return errno == ENOENT ? 0 : -1; + if (fstat(backup->old_file, &original) != 0 || !S_ISREG(original.st_mode)) return -1; + if (!backup->created && !create_background_backup(destination, backup)) return -1; + const char *private_leaf = strrchr(backup->directory, '/'); + private_leaf = private_leaf ? private_leaf + 1 : backup->directory; + if (lstat(directory, &named) != 0 || !background_same_inode(&parent_identity, &named) || + fstatat(backup->parent, private_leaf, &named, AT_SYMLINK_NOFOLLOW) != 0 || + !background_same_inode(&backup->identity, &named)) return -1; + if (background_move_noreplace(backup->parent, leaf, backup->descriptor, "original") != 0) return -1; + if (fstatat(backup->descriptor, "original", &named, AT_SYMLINK_NOFOLLOW) != 0 || + !S_ISREG(named.st_mode) || !background_same_inode(&original, &named) || + original.st_size != named.st_size || original.st_mtim.tv_sec != named.st_mtim.tv_sec || + original.st_mtim.tv_nsec != named.st_mtim.tv_nsec) { + if (background_move_noreplace(backup->descriptor, "original", backup->parent, leaf) != 0) + log_warn("Changed floor-plan entry retained in private backup: %s", backup->path); + return -1; + } + return 1; +} + +static bool background_private_matches(const background_backup_t *backup, const char *name, int fd) { + struct stat held, named; + return fd >= 0 && fstat(fd, &held) == 0 && S_ISREG(held.st_mode) && + fstatat(backup->descriptor, name, &named, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(named.st_mode) && background_same_inode(&held, &named); +} + +static bool background_remove_private(const background_backup_t *backup, const char *name, int fd) { + struct stat named; + if (fstatat(backup->descriptor, name, &named, AT_SYMLINK_NOFOLLOW) != 0) + return errno == ENOENT; + if (background_private_matches(backup, name, fd) && unlinkat(backup->descriptor, name, 0) == 0) + return true; + log_warn("Unknown or unavailable floor-plan private artifact retained: %s/%s", backup->directory, name); + return false; +} + +static bool background_parent_matches(const background_backup_t *backup, const char *directory) { + struct stat named; + return backup->parent >= 0 && lstat(directory, &named) == 0 && + S_ISDIR(named.st_mode) && background_same_inode(&backup->parent_identity, &named); +} + +static bool background_publication_matches(const background_backup_t *backup, const char *leaf, + const char *directory) { + struct stat held, named; + return background_parent_matches(backup, directory) && + fstat(backup->upload_file, &held) == 0 && + fstatat(backup->parent, leaf, &named, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(named.st_mode) && background_same_inode(&held, &named) && + held.st_size == backup->upload_identity.st_size && + held.st_mtim.tv_sec == backup->upload_identity.st_mtim.tv_sec && + held.st_mtim.tv_nsec == backup->upload_identity.st_mtim.tv_nsec; +} + +static bool restore_background_original(const background_backup_t *backup, const char *leaf) { + if (background_private_matches(backup, "original", backup->old_file) && + background_move_noreplace(backup->descriptor, "original", backup->parent, leaf) == 0) + return true; + log_warn("Floor-plan original retained after restoration conflict/failure: %s", backup->path); + return false; +} + +/* Bind a failed publication inside our private directory before deciding whether + * it is this request's upload. Never stat then unlink the public pathname. */ +static void recover_background_publication(const background_backup_t *backup, const char *leaf) { + if (background_move_noreplace(backup->parent, leaf, backup->descriptor, "failed-upload") != 0) { + if (errno != ENOENT) log_warn("Floor-plan failed publication retained at public entry: %s", leaf); + return; + } + if (background_private_matches(backup, "failed-upload", backup->upload_file)) { + background_remove_private(backup, "failed-upload", backup->upload_file); + } else if (background_move_noreplace(backup->descriptor, "failed-upload", backup->parent, leaf) != 0) { + log_warn("Changed floor-plan publication retained privately: %s/failed-upload", backup->directory); + } +} + static bool background_file_path(const char *uuid, const char *mime, char *path, size_t size) { const char *extension = background_extension(mime); @@ -902,24 +1062,39 @@ static void remove_background_files(const char *uuid) { pthread_mutex_unlock(&background_files_mutex); } -static bool write_background_temporary( - const char *destination, const unsigned char *body, size_t body_len, - char *temporary, size_t temporary_size) { - int length = snprintf(temporary, temporary_size, "%s.tmp.XXXXXX", - destination); - if (length < 0 || (size_t)length >= temporary_size) return false; - int descriptor = mkstemp(temporary); +static bool write_background_temporary(background_backup_t *backup, + const unsigned char *body, size_t body_len) { + int descriptor = -1; + for (unsigned attempt = 0; attempt < 8; ++attempt) { + char uuid[LIGHTNVR_UUID_STRING_SIZE]; + if (lightnvr_uuid_generate_v4(uuid) != 0) return false; + int length = snprintf(backup->upload_name, sizeof(backup->upload_name), "upload.tmp.%s", uuid); + if (length < 0 || (size_t)length >= sizeof(backup->upload_name)) return false; + descriptor = openat(backup->descriptor, backup->upload_name, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (descriptor >= 0 || errno != EEXIST) break; + } if (descriptor < 0) return false; + int held = fcntl(descriptor, F_DUPFD_CLOEXEC, 0); + if (held < 0) { + background_remove_private(backup, backup->upload_name, descriptor); + close(descriptor); + return false; + } FILE *file = fdopen(descriptor, "wb"); if (!file) { + background_remove_private(backup, backup->upload_name, held); close(descriptor); - unlink(temporary); + close(held); return false; } bool written = fwrite(body, 1, body_len, file) == body_len && fflush(file) == 0 && fsync(fileno(file)) == 0; if (fclose(file) != 0) written = false; - if (!written) unlink(temporary); + if (!written) { + background_remove_private(backup, backup->upload_name, held); + close(held); + } else backup->upload_file = held; return written; } @@ -1011,7 +1186,6 @@ void handle_put_operator_floor_plan_background(const http_request_t *req, } char directory[BACKGROUND_PATH_SIZE]; char path[BACKGROUND_PATH_SIZE]; - char temporary[BACKGROUND_PATH_SIZE + 16]; int directory_length = snprintf(directory, sizeof(directory), "%s/floor_plans", g_config.storage_path); if (directory_length < 0 || @@ -1024,69 +1198,61 @@ void handle_put_operator_floor_plan_background(const http_request_t *req, pthread_mutex_lock(&background_files_mutex); operator_floor_plan_t existing; - db_operator_floor_plan_result_t result = - db_operator_floor_plan_get(uuid, &existing); + db_operator_floor_plan_result_t result = db_operator_floor_plan_get(uuid, &existing); if (result != DB_OPERATOR_FLOOR_PLAN_OK) { pthread_mutex_unlock(&background_files_mutex); set_db_error(res, result); return; } - if (mkdir_recursive(directory) != 0 || - !write_background_temporary(path, req->body, req->body_len, - temporary, sizeof(temporary))) { - pthread_mutex_unlock(&background_files_mutex); - http_response_set_json_error(res, 500, - "Failed to store floor plan background"); - return; - } + background_backup_t backup = {.descriptor = -1, .parent = -1, .old_file = -1, .upload_file = -1}; + bool backed_up = false, published = false; + const char *leaf = strrchr(path, '/'); + leaf = leaf ? leaf + 1 : path; + if (mkdir_recursive(directory) != 0) goto background_failure; + backup.parent = open(directory, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (backup.parent < 0 || fstat(backup.parent, &backup.parent_identity) != 0 || + !create_background_backup(path, &backup) || !background_parent_matches(&backup, directory)) + goto background_failure; + if (!write_background_temporary(&backup, req->body, req->body_len)) goto background_failure; + if (!background_private_matches(&backup, backup.upload_name, backup.upload_file) || + fstat(backup.upload_file, &backup.upload_identity) != 0) goto background_failure; - // Preserve a same-type background until the metadata update succeeds, so - // an SQLite failure does not turn a valid existing background into a 404. - char backup[BACKGROUND_PATH_SIZE + 8]; - bool backed_up = false; - int backup_length = snprintf(backup, sizeof(backup), "%s.bak", path); - bool valid_backup_path = backup_length >= 0 && - (size_t)backup_length < sizeof(backup); - if (valid_backup_path) unlink(backup); - if (strcmp(existing.background_mime, mime) == 0 && valid_backup_path && - access(path, F_OK) == 0) { - if (rename(path, backup) != 0) { - unlink(temporary); - pthread_mutex_unlock(&background_files_mutex); - http_response_set_json_error( - res, 500, "Failed to store floor plan background"); - return; - } - backed_up = true; - } - if (rename(temporary, path) != 0) { - unlink(temporary); - if (backed_up) rename(backup, path); - pthread_mutex_unlock(&background_files_mutex); - http_response_set_json_error(res, 500, - "Failed to store floor plan background"); - return; - } + if (strcmp(existing.background_mime, mime) == 0) { + int bound = bind_background_original(path, directory, &backup); + if (bound < 0) goto background_failure; + backed_up = bound > 0; + } + if (!background_parent_matches(&backup, directory) || + background_move_noreplace(backup.descriptor, backup.upload_name, backup.parent, leaf) != 0) + goto background_failure; + published = true; + if (fsync(backup.parent) != 0 || !background_publication_matches(&backup, leaf, directory)) + goto background_failure; + /* Filesystem identity and SQLite metadata cannot be committed atomically. + * This final snapshot detects changes already visible before the DB call. */ result = db_operator_floor_plan_set_background(uuid, mime); - if (result != DB_OPERATOR_FLOOR_PLAN_OK) { - unlink(path); - if (backed_up) rename(backup, path); - pthread_mutex_unlock(&background_files_mutex); - set_db_error(res, result); - return; - } - if (backed_up) unlink(backup); + if (result != DB_OPERATOR_FLOOR_PLAN_OK) goto background_failure; + if (backed_up) background_remove_private(&backup, "original", backup.old_file); char obsolete[BACKGROUND_PATH_SIZE]; - const char *other = strcmp(mime, "image/png") == 0 - ? "image/jpeg" : "image/png"; - if (background_file_path(uuid, other, obsolete, sizeof(obsolete))) { - unlink(obsolete); - } + const char *other = strcmp(mime, "image/png") == 0 ? "image/jpeg" : "image/png"; + if (background_file_path(uuid, other, obsolete, sizeof(obsolete))) unlink(obsolete); + finish_background_backup(&backup); pthread_mutex_unlock(&background_files_mutex); cJSON *json = load_one_json(uuid, &user, res); if (!json) return; send_json(res, 200, json); cJSON_Delete(json); + return; + +background_failure: + if (published) recover_background_publication(&backup, leaf); + bool restored = !backed_up || restore_background_original(&backup, leaf); + if (restored && backup.upload_name[0]) + background_remove_private(&backup, backup.upload_name, backup.upload_file); + finish_background_backup(&backup); + pthread_mutex_unlock(&background_files_mutex); + if (result != DB_OPERATOR_FLOOR_PLAN_OK) set_db_error(res, result); + else http_response_set_json_error(res, 500, "Failed to store floor plan background"); } void handle_delete_operator_floor_plan_background(const http_request_t *req, diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 7bcee145f..72fe73e43 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -139,6 +139,9 @@ target_sources(test_stream_frame_rate PRIVATE # ==================================================================== add_layer2_test(test_config) add_layer2_test(test_db_core_initialization) +add_layer2_test(test_floor_plan_background_publication) +target_link_options(test_floor_plan_background_publication PRIVATE -Wl,--wrap=mkstemp -Wl,--wrap=openat -Wl,--wrap=fstat -Wl,--wrap=syscall -Wl,--wrap=mkdtemp -Wl,--wrap=access -Wl,--wrap=rename -Wl,--wrap=db_operator_floor_plan_set_background) +set_tests_properties(test_floor_plan_background_publication PROPERTIES TIMEOUT 30) add_layer2_test(test_logger) add_layer2_test(test_strings) add_layer2_test(test_interruptible_sleep) diff --git a/tests/unit/test_floor_plan_background_publication.c b/tests/unit/test_floor_plan_background_publication.c new file mode 100644 index 000000000..efa2049a6 --- /dev/null +++ b/tests/unit/test_floor_plan_background_publication.c @@ -0,0 +1,456 @@ +#define _GNU_SOURCE +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "unity.h" +#include "core/config.h" +#include "core/path_utils.h" +#include "database/db_core.h" +#include "database/db_operator_floor_plans.h" +#include "utils/strings.h" +#include "web/api_handlers_operator_floor_plans.h" +#include "web/request_response.h" + +static char root[128], storage[256], database[256]; +static char path[512], backup[520], saved[256], sentinel[256], uploaded[256]; +static char endpoint[128], uuid[CAMERA_UUID_STRING_SIZE]; +static char original_storage[MAX_PATH_LENGTH]; +static bool armed_access, injected, fail_publication, publication_competitor; +static bool fail_metadata, metadata_competitor; +static int replacement_kind, binding_error, publication_race_kind, publication_error, restore_error; +static char observed_upload[1024], observed_quarantine[1024]; +static int recovery_error; +static bool recovery_restore_collision, replace_upload_directory; +static char displaced_private[256], replaced_private[1024]; +static bool fail_backup_creation, tamper_backup_directory, mismatch_restore_collision; +static char altered_directory[1024]; +static char observed_backup[1024]; +static const unsigned char old_png[] = {0x89,'P','N','G',13,10,26,10,'o','l','d'}; +static const unsigned char new_png[] = {0x89,'P','N','G',13,10,26,10,'n','e','w'}; + +static void put_bytes(const char *name, const void *data, size_t size) { + TEST_ASSERT_EQUAL_INT(0, ensure_path(name)); + FILE *file = fopen(name, "wb"); TEST_ASSERT_NOT_NULL(file); + size_t count = fwrite(data, 1, size, file); int result = fclose(file); + TEST_ASSERT_EQUAL_UINT(size, count); TEST_ASSERT_EQUAL_INT(0, result); +} +static void put_text(const char *name, const char *text) { put_bytes(name, text, strlen(text)); } +static void assert_bytes(const char *name, const void *expected, size_t size) { + unsigned char data[128]; FILE *file = fopen(name, "rb"); TEST_ASSERT_NOT_NULL(file); + size_t count = fread(data, 1, sizeof(data), file); int result = fclose(file); + TEST_ASSERT_EQUAL_INT(0, result); TEST_ASSERT_EQUAL_UINT(size, count); + TEST_ASSERT_EQUAL_MEMORY(expected, data, size); +} +static void assert_text(const char *name, const char *text) { assert_bytes(name, text, strlen(text)); } +static bool before_upload_publication(void) { + if (publication_race_kind) { + injected = true; + if (publication_race_kind == 1) put_text(path, "publication competitor"); + else TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, path)); + publication_race_kind = 0; + } + if (fail_publication || publication_error) { + fail_publication = false; injected = true; + if (publication_competitor) put_text(path, "publication rollback competitor"); + errno = publication_error ? publication_error : ENOSPC; + return false; + } + return true; +} +int __real_access(const char *, int); +int __real_rename(const char *, const char *); +static void replace_inspected_background(void) { + armed_access = false; injected = true; + TEST_ASSERT_EQUAL_INT(0, __real_rename(path, saved)); + if (replacement_kind == 1) put_text(path, "unrelated replacement"); + else TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, path)); +} +int __wrap_access(const char *name, int mode) { + int result = __real_access(name, mode); int saved_errno = errno; + if (armed_access && !strcmp(name, path) && result == 0) replace_inspected_background(); + errno = saved_errno; return result; +} +int __real_fstat(int, struct stat *); +int __wrap_fstat(int fd, struct stat *info) { + int result = __real_fstat(fd, info); int saved_errno = errno; + if (armed_access && result == 0 && S_ISREG(info->st_mode)) { + char proc[64], name[1024]; snprintf(proc, sizeof(proc), "/proc/self/fd/%d", fd); + ssize_t size = readlink(proc, name, sizeof(name)-1); + if (size > 0) { name[size] = 0; if (!strcmp(name, path)) replace_inspected_background(); } + } + errno = saved_errno; return result; +} +long __real_syscall(long, ...); +long __wrap_syscall(long number, ...) { + if (number != SYS_renameat2) { TEST_FAIL_MESSAGE("Unexpected background syscall"); errno = ENOSYS; return -1; } + va_list args; va_start(args, number); + int from = va_arg(args, int); const char *oldname = va_arg(args, const char *); + int to = va_arg(args, int); const char *newname = va_arg(args, const char *); + unsigned flags = va_arg(args, unsigned); va_end(args); + if (!strcmp(newname, "original")) { + char proc[64], name[1024]; snprintf(proc, sizeof(proc), "/proc/self/fd/%d", to); + ssize_t size = readlink(proc, name, sizeof(name)-1); TEST_ASSERT_TRUE(size > 0); name[size] = 0; + int length = snprintf(observed_backup, sizeof(observed_backup), "%s/original", name); + TEST_ASSERT_TRUE(length > 0 && (size_t)length < sizeof(observed_backup)); + if (binding_error) { errno = binding_error; return -1; } + } + if (strstr(oldname, ".tmp.") && !strcmp(newname, strrchr(path, '/') + 1)) { + char proc[64], directory[1024]; snprintf(proc, sizeof(proc), "/proc/self/fd/%d", from); + ssize_t size = readlink(proc, directory, sizeof(directory)-1); TEST_ASSERT_TRUE(size > 0); directory[size] = 0; + int length = snprintf(observed_upload, sizeof(observed_upload), "%s/%s", directory, oldname); + TEST_ASSERT_TRUE(length > 0 && (size_t)length < sizeof(observed_upload)); + if (!before_upload_publication()) return -1; + } + if (!strcmp(newname, "failed-upload")) { + char proc[64], directory[1024]; snprintf(proc, sizeof(proc), "/proc/self/fd/%d", to); + ssize_t size = readlink(proc, directory, sizeof(directory)-1); TEST_ASSERT_TRUE(size > 0); directory[size] = 0; + int length = snprintf(observed_quarantine, sizeof(observed_quarantine), "%s/failed-upload", directory); + TEST_ASSERT_TRUE(length > 0 && (size_t)length < sizeof(observed_quarantine)); + if (recovery_error) { errno = recovery_error; return -1; } + } + if (recovery_restore_collision && !strcmp(oldname, "failed-upload")) + put_text(path, "recovery restore competitor"); + if (restore_error && !strcmp(oldname, "original") && strcmp(newname, "original")) { + errno = restore_error; return -1; + } + if (mismatch_restore_collision && !strcmp(oldname, "original") && strcmp(newname, "original")) + put_text(path, "identity restore competitor"); + return __real_syscall(number, from, oldname, to, newname, flags); +} +static void substitute_upload_directory(const char *directory) { + replace_upload_directory = false; injected = true; + safe_strcpy(replaced_private, directory, sizeof(replaced_private), 0); + TEST_ASSERT_EQUAL_INT(0, __real_rename(directory, displaced_private)); + TEST_ASSERT_EQUAL_INT(0, mkdir(directory, 0700)); + char marker[1200]; int length = snprintf(marker, sizeof(marker), "%s/marker", directory); + TEST_ASSERT_TRUE(length > 0 && (size_t)length < sizeof(marker)); + put_text(marker, "replacement directory marker"); +} +int __real_mkstemp(char *); +int __wrap_mkstemp(char *name) { + if (replace_upload_directory && strstr(name, "/upload.tmp.")) { + char directory[1024]; safe_strcpy(directory, name, sizeof(directory), 0); + *strrchr(directory, '/') = 0; substitute_upload_directory(directory); + } + return __real_mkstemp(name); +} +int __real_openat(int, const char *, int, ...); +int __wrap_openat(int fd, const char *name, int flags, ...) { + mode_t mode = 0; + if (flags & O_CREAT) { va_list args; va_start(args, flags); mode = (mode_t)va_arg(args, int); va_end(args); } + if (replace_upload_directory && !strncmp(name, "upload.tmp.", 11) && (flags & O_CREAT)) { + char proc[64], directory[1024]; snprintf(proc, sizeof(proc), "/proc/self/fd/%d", fd); + ssize_t size = readlink(proc, directory, sizeof(directory)-1); TEST_ASSERT_TRUE(size > 0); directory[size] = 0; + substitute_upload_directory(directory); + } + return flags & O_CREAT ? __real_openat(fd, name, flags, mode) : __real_openat(fd, name, flags); +} +char *__real_mkdtemp(char *); +char *__wrap_mkdtemp(char *name) { + if (fail_backup_creation && strstr(name, ".backup.")) { errno = EACCES; return NULL; } + char *result = __real_mkdtemp(name); + if (result && tamper_backup_directory && strstr(name, ".backup.")) { + TEST_ASSERT_EQUAL_INT(0, chmod(name, 0755)); + safe_strcpy(altered_directory, name, sizeof(altered_directory), 0); + } + return result; +} +int __wrap_rename(const char *from, const char *to) { + if (!strcmp(from, path) && (strstr(to, ".bak") || strstr(to, "/original"))) + safe_strcpy(observed_backup, to, sizeof(observed_backup), 0); + if (!strcmp(to, path) && strstr(from, ".tmp.")) { + safe_strcpy(observed_upload, from, sizeof(observed_upload), 0); + if (!before_upload_publication()) return -1; + } + if (restore_error && !strcmp(from, observed_backup) && !strcmp(to, path)) { + errno = restore_error; return -1; + } + return __real_rename(from, to); +} +db_operator_floor_plan_result_t __real_db_operator_floor_plan_set_background(const char *, const char *); +db_operator_floor_plan_result_t __wrap_db_operator_floor_plan_set_background(const char *id, const char *mime) { + if (fail_metadata && mime && !strcmp(id, uuid)) { + fail_metadata = false; injected = true; + if (metadata_competitor) { + TEST_ASSERT_EQUAL_INT(0, __real_rename(path, uploaded)); + put_text(path, "metadata rollback competitor"); + } + return DB_OPERATOR_FLOOR_PLAN_ERROR; + } + return __real_db_operator_floor_plan_set_background(id, mime); +} +static void remove_owned_tree(const char *directory) { + DIR *dir = opendir(directory); if (!dir) return; + struct dirent *entry; + while ((entry = readdir(dir))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + char name[1024]; int size = snprintf(name, sizeof(name), "%s/%s", directory, entry->d_name); + if (size < 0 || (size_t)size >= sizeof(name)) continue; + struct stat info; + if (lstat(name, &info) == 0 && S_ISDIR(info.st_mode)) remove_owned_tree(name); + else unlink(name); + } + closedir(dir); rmdir(directory); +} +static int upload_background(void) { + http_request_t request; http_response_t response; + http_request_init(&request); http_response_init(&response); + request.method = HTTP_METHOD_PUT; + safe_strcpy(request.path, endpoint, sizeof(request.path), 0); + strcpy(request.client_ip, "127.0.0.1"); + request.body = (void *)new_png; request.body_len = sizeof(new_png); + strcpy(request.headers[0].name, "Content-Type"); + strcpy(request.headers[0].value, "image/png"); request.num_headers = 1; + handle_put_operator_floor_plan_background(&request, &response); + int status = response.status_code; + http_response_free(&response); /* Release before assertions can longjmp. */ + return status; +} +static void assert_metadata_unchanged(void) { + operator_floor_plan_t plan; + TEST_ASSERT_EQUAL_INT(DB_OPERATOR_FLOOR_PLAN_OK, db_operator_floor_plan_get(uuid, &plan)); + TEST_ASSERT_EQUAL_STRING("image/png", plan.background_mime); + TEST_ASSERT_EQUAL_INT64(1, plan.revision); +} +void setUp(void) { + armed_access = injected = fail_publication = publication_competitor = false; + fail_metadata = metadata_competitor = fail_backup_creation = false; replacement_kind = binding_error = publication_race_kind = publication_error = restore_error = 0; observed_upload[0] = observed_quarantine[0] = 0; recovery_error = 0; recovery_restore_collision = replace_upload_directory = false; replaced_private[0] = 0; observed_backup[0] = 0; tamper_backup_directory = mismatch_restore_collision = false; altered_directory[0] = 0; + g_config.web_auth_enabled = false; g_config.demo_mode = false; + safe_strcpy(g_config.storage_path, storage, sizeof(g_config.storage_path), 0); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec(get_db_handle(), "DELETE FROM operator_floor_plans;", NULL, NULL, NULL)); + remove_owned_tree(storage); remove_owned_tree(displaced_private); TEST_ASSERT_EQUAL_INT(0, mkdir(storage, 0700)); + unlink(saved); unlink(sentinel); unlink(uploaded); + operator_floor_plan_t plan = {0}; + strcpy(plan.name, "Private floor plan"); plan.canvas_width = 1000; plan.canvas_height = 700; + TEST_ASSERT_EQUAL_INT(DB_OPERATOR_FLOOR_PLAN_OK, db_operator_floor_plan_create(&plan, NULL, 0, NULL)); + strcpy(uuid, plan.uuid); + TEST_ASSERT_EQUAL_INT(DB_OPERATOR_FLOOR_PLAN_OK, db_operator_floor_plan_set_background(uuid, "image/png")); + snprintf(path, sizeof(path), "%s/floor_plans/%s.png", storage, uuid); + snprintf(backup, sizeof(backup), "%s.bak", path); + snprintf(endpoint, sizeof(endpoint), "/api/live/plans/%s/background", uuid); + put_bytes(path, old_png, sizeof(old_png)); put_text(sentinel, "sentinel unchanged"); +} +void tearDown(void) { + armed_access = fail_publication = fail_metadata = false; + g_config.web_auth_enabled = false; + safe_strcpy(g_config.storage_path, original_storage, sizeof(g_config.storage_path), 0); +} +static void test_normal_same_type_replacement_updates_bytes(void) { + TEST_ASSERT_EQUAL_INT(200, upload_background()); + assert_bytes(path, new_png, sizeof(new_png)); assert_metadata_unchanged(); + TEST_ASSERT_EQUAL_INT(-1, access(backup, F_OK)); +} +static void test_publication_failure_restores_original_without_competitor(void) { + fail_publication = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_metadata_failure_restores_original_without_competitor(void) { + fail_metadata = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_replacement_after_access_is_not_backed_up_and_deleted(void) { + armed_access = true; replacement_kind = 1; + int status = upload_background(); TEST_ASSERT_TRUE(injected); + assert_bytes(saved, old_png, sizeof(old_png)); + assert_text(path, "unrelated replacement"); + TEST_ASSERT_TRUE(status >= 400); assert_metadata_unchanged(); +} +static void test_symlink_after_access_is_preserved_without_touching_target(void) { + armed_access = true; replacement_kind = 2; + int status = upload_background(); TEST_ASSERT_TRUE(injected); + assert_text(sentinel, "sentinel unchanged"); assert_bytes(saved, old_png, sizeof(old_png)); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(path, &info)); TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); + TEST_ASSERT_TRUE(status >= 400); assert_metadata_unchanged(); +} +static void test_publication_rollback_does_not_overwrite_competitor(void) { + fail_publication = publication_competitor = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_text(path, "publication rollback competitor"); + TEST_ASSERT_TRUE(observed_backup[0] != 0); + assert_bytes(observed_backup, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_metadata_rollback_does_not_unlink_competitor(void) { + fail_metadata = metadata_competitor = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_text(path, "metadata rollback competitor"); + TEST_ASSERT_TRUE(observed_backup[0] != 0); + assert_bytes(observed_backup, old_png, sizeof(old_png)); + assert_bytes(uploaded, new_png, sizeof(new_png)); assert_metadata_unchanged(); +} +static void test_preexisting_backup_is_not_silently_deleted(void) { + put_text(backup, "unknown preexisting backup"); + int status = upload_background(); + assert_text(backup, "unknown preexisting backup"); + TEST_ASSERT_TRUE(status == 200 || status >= 400); +} +static void test_unauthenticated_request_preserves_existing_file(void) { + g_config.web_auth_enabled = true; + TEST_ASSERT_EQUAL_INT(401, upload_background()); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_preexisting_backup_symlink_is_preserved(void) { + TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, backup)); + TEST_ASSERT_EQUAL_INT(200, upload_background()); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(backup, &info)); + TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); assert_text(sentinel, "sentinel unchanged"); + assert_bytes(path, new_png, sizeof(new_png)); +} +static void test_private_backup_creation_failure_preserves_old_file(void) { + fail_backup_creation = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_invalid_private_directory_is_retained_without_touching_background(void) { + tamper_backup_directory = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); + struct stat info; TEST_ASSERT_TRUE(altered_directory[0] != 0); + TEST_ASSERT_EQUAL_INT(0, lstat(altered_directory, &info)); + TEST_ASSERT_TRUE(S_ISDIR(info.st_mode)); +} +static void test_initial_upload_without_old_background_succeeds(void) { + TEST_ASSERT_EQUAL_INT(0, unlink(path)); + TEST_ASSERT_EQUAL_INT(DB_OPERATOR_FLOOR_PLAN_OK, db_operator_floor_plan_set_background(uuid, NULL)); + TEST_ASSERT_EQUAL_INT(200, upload_background()); + assert_bytes(path, new_png, sizeof(new_png)); assert_metadata_unchanged(); +} +static void test_preexisting_symlink_background_is_rejected(void) { + TEST_ASSERT_EQUAL_INT(0, unlink(path)); TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, path)); + TEST_ASSERT_EQUAL_INT(500, upload_background()); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(path, &info)); TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); + assert_text(sentinel, "sentinel unchanged"); assert_metadata_unchanged(); +} +static void test_unsupported_old_file_binding_preserves_background(void) { + binding_error = ENOSYS; + TEST_ASSERT_EQUAL_INT(500, upload_background()); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_identity_restore_collision_preserves_all_entries(void) { + armed_access = true; replacement_kind = 1; mismatch_restore_collision = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_bytes(saved, old_png, sizeof(old_png)); + assert_text(path, "identity restore competitor"); + assert_text(observed_backup, "unrelated replacement"); assert_metadata_unchanged(); +} +static void test_upload_publication_does_not_overwrite_new_competitor(void) { + publication_race_kind = 1; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_text(path, "publication competitor"); + assert_bytes(observed_backup, old_png, sizeof(old_png)); + assert_bytes(observed_upload, new_png, sizeof(new_png)); assert_metadata_unchanged(); +} +static void test_upload_publication_preserves_competing_symlink(void) { + publication_race_kind = 2; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(path, &info)); TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); + assert_text(sentinel, "sentinel unchanged"); + assert_bytes(observed_backup, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_unsupported_publication_restores_old_background(void) { + publication_error = ENOSYS; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_unsupported_restore_retains_original_private_backup(void) { + fail_publication = true; restore_error = ENOSYS; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + TEST_ASSERT_EQUAL_INT(-1, access(path, F_OK)); + assert_bytes(observed_backup, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_failed_recovery_preserves_upload_and_original(void) { + fail_metadata = true; recovery_error = ENOSYS; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_bytes(path, new_png, sizeof(new_png)); + assert_bytes(observed_backup, old_png, sizeof(old_png)); assert_metadata_unchanged(); +} +static void test_changed_upload_restore_collision_preserves_every_entry(void) { + fail_metadata = metadata_competitor = recovery_restore_collision = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_text(path, "recovery restore competitor"); + assert_text(observed_quarantine, "metadata rollback competitor"); + assert_bytes(observed_backup, old_png, sizeof(old_png)); + assert_bytes(uploaded, new_png, sizeof(new_png)); assert_metadata_unchanged(); +} +static int descriptor_count(void) { + DIR *directory = opendir("/proc/self/fd"); TEST_ASSERT_NOT_NULL(directory); + int count = 0; struct dirent *entry; + while ((entry = readdir(directory))) if (strcmp(entry->d_name, ".") && strcmp(entry->d_name, "..")) ++count; + closedir(directory); return count; +} +static void test_repeated_ordinary_failure_paths_release_descriptors(void) { + int before = descriptor_count(); + for (int index = 0; index < 9; ++index) { + binding_error = index % 3 == 0 ? ENOSYS : 0; + publication_error = index % 3 == 1 ? ENOSYS : 0; + fail_metadata = index % 3 == 2; + TEST_ASSERT_EQUAL_INT(500, upload_background()); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); + } + TEST_ASSERT_EQUAL_INT(before, descriptor_count()); +} +static void test_upload_creation_uses_held_private_directory(void) { + replace_upload_directory = true; + TEST_ASSERT_EQUAL_INT(500, upload_background()); TEST_ASSERT_TRUE(injected); + assert_bytes(path, old_png, sizeof(old_png)); assert_metadata_unchanged(); + DIR *directory = opendir(replaced_private); TEST_ASSERT_NOT_NULL(directory); + int unexpected = 0; struct dirent *entry; + while ((entry = readdir(directory))) + if (strcmp(entry->d_name, ".") && strcmp(entry->d_name, "..") && strcmp(entry->d_name, "marker")) ++unexpected; + closedir(directory); + TEST_ASSERT_EQUAL_INT(0, unexpected); + char marker[1200]; snprintf(marker, sizeof(marker), "%s/marker", replaced_private); + assert_text(marker, "replacement directory marker"); +} +int main(void) { + strcpy(root, "/tmp/lightnvr-floor-plan-background-publication-XXXXXX"); if (!mkdtemp(root)) return 2; + snprintf(storage, sizeof(storage), "%s/storage", root); + snprintf(database, sizeof(database), "%s/database.sqlite", root); + snprintf(saved, sizeof(saved), "%s/original", root); + snprintf(uploaded, sizeof(uploaded), "%s/uploaded", root); + snprintf(displaced_private, sizeof(displaced_private), "%s/displaced-private", root); + snprintf(sentinel, sizeof(sentinel), "%s/sentinel", root); + load_default_config(&g_config); + safe_strcpy(original_storage, g_config.storage_path, sizeof(original_storage), 0); + if (init_database_ex(database, DB_INIT_NO_BACKUP) != 0) { + free(g_config.streams); remove_owned_tree(root); return 2; + } + UNITY_BEGIN(); + RUN_TEST(test_normal_same_type_replacement_updates_bytes); + RUN_TEST(test_publication_failure_restores_original_without_competitor); + RUN_TEST(test_metadata_failure_restores_original_without_competitor); + RUN_TEST(test_replacement_after_access_is_not_backed_up_and_deleted); + RUN_TEST(test_symlink_after_access_is_preserved_without_touching_target); + RUN_TEST(test_publication_rollback_does_not_overwrite_competitor); + RUN_TEST(test_metadata_rollback_does_not_unlink_competitor); + RUN_TEST(test_preexisting_backup_is_not_silently_deleted); + RUN_TEST(test_unauthenticated_request_preserves_existing_file); + RUN_TEST(test_preexisting_backup_symlink_is_preserved); + RUN_TEST(test_private_backup_creation_failure_preserves_old_file); + RUN_TEST(test_invalid_private_directory_is_retained_without_touching_background); + RUN_TEST(test_initial_upload_without_old_background_succeeds); + RUN_TEST(test_preexisting_symlink_background_is_rejected); + RUN_TEST(test_unsupported_old_file_binding_preserves_background); + RUN_TEST(test_identity_restore_collision_preserves_all_entries); + RUN_TEST(test_upload_publication_does_not_overwrite_new_competitor); + RUN_TEST(test_upload_publication_preserves_competing_symlink); + RUN_TEST(test_unsupported_publication_restores_old_background); + RUN_TEST(test_unsupported_restore_retains_original_private_backup); + RUN_TEST(test_failed_recovery_preserves_upload_and_original); + RUN_TEST(test_changed_upload_restore_collision_preserves_every_entry); + RUN_TEST(test_repeated_ordinary_failure_paths_release_descriptors); + RUN_TEST(test_upload_creation_uses_held_private_directory); + int result = UNITY_END(); + shutdown_database(); free(g_config.streams); g_config.streams = NULL; + remove_owned_tree(root); return result; +} From c914cfb30e9a5af105d687816c5ca6b2fc5889ea Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 31 Oct 2026 10:47:45 -0400 Subject: [PATCH 2/2] Select CI tests through CMake suite membership and browser discovery --- .github/workflows/integration-test.yml | 77 +--------- .github/workflows/sanitizer.yml | 3 +- tests/CMakeLists.txt | 3 + tests/cmake/CiSuites.cmake | 30 ++++ tests/unit/CMakeLists.txt | 137 +++++++++--------- tests/unit/cmake/system_health/startup.cmake | 2 +- ...=> bulkTagsReconciliation.browser.spec.js} | 2 +- ....js => zoneEditorCheckbox.browser.spec.js} | 2 +- 8 files changed, 112 insertions(+), 144 deletions(-) create mode 100644 tests/cmake/CiSuites.cmake rename web/tests/e2e/{bulkTagsReconciliation.spec.js => bulkTagsReconciliation.browser.spec.js} (99%) rename web/tests/e2e/{zoneEditorCheckbox.spec.js => zoneEditorCheckbox.browser.spec.js} (99%) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index f867fd467..caad54018 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -75,79 +75,14 @@ jobs: -DENABLE_MQTT=OFF \ -DCMAKE_C_FLAGS="--coverage -fprofile-update=atomic" \ -DCMAKE_EXE_LINKER_FLAGS="--coverage" - make -j$(nproc) \ - test_storage_pressure \ - test_storage_pressure_extended \ - test_detection_result_structures \ - test_stream_frame_rate \ - test_recording_video_params \ - test_mp4_segment_boundaries \ - test_storage_retention_sqlite \ - test_storage_manager_retention \ - test_storage_target_pressure_cleanup \ - test_storage_migration \ - test_storage_archive \ - test_authorization \ - test_api_handlers_recordings_playback \ - test_db_storage_targets \ - test_db_storage_policies \ - test_api_handlers_storage_targets \ - test_api_handlers_storage_policies \ - test_sqlite_migration_boundaries \ - test_config \ - test_logger \ - test_strings \ - test_memory \ - test_request_response \ - test_shutdown_coordinator \ - test_system_health_startup \ - test_detection_config \ - test_detection_model_motion \ - test_db_streams \ - test_db_camera_favorites \ - test_api_handlers_camera_favorites \ - test_db_backup \ - test_mp4_failed_open_memory \ - test_db_recordings_extended \ - test_db_detections \ - test_db_zones \ - test_db_events \ - test_db_auth \ - test_db_transactions \ - test_db_maintenance \ - test_db_query_builder \ - test_logger_json \ - test_batch_delete_progress \ - test_db_recordings_sync \ - test_httpd_utils \ - test_zone_filter \ - test_onvif_soap_fault \ - test_faac_encoder \ - test_audio_transcoder \ - test_mp4_audio_recovery \ - test_mp4_forced_reconnect \ - test_mp4_recovery_watchdog \ - test_audio_voice_enhancement \ - test_audio_voice_filter_faults \ - test_udt_recording \ - test_detection_current_frame \ - test_detection_io_recovery \ - test_hls_cleanup \ - test_stream_manager \ - test_stream_state \ - test_packet_buffer \ - test_timestamp_manager \ - test_motion_trigger_parse \ - test_motion_max_area \ - test_url_utils + cmake --build . --parallel "$(nproc)" --target ci-unit-tests echo "Unity test binaries built:" ls -la bin/test_* - name: Run Unity tests run: | cd build - ctest --output-on-failure -V \ - -R "test_sqlite_migration_boundaries|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_camera_favorites|test_api_handlers_camera_favorites|test_db_backup|test_mp4_failed_open_memory|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_faac_encoder|test_audio_transcoder|test_mp4_audio_recovery|test_mp4_forced_reconnect|test_mp4_recovery_watchdog|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_hls_cleanup|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils|test_system_health_startup$" + ctest --output-on-failure --no-tests=error -V -L '^ci-unit$' - name: Generate coverage report if: always() @@ -606,13 +541,9 @@ jobs: npm ci npx playwright install --with-deps chromium - - name: Bulk tag reconciliation regression - if: ${{ matrix.shard == 1 }} - run: npm --prefix web run test:e2e -- --testPathPatterns=bulkTagsReconciliation - - - name: Zone Editor checkbox regression + - name: Component browser regressions if: ${{ matrix.shard == 1 }} - run: npm --prefix web run test:e2e -- --testPathPatterns=zoneEditorCheckbox + run: npm --prefix web run test:e2e -- --testPathPatterns='\.browser\.spec\.js$' - name: Run Playwright UI tests run: | diff --git a/.github/workflows/sanitizer.yml b/.github/workflows/sanitizer.yml index 5768c9a2c..7183485c6 100644 --- a/.github/workflows/sanitizer.yml +++ b/.github/workflows/sanitizer.yml @@ -59,8 +59,7 @@ jobs: run: | cd build echo "Running tests with ASan + UBSan enabled..." - ctest --output-on-failure -V \ - -R "test_sqlite_migration_boundaries|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_faac_encoder|test_audio_transcoder|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_audit_log|test_audit_summary" + ctest --output-on-failure --no-tests=error -V -L '^ci-sanitizer$' - name: Smoke test - start and stop service run: | diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6f5d89199..798e29b0e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,7 @@ # Tests CMakeLists.txt +include(cmake/CiSuites.cmake) + # Get the ENABLE_SOD option from parent if(NOT DEFINED ENABLE_SOD) option(ENABLE_SOD "Enable SOD library for object detection" ON) @@ -136,6 +138,7 @@ set_target_properties(test_db_backup # Add database backup test to CTest add_test(NAME test_db_backup COMMAND test_db_backup) +lightnvr_ci_test(test_db_backup CI_UNIT) # Add stream detection test add_executable(test_stream_detection test_stream_detection.c) diff --git a/tests/cmake/CiSuites.cmake b/tests/cmake/CiSuites.cmake new file mode 100644 index 000000000..811d1cfc0 --- /dev/null +++ b/tests/cmake/CiSuites.cmake @@ -0,0 +1,30 @@ +# CI suite membership lives beside each test registration. For example: +# add_layer2_test(test_example CI_UNIT CI_SANITIZER) +# Both the aggregate build target and CTest label come from that declaration. +add_custom_target(ci-unit-tests) +add_custom_target(ci-sanitizer-tests) + +function(lightnvr_ci_test TEST_TARGET) + cmake_parse_arguments(ARG "CI_UNIT;CI_SANITIZER" "" "TESTS" ${ARGN}) + if(ARG_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "Unknown CI test options: ${ARG_UNPARSED_ARGUMENTS}") + endif() + if(NOT TARGET ${TEST_TARGET}) + message(FATAL_ERROR "Missing CI test target: ${TEST_TARGET}") + endif() + if(NOT ARG_TESTS) + set(ARG_TESTS ${TEST_TARGET}) + endif() + foreach(SUITE IN ITEMS UNIT SANITIZER) + if(ARG_CI_${SUITE}) + string(TOLOWER "${SUITE}" SUITE_LABEL) + add_dependencies(ci-${SUITE_LABEL}-tests ${TEST_TARGET}) + foreach(TEST_NAME IN LISTS ARG_TESTS) + if(NOT TEST ${TEST_NAME}) + message(FATAL_ERROR "Missing CI test registration: ${TEST_NAME}") + endif() + set_property(TEST ${TEST_NAME} APPEND PROPERTY LABELS ci-${SUITE_LABEL}) + endforeach() + endif() + endforeach() +endfunction() diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 684667ba7..2a8391a96 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -20,6 +20,7 @@ macro(add_layer1_test TEST_NAME) target_link_libraries(${TEST_NAME} unity_lib) set_target_properties(${TEST_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + lightnvr_ci_test(${TEST_NAME} ${ARGN}) endmacro() macro(add_layer2_test TEST_NAME) @@ -53,8 +54,11 @@ macro(add_layer2_test TEST_NAME) ${CMAKE_CURRENT_SOURCE_DIR}/s3_fixture.py $) add_test(NAME test_storage_archive_operator COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_archive_operator.py) + lightnvr_ci_test(${TEST_NAME} ${ARGN} + TESTS test_storage_archive_s3 test_storage_archive_operator) else() add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + lightnvr_ci_test(${TEST_NAME} ${ARGN}) endif() endmacro() @@ -65,14 +69,14 @@ target_sources(test_hls_cache_cleanup PRIVATE # Variant for Layer 2 tests that require CURL macro(add_layer2_test_with_curl TEST_NAME) - add_layer2_test(${TEST_NAME}) + add_layer2_test(${TEST_NAME} ${ARGN}) target_link_libraries(${TEST_NAME} ${CURL_LIBRARIES}) endmacro() # Convenience wrappers for Layer 2 tests with common external deps. macro(add_layer2_test_with_ffmpeg TEST_NAME) - add_layer2_test(${TEST_NAME}) + add_layer2_test(${TEST_NAME} ${ARGN}) target_link_libraries(${TEST_NAME} ${FFMPEG_LIBRARIES}) endmacro() @@ -108,6 +112,7 @@ macro(add_layer3_test TEST_NAME) endif() set_target_properties(${TEST_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + lightnvr_ci_test(${TEST_NAME} ${ARGN}) endmacro() # ==================================================================== @@ -116,42 +121,42 @@ endmacro() # disk_pressure_level_str() and evaluate_disk_pressure_level() are # both static inline in storage_manager.h, so no .c files are linked. # ==================================================================== -add_layer1_test(test_storage_pressure) +add_layer1_test(test_storage_pressure CI_UNIT CI_SANITIZER) # ==================================================================== # Layer 2: test_storage_retention_sqlite # Integration test — runs real SQL queries via db_core / db_recordings. # ==================================================================== -add_layer2_test(test_storage_retention_sqlite) +add_layer2_test(test_storage_retention_sqlite CI_UNIT CI_SANITIZER) # ==================================================================== # Layer 1: Pure logic tests — unity_lib + project headers only # (Only tests whose functions are static inline in headers) # ==================================================================== -add_layer1_test(test_storage_pressure_extended) -add_layer1_test(test_detection_result_structures) -add_layer1_test(test_stream_frame_rate) +add_layer1_test(test_storage_pressure_extended CI_UNIT CI_SANITIZER) +add_layer1_test(test_detection_result_structures CI_UNIT CI_SANITIZER) +add_layer1_test(test_stream_frame_rate CI_UNIT CI_SANITIZER) target_sources(test_stream_frame_rate PRIVATE ${CMAKE_SOURCE_DIR}/src/video/stream_frame_rate.c) # ==================================================================== # Layer 2: lightnvr_lib tests — compiled functions + optional SQLite # ==================================================================== -add_layer2_test(test_config) +add_layer2_test(test_config CI_UNIT CI_SANITIZER) add_layer2_test(test_db_core_initialization) -add_layer2_test(test_sqlite_migration_boundaries) +add_layer2_test(test_sqlite_migration_boundaries CI_UNIT CI_SANITIZER) set_tests_properties(test_sqlite_migration_boundaries PROPERTIES TIMEOUT 20) -add_layer2_test(test_logger) -add_layer2_test(test_strings) +add_layer2_test(test_logger CI_UNIT CI_SANITIZER) +add_layer2_test(test_strings CI_UNIT CI_SANITIZER) add_layer2_test(test_interruptible_sleep) add_layer2_test(test_stream_metrics) -add_layer2_test(test_memory) +add_layer2_test(test_memory CI_UNIT CI_SANITIZER) add_layer2_test(test_path_utils) -add_layer2_test(test_request_response) -add_layer2_test(test_shutdown_coordinator) +add_layer2_test(test_request_response CI_UNIT CI_SANITIZER) +add_layer2_test(test_shutdown_coordinator CI_UNIT CI_SANITIZER) add_layer2_test(test_go2rtc_lifecycle) -add_layer2_test(test_detection_config) -add_layer2_test_with_curl(test_detection_model_motion) +add_layer2_test(test_detection_config CI_UNIT CI_SANITIZER) +add_layer2_test_with_curl(test_detection_model_motion CI_UNIT CI_SANITIZER) add_layer2_test_with_curl(test_detection_system_onvif) add_layer3_test(test_onvif_poll_recovery) target_link_options(test_onvif_poll_recovery PRIVATE @@ -163,7 +168,7 @@ target_compile_definitions(test_onvif_lpr_event PRIVATE TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" ) add_layer2_test_with_ffmpeg(test_api_detection) -add_layer2_test_with_ffmpeg(test_mp4_failed_open_memory) +add_layer2_test_with_ffmpeg(test_mp4_failed_open_memory CI_UNIT) add_layer2_test_with_ffmpeg(test_ffmpeg_rgb24_padding) if(TARGET lightnvr-ffmpeg-guard) add_executable(test_ffmpeg_guard_probe ffmpeg_guard_probe.c) @@ -182,8 +187,8 @@ if(TARGET lightnvr-ffmpeg-guard) "$" "$") endif() -add_layer2_test_with_curl(test_url_utils) -add_layer2_test(test_db_streams) +add_layer2_test_with_curl(test_url_utils CI_UNIT) +add_layer2_test(test_db_streams CI_UNIT CI_SANITIZER) add_layer2_test(test_db_detection_engines) add_layer2_test(test_db_lpr_reads) add_layer2_test(test_api_handlers_lpr) @@ -198,8 +203,8 @@ add_layer2_test(test_api_handlers_fleet) add_layer2_test(test_api_handlers_workspaces) add_layer2_test(test_db_live_saved_layouts) add_layer2_test(test_api_handlers_live_layouts) -add_layer2_test(test_db_camera_favorites) -add_layer2_test(test_api_handlers_camera_favorites) +add_layer2_test(test_db_camera_favorites CI_UNIT) +add_layer2_test(test_api_handlers_camera_favorites CI_UNIT) add_layer2_test(test_db_operator_floor_plans) add_layer2_test(test_api_handlers_operator_floor_plans) add_layer2_test(test_api_handlers_investigations) @@ -207,10 +212,10 @@ add_layer2_test(test_db_investigation_bookmarks) add_layer2_test(test_api_handlers_investigation_bookmarks) add_layer2_test(test_db_camera_collections) add_layer2_test(test_api_handlers_camera_collections) -add_layer2_test(test_db_recordings_extended) +add_layer2_test(test_db_recordings_extended CI_UNIT CI_SANITIZER) add_layer2_test(test_api_handlers_recordings_list) add_layer2_test(test_recording_transcode) -add_layer2_test(test_api_handlers_recordings_playback) +add_layer2_test(test_api_handlers_recordings_playback CI_UNIT) target_link_options(test_api_handlers_recordings_playback PRIVATE "-Wl,--wrap=storage_source_resolve" "-Wl,--wrap=storage_source_remote" @@ -220,22 +225,22 @@ target_link_options(test_api_handlers_recordings_playback PRIVATE "-Wl,--wrap=request_recording_transcode_cache" "-Wl,--wrap=http_serve_file" ) -add_layer2_test(test_storage_manager_retention) -add_layer2_test(test_storage_target_pressure_cleanup) -add_layer2_test(test_storage_migration) +add_layer2_test(test_storage_manager_retention CI_UNIT) +add_layer2_test(test_storage_target_pressure_cleanup CI_UNIT) +add_layer2_test(test_storage_migration CI_UNIT) add_layer2_test(test_storage_deletion_ledger) # Layer 2: deletion ledger scoping, pruning, indexes if(ENABLE_S3_ARCHIVE) - add_layer2_test(test_storage_archive) + add_layer2_test(test_storage_archive CI_UNIT) target_link_options(test_storage_archive PRIVATE "-Wl,--wrap=uv_timer_start" "-Wl,--wrap=db_open_readonly_connection") endif() -add_layer2_test(test_db_detections) -add_layer2_test(test_db_zones) -add_layer2_test(test_db_events) -add_layer2_test(test_db_auth) -add_layer2_test(test_authorization) -add_layer2_test(test_audit_log) -add_layer2_test(test_audit_summary) +add_layer2_test(test_db_detections CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_zones CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_events CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_auth CI_UNIT CI_SANITIZER) +add_layer2_test(test_authorization CI_UNIT) +add_layer2_test(test_audit_log CI_SANITIZER) +add_layer2_test(test_audit_summary CI_SANITIZER) add_layer2_test(test_event_envelope) add_layer2_test(test_event_bus) add_layer2_test(test_event_detection_adapter) @@ -247,20 +252,20 @@ add_layer2_test(test_api_handlers_event_routes) add_layer2_test(test_event_router) add_layer2_test(test_db_event_destinations) add_layer2_test(test_api_handlers_event_destinations) -add_layer2_test(test_db_storage_targets) -add_layer2_test(test_db_storage_policies) -add_layer2_test(test_api_handlers_storage_targets) -add_layer2_test(test_api_handlers_storage_policies) -add_layer2_test(test_db_transactions) -add_layer2_test(test_db_maintenance) -add_layer2_test(test_db_query_builder) +add_layer2_test(test_db_storage_targets CI_UNIT) +add_layer2_test(test_db_storage_policies CI_UNIT) +add_layer2_test(test_api_handlers_storage_targets CI_UNIT) +add_layer2_test(test_api_handlers_storage_policies CI_UNIT) +add_layer2_test(test_db_transactions CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_maintenance CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_query_builder CI_UNIT CI_SANITIZER) add_layer2_test(test_db_system_settings) -add_layer2_test(test_logger_json) -add_layer2_test(test_batch_delete_progress) -add_layer2_test(test_db_recordings_sync) -add_layer2_test(test_httpd_utils) -add_layer2_test(test_zone_filter) -add_layer2_test(test_onvif_soap_fault) +add_layer2_test(test_logger_json CI_UNIT CI_SANITIZER) +add_layer2_test(test_batch_delete_progress CI_UNIT CI_SANITIZER) +add_layer2_test(test_db_recordings_sync CI_UNIT CI_SANITIZER) +add_layer2_test(test_httpd_utils CI_UNIT CI_SANITIZER) +add_layer2_test(test_zone_filter CI_UNIT CI_SANITIZER) +add_layer2_test(test_onvif_soap_fault CI_UNIT) add_layer2_test(test_onvif_discovery_response) add_layer2_test(test_go2rtc_base_path) add_layer2_test(test_yaml_validate) @@ -342,26 +347,26 @@ endif() # ==================================================================== # Layer 3: Integration tests — lightnvr_lib + FFmpeg # ==================================================================== -add_layer3_test(test_faac_encoder) -add_layer3_test(test_audio_transcoder) -add_layer3_test(test_mp4_forced_reconnect) +add_layer3_test(test_faac_encoder CI_UNIT CI_SANITIZER) +add_layer3_test(test_audio_transcoder CI_UNIT CI_SANITIZER) +add_layer3_test(test_mp4_forced_reconnect CI_UNIT) target_link_options(test_mp4_forced_reconnect PRIVATE "-Wl,--wrap=record_segment" "-Wl,--wrap=av_usleep" "-Wl,--wrap=get_stream_config_by_name" "-Wl,--wrap=storage_should_pause_recording" "-Wl,--wrap=prepare_placed_mp4_recording_path") -add_layer3_test(test_mp4_recovery_watchdog) +add_layer3_test(test_mp4_recovery_watchdog CI_UNIT) target_link_options(test_mp4_recovery_watchdog PRIVATE "-Wl,--wrap=sleep" "-Wl,--wrap=mp4_writer_start_recording_thread" "-Wl,--wrap=mp4_writer_stop_recording_thread" "-Wl,--wrap=mp4_writer_is_recording" "-Wl,--wrap=go2rtc_integration_is_using_go2rtc_for_recording" "-Wl,--wrap=_log_message_ctx") set_tests_properties(test_mp4_forced_reconnect test_mp4_recovery_watchdog PROPERTIES TIMEOUT 10) -add_layer3_test(test_mp4_audio_recovery) +add_layer3_test(test_mp4_audio_recovery CI_UNIT) target_link_options(test_mp4_audio_recovery PRIVATE "-Wl,--wrap=av_read_frame" "-Wl,--wrap=cleanup_audio_transcoder") -add_layer3_test(test_stream_manager) -add_layer3_test(test_stream_state) -add_layer3_test(test_packet_buffer) -add_layer3_test(test_hls_cleanup) +add_layer3_test(test_stream_manager CI_UNIT CI_SANITIZER) +add_layer3_test(test_stream_state CI_UNIT CI_SANITIZER) +add_layer3_test(test_packet_buffer CI_UNIT CI_SANITIZER) +add_layer3_test(test_hls_cleanup CI_UNIT) target_compile_definitions(test_hls_cleanup PRIVATE TEST_RECORDING_PATH="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4" ) @@ -374,33 +379,33 @@ target_link_options(test_hls_reconnect_backoff PRIVATE "-Wl,--wrap=av_usleep") set_tests_properties(test_hls_reconnect_backoff PROPERTIES TIMEOUT 10) add_layer3_test(test_mp4_writer_watchdog) add_layer3_test(test_mp4_segment_timestamps) -add_layer3_test(test_mp4_segment_boundaries) -add_layer3_test(test_recording_video_params) +add_layer3_test(test_mp4_segment_boundaries CI_UNIT CI_SANITIZER) +add_layer3_test(test_recording_video_params CI_UNIT CI_SANITIZER) target_link_options(test_recording_video_params PRIVATE "-Wl,--wrap=update_stream_video_params") add_layer3_test(test_mp4_faststart) target_compile_definitions(test_mp4_faststart PRIVATE RECORDING_FIXTURE="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4") -add_layer3_test(test_audio_voice_enhancement) +add_layer3_test(test_audio_voice_enhancement CI_UNIT CI_SANITIZER) if(ENABLE_AUDIO_FILTERS) # Fault injection for the voice-enhancement graph (issue #652). - add_layer3_test(test_audio_voice_filter_faults) + add_layer3_test(test_audio_voice_filter_faults CI_UNIT CI_SANITIZER) target_link_options(test_audio_voice_filter_faults PRIVATE "-Wl,--wrap=avfilter_inout_alloc" "-Wl,--wrap=av_strdup" "-Wl,--wrap=avfilter_graph_config" "-Wl,--wrap=av_buffersrc_add_frame_flags" "-Wl,--wrap=av_buffersink_get_frame" "-Wl,--wrap=av_audio_fifo_write" ) endif() -add_layer3_test(test_timestamp_manager) +add_layer3_test(test_timestamp_manager CI_UNIT CI_SANITIZER) add_layer3_test(test_api_handlers_system) add_layer3_test(test_api_read_isolation) set_tests_properties(test_api_read_isolation PROPERTIES TIMEOUT 15) add_layer1_test(test_external_motion_trigger) # Layer 1: external_motion_trigger state-machine (PR #356) -add_layer2_test(test_motion_trigger_parse) # Layer 2: motion trigger body parsing (#466) -add_layer2_test(test_motion_max_area) # Layer 2: motion detector max-area sanity cap +add_layer2_test(test_motion_trigger_parse CI_UNIT) # Layer 2: motion trigger body parsing (#466) +add_layer2_test(test_motion_max_area CI_UNIT) # Layer 2: motion detector max-area sanity cap add_layer3_test(test_cross_stream_motion_trigger) add_layer3_test(test_annotation_writer_registry) # Layer 3: continuous-writer probe behind annotation mode (#547) -add_layer3_test(test_detection_current_frame) +add_layer3_test(test_detection_current_frame CI_UNIT CI_SANITIZER) target_compile_definitions(test_detection_current_frame PRIVATE TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures/detection" ) @@ -411,7 +416,7 @@ target_link_options(test_detection_current_frame PRIVATE "-Wl,--wrap=filter_detections_by_stream_objects" ) set_tests_properties(test_detection_current_frame PROPERTIES TIMEOUT 15) -add_layer3_test(test_detection_io_recovery) +add_layer3_test(test_detection_io_recovery CI_UNIT CI_SANITIZER) target_compile_definitions(test_detection_io_recovery PRIVATE TEST_RECORDING_PATH="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4" ) @@ -421,7 +426,7 @@ target_link_options(test_detection_io_recovery PRIVATE "-Wl,--wrap=_log_message_ctx" ) set_tests_properties(test_detection_io_recovery PROPERTIES TIMEOUT 30) -add_layer3_test(test_udt_recording) +add_layer3_test(test_udt_recording CI_UNIT CI_SANITIZER) target_compile_definitions(test_udt_recording PRIVATE TEST_RECORDING_PATH="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4" ) diff --git a/tests/unit/cmake/system_health/startup.cmake b/tests/unit/cmake/system_health/startup.cmake index decdf19dc..ad9c3df56 100644 --- a/tests/unit/cmake/system_health/startup.cmake +++ b/tests/unit/cmake/system_health/startup.cmake @@ -1,4 +1,4 @@ -add_layer3_test(test_system_health_startup) +add_layer3_test(test_system_health_startup CI_UNIT) target_link_options(test_system_health_startup PRIVATE -Wl,--wrap=pthread_cond_wait -Wl,--wrap=pthread_mutex_lock -Wl,--wrap=pthread_create -Wl,--wrap=pthread_join) set_tests_properties(test_system_health_startup PROPERTIES TIMEOUT 30) diff --git a/web/tests/e2e/bulkTagsReconciliation.spec.js b/web/tests/e2e/bulkTagsReconciliation.browser.spec.js similarity index 99% rename from web/tests/e2e/bulkTagsReconciliation.spec.js rename to web/tests/e2e/bulkTagsReconciliation.browser.spec.js index 0440b8b46..94b030fd4 100644 --- a/web/tests/e2e/bulkTagsReconciliation.spec.js +++ b/web/tests/e2e/bulkTagsReconciliation.browser.spec.js @@ -1,5 +1,5 @@ /** Real component effects with delayed API and parent recording snapshots. */ -// Run with: npm --prefix web run test:e2e -- --testPathPatterns=bulkTagsReconciliation +// Run with: npm --prefix web run test:e2e -- --testPathPatterns='\.browser\.spec\.js$' const browserTest = it; async function openBrowser({ delayedCatalog = false diff --git a/web/tests/e2e/zoneEditorCheckbox.spec.js b/web/tests/e2e/zoneEditorCheckbox.browser.spec.js similarity index 99% rename from web/tests/e2e/zoneEditorCheckbox.spec.js rename to web/tests/e2e/zoneEditorCheckbox.browser.spec.js index 26104885f..ebfb8d134 100644 --- a/web/tests/e2e/zoneEditorCheckbox.spec.js +++ b/web/tests/e2e/zoneEditorCheckbox.browser.spec.js @@ -17,7 +17,7 @@ const fixtureZones = [{ }] }]; const t = key => 'translated:' + key; -// Run with: npm --prefix web run test:e2e -- --testPathPatterns=zoneEditorCheckbox +// Run with: npm --prefix web run test:e2e -- --testPathPatterns='\.browser\.spec\.js$' const browserTest = it; async function openZoneBrowser() { const {