test: cover deletion lifecycle edge cases

This commit is contained in:
DanConwayDev
2026-07-17 08:14:51 +01:00
parent 75eb6a411d
commit d1961ecd8c
5 changed files with 183 additions and 0 deletions
+35
View File
@@ -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);
+38
View File
@@ -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();
+36
View File
@@ -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
);
}
}
+63
View File
@@ -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
);
}
}
+11
View File
@@ -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(