#[test]
fn local_message_deletion_survives_restart_and_protocol_and_sibling_replay() {
for grouped in [false, true] {
let owner = Keys::generate();
let device = Keys::generate();
let sibling = Keys::generate();
let peer = Keys::generate();
let (mut core, _, directory) =
logged_in_test_core_with_updates("local-delete-replay", &owner, &device);
configure_test_device_sync_profile(&mut core, &owner, &device, &sibling, None);
let chat_id = if grouped {
let group = test_group_snapshot(
"local-delete-group",
"Delete group",
owner.public_key(),
vec![owner.public_key(), peer.public_key()],
vec![owner.public_key()],
1,
);
let id = group_chat_id(&group.group_id);
core.apply_group_decrypted_event(GroupIncomingEvent::MetadataUpdated(group));
id
} else {
peer.public_key().to_hex()
};
let now = unix_now().get();
for (author, id) in [
(owner.public_key(), "deleted-mine"),
(peer.public_key(), "deleted-theirs"),
] {
core.apply_runtime_text_message(
author,
Some(chat_id.clone()),
"locally-deleted history".into(),
now,
None,
Some(id.into()),
None,
);
}
core.persist_best_effort();
let stale = core.build_device_sync_packets_for_test(100, true);
core.active_chat_id = Some(chat_id.clone());
for id in ["deleted-mine", "deleted-theirs"] {
core.handle_action(AppAction::DeleteLocalMessage {
chat_id: chat_id.clone(),
message_id: id.into(),
});
assert!(!core.threads[&chat_id].messages.iter().any(|m| m.id == id));
assert!(!core
.app_store
.message_exists(&chat_id, Some(id), None)
.unwrap());
}
assert!(core
.app_store
.search_messages_fts("locally-deleted", Some(&chat_id), 10)
.unwrap()
.is_empty());
drop(core);
let mut core = logged_in_test_core_at_data_dir(
&owner,
&device,
directory.path().to_string_lossy().into_owned(),
);
core.load_persisted().unwrap();
configure_test_device_sync_profile(&mut core, &owner, &device, &sibling, None);
for (author, id) in [
(owner.public_key(), "deleted-mine"),
(peer.public_key(), "deleted-theirs"),
] {
core.apply_runtime_text_message(
author,
Some(chat_id.clone()),
"locally-deleted history".into(),
now,
None,
Some(id.into()),
None,
);
}
for packet in stale {
core.handle_device_sync_packet(&sibling.public_key().to_hex(), 7369, &packet);
}
assert!(
core.threads.get(&chat_id).is_none_or(|thread| {
thread
.messages
.iter()
.all(|m| !m.id.starts_with("deleted-"))
}),
"local deletion was undone by old protocol/sibling history"
);
core.apply_runtime_text_message(
peer.public_key(),
Some(chat_id.clone()),
"A new message still arrives".into(),
now,
None,
Some("different-id".into()),
None,
);
assert!(core.threads[&chat_id]
.messages
.iter()
.any(|m| m.id == "different-id"));
let unrelated_peer = Keys::generate();
let unrelated_chat = unrelated_peer.public_key().to_hex();
core.apply_runtime_text_message(
unrelated_peer.public_key(),
None,
"Same ID in a different chat".into(),
now,
None,
Some("deleted-mine".into()),
None,
);
assert!(core.threads[&unrelated_chat]
.messages
.iter()
.any(|m| m.id == "deleted-mine"));
}
}
#[test]
fn local_message_deletion_removes_a_row_outside_the_loaded_page() {
let owner = Keys::generate();
let device = Keys::generate();
let peer = Keys::generate();
let (mut core, _, _directory) =
logged_in_test_core_with_updates("local-delete-unloaded", &owner, &device);
let chat_id = peer.public_key().to_hex();
for id in ["older", "latest"] {
core.apply_runtime_text_message(
peer.public_key(),
None,
id.into(),
unix_now().get(),
None,
Some(id.into()),
None,
);
}
core.persist_best_effort();
core.threads
.get_mut(&chat_id)
.unwrap()
.messages
.retain(|m| m.id == "latest");
core.handle_action(AppAction::DeleteLocalMessage {
chat_id: chat_id.clone(),
message_id: "older".into(),
});
assert!(!core
.app_store
.message_exists(&chat_id, Some("older"), None)
.unwrap());
assert!(core
.app_store
.message_exists(&chat_id, Some("latest"), None)
.unwrap());
}
#[test]
fn local_message_deletion_keeps_visible_message_when_sqlite_delete_fails() {
let owner = Keys::generate();
let device = Keys::generate();
let peer = Keys::generate();
let (mut core, _, _directory) =
logged_in_test_core_with_updates("local-delete-rollback", &owner, &device);
let chat_id = peer.public_key().to_hex();
core.apply_runtime_text_message(
peer.public_key(),
None,
"Keep on failure".into(),
unix_now().get(),
None,
Some("kept".into()),
None,
);
core.persist_best_effort();
core.app_store
.shared()
.lock()
.unwrap()
.execute_batch(
"CREATE TRIGGER fail_user_delete BEFORE DELETE ON messages
BEGIN SELECT RAISE(ABORT, 'injected delete failure'); END;",
)
.unwrap();
core.handle_action(AppAction::DeleteLocalMessage {
chat_id: chat_id.clone(),
message_id: "kept".into(),
});
assert!(core.threads[&chat_id]
.messages
.iter()
.any(|m| m.id == "kept"));
assert!(core
.app_store
.message_exists(&chat_id, Some("kept"), None)
.unwrap());
assert!(
core.export_sync_record_values_for_test()
.unwrap()
.iter()
.all(|r| r["type"] != "privateMessageDeletion"),
"failed delete leaked a sync event"
);
core.app_store
.shared()
.lock()
.unwrap()
.execute_batch("DROP TRIGGER fail_user_delete")
.unwrap();
core.app_store.delete_message(&chat_id, "kept").unwrap();
core.threads.get_mut(&chat_id).unwrap().messages.clear();
core.apply_runtime_text_message(
peer.public_key(),
None,
"Keep on failure".into(),
unix_now().get(),
None,
Some("kept".into()),
None,
);
assert!(core.threads[&chat_id]
.messages
.iter()
.any(|m| m.id == "kept"));
core.handle_action(AppAction::DeleteLocalMessage {
chat_id: chat_id.clone(),
message_id: "kept".into(),
});
assert!(!core
.app_store
.message_exists(&chat_id, Some("kept"), None)
.unwrap());
}
#[test]
fn local_message_deletion_rejects_late_notification_preview_and_source_alias() {
let owner = Keys::generate();
let device = Keys::generate();
let peer = Keys::generate();
let (mut core, _, _directory) =
logged_in_test_core_with_updates("local-delete-preview", &owner, &device);
let chat_id = peer.public_key().to_hex();
let outer_id = "ab".repeat(32);
let now = unix_now().get();
core.apply_runtime_text_message(
peer.public_key(),
None,
"Deleted notification".into(),
now,
None,
Some("canonical-id".into()),
Some(outer_id.clone()),
);
core.persist_best_effort();
let preview = core.threads[&chat_id]
.messages
.iter()
.find(|m| m.id == "canonical-id")
.unwrap()
.clone();
let stale_thread = core.threads[&chat_id].clone();
core.handle_action(AppAction::DeleteLocalMessage {
chat_id: chat_id.clone(),
message_id: "canonical-id".into(),
});
core.app_store
.save_message_receipts(
&chat_id,
std::slice::from_ref(&preview),
Some(&stale_thread),
)
.unwrap();
assert!(
!core
.app_store
.message_exists(&chat_id, Some("canonical-id"), None)
.unwrap(),
"a delayed receipt save must not restore the deleted row"
);
core.app_store
.upsert_notification_preview_message(&chat_id, 0, now, &preview)
.unwrap();
assert!(
!core
.app_store
.message_exists(&chat_id, Some("canonical-id"), None)
.unwrap(),
"a stale notification preview must not restore the deleted row"
);
core.push_incoming_message_from(
&chat_id,
None,
"Deleted notification".into(),
now,
None,
None,
Some(peer.public_key().to_hex()),
Some(outer_id),
);
assert!(
core.threads[&chat_id].messages.is_empty(),
"outer-event replay must stay deleted even without the canonical inner ID"
);
}
#[test]
fn delete_for_me_reconciles_private_events_after_offline_restart() {
let mut pair = chat_read_sync_pair("private-message-deletion");
let peer = Keys::generate();
let chat = peer.public_key().to_hex();
for core in [&mut pair.a, &mut pair.b] {
chat_read_sync_incoming(core, &peer, "keep", 201);
chat_read_sync_incoming(core, &peer, "remove", 202);
}
chat_read_sync_incoming(&mut pair.a, &peer, "late", 203);
for id in ["remove", "late"] {
pair.a.handle_action(AppAction::DeleteLocalMessage {
chat_id: chat.clone(),
message_id: id.into(),
});
}
drop(pair.a);
pair.a = logged_in_test_core_at_data_dir(
&pair.owner,
&pair.a_device,
pair.a_dir.path().to_string_lossy().into_owned(),
);
pair.a.load_persisted().unwrap();
configure_test_device_sync_profile(
&mut pair.a,
&pair.owner,
&pair.a_device,
&pair.b_device,
None,
);
let (a_rx, b_rx) = install_receipt_test_link(&mut pair);
pair.b.refresh_device_sync_on_foreground();
let trace = pump_receipt_test_link(&mut pair, &a_rx, &b_rx);
assert!(
pair.b.message_for_mutation(&chat, "remove").is_none(),
"sibling retained the deleted message"
);
assert_eq!(pair.b.threads[&chat].unread_count, 1);
let events: Vec<_> = trace
.iter()
.filter(|p| p["type"] == "historyRecords")
.flat_map(|p| p["records"].as_array().into_iter().flatten())
.filter(|r| r["type"] == "privateMessageDeletion")
.collect();
assert_eq!(events.len(), 2);
for record in events {
let event: nostr_identity::PrivateEvent =
serde_json::from_value(record["event"].clone()).unwrap();
let (setting, target, value) =
nostr_identity::parse_private_setting(&event, pair.owner.public_key()).unwrap();
assert_eq!(setting, "messageDeletion");
assert_eq!(target, chat);
assert!(["remove", "late"].contains(&value.as_str().unwrap()));
}
let group = "group:delete-live";
for core in [&mut pair.a, &mut pair.b] {
core.push_incoming_message_from(
group,
Some("keep".into()),
"group body".into(),
204,
None,
None,
Some(chat.clone()),
None,
);
}
let pending = pair.a.pending_relay_publishes.len();
pair.a.delete_local_message(group, "keep");
assert_eq!(pair.a.pending_relay_publishes.len(), pending);
pump_receipt_test_link(&mut pair, &a_rx, &b_rx);
assert!(pair.b.message_for_mutation(group, "keep").is_none());
assert!(pair.b.message_for_mutation(&chat, "keep").is_some());
drop(pair.b);
let mut phone = logged_in_test_core_at_data_dir(
&pair.owner,
&pair.b_device,
pair._b_dir.path().to_string_lossy().into_owned(),
);
phone.load_persisted().unwrap();
chat_read_sync_incoming(&mut phone, &peer, "late", 203);
assert!(phone.message_for_mutation(&chat, "late").is_none());
assert!(phone.message_for_mutation(&chat, "keep").is_some());
}