diff --git a/src/nostr/lifecycle/deletion/cleanup.rs b/src/nostr/lifecycle/deletion/cleanup.rs index 7c2eeb5..e65485c 100644 --- a/src/nostr/lifecycle/deletion/cleanup.rs +++ b/src/nostr/lifecycle/deletion/cleanup.rs @@ -420,6 +420,41 @@ mod tests { assert!(main_has(&service, &request).await); } + #[tokio::test] + async fn cleanup_repairs_orphan_metadata_idempotently_after_partial_removal() { + let service = service(false); + let request = deletion(); + service + .ctx + .tombstones() + .record_request( + &request, + Timestamp::from_secs(0), + RequestClassification::LocallyActionable, + ) + .await + .unwrap(); + service + .ctx + .tombstones() + .remove_request_payload_without_metadata(request.id) + .await + .unwrap(); + + let first = service + .cleanup_expired_requests(Timestamp::from_secs(15)) + .await + .unwrap(); + assert_eq!(first.canonical_records_examined, 0); + assert_eq!(first.metadata_rows_removed, 1); + let rerun = service + .cleanup_expired_requests(Timestamp::from_secs(15)) + .await + .unwrap(); + assert_eq!(rerun.metadata_rows_removed, 0); + assert_eq!(rerun.failures, 0); + } + #[tokio::test] async fn startup_reconciliation_and_periodic_cleanup_share_serving_boundary() { let startup = service(false); diff --git a/src/nostr/lifecycle/deletion/policy.rs b/src/nostr/lifecycle/deletion/policy.rs index 15542d0..ddc1a23 100644 --- a/src/nostr/lifecycle/deletion/policy.rs +++ b/src/nostr/lifecycle/deletion/policy.rs @@ -615,6 +615,44 @@ mod tests { assert_eq!(record.last_used_at, None); } + #[tokio::test] + async fn nip09_failed_archive_preserves_target_and_leaves_request_unused() { + let mut ctx = make_context(); + let directory = tempfile::tempdir().unwrap(); + ctx.git_data_path = directory.path().to_path_buf(); + let keys = Keys::generate(); + let target = make_announcement_event(&keys, "archive-failure"); + let owner = owner_directory_component(&keys.public_key()); + std::fs::create_dir_all(directory.path().join(&owner).join("archive-failure.git")).unwrap(); + // A file where the archive directory belongs makes the archival attempt + // fail, so the fail-safe path must not award deletion-use credit. + std::fs::write(directory.path().join(".archive"), "not a directory").unwrap(); + ctx.database.save_event(&target).await.unwrap(); + let request = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![Tag::event(target.id)]) + .finalize(&keys) + .unwrap(); + + assert!(matches!( + DeletionPolicy::new(ctx.clone()).handle(&request).await, + WritePolicyResult::Accept + )); + assert!(ctx + .database + .event_by_id(&target.id) + .await + .unwrap() + .is_some()); + assert_eq!( + ctx.tombstones + .lifecycle_for_request(&request.id) + .await + .unwrap() + .last_used_at, + None + ); + } + #[tokio::test] async fn coordinate_deletion_requests_with_different_cutoffs_coexist() { let ctx = make_context(); diff --git a/src/nostr/lifecycle/deletion/service.rs b/src/nostr/lifecycle/deletion/service.rs index f89f86e..abdc9e1 100644 --- a/src/nostr/lifecycle/deletion/service.rs +++ b/src/nostr/lifecycle/deletion/service.rs @@ -1327,4 +1327,40 @@ mod tests { None ); } + + #[tokio::test] + async fn coordinate_request_with_insufficient_cutoff_never_receives_use_credit() { + let ctx = context(crate::config::Config::for_testing()); + let service = DeletionService::new(ctx.clone()); + let keys = Keys::generate(); + let coordinate = format!("30617:{}:repo", keys.public_key().to_hex()); + let request = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![Tag::custom("a", vec![coordinate])]) + .custom_created_at(Timestamp::from_secs(100)) + .finalize(&keys) + .unwrap(); + ctx.tombstones + .record_request( + &request, + Timestamp::from_secs(10), + RequestClassification::LocallyActionable, + ) + .await + .unwrap(); + let incoming = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(vec![Tag::identifier("repo")]) + .custom_created_at(Timestamp::from_secs(101)) + .finalize(&keys) + .unwrap(); + + assert!(service.gate(&incoming).await.is_none()); + assert_eq!( + ctx.tombstones + .lifecycle_for_request(&request.id) + .await + .unwrap() + .last_used_at, + None + ); + } } diff --git a/src/nostr/lifecycle/deletion/startup.rs b/src/nostr/lifecycle/deletion/startup.rs index f2159c9..d64f0c1 100644 --- a/src/nostr/lifecycle/deletion/startup.rs +++ b/src/nostr/lifecycle/deletion/startup.rs @@ -837,4 +837,67 @@ mod tests { .unwrap() .is_some()); } + + #[tokio::test] + async fn mode_round_trip_reclassifies_without_changing_lifecycle_timestamps() { + let normal = service(false); + let request = deletion(&Keys::generate()); + normal + .ctx + .tombstones() + .record_request( + &request, + Timestamp::from_secs(10), + RequestClassification::LocallyActionable, + ) + .await + .unwrap(); + normal + .ctx + .tombstones() + .mark_request_used(&request.id, Timestamp::from_secs(20)) + .await + .unwrap(); + + let mut disrespector_context = normal.ctx.clone(); + disrespector_context.config.deletion_request_disrespector = true; + let disrespector = DeletionService::new(disrespector_context); + let entering = disrespector + .run_request_lifecycle_startup_reconciliation(Timestamp::from_secs(30)) + .await + .unwrap(); + assert_eq!(entering.requests_reclassified, 1); + let entering_record = disrespector + .ctx + .tombstones() + .lifecycle_for_request_result(&request.id) + .await + .unwrap() + .unwrap(); + assert_eq!(entering_record.first_seen_at, Timestamp::from_secs(10)); + assert_eq!(entering_record.last_used_at, Some(Timestamp::from_secs(20))); + assert_eq!( + entering_record.classification, + RequestClassification::Disrespector + ); + + let leaving = normal + .run_request_lifecycle_startup_reconciliation(Timestamp::from_secs(40)) + .await + .unwrap(); + assert_eq!(leaving.requests_reclassified, 1); + let leaving_record = normal + .ctx + .tombstones() + .lifecycle_for_request_result(&request.id) + .await + .unwrap() + .unwrap(); + assert_eq!(leaving_record.first_seen_at, Timestamp::from_secs(10)); + assert_eq!(leaving_record.last_used_at, Some(Timestamp::from_secs(20))); + assert_eq!( + leaving_record.classification, + RequestClassification::LocallyActionable + ); + } } diff --git a/src/nostr/lifecycle/tombstones.rs b/src/nostr/lifecycle/tombstones.rs index 96b5233..8a94e16 100644 --- a/src/nostr/lifecycle/tombstones.rs +++ b/src/nostr/lifecycle/tombstones.rs @@ -207,6 +207,17 @@ impl Tombstones { .map_err(|error| anyhow::anyhow!("Failed to save test request payload: {error}")) } + #[cfg(test)] + pub(crate) async fn remove_request_payload_without_metadata( + &self, + request_id: EventId, + ) -> anyhow::Result<()> { + self.db + .delete(Filter::new().ids(vec![request_id])) + .await + .map_err(|error| anyhow::anyhow!("Failed to remove test request payload: {error}")) + } + /// Persist a signed deletion/vanish request and relay-generated lifecycle /// metadata. Exact replays retain their original `first_seen_at`. pub async fn record_request(