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/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/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..dbb36023e 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,19 +203,22 @@ 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_floor_plan_background_publication CI_UNIT CI_SANITIZER) +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_api_handlers_investigations) 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 +228,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 +255,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 +350,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 +382,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 +419,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 +429,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/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; +} 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 {