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/storage/storage_migration.c b/src/storage/storage_migration.c index 9f1e57e69..58e1b91ff 100644 --- a/src/storage/storage_migration.c +++ b/src/storage/storage_migration.c @@ -11,6 +11,7 @@ #include "storage/storage_migration.h" #include +#include #include #include #include @@ -24,6 +25,9 @@ #include #include #include +#ifdef __linux__ +#include +#endif #include "core/logger.h" #include "core/path_utils.h" @@ -37,6 +41,7 @@ #include "storage/storage_deletion.h" #include "storage/storage_source.h" #include "utils/strings.h" +#include "utils/uuid.h" #define MIGRATION_COPY_BUFFER (256U * 1024U) #define MIGRATION_PROGRESS_INTERVAL (4ULL * 1024ULL * 1024ULL) @@ -117,27 +122,20 @@ static int finish_digest(mbedtls_sha256_context *context, return 0; } -static int sha256_file(const char *path, uint64_t *size, - char checksum[STORAGE_MIGRATION_CHECKSUM_MAX], - char error[STORAGE_MIGRATION_ERROR_MAX]) { - int descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); - if (descriptor < 0) { - set_error(error, "Cannot open", path); - return -1; - } +static int sha256_descriptor(int descriptor, const char *path, uint64_t *size, + char checksum[STORAGE_MIGRATION_CHECKSUM_MAX], char error[STORAGE_MIGRATION_ERROR_MAX]) { + if (lseek(descriptor, 0, SEEK_SET) < 0) { set_error(error, "Cannot seek", path); return -1; } struct stat status; if (fstat(descriptor, &status) != 0 || !S_ISREG(status.st_mode) || status.st_size < 0) { if (errno == 0) errno = EINVAL; set_error(error, "Not a readable regular file", path); - close(descriptor); return -1; } mbedtls_sha256_context context; mbedtls_sha256_init(&context); if (mbedtls_sha256_starts(&context, 0) != 0) { mbedtls_sha256_free(&context); - close(descriptor); safe_strcpy(error, "Could not initialize SHA-256", STORAGE_MIGRATION_ERROR_MAX, 0); return -1; @@ -145,7 +143,6 @@ static int sha256_file(const char *path, uint64_t *size, unsigned char *buffer = malloc(MIGRATION_COPY_BUFFER); if (!buffer) { mbedtls_sha256_free(&context); - close(descriptor); safe_strcpy(error, "Out of memory while verifying recording", STORAGE_MIGRATION_ERROR_MAX, 0); return -1; @@ -170,7 +167,6 @@ static int sha256_file(const char *path, uint64_t *size, total += (uint64_t)count; } free(buffer); - close(descriptor); if (result == 0 && finish_digest(&context, checksum) != 0) { safe_strcpy(error, "Could not finish SHA-256", STORAGE_MIGRATION_ERROR_MAX, 0); @@ -182,6 +178,15 @@ static int sha256_file(const char *path, uint64_t *size, return result; } +static int sha256_file(const char *path, uint64_t *size, + char checksum[STORAGE_MIGRATION_CHECKSUM_MAX], char error[STORAGE_MIGRATION_ERROR_MAX]) { + int descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (descriptor < 0) { set_error(error, "Cannot open", path); return -1; } + int result = sha256_descriptor(descriptor, path, size, checksum, error); + close(descriptor); + return result; +} + static int write_all(int descriptor, const unsigned char *buffer, size_t length) { size_t offset = 0; @@ -249,9 +254,139 @@ static int verified_existing_destination( return 1; } +typedef struct { + int fd; + int parent; + struct stat identity; + struct stat parent_identity; + char parent_path[MAX_PATH_LENGTH]; + char leaf[MAX_PATH_LENGTH]; +} migration_publication_t; + +static bool same_inode(const struct stat *a, const struct stat *b) { + return a->st_dev == b->st_dev && a->st_ino == b->st_ino; +} + +static bool same_verified_file(const struct stat *a, const struct stat *b) { + return same_inode(a, b) && S_ISREG(b->st_mode) && a->st_size == b->st_size && + a->st_mtim.tv_sec == b->st_mtim.tv_sec && a->st_mtim.tv_nsec == b->st_mtim.tv_nsec; +} + +static void release_publication(migration_publication_t *publication) { + if (publication->fd >= 0) close(publication->fd); + if (publication->parent >= 0) close(publication->parent); + publication->fd = publication->parent = -1; +} + +/* This is a precommit snapshot, not an atomic filesystem/SQLite transaction. + * Held descriptors also cannot exclude writers that already have the inode open. */ +static bool publication_still_verified(const migration_publication_t *publication) { + if (publication->fd < 0) return true; /* Other transfer paths are unchanged. */ + struct stat held, named, parent; + return fstat(publication->fd, &held) == 0 && + same_verified_file(&publication->identity, &held) && + fstatat(publication->parent, publication->leaf, &named, AT_SYMLINK_NOFOLLOW) == 0 && + same_verified_file(&publication->identity, &named) && + lstat(publication->parent_path, &parent) == 0 && + same_inode(&publication->parent_identity, &parent); +} + +static int move_migration_noreplace(int from, const char *old_name, + int to, const char *new_name) { +#if defined(__linux__) && defined(SYS_renameat2) + return (int)syscall(SYS_renameat2, from, old_name, to, new_name, 1U); +#else + (void)from; (void)old_name; (void)to; (void)new_name; + errno = ENOSYS; + return -1; +#endif +} + +/* Bind the pathname to the already verified descriptor inside an owned private + * directory. Restore without replacing anything on failure; preserve and log + * the private entry when restoration is impossible. */ +static int publish_verified_temporary(int descriptor, const struct stat *verified, + const char *temporary, const char *destination, const char *job_uuid, + migration_publication_t *publication, + char error[STORAGE_MIGRATION_ERROR_MAX]) { + char parent_path[MAX_PATH_LENGTH], private_name[MAX_PATH_LENGTH] = {0}; + safe_strcpy(parent_path, destination, sizeof(parent_path), 0); + char *parent_name = dirname(parent_path); + const char *destination_leaf = strrchr(destination, '/'); + destination_leaf = destination_leaf ? destination_leaf + 1 : destination; + const char *temporary_leaf = strrchr(temporary, '/'); + temporary_leaf = temporary_leaf ? temporary_leaf + 1 : temporary; + int parent = open(parent_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + int private_fd = -1, result = -1; + bool private_created = false, identity_known = false, retained = false; + struct stat directory_identity, current, parent_identity; + if (parent < 0 || fstat(parent, &parent_identity) != 0) goto finish; + for (unsigned attempt = 0; attempt < 4; ++attempt) { + char uuid[LIGHTNVR_UUID_STRING_SIZE]; + if (lightnvr_uuid_generate_v4(uuid) != 0) goto finish; + int length = snprintf(private_name, sizeof(private_name), "%s.migration-%s.publish-%s", + destination_leaf, job_uuid, uuid); + if (length < 0 || (size_t)length >= sizeof(private_name)) { errno = ENAMETOOLONG; goto finish; } + if (mkdirat(parent, private_name, 0700) == 0) { private_created = true; break; } + if (errno != EEXIST) goto finish; + } + if (!private_created || fstatat(parent, private_name, &directory_identity, AT_SYMLINK_NOFOLLOW) != 0) + goto finish; + identity_known = true; + if (!S_ISDIR(directory_identity.st_mode) || directory_identity.st_uid != geteuid() || + (directory_identity.st_mode & 0777) != 0700) { errno = EPERM; goto finish; } + private_fd = openat(parent, private_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (private_fd < 0 || fstat(private_fd, ¤t) != 0 || !same_inode(&directory_identity, ¤t)) + goto finish; + if (move_migration_noreplace(parent, temporary_leaf, private_fd, "verified") != 0) goto finish; + retained = true; + if (fstatat(private_fd, "verified", ¤t, AT_SYMLINK_NOFOLLOW) != 0 || + !same_verified_file(verified, ¤t)) { + safe_strcpy(error, "Temporary identity changed after verification; retained", STORAGE_MIGRATION_ERROR_MAX, 0); + result = -2; + goto restore; + } + if (move_migration_noreplace(private_fd, "verified", parent, destination_leaf) != 0) { + if (errno == EEXIST) { + safe_strcpy(error, "Destination appeared while migration was copying", STORAGE_MIGRATION_ERROR_MAX, 0); + result = -2; + } else set_error(error, "Cannot publish verified destination", destination); + goto restore; + } + retained = false; + if (fsync(parent) != 0) { + set_error(error, "Cannot sync published destination directory", destination); + goto finish; + } + publication->fd = descriptor; + publication->parent = parent; + publication->identity = *verified; + publication->parent_identity = parent_identity; + safe_strcpy(publication->parent_path, parent_name, sizeof(publication->parent_path), 0); + safe_strcpy(publication->leaf, destination_leaf, sizeof(publication->leaf), 0); + result = 0; + goto finish; +restore: + if (move_migration_noreplace(private_fd, "verified", parent, temporary_leaf) == 0) retained = false; +finish: + if (result != 0 && !error[0]) set_error(error, "Cannot bind verified temporary", temporary); + if (private_created) { + /* Best effort, empty-directory-only removal. A parent entry can still + * change after this check; no recursive deletion is performed. */ + if (retained || !identity_known || + fstatat(parent, private_name, ¤t, AT_SYMLINK_NOFOLLOW) != 0 || + !same_inode(&directory_identity, ¤t) || + unlinkat(parent, private_name, AT_REMOVEDIR) != 0) + log_warn("Migration private artifact retained for inspection: %s/%s", parent_name, private_name); + } + if (private_fd >= 0) close(private_fd); + if (result != 0 && parent >= 0) close(parent); + return result; +} + static int copy_and_verify( storage_migration_job_t *job, const char *source_path, - const char *destination_path, + const char *destination_path, migration_publication_t *publication, char checksum[STORAGE_MIGRATION_CHECKSUM_MAX], char error[STORAGE_MIGRATION_ERROR_MAX]) { int existing = verified_existing_destination( @@ -285,10 +420,22 @@ static int copy_and_verify( close(source); return -2; } - int destination = open(temporary_path, - O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | - O_NOFOLLOW, + int destination = -1; + for (unsigned int attempt = 0; attempt < 8; ++attempt) { + destination = open(temporary_path, + O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, source_status.st_mode & 0777); + if (destination >= 0 || errno != EEXIST) break; + log_warn("Unowned migration temporary retained for inspection: %s", temporary_path); + char attempt_uuid[LIGHTNVR_UUID_STRING_SIZE]; + if (lightnvr_uuid_generate_v4(attempt_uuid) != 0) break; + length = snprintf(temporary_path, sizeof(temporary_path), + "%s.migration-%s.%s.part", destination_path, job->uuid, attempt_uuid); + if (length < 0 || (size_t)length >= sizeof(temporary_path)) { + errno = ENAMETOOLONG; + break; + } + } if (destination < 0) { set_error(error, "Cannot create temporary destination", temporary_path); @@ -379,7 +526,6 @@ static int copy_and_verify( result = -1; } free(buffer); - close(destination); close(source); char source_checksum[STORAGE_MIGRATION_CHECKSUM_MAX]; if (result == 0 && finish_digest(&context, source_checksum) != 0) { @@ -390,7 +536,7 @@ static int copy_and_verify( mbedtls_sha256_free(&context); } if (result != 0) { - if (result == -3) unlink(temporary_path); + close(destination); return result; } @@ -400,43 +546,36 @@ static int copy_and_verify( DB_STORAGE_MIGRATION_OK) { safe_strcpy(error, "Could not persist verification state", STORAGE_MIGRATION_ERROR_MAX, 0); + close(destination); return -1; } uint64_t destination_size = 0; char destination_checksum[STORAGE_MIGRATION_CHECKSUM_MAX]; - if (sha256_file(temporary_path, &destination_size, - destination_checksum, error) != 0) { + struct stat verified, after_verification; + if (fstat(destination, &verified) != 0 || + sha256_descriptor(destination, temporary_path, &destination_size, + destination_checksum, error) != 0 || + fstat(destination, &after_verification) != 0 || + !same_verified_file(&verified, &after_verification)) { + close(destination); return -1; } if (destination_size != copied || strcmp(source_checksum, destination_checksum) != 0) { safe_strcpy(error, "Destination checksum verification failed", STORAGE_MIGRATION_ERROR_MAX, 0); - unlink(temporary_path); + close(destination); return -1; } if (migration_cancel_requested(job)) { safe_strcpy(error, "Cancelled by operator", STORAGE_MIGRATION_ERROR_MAX, 0); - unlink(temporary_path); + close(destination); return -3; } - if (lstat(destination_path, &source_status) == 0) { - safe_strcpy(error, "Destination appeared while migration was copying", - STORAGE_MIGRATION_ERROR_MAX, 0); - unlink(temporary_path); - return -2; - } - if (errno != ENOENT || rename(temporary_path, destination_path) != 0) { - set_error(error, "Cannot publish verified destination", - destination_path); - return -1; - } - if (fsync_parent(destination_path) != 0) { - set_error(error, "Cannot sync destination directory for", - destination_path); - return -1; - } + result = publish_verified_temporary(destination, &verified, temporary_path, + destination_path, job->uuid, publication, error); + if (result != 0) { close(destination); return result; } safe_strcpy(checksum, source_checksum, STORAGE_MIGRATION_CHECKSUM_MAX, 0); return 0; @@ -529,16 +668,64 @@ static void cleanup_abandoned(void) { char error[256] = {0}, path[MAX_PATH_LENGTH], temporary[MAX_PATH_LENGTH]; if (db_storage_migration_get(uuid, &job) == DB_STORAGE_MIGRATION_OK && db_storage_target_get(job.destination_target_uuid, &target) == DB_STORAGE_TARGET_OK) { - bool retained = destination_retained(&job); + int retained = destination_retained(&job); if (!strcmp(target.target_type, "s3")) { result = storage_s3_probe(&target, false); if (!result) result = storage_s3_abort_upload(&target, job.destination_object_key, upload, error); if (!result && !retained) result = storage_s3_delete(&target, job.destination_object_key, error); } else if (db_storage_target_mount_guard_active(&target) && !db_storage_target_resolve_path(target.uuid, job.destination_object_key, path)) { - result = retained || unlink(path) == 0 || errno == ENOENT ? 0 : -1; - int n = snprintf(temporary, sizeof(temporary), "%s.migration-%s.part", path, uuid); - if (n > 0 && n < (int)sizeof(temporary) && unlink(temporary) && errno != ENOENT) result = -1; + struct stat status; + if (retained < 0) { + safe_strcpy(error, "Cannot establish destination ownership; retained", + sizeof(error), 0); + result = -1; + } else if (retained > 0) { + result = 0; + } else if (lstat(path, &status) != 0) { + result = errno == ENOENT ? 0 : -1; + if (result != 0) + safe_strcpy(error, "Cannot inspect unowned destination; retained", + sizeof(error), 0); + } else { + /* A failed/cancelled job does not prove it published this + * pathname. Preserve unknown objects, even matching bytes. */ + safe_strcpy(error, "Final destination retained: migration ownership is not recorded", + sizeof(error), 0); + log_warn("Unowned migration destination retained for inspection: %s", path); + result = -1; + } + /* Job-derived names do not persist inode ownership. Inspect all + * attempt names, including retry artifacts, but never reclaim them. */ + int n = snprintf(temporary, sizeof(temporary), "%s.migration-%s", path, uuid); + if (n <= 0 || n >= (int)sizeof(temporary)) { + result = -1; + } else { + char parent_path[MAX_PATH_LENGTH]; + safe_strcpy(parent_path, temporary, sizeof(parent_path), 0); + char *leaf = strrchr(temporary, '/'); + const char *prefix = leaf ? leaf + 1 : temporary; + DIR *directory = opendir(dirname(parent_path)); + if (!directory) { + if (errno != ENOENT) result = -1; + } else { + struct dirent *entry; + errno = 0; + while ((entry = readdir(directory))) { + if (!strncmp(entry->d_name, prefix, strlen(prefix)) && + entry->d_name[strlen(prefix)] == '.') { + safe_strcpy(error, "Temporary destination retained: migration ownership is not recorded", + sizeof(error), 0); + log_warn("Unowned migration attempt retained for inspection: %s/%s", + parent_path, entry->d_name); + result = -1; + } + errno = 0; + } + if (errno != 0) result = -1; + closedir(directory); + } + } } } pthread_mutex_lock(mutex); @@ -547,9 +734,13 @@ static void cleanup_abandoned(void) { "UPDATE storage_migration_jobs SET artifacts_cleaned=1,upload_id='',upload_parts=''," "state=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE state END WHERE uuid=?;" : "UPDATE storage_migration_jobs SET next_attempt_at=strftime('%s','now')+60," - "last_error='Unpublished transfer artifacts await cleanup' WHERE uuid=?;"; + "last_error=? WHERE uuid=?;"; if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) { - sqlite3_bind_text(stmt, 1, uuid, -1, SQLITE_TRANSIENT); + int parameter = 1; + if (result != 0) + sqlite3_bind_text(stmt, parameter++, error[0] ? error : + "Unpublished transfer artifacts await cleanup", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, parameter, uuid, -1, SQLITE_TRANSIENT); sqlite3_step(stmt); } if (stmt) sqlite3_finalize(stmt); @@ -662,6 +853,7 @@ int storage_migration_process_one(void) { char checksum[STORAGE_MIGRATION_CHECKSUM_MAX]; char error[STORAGE_MIGRATION_ERROR_MAX] = {0}; int copied; + migration_publication_t publication = {.fd = -1, .parent = -1}; if (object_source && object_destination) { int resolved = storage_source_resolve(job.recording_id, source_path, error); if (resolved != STORAGE_SOURCE_READY) { @@ -704,30 +896,36 @@ int storage_migration_process_one(void) { } else if (object_source) { copied = restore_to_filesystem(&job, &source_target, &destination_target, destination_path, checksum, error); } else { - copied = copy_and_verify(&job, source_path, destination_path, checksum, error); + copied = copy_and_verify(&job, source_path, destination_path, &publication, checksum, error); } if (copied != 0) { if (copied == -3) { db_storage_migration_mark_cancelled(job.uuid, &job); + release_publication(&publication); return 1; } db_storage_migration_record_failure(&job, error[0] ? error : "Recording copy failed", copied != -2); + release_publication(&publication); return 1; } if (migration_cancel_requested(&job)) { if (retained_destination) { db_storage_migration_mark_cancelled(job.uuid, &job); + release_publication(&publication); return 1; } if (object_destination) { if (storage_s3_delete(&destination_target, job.destination_object_key, error) != 0) { db_storage_migration_record_failure(&job, "Cancelled upload awaits destination cleanup", true); + release_publication(&publication); return 1; } - } else unlink(destination_path); + } else if (publication.fd < 0) unlink(destination_path); + else log_warn("Cancelled filesystem destination retained for inspection: %s", destination_path); db_storage_migration_mark_cancelled(job.uuid, &job); + release_publication(&publication); return 1; } if (db_storage_migration_update_progress( @@ -735,6 +933,7 @@ int storage_migration_process_one(void) { DB_STORAGE_MIGRATION_OK) { db_storage_migration_record_failure( &job, "Could not persist commit state", true); + release_publication(&publication); return 1; } if (storage_target_probe_and_publish(job.destination_target_uuid, false, @@ -744,10 +943,18 @@ int storage_migration_process_one(void) { !db_storage_target_mount_guard_active(&destination_target)) { db_storage_migration_record_failure( &job, "Destination target became unavailable before commit", true); + release_publication(&publication); return 1; } if (!object_source && !db_storage_target_mount_guard_active(&source_target)) { db_storage_migration_record_failure(&job, "Source mount became unavailable before commit", true); + release_publication(&publication); + return 1; + } + if (!publication_still_verified(&publication)) { + db_storage_migration_record_failure(&job, + "Published destination identity changed before commit; retained", false); + release_publication(&publication); return 1; } db_storage_migration_result_t committed = strcmp(job.operation, "copy") == 0 @@ -757,9 +964,12 @@ int storage_migration_process_one(void) { if (!retained_destination && object_destination) { if (storage_s3_delete(&destination_target, job.destination_object_key, error) != 0) { db_storage_migration_record_failure(&job, "Uncommitted archive copy awaits cleanup", true); + release_publication(&publication); return 1; } - } else if (!retained_destination) unlink(destination_path); + } else if (!retained_destination && publication.fd < 0) unlink(destination_path); + else if (!retained_destination) + log_warn("Uncommitted filesystem destination retained for inspection: %s", destination_path); if (migration_cancel_requested(&job)) { db_storage_migration_mark_cancelled(job.uuid, &job); } else if (committed == DB_STORAGE_MIGRATION_SOURCE_CHANGED) { @@ -770,6 +980,7 @@ int storage_migration_process_one(void) { db_storage_migration_record_failure( &job, "Could not atomically commit recording location", true); } + release_publication(&publication); return 1; } if (strcmp(job.operation, "move") == 0) { @@ -779,6 +990,7 @@ int storage_migration_process_one(void) { log_info("Storage replication %s completed for recording %llu", job.uuid, (unsigned long long)job.recording_id); } + release_publication(&publication); return 1; } 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..ba14ab1b6 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,25 @@ 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_migration_publication CI_UNIT CI_SANITIZER) +target_link_options(test_storage_migration_publication PRIVATE -Wl,--wrap=lstat -Wl,--wrap=syscall -Wl,--wrap=unlink -Wl,--wrap=db_storage_migration_update_progress -Wl,--wrap=db_storage_migration_commit_location) +set_tests_properties(test_storage_migration_publication PROPERTIES TIMEOUT 30) 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_storage_migration_publication.c b/tests/unit/test_storage_migration_publication.c new file mode 100644 index 000000000..7e5f8162f --- /dev/null +++ b/tests/unit/test_storage_migration_publication.c @@ -0,0 +1,501 @@ +#define _GNU_SOURCE +#define _POSIX_C_SOURCE 200809L +#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_recordings.h" +#include "database/db_storage_migrations.h" +#include "database/db_storage_targets.h" +#include "storage/storage_migration.h" +#include "utils/strings.h" + +static char root[128], source_root[256], destination_root[256], db_path[256]; +static char source_path[MAX_PATH_LENGTH], destination_path[MAX_PATH_LENGTH]; +static char temporary_path[MAX_PATH_LENGTH], sentinel[256], verified_part[256]; +static char source_uuid[LIGHTNVR_UUID_STRING_SIZE], destination_uuid[LIGHTNVR_UUID_STRING_SIZE]; +static bool armed, injected; +static int replacement_kind, publication_error, destination_stat_error; +static bool fail_part_unlink; +static int quarantine_error, committing_replacement; +static bool restore_collision, commit_failure_replacement, cancel_after_publication; +static char private_artifact[MAX_PATH_LENGTH], active_job[LIGHTNVR_UUID_STRING_SIZE]; +static char relocated_parent[256]; +int __real_unlink(const char *); +int __wrap_unlink(const char *path) { + if (fail_part_unlink && !strcmp(path, temporary_path)) { errno = EACCES; return -1; } + return __real_unlink(path); +} +static const char payload[] = "verified source recording"; +static void put_file(const char *path, const char *bytes) { + TEST_ASSERT_EQUAL_INT(0, ensure_path(path)); + FILE *file = fopen(path, "w"); TEST_ASSERT_NOT_NULL(file); + size_t length = fwrite(bytes, 1, strlen(bytes), file); int closed = fclose(file); + TEST_ASSERT_EQUAL_UINT(strlen(bytes), length); TEST_ASSERT_EQUAL_INT(0, closed); +} +static void inject_before_publication(void) { + armed = false; injected = true; + if (replacement_kind == 1) put_file(destination_path, "concurrent destination data"); + else if (replacement_kind == 2) TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, destination_path)); + else if (replacement_kind == 4) put_file(temporary_path, "modified verified inode"); + else { + TEST_ASSERT_EQUAL_INT(0, rename(temporary_path, verified_part)); + put_file(temporary_path, "unverified replacement data"); + } +} +int __real_lstat(const char *, struct stat *); +int __wrap_lstat(const char *path, struct stat *info) { + if (destination_stat_error && !strcmp(path, destination_path)) { errno = destination_stat_error; return -1; } + int result = __real_lstat(path, info); int saved = errno; + if (armed && result != 0 && saved == ENOENT && strcmp(path, destination_path) == 0 && + access(temporary_path, F_OK) == 0) inject_before_publication(); + errno = saved; return result; +} +long __real_syscall(long, ...); +long __wrap_syscall(long number, ...) { + if (number != SYS_renameat2) { + TEST_FAIL_MESSAGE("Unexpected syscall in publication seam"); errno = ENOSYS; return -1; + } + va_list args; va_start(args, number); + int oldfd = va_arg(args, int); const char *oldname = va_arg(args, const char *); + int newfd = va_arg(args, int); const char *newname = va_arg(args, const char *); + unsigned int flags = va_arg(args, unsigned int); va_end(args); + bool quarantine = !strcmp(oldname, temporary_path) || + (!strcmp(newname, "verified") && strstr(oldname, ".migration-")); + if (armed && ((replacement_kind >= 3 && quarantine) || + (replacement_kind < 3 && (!strcmp(newname, destination_path) || !strcmp(newname, "clip.mp4"))))) + inject_before_publication(); + if (quarantine_error && quarantine) { errno = quarantine_error; return -1; } + if (publication_error && (!strcmp(newname, destination_path) || !strcmp(newname, "clip.mp4"))) { + errno = publication_error; return -1; + } + if (quarantine && !strcmp(newname, "verified")) { + char descriptor_path[64], directory[MAX_PATH_LENGTH]; + snprintf(descriptor_path, sizeof(descriptor_path), "/proc/self/fd/%d", newfd); + ssize_t length = readlink(descriptor_path, directory, sizeof(directory)-1); + TEST_ASSERT_TRUE(length > 0); directory[length] = '\0'; + int size = snprintf(private_artifact, sizeof(private_artifact), "%s/verified", directory); + TEST_ASSERT_TRUE(size > 0 && (size_t)size < sizeof(private_artifact)); + } + if (restore_collision && !strcmp(oldname, "verified") && strstr(newname, ".part")) + put_file(temporary_path, "restore collision sentinel"); + long result = __real_syscall(number, oldfd, oldname, newfd, newname, flags); + if (result == 0 && cancel_after_publication && !strcmp(newname, "clip.mp4")) { + cancel_after_publication = false; + TEST_ASSERT_EQUAL_INT(0, rename(destination_path, verified_part)); + put_file(destination_path, "cancelled substitution sentinel"); + storage_migration_job_t job; + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_request_cancel(active_job, &job)); + } + return result; +} +db_storage_migration_result_t __real_db_storage_migration_update_progress( + const char *, const char *, uint64_t, uint64_t); +db_storage_migration_result_t __wrap_db_storage_migration_update_progress( + const char *uuid, const char *state, uint64_t copied, uint64_t total) { + if (committing_replacement && !strcmp(state, "committing")) { + int kind = committing_replacement; committing_replacement = 0; + if (kind == 2) { + char parent[MAX_PATH_LENGTH], relocated_file[MAX_PATH_LENGTH]; + strcpy(parent, destination_path); *strrchr(parent, '/') = 0; + TEST_ASSERT_EQUAL_INT(0, rename(parent, relocated_parent)); + TEST_ASSERT_EQUAL_INT(0, mkdir(parent, 0700)); + int size = snprintf(relocated_file, sizeof(relocated_file), "%s/clip.mp4", relocated_parent); + TEST_ASSERT_TRUE(size > 0 && (size_t)size < sizeof(relocated_file)); + TEST_ASSERT_EQUAL_INT(0, link(relocated_file, destination_path)); + } else { + TEST_ASSERT_EQUAL_INT(0, rename(destination_path, verified_part)); + put_file(destination_path, "substituted published destination"); + } + } + return __real_db_storage_migration_update_progress(uuid, state, copied, total); +} +db_storage_migration_result_t __real_db_storage_migration_commit_location( + const storage_migration_job_t *, const char *, const char *); +db_storage_migration_result_t __wrap_db_storage_migration_commit_location( + const storage_migration_job_t *job, const char *path, const char *checksum) { + if (commit_failure_replacement) { + TEST_ASSERT_EQUAL_INT(0, rename(destination_path, verified_part)); + put_file(destination_path, "failed commit substitution sentinel"); + return DB_STORAGE_MIGRATION_ERROR; + } + return __real_db_storage_migration_commit_location(job, path, checksum); +} +static void assert_bytes(const char *path, const char *expected) { + FILE *file = fopen(path, "r"); TEST_ASSERT_NOT_NULL(file); + char actual[128] = {0}; size_t bytes = fread(actual, 1, sizeof(actual)-1, file); int closed = fclose(file); + TEST_ASSERT_EQUAL_INT(0, closed); TEST_ASSERT_EQUAL_UINT(strlen(expected), bytes); + TEST_ASSERT_EQUAL_STRING(expected, actual); +} +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 path[1024]; snprintf(path, sizeof(path), "%s/%s", directory, entry->d_name); + struct stat info; + if (__real_lstat(path, &info) == 0 && S_ISDIR(info.st_mode)) remove_owned_tree(path); + else unlink(path); + } + closedir(dir); rmdir(directory); +} +static void configure_targets(void) { + TEST_ASSERT_EQUAL_INT(0, db_storage_target_bootstrap_default(source_root, source_uuid)); + storage_target_t source; + TEST_ASSERT_EQUAL_INT(DB_STORAGE_TARGET_OK, db_storage_target_probe(source_uuid, true, &source)); + storage_target_t destination = {0}; + strcpy(destination.name, "Migration destination"); strcpy(destination.target_type, "filesystem"); + safe_strcpy(destination.root_path, destination_root, sizeof(destination.root_path), 0); + strcpy(destination.storage_class, "warm"); destination.enabled = true; + destination.high_watermark_pct = 90.0; destination.low_watermark_pct = 80.0; + TEST_ASSERT_EQUAL_INT(DB_STORAGE_TARGET_OK, db_storage_target_create(&destination)); + strcpy(destination_uuid, destination.uuid); +} +void setUp(void) { + armed = injected = fail_part_unlink = false; replacement_kind = publication_error = destination_stat_error = 0; quarantine_error = committing_replacement = 0; temporary_path[0] = '\0'; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec(get_db_handle(), + "DELETE FROM storage_migration_jobs;DELETE FROM detections;DELETE FROM recordings;DELETE FROM storage_targets;", + NULL, NULL, NULL)); + remove_owned_tree(source_root); remove_owned_tree(destination_root); remove_owned_tree(relocated_parent); + TEST_ASSERT_EQUAL_INT(0, mkdir(source_root, 0700)); TEST_ASSERT_EQUAL_INT(0, mkdir(destination_root, 0700)); + restore_collision = commit_failure_replacement = cancel_after_publication = false; private_artifact[0] = 0; + unlink(sentinel); unlink(verified_part); put_file(sentinel, "sentinel unchanged"); + configure_targets(); +} +void tearDown(void) { armed = fail_part_unlink = false; } +static uint64_t create_job(storage_migration_job_t *job, const char *operation) { + put_file(source_path, payload); + recording_metadata_t recording = {0}; + strcpy(recording.stream_name, "migration-camera"); + strcpy(recording.camera_uuid, "11111111-1111-4111-8111-111111111111"); + safe_strcpy(recording.file_path, source_path, sizeof(recording.file_path), 0); + strcpy(recording.storage_target_uuid, source_uuid); strcpy(recording.object_key, "camera/clip.mp4"); + strcpy(recording.placement_reason, "test"); strcpy(recording.codec, "h264"); + strcpy(recording.trigger_type, "continuous"); recording.start_time = 100; recording.end_time = 200; + recording.size_bytes = strlen(payload); recording.width = 1920; recording.height = 1080; + recording.fps = 30; recording.is_complete = true; recording.retention_override_days = -1; + recording.retention_tier = RETENTION_TIER_STANDARD; recording.disk_pressure_eligible = true; + uint64_t id = add_recording_metadata(&recording); TEST_ASSERT_NOT_EQUAL_UINT64(0, id); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, + db_storage_migration_create_operation(id, destination_uuid, operation, 0, job)); + strcpy(active_job, job->uuid); + size_t needed = strlen(destination_path) + strlen(job->uuid) + sizeof(".migration-.part"); + TEST_ASSERT_TRUE(needed <= sizeof(temporary_path)); + strcpy(temporary_path, destination_path); + strcat(temporary_path, ".migration-"); strcat(temporary_path, job->uuid); strcat(temporary_path, ".part"); + return id; +} +static void assert_source_location(uint64_t id) { + recording_metadata_t recording; + TEST_ASSERT_EQUAL_INT(0, get_recording_metadata_by_id(id, &recording)); + TEST_ASSERT_EQUAL_STRING(source_uuid, recording.storage_target_uuid); + TEST_ASSERT_EQUAL_STRING(source_path, recording.file_path); + assert_bytes(source_path, payload); +} +static void test_normal_move_commits_verified_destination_and_removes_source(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("completed", job.state); TEST_ASSERT_EQUAL_UINT(64, strlen(job.checksum)); + assert_bytes(destination_path, payload); TEST_ASSERT_EQUAL_INT(-1, access(source_path, F_OK)); + TEST_ASSERT_EQUAL_INT(-1, access(temporary_path, F_OK)); + recording_metadata_t recording; + TEST_ASSERT_EQUAL_INT(0, get_recording_metadata_by_id(id, &recording)); + TEST_ASSERT_EQUAL_STRING(destination_uuid, recording.storage_target_uuid); + TEST_ASSERT_EQUAL_STRING(destination_path, recording.file_path); +} +static void test_normal_copy_keeps_source_and_verified_replica(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "copy"); + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("completed", job.state); assert_source_location(id); + assert_bytes(destination_path, payload); +} +static void test_regular_destination_appearing_after_check_is_not_overwritten(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + replacement_kind = 1; armed = true; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); TEST_ASSERT_TRUE(injected); + assert_bytes(destination_path, "concurrent destination data"); assert_source_location(id); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("failed", job.state); +} +static void test_symlink_destination_appearing_after_check_is_retained(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + replacement_kind = 2; armed = true; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); TEST_ASSERT_TRUE(injected); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(destination_path, &info)); + TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); assert_bytes(sentinel, "sentinel unchanged"); + assert_source_location(id); +} +static void test_existing_collision_survives_later_abandoned_cleanup(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + put_file(destination_path, "preexisting unrelated data"); + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_bytes(destination_path, "preexisting unrelated data"); assert_source_location(id); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("failed", job.state); + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + assert_bytes(destination_path, "preexisting unrelated data"); assert_source_location(id); +} +static void test_unverified_temporary_replacement_is_not_committed(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + replacement_kind = 3; armed = true; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); TEST_ASSERT_TRUE(injected); + assert_source_location(id); + TEST_ASSERT_EQUAL_INT(-1, access(destination_path, F_OK)); + assert_bytes(verified_part, payload); +} +static void test_unavailable_registered_source_preserves_metadata_and_bytes(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + char moved[256]; snprintf(moved, sizeof(moved), "%s/source-unavailable", root); + TEST_ASSERT_EQUAL_INT(0, rename(source_root, moved)); + int result = storage_migration_process_one(); + TEST_ASSERT_EQUAL_INT(0, rename(moved, source_root)); + TEST_ASSERT_EQUAL_INT(1, result); assert_source_location(id); + TEST_ASSERT_EQUAL_INT(-1, access(destination_path, F_OK)); +} +static void publication_failure(int error) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + publication_error = error; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_source_location(id); TEST_ASSERT_EQUAL_INT(-1, access(destination_path, F_OK)); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("retry_wait", job.state); + assert_bytes(temporary_path, payload); +} +static void test_unsupported_atomic_publication_retains_source_and_temporary(void) { publication_failure(ENOSYS); } +static void test_unsupported_filesystem_publication_retains_source_and_temporary(void) { publication_failure(EINVAL); } +static void test_publication_io_failure_retains_source_and_temporary(void) { publication_failure(EIO); } +static void test_collision_cleanup_failure_never_overwrites_competing_destination(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + replacement_kind = 1; armed = fail_part_unlink = true; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); TEST_ASSERT_TRUE(injected); + assert_bytes(destination_path, "concurrent destination data"); assert_source_location(id); + assert_bytes(temporary_path, payload); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("failed", job.state); +} +static int artifacts_cleaned(const char *uuid) { + sqlite3_stmt *statement = NULL; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_prepare_v2(get_db_handle(), + "SELECT artifacts_cleaned FROM storage_migration_jobs WHERE uuid=?", -1, &statement, NULL)); + sqlite3_bind_text(statement, 1, uuid, -1, SQLITE_TRANSIENT); + int result = sqlite3_step(statement); int cleaned = result == SQLITE_ROW ? sqlite3_column_int(statement, 0) : -1; + sqlite3_finalize(statement); TEST_ASSERT_EQUAL_INT(SQLITE_ROW, result); return cleaned; +} +static void assert_cleanup_pending(storage_migration_job_t *job, const char *state) { + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job->uuid, job)); + TEST_ASSERT_EQUAL_STRING(state, job->state); TEST_ASSERT_EQUAL_INT(0, artifacts_cleaned(job->uuid)); + TEST_ASSERT_GREATER_THAN_INT64(0, job->next_attempt_at); + TEST_ASSERT_TRUE(job->last_error[0] != '\0'); +} +static void test_abandoned_cleanup_retains_unowned_symlink(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + TEST_ASSERT_EQUAL_INT(0, ensure_path(destination_path)); + TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, destination_path)); + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(destination_path, &info)); + TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); assert_bytes(sentinel, "sentinel unchanged"); + assert_source_location(id); assert_cleanup_pending(&job, "failed"); +} +static void test_cancelled_job_retains_unowned_final_destination(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + put_file(destination_path, "unowned cancelled destination"); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_request_cancel(job.uuid, &job)); + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + assert_bytes(destination_path, "unowned cancelled destination"); assert_source_location(id); + assert_cleanup_pending(&job, "cancelled"); +} +static void test_cancelled_job_without_artifacts_completes_cleanup(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_request_cancel(job.uuid, &job)); + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("cancelled", job.state); TEST_ASSERT_EQUAL_INT(1, artifacts_cleaned(job.uuid)); + assert_source_location(id); +} +static void test_matching_bytes_do_not_prove_abandoned_destination_ownership(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + put_file(destination_path, payload); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_request_cancel(job.uuid, &job)); + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + assert_bytes(destination_path, payload); assert_source_location(id); assert_cleanup_pending(&job, "cancelled"); +} +static void test_destination_inspection_error_keeps_cleanup_pending(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_request_cancel(job.uuid, &job)); + destination_stat_error = EACCES; + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + destination_stat_error = 0; + assert_source_location(id); assert_cleanup_pending(&job, "cancelled"); +} +static void test_preexisting_temporary_is_preserved_while_fresh_attempt_succeeds(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + put_file(temporary_path, "preexisting temporary sentinel"); + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_bytes(temporary_path, "preexisting temporary sentinel"); + recording_metadata_t recording; + TEST_ASSERT_EQUAL_INT(0, get_recording_metadata_by_id(id, &recording)); + TEST_ASSERT_EQUAL_STRING(destination_uuid, recording.storage_target_uuid); + assert_bytes(destination_path, payload); + TEST_ASSERT_EQUAL_INT(-1, access(source_path, F_OK)); +} +static void test_preexisting_temporary_survives_failed_job_and_later_cleanup(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + put_file(temporary_path, "unowned temporary cleanup sentinel"); + put_file(destination_path, "unowned final cleanup sentinel"); + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_bytes(temporary_path, "unowned temporary cleanup sentinel"); + assert_source_location(id); + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + assert_bytes(temporary_path, "unowned temporary cleanup sentinel"); + assert_bytes(destination_path, "unowned final cleanup sentinel"); + assert_source_location(id); assert_cleanup_pending(&job, "failed"); +} +static void test_retry_preserves_previous_attempt_and_uses_fresh_artifact(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + publication_error = EIO; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_source_location(id); assert_bytes(temporary_path, payload); + publication_error = 0; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec(get_db_handle(), + "UPDATE storage_migration_jobs SET next_attempt_at=0;", NULL, NULL, NULL)); + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_bytes(temporary_path, payload); assert_bytes(destination_path, payload); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("completed", job.state); +} +static void test_preexisting_temporary_symlink_and_target_survive(void) { + storage_migration_job_t job; create_job(&job, "copy"); + TEST_ASSERT_EQUAL_INT(0, ensure_path(temporary_path)); + TEST_ASSERT_EQUAL_INT(0, symlink(sentinel, temporary_path)); + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_bytes(sentinel, "sentinel unchanged"); assert_bytes(destination_path, payload); + struct stat info; TEST_ASSERT_EQUAL_INT(0, lstat(temporary_path, &info)); + TEST_ASSERT_TRUE(S_ISLNK(info.st_mode)); +} +static void test_fresh_attempt_without_legacy_name_keeps_cleanup_pending(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + char fresh[MAX_PATH_LENGTH]; + int length = snprintf(fresh, sizeof(fresh), "%s.migration-%s.%s.part", destination_path, + job.uuid, "11111111-1111-4111-8111-111111111111"); + TEST_ASSERT_TRUE(length > 0 && (size_t)length < sizeof(fresh)); + put_file(fresh, "unowned fresh attempt"); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_request_cancel(job.uuid, &job)); + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + assert_bytes(fresh, "unowned fresh attempt"); assert_source_location(id); + TEST_ASSERT_EQUAL_INT(-1, access(temporary_path, F_OK)); + assert_cleanup_pending(&job, "cancelled"); +} +static void test_quarantine_unsupported_preserves_verified_temporary(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + quarantine_error = ENOSYS; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_source_location(id); assert_bytes(temporary_path, payload); + TEST_ASSERT_EQUAL_INT(-1, access(destination_path, F_OK)); +} +static void test_post_publication_substitution_is_rejected_before_commit(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + committing_replacement = 1; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_source_location(id); assert_bytes(verified_part, payload); + assert_bytes(destination_path, "substituted published destination"); +} +static void test_restore_collision_retains_private_entry_and_cleanup_debt(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + replacement_kind = 3; armed = restore_collision = true; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_source_location(id); assert_bytes(verified_part, payload); + assert_bytes(temporary_path, "restore collision sentinel"); + assert_bytes(private_artifact, "unverified replacement data"); + TEST_ASSERT_EQUAL_INT(0, unlink(temporary_path)); /* Remove only test-created sentinel. */ + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + assert_bytes(private_artifact, "unverified replacement data"); + assert_cleanup_pending(&job, "failed"); +} +static void test_failed_commit_does_not_unlink_substituted_destination(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + commit_failure_replacement = true; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_source_location(id); assert_bytes(verified_part, payload); + assert_bytes(destination_path, "failed commit substitution sentinel"); +} +static void test_cancelled_publication_does_not_unlink_substituted_destination(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + cancel_after_publication = true; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_source_location(id); assert_bytes(verified_part, payload); + assert_bytes(destination_path, "cancelled substitution sentinel"); + TEST_ASSERT_EQUAL_INT(0, storage_migration_process_one()); + assert_cleanup_pending(&job, "cancelled"); +} +static void test_modified_verified_inode_is_rejected(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + replacement_kind = 4; armed = true; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); TEST_ASSERT_TRUE(injected); + assert_source_location(id); assert_bytes(temporary_path, "modified verified inode"); + TEST_ASSERT_EQUAL_INT(-1, access(destination_path, F_OK)); +} +static void test_parent_replacement_with_same_file_inode_is_rejected(void) { + storage_migration_job_t job; uint64_t id = create_job(&job, "move"); + committing_replacement = 2; + TEST_ASSERT_EQUAL_INT(1, storage_migration_process_one()); + assert_source_location(id); assert_bytes(destination_path, payload); + TEST_ASSERT_EQUAL_INT(DB_STORAGE_MIGRATION_OK, db_storage_migration_get(job.uuid, &job)); + TEST_ASSERT_EQUAL_STRING("failed", job.state); +} +int main(void) { + strcpy(root, "/tmp/lightnvr-storage-migration-publication-XXXXXX"); if (!mkdtemp(root)) return 2; + snprintf(source_root, sizeof(source_root), "%s/source", root); + snprintf(destination_root, sizeof(destination_root), "%s/destination", root); + snprintf(db_path, sizeof(db_path), "%s/database.sqlite", root); + snprintf(relocated_parent, sizeof(relocated_parent), "%s/relocated-parent", root); + snprintf(source_path, sizeof(source_path), "%s/camera/clip.mp4", source_root); + snprintf(destination_path, sizeof(destination_path), "%s/camera/clip.mp4", destination_root); + snprintf(sentinel, sizeof(sentinel), "%s/sentinel", root); + snprintf(verified_part, sizeof(verified_part), "%s/verified-part", root); + load_default_config(&g_config); + if (init_database_ex(db_path, DB_INIT_NO_BACKUP) != 0) { free(g_config.streams); remove_owned_tree(root); return 2; } + UNITY_BEGIN(); + RUN_TEST(test_normal_move_commits_verified_destination_and_removes_source); + RUN_TEST(test_normal_copy_keeps_source_and_verified_replica); + RUN_TEST(test_regular_destination_appearing_after_check_is_not_overwritten); + RUN_TEST(test_symlink_destination_appearing_after_check_is_retained); + RUN_TEST(test_existing_collision_survives_later_abandoned_cleanup); + RUN_TEST(test_unverified_temporary_replacement_is_not_committed); + RUN_TEST(test_unavailable_registered_source_preserves_metadata_and_bytes); + RUN_TEST(test_unsupported_atomic_publication_retains_source_and_temporary); + RUN_TEST(test_unsupported_filesystem_publication_retains_source_and_temporary); + RUN_TEST(test_publication_io_failure_retains_source_and_temporary); + RUN_TEST(test_collision_cleanup_failure_never_overwrites_competing_destination); + RUN_TEST(test_abandoned_cleanup_retains_unowned_symlink); + RUN_TEST(test_cancelled_job_retains_unowned_final_destination); + RUN_TEST(test_cancelled_job_without_artifacts_completes_cleanup); + RUN_TEST(test_matching_bytes_do_not_prove_abandoned_destination_ownership); + RUN_TEST(test_destination_inspection_error_keeps_cleanup_pending); + RUN_TEST(test_preexisting_temporary_is_preserved_while_fresh_attempt_succeeds); + RUN_TEST(test_preexisting_temporary_survives_failed_job_and_later_cleanup); + RUN_TEST(test_retry_preserves_previous_attempt_and_uses_fresh_artifact); + RUN_TEST(test_preexisting_temporary_symlink_and_target_survive); + RUN_TEST(test_fresh_attempt_without_legacy_name_keeps_cleanup_pending); + RUN_TEST(test_quarantine_unsupported_preserves_verified_temporary); + RUN_TEST(test_post_publication_substitution_is_rejected_before_commit); + RUN_TEST(test_restore_collision_retains_private_entry_and_cleanup_debt); + RUN_TEST(test_failed_commit_does_not_unlink_substituted_destination); + RUN_TEST(test_cancelled_publication_does_not_unlink_substituted_destination); + RUN_TEST(test_modified_verified_inode_is_rejected); + RUN_TEST(test_parent_replacement_with_same_file_inode_is_rejected); + 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 {