fix(relay): return stable deletion lifecycle conflicts

Signed-off-by: Codex <noreply@openai.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Codex
2026-09-29 02:27:25 +00:00
parent 3b94325cd4
commit bd95c4a984

View File

@@ -432,8 +432,9 @@ pub async fn unarchive_community(
let record = match result {
buzz_db::UnarchiveCommunityResult::Unarchived(record) => record,
buzz_db::UnarchiveCommunityResult::DeletionPending => {
return Err(api_error(
return Err(deletion_api_error(
StatusCode::CONFLICT,
"deletion_lifecycle_conflict",
"community deletion is pending",
));
}
@@ -762,8 +763,9 @@ pub async fn transfer_community(
));
}
buzz_db::relay_members::TransferResult::DeletionPending => {
return Err(api_error(
return Err(deletion_api_error(
StatusCode::CONFLICT,
"deletion_lifecycle_conflict",
"community deletion is pending",
));
}
@@ -2151,6 +2153,128 @@ mod postgres_tests {
assert_eq!(owner_member.role, "owner");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn unarchive_pending_deletion_returns_stable_conflict_without_mutation() {
let operator = Keys::generate();
let owner = Keys::generate();
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
return;
};
let host = format!("community-{}.example", Uuid::new_v4().simple());
assert_eq!(
provision_community(Arc::clone(&state), &operator, &host, &owner)
.await
.status(),
StatusCode::OK
);
let community = state
.db
.lookup_community_by_host(&host)
.await
.expect("lookup community")
.expect("community exists");
archive_for_owner_deletion(&state, &host, &owner).await;
let request_id = Uuid::new_v4();
assert_eq!(
signed_operator_request(
Arc::clone(&state),
&operator,
"POST",
"/operator/communities/delete",
Some(owner_delete_body(&host, &owner, request_id)),
)
.await
.status(),
StatusCode::ACCEPTED
);
let pool = sqlx::PgPool::connect(&crate::test_support::database_url())
.await
.expect("connect lifecycle assertion pool");
let before_lifecycle: (
Option<chrono::DateTime<chrono::Utc>>,
String,
Option<chrono::DateTime<chrono::Utc>>,
) = sqlx::query_as(
"SELECT archived_at, deletion_state, deleted_at FROM communities WHERE id = $1",
)
.bind(community.id.as_uuid())
.fetch_one(&pool)
.await
.expect("read lifecycle before unarchive conflict");
let before_request = state
.db
.deletion_store()
.get(request_id)
.await
.expect("read deletion request before unarchive conflict");
let owner_hex = owner.public_key().to_hex();
let before_owner_role = state
.db
.get_relay_member(community.id, &owner_hex)
.await
.expect("read owner before unarchive conflict")
.expect("owner exists before unarchive conflict")
.role;
let body = serde_json::json!({
"host": host,
"owner_pubkey": owner_hex,
})
.to_string();
let response = signed_operator_request(
Arc::clone(&state),
&operator,
"POST",
"/operator/communities/unarchive",
Some(body),
)
.await;
assert_eq!(response.status(), StatusCode::CONFLICT);
let json = read_json(response).await;
assert_eq!(json["code"], "deletion_lifecycle_conflict");
assert_eq!(json["error"], "community deletion is pending");
let after_lifecycle: (
Option<chrono::DateTime<chrono::Utc>>,
String,
Option<chrono::DateTime<chrono::Utc>>,
) = sqlx::query_as(
"SELECT archived_at, deletion_state, deleted_at FROM communities WHERE id = $1",
)
.bind(community.id.as_uuid())
.fetch_one(&pool)
.await
.expect("read lifecycle after unarchive conflict");
assert_eq!(after_lifecycle, before_lifecycle);
assert_eq!(
state
.db
.deletion_store()
.get(request_id)
.await
.expect("read deletion request after unarchive conflict"),
before_request
);
assert_eq!(
state
.db
.get_relay_member(community.id, &owner.public_key().to_hex())
.await
.expect("read owner after unarchive conflict")
.expect("owner exists after unarchive conflict")
.role,
before_owner_role
);
assert!(state
.db
.lookup_community_by_host(&host)
.await
.expect("active lookup after unarchive conflict")
.is_none());
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn archive_publish_failure_is_retryable_and_preserves_timestamp() {
@@ -2485,6 +2609,137 @@ mod postgres_tests {
.await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn transfer_pending_deletion_returns_stable_conflict_without_mutation() {
let operator = Keys::generate();
let initial_owner = Keys::generate();
let new_owner = Keys::generate();
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
return;
};
let host = format!("community-{}.example", Uuid::new_v4().simple());
assert_eq!(
provision_community(Arc::clone(&state), &operator, &host, &initial_owner)
.await
.status(),
StatusCode::OK
);
let community = state
.db
.lookup_community_by_host(&host)
.await
.expect("lookup community")
.expect("community exists");
archive_for_owner_deletion(&state, &host, &initial_owner).await;
let request_id = Uuid::new_v4();
assert_eq!(
signed_operator_request(
Arc::clone(&state),
&operator,
"POST",
"/operator/communities/delete",
Some(owner_delete_body(&host, &initial_owner, request_id)),
)
.await
.status(),
StatusCode::ACCEPTED
);
let pool = sqlx::PgPool::connect(&crate::test_support::database_url())
.await
.expect("connect lifecycle assertion pool");
let before_lifecycle: (
Option<chrono::DateTime<chrono::Utc>>,
String,
Option<chrono::DateTime<chrono::Utc>>,
) = sqlx::query_as(
"SELECT archived_at, deletion_state, deleted_at FROM communities WHERE id = $1",
)
.bind(community.id.as_uuid())
.fetch_one(&pool)
.await
.expect("read lifecycle before transfer conflict");
let before_request = state
.db
.deletion_store()
.get(request_id)
.await
.expect("read deletion request before transfer conflict");
let initial_owner_hex = initial_owner.public_key().to_hex();
let new_owner_hex = new_owner.public_key().to_hex();
let before_owner_role = state
.db
.get_relay_member(community.id, &initial_owner_hex)
.await
.expect("read owner before transfer conflict")
.expect("owner exists before transfer conflict")
.role;
assert!(state
.db
.get_relay_member(community.id, &new_owner_hex)
.await
.expect("read transferee before transfer conflict")
.is_none());
let body = serde_json::json!({
"community_id": community.id.to_string(),
"new_owner_pubkey": new_owner_hex,
"expected_owner_pubkey": initial_owner_hex,
})
.to_string();
let response = signed_operator_request(
Arc::clone(&state),
&operator,
"POST",
"/operator/communities/transfer",
Some(body),
)
.await;
assert_eq!(response.status(), StatusCode::CONFLICT);
let json = read_json(response).await;
assert_eq!(json["code"], "deletion_lifecycle_conflict");
assert_eq!(json["error"], "community deletion is pending");
let after_lifecycle: (
Option<chrono::DateTime<chrono::Utc>>,
String,
Option<chrono::DateTime<chrono::Utc>>,
) = sqlx::query_as(
"SELECT archived_at, deletion_state, deleted_at FROM communities WHERE id = $1",
)
.bind(community.id.as_uuid())
.fetch_one(&pool)
.await
.expect("read lifecycle after transfer conflict");
assert_eq!(after_lifecycle, before_lifecycle);
assert_eq!(
state
.db
.deletion_store()
.get(request_id)
.await
.expect("read deletion request after transfer conflict"),
before_request
);
assert_eq!(
state
.db
.get_relay_member(community.id, &initial_owner.public_key().to_hex())
.await
.expect("read owner after transfer conflict")
.expect("owner exists after transfer conflict")
.role,
before_owner_role
);
assert!(state
.db
.get_relay_member(community.id, &new_owner.public_key().to_hex())
.await
.expect("read transferee after transfer conflict")
.is_none());
}
/// Transfer with an invalid community_id returns 400.
#[tokio::test]
#[ignore = "requires Postgres"]