diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 50266c4ddc..87073eebf0 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -53,7 +53,6 @@ pub struct AppState { pub huddle_state: Mutex, pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, /// Tauri handle for emitting huddle events. - /// /// Set once during `setup()` in `lib.rs`; never cleared. pub app_handle: Mutex>, /// Port of the localhost media streaming proxy (set during setup). diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_security_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_security_tests.rs new file mode 100644 index 0000000000..c5ada4fa79 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_security_tests.rs @@ -0,0 +1,351 @@ +//! Security-focused inbound regressions: tombstone (kind:5) authorization +//! and retained-head coverage, the inbound signature gate, and +//! restart-rehydration of the private-config overlay. +//! +//! Extracted from `inbound_tests.rs` to keep it under the file-size cap; +//! `#[path]`-included from there. Helpers (`AGENT_PUBKEY`, +//! `private_agent_payload`, `local_agent`) live in the parent module. + +#[allow(unused_imports)] +use super::super::*; +use super::*; + +// ── Tombstone (kind:5) consume ──────────────────────────────────────────── + +fn deletion_event(coord: &str) -> nostr::Event { + deletion_event_with_keys(coord, &nostr::Keys::generate()) +} + +fn deletion_event_with_keys(coord: &str, keys: &nostr::Keys) -> nostr::Event { + use nostr::{EventBuilder, JsonUtil, Kind, Tag}; + let event = EventBuilder::new(Kind::Custom(5), "") + .tags(vec![Tag::parse(["a", coord]).unwrap()]) + .sign_with_keys(keys) + .unwrap(); + nostr::Event::from_json(event.as_json()).unwrap() +} + +/// A deletion event whose coordinate owner IS its signer — the only shape +/// `parse_deletion_coordinate` accepts since the owner check landed. +fn owned_deletion_event(kind: u32, d_tag: &str) -> nostr::Event { + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + deletion_event_with_keys(&format!("{kind}:{owner}:{d_tag}"), &keys) +} + +/// Like [`deletion_event_with_keys`] but with a controlled `created_at`, for +/// tests that race a tombstone against a retained head's timestamp. +fn owned_deletion_event_at( + keys: &nostr::Keys, + kind: u32, + d_tag: &str, + created_at: u64, +) -> nostr::Event { + use nostr::{EventBuilder, JsonUtil, Kind, Tag, Timestamp}; + let owner = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(5), "") + .tags(vec![ + Tag::parse(["a", &format!("{kind}:{owner}:{d_tag}")]).unwrap() + ]) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap(); + nostr::Event::from_json(event.as_json()).unwrap() +} + +// ── resolve_inbound_tombstone: authorization + retained-head coverage ────── +// +// The seam is deliberately a pure function over (event, scope_owner, conn) so +// the security decision is testable WITHOUT a live AppHandle — the production +// caller (`reconcile_inbound_tombstone`) is a `#[tauri::command]` body that +// only adds scope resolution and the per-kind store mutation around it. These +// are the direct-IPC regressions: the frontend owner filter never runs here. + +/// Issue-4 regression: a VALIDLY SIGNED tombstone whose signer owns the +/// coordinate it names — so it clears `parse_deletion_coordinate` — but who is +/// not the workspace owner must no-op. Local stores match by d-tag alone, so +/// without the scope-owner binding this would delete the victim's record. +#[test] +fn resolve_inbound_tombstone_rejects_foreign_owner() { + use crate::managed_agents::retention::open_retention_db; + use std::path::Path; + + let conn = open_retention_db(Path::new(":memory:")).unwrap(); + let scope_owner_keys = nostr::Keys::generate(); + let attacker_keys = nostr::Keys::generate(); + + // The attacker names their OWN coordinate (passes the NIP-09 signer == + // coordinate-owner check) with a d-tag colliding with the victim's agent. + let forged = owned_deletion_event_at(&attacker_keys, 30177, AGENT_PUBKEY, 100); + let resolved = + resolve_inbound_tombstone(&forged, &scope_owner_keys.public_key(), &conn).unwrap(); + assert_eq!(resolved, None, "foreign-owner tombstone must not authorize"); + + // Nothing was retained: the attacker cannot even park a kind:5 row in the + // victim's scoped store. + assert!( + !crate::managed_agents::retention::has_retained_personas( + &conn, + &attacker_keys.public_key().to_hex() + ) + .unwrap(), + "a rejected tombstone must leave no retained row" + ); + + // POSITIVE CONTROL: the same event shape signed by the scope owner + // resolves — proving the rejection above is the owner binding, not a + // broken fixture. + let owned = owned_deletion_event_at(&scope_owner_keys, 30177, AGENT_PUBKEY, 100); + assert_eq!( + resolve_inbound_tombstone(&owned, &scope_owner_keys.public_key(), &conn).unwrap(), + Some((30177, AGENT_PUBKEY.to_string())), + "control: the scope owner's tombstone authorizes" + ); +} + +/// Issue-2 regression: head → tombstone → restart. The tombstone must remove +/// the retained kind:30179 head so a fresh boot's `hydrate_from_retention` +/// cannot rematerialize the deleted secret-bearing agent. +#[test] +fn tombstoned_private_head_does_not_survive_restart_hydration() { + use crate::managed_agents::{ + private_config_overlay::{hydrate_from_retention, PrivateConfigOverlay}, + retention::open_retention_db, + }; + use buzz_core_pkg::private_managed_agent; + use tempfile::TempDir; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("retention.db"); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let agent_hex = agent_keys.public_key().to_hex(); + + // Session 1: a private head lands and is retained durably. + { + let conn = open_retention_db(&db_path).unwrap(); + let payload = private_agent_payload(&owner_keys, &agent_keys, "doomed", 4); + let event = private_managed_agent::build_event(&owner_keys, &payload, 20).unwrap(); + let mut overlay = PrivateConfigOverlay::default(); + apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay) + .unwrap(); + + // The delete arrives: a kind:5 covering the PUBLIC coordinate (either + // agent kind must cover both heads — the sibling tombstone may be + // lost). + let tombstone = owned_deletion_event_at(&owner_keys, 30177, &agent_hex, 30); + let resolved = + resolve_inbound_tombstone(&tombstone, &owner_keys.public_key(), &conn).unwrap(); + assert_eq!(resolved, Some((30177, agent_hex.clone()))); + } + + // Session 2 (restart, fresh overlay): hydration must NOT resurrect it. + let conn = open_retention_db(&db_path).unwrap(); + let overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); + assert!( + overlay.resolved_records(&[]).is_empty(), + "a tombstoned 30179 head must not rehydrate after restart" + ); + + // Replay: the same tombstone re-received dedupes to a no-op instead of + // re-deleting (retention row already at its created_at). + let replay = owned_deletion_event_at(&owner_keys, 30177, &agent_hex, 30); + assert_eq!( + resolve_inbound_tombstone(&replay, &owner_keys.public_key(), &conn).unwrap(), + None, + "a replayed tombstone must dedupe" + ); +} + +/// NIP-09 bound: a deletion covers events up to its own `created_at`. A head +/// NEWER than the tombstone is a recreation (or the tombstone is a late +/// replay) — the record and its heads must survive. +#[test] +fn tombstone_older_than_the_retained_head_preserves_it() { + use crate::managed_agents::{ + private_config_overlay::{hydrate_from_retention, PrivateConfigOverlay}, + retention::open_retention_db, + }; + use buzz_core_pkg::private_managed_agent; + use std::path::Path; + + let conn = open_retention_db(Path::new(":memory:")).unwrap(); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let agent_hex = agent_keys.public_key().to_hex(); + + // Head at t=50; tombstone authored earlier at t=30 arrives late. + let payload = private_agent_payload(&owner_keys, &agent_keys, "survivor", 4); + let event = private_managed_agent::build_event(&owner_keys, &payload, 50).unwrap(); + let mut overlay = PrivateConfigOverlay::default(); + apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay).unwrap(); + + let stale_tombstone = owned_deletion_event_at(&owner_keys, 30179, &agent_hex, 30); + assert_eq!( + resolve_inbound_tombstone(&stale_tombstone, &owner_keys.public_key(), &conn).unwrap(), + None, + "a tombstone older than the head must not delete it" + ); + let overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); + assert_eq!( + overlay.resolved_records(&[]).len(), + 1, + "the newer head survives the stale tombstone" + ); +} + +#[test] +fn parse_deletion_coordinate_extracts_kind_and_d_tag() { + // Persona / team / agent coordinates all route by their leading kind. + let p = owned_deletion_event(30175, "my-persona"); + assert_eq!( + parse_deletion_coordinate(&p), + Some((30175, "my-persona".to_string())) + ); + let a = owned_deletion_event(30177, "agentpubkeyhex"); + assert_eq!( + parse_deletion_coordinate(&a), + Some((30177, "agentpubkeyhex".to_string())) + ); +} + +#[test] +fn parse_deletion_coordinate_rejects_foreign_owner() { + // A validly signed kind:5 naming ANOTHER owner's coordinate must no-op: + // NIP-09 scopes deletion to the record's own author. + let foreign_owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let forged = deletion_event(&format!("30175:{foreign_owner}:my-persona")); + assert_eq!(parse_deletion_coordinate(&forged), None); +} + +#[test] +fn parse_deletion_coordinate_handles_colon_in_d_tag_and_rejects_malformed() { + // A d-tag containing ':' keeps its remainder intact (splitn(3)). + let weird = owned_deletion_event(30176, "a:b:c"); + assert_eq!( + parse_deletion_coordinate(&weird), + Some((30176, "a:b:c".to_string())) + ); + // Missing d-tag segment / non-numeric kind → None (no-op). + assert_eq!( + parse_deletion_coordinate(&deletion_event("30175:owner")), + None + ); + assert_eq!( + parse_deletion_coordinate(&deletion_event("notakind:owner:d")), + None + ); +} + +#[test] +fn tombstone_removal_predicates_match_apply_fn_keys() { + // The deletion path removes by the SAME per-kind key the apply fns use. + // Persona: by persona_d_tag (slug/id). + let mut personas = vec![local_in_app()]; + let target = persona_d_tag(&personas[0]); + personas.retain(|r| persona_d_tag(r) != target); + assert!(personas.is_empty(), "persona removed by its d-tag"); + + // Team: by id. + let mut teams = vec![local_team()]; + teams.retain(|r| r.id != TEAM_ID); + assert!(teams.is_empty(), "team removed by id"); + + // Managed-agent: by pubkey. A non-matching d-tag is a no-op. + let mut agents = vec![local_agent()]; + agents.retain(|r| r.pubkey != "someoneelse"); + assert_eq!(agents.len(), 1, "non-matching agent tombstone no-ops"); + agents.retain(|r| r.pubkey != AGENT_PUBKEY); + assert!(agents.is_empty(), "agent removed by pubkey"); +} + +// ── Inbound signature gate ────────────────────────────────────────────────── + +#[test] +fn inbound_gate_rejects_tampered_event() { + use nostr::JsonUtil; + // A validly signed event whose content was altered post-signing: the + // pubkey is real, the sig no longer covers the bytes. Must die at the + // gate before any store logic runs. + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30175), "{}") + .tags(vec![nostr::Tag::parse(["d", "victim-slug"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let tampered = event.as_json().replace( + "\"content\":\"{}\"", + "\"content\":\"{\\\"system_prompt\\\":\\\"pwned\\\"}\"", + ); + assert_ne!( + tampered, + event.as_json(), + "string replace must have taken effect — if this fails the test is testing an un-tampered event" + ); + + let err = parse_verified_inbound_event(&tampered).unwrap_err(); + assert!( + err.contains("signature"), + "tampered event must fail the signature gate: {err}" + ); +} + +#[test] +fn inbound_gate_accepts_validly_signed_event() { + use nostr::JsonUtil; + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30175), "{}") + .tags(vec![nostr::Tag::parse(["d", "slug"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let parsed = parse_verified_inbound_event(&event.as_json()).unwrap(); + assert_eq!(parsed.pubkey, keys.public_key()); +} + +/// Item-0 FIX verification: after a "restart" (same retention db, fresh +/// overlay), `hydrate_from_retention` repopulates the overlay from the durable +/// rows — so the resolve sites see relay config instead of stale disk. +#[test] +fn sami_fix_overlay_rehydrates_from_retention_after_restart() { + use crate::managed_agents::{ + private_config_overlay::{hydrate_from_retention, PrivateConfigOverlay}, + retention::open_retention_db, + }; + use buzz_core_pkg::private_managed_agent; + use tempfile::TempDir; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("retention.db"); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + + let payload = private_agent_payload(&owner_keys, &agent_keys, "relay name", 4); + let event = private_managed_agent::build_event(&owner_keys, &payload, 20).unwrap(); + + // Session 1: the event lands and is retained durably. + { + let conn = open_retention_db(&db_path).unwrap(); + let mut overlay = PrivateConfigOverlay::default(); + apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay) + .unwrap(); + } + + // Session 2 (restart): hydrate straight from the retained rows — no + // inbound event required. + let conn = open_retention_db(&db_path).unwrap(); + let overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); + let resolved = overlay.resolved_records(&[]); + assert_eq!(resolved.len(), 1, "FIX: overlay rehydrates from retention"); + assert_eq!(resolved[0].name, "relay name"); + assert_eq!(resolved[0].parallelism, 4); + + // NEGATIVE CONTROL: a different owner's keys must hydrate NOTHING — proves + // the query is scoped by owner pubkey and not just returning every row. + let stranger = nostr::Keys::generate(); + assert!( + hydrate_from_retention(&conn, &stranger) + .unwrap() + .resolved_records(&[]) + .is_empty(), + "control: hydration is owner-scoped" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 35ee9d8a8a..e2679fa69b 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -901,345 +901,10 @@ fn inbound_team_propagates_persist_teams_error() { assert_eq!(err, "disk full"); } -// ── Tombstone (kind:5) consume ──────────────────────────────────────────── - -fn deletion_event(coord: &str) -> nostr::Event { - deletion_event_with_keys(coord, &nostr::Keys::generate()) -} - -fn deletion_event_with_keys(coord: &str, keys: &nostr::Keys) -> nostr::Event { - use nostr::{EventBuilder, JsonUtil, Kind, Tag}; - let event = EventBuilder::new(Kind::Custom(5), "") - .tags(vec![Tag::parse(["a", coord]).unwrap()]) - .sign_with_keys(keys) - .unwrap(); - nostr::Event::from_json(event.as_json()).unwrap() -} - -/// A deletion event whose coordinate owner IS its signer — the only shape -/// `parse_deletion_coordinate` accepts since the owner check landed. -fn owned_deletion_event(kind: u32, d_tag: &str) -> nostr::Event { - let keys = nostr::Keys::generate(); - let owner = keys.public_key().to_hex(); - deletion_event_with_keys(&format!("{kind}:{owner}:{d_tag}"), &keys) -} - -/// Like [`deletion_event_with_keys`] but with a controlled `created_at`, for -/// tests that race a tombstone against a retained head's timestamp. -fn owned_deletion_event_at( - keys: &nostr::Keys, - kind: u32, - d_tag: &str, - created_at: u64, -) -> nostr::Event { - use nostr::{EventBuilder, JsonUtil, Kind, Tag, Timestamp}; - let owner = keys.public_key().to_hex(); - let event = EventBuilder::new(Kind::Custom(5), "") - .tags(vec![ - Tag::parse(["a", &format!("{kind}:{owner}:{d_tag}")]).unwrap() - ]) - .custom_created_at(Timestamp::from(created_at)) - .sign_with_keys(keys) - .unwrap(); - nostr::Event::from_json(event.as_json()).unwrap() -} - -// ── resolve_inbound_tombstone: authorization + retained-head coverage ────── -// -// The seam is deliberately a pure function over (event, scope_owner, conn) so -// the security decision is testable WITHOUT a live AppHandle — the production -// caller (`reconcile_inbound_tombstone`) is a `#[tauri::command]` body that -// only adds scope resolution and the per-kind store mutation around it. These -// are the direct-IPC regressions: the frontend owner filter never runs here. - -/// Issue-4 regression: a VALIDLY SIGNED tombstone whose signer owns the -/// coordinate it names — so it clears `parse_deletion_coordinate` — but who is -/// not the workspace owner must no-op. Local stores match by d-tag alone, so -/// without the scope-owner binding this would delete the victim's record. -#[test] -fn resolve_inbound_tombstone_rejects_foreign_owner() { - use crate::managed_agents::retention::open_retention_db; - use std::path::Path; - - let conn = open_retention_db(Path::new(":memory:")).unwrap(); - let scope_owner_keys = nostr::Keys::generate(); - let attacker_keys = nostr::Keys::generate(); - - // The attacker names their OWN coordinate (passes the NIP-09 signer == - // coordinate-owner check) with a d-tag colliding with the victim's agent. - let forged = owned_deletion_event_at(&attacker_keys, 30177, AGENT_PUBKEY, 100); - let resolved = - resolve_inbound_tombstone(&forged, &scope_owner_keys.public_key(), &conn).unwrap(); - assert_eq!(resolved, None, "foreign-owner tombstone must not authorize"); - - // Nothing was retained: the attacker cannot even park a kind:5 row in the - // victim's scoped store. - assert!( - !crate::managed_agents::retention::has_retained_personas( - &conn, - &attacker_keys.public_key().to_hex() - ) - .unwrap(), - "a rejected tombstone must leave no retained row" - ); - - // POSITIVE CONTROL: the same event shape signed by the scope owner - // resolves — proving the rejection above is the owner binding, not a - // broken fixture. - let owned = owned_deletion_event_at(&scope_owner_keys, 30177, AGENT_PUBKEY, 100); - assert_eq!( - resolve_inbound_tombstone(&owned, &scope_owner_keys.public_key(), &conn).unwrap(), - Some((30177, AGENT_PUBKEY.to_string())), - "control: the scope owner's tombstone authorizes" - ); -} - -/// Issue-2 regression: head → tombstone → restart. The tombstone must remove -/// the retained kind:30179 head so a fresh boot's `hydrate_from_retention` -/// cannot rematerialize the deleted secret-bearing agent. -#[test] -fn tombstoned_private_head_does_not_survive_restart_hydration() { - use crate::managed_agents::{ - private_config_overlay::{hydrate_from_retention, PrivateConfigOverlay}, - retention::open_retention_db, - }; - use buzz_core_pkg::private_managed_agent; - use tempfile::TempDir; - - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("retention.db"); - let owner_keys = nostr::Keys::generate(); - let agent_keys = nostr::Keys::generate(); - let agent_hex = agent_keys.public_key().to_hex(); - - // Session 1: a private head lands and is retained durably. - { - let conn = open_retention_db(&db_path).unwrap(); - let payload = private_agent_payload(&owner_keys, &agent_keys, "doomed", 4); - let event = private_managed_agent::build_event(&owner_keys, &payload, 20).unwrap(); - let mut overlay = PrivateConfigOverlay::default(); - apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay) - .unwrap(); - - // The delete arrives: a kind:5 covering the PUBLIC coordinate (either - // agent kind must cover both heads — the sibling tombstone may be - // lost). - let tombstone = owned_deletion_event_at(&owner_keys, 30177, &agent_hex, 30); - let resolved = - resolve_inbound_tombstone(&tombstone, &owner_keys.public_key(), &conn).unwrap(); - assert_eq!(resolved, Some((30177, agent_hex.clone()))); - } - - // Session 2 (restart, fresh overlay): hydration must NOT resurrect it. - let conn = open_retention_db(&db_path).unwrap(); - let overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); - assert!( - overlay.resolved_records(&[]).is_empty(), - "a tombstoned 30179 head must not rehydrate after restart" - ); - - // Replay: the same tombstone re-received dedupes to a no-op instead of - // re-deleting (retention row already at its created_at). - let replay = owned_deletion_event_at(&owner_keys, 30177, &agent_hex, 30); - assert_eq!( - resolve_inbound_tombstone(&replay, &owner_keys.public_key(), &conn).unwrap(), - None, - "a replayed tombstone must dedupe" - ); -} - -/// NIP-09 bound: a deletion covers events up to its own `created_at`. A head -/// NEWER than the tombstone is a recreation (or the tombstone is a late -/// replay) — the record and its heads must survive. -#[test] -fn tombstone_older_than_the_retained_head_preserves_it() { - use crate::managed_agents::{ - private_config_overlay::{hydrate_from_retention, PrivateConfigOverlay}, - retention::open_retention_db, - }; - use buzz_core_pkg::private_managed_agent; - use std::path::Path; - - let conn = open_retention_db(Path::new(":memory:")).unwrap(); - let owner_keys = nostr::Keys::generate(); - let agent_keys = nostr::Keys::generate(); - let agent_hex = agent_keys.public_key().to_hex(); - - // Head at t=50; tombstone authored earlier at t=30 arrives late. - let payload = private_agent_payload(&owner_keys, &agent_keys, "survivor", 4); - let event = private_managed_agent::build_event(&owner_keys, &payload, 50).unwrap(); - let mut overlay = PrivateConfigOverlay::default(); - apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay).unwrap(); - - let stale_tombstone = owned_deletion_event_at(&owner_keys, 30179, &agent_hex, 30); - assert_eq!( - resolve_inbound_tombstone(&stale_tombstone, &owner_keys.public_key(), &conn).unwrap(), - None, - "a tombstone older than the head must not delete it" - ); - let overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); - assert_eq!( - overlay.resolved_records(&[]).len(), - 1, - "the newer head survives the stale tombstone" - ); -} - -#[test] -fn parse_deletion_coordinate_extracts_kind_and_d_tag() { - // Persona / team / agent coordinates all route by their leading kind. - let p = owned_deletion_event(30175, "my-persona"); - assert_eq!( - parse_deletion_coordinate(&p), - Some((30175, "my-persona".to_string())) - ); - let a = owned_deletion_event(30177, "agentpubkeyhex"); - assert_eq!( - parse_deletion_coordinate(&a), - Some((30177, "agentpubkeyhex".to_string())) - ); -} - -#[test] -fn parse_deletion_coordinate_rejects_foreign_owner() { - // A validly signed kind:5 naming ANOTHER owner's coordinate must no-op: - // NIP-09 scopes deletion to the record's own author. - let foreign_owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - let forged = deletion_event(&format!("30175:{foreign_owner}:my-persona")); - assert_eq!(parse_deletion_coordinate(&forged), None); -} - -#[test] -fn parse_deletion_coordinate_handles_colon_in_d_tag_and_rejects_malformed() { - // A d-tag containing ':' keeps its remainder intact (splitn(3)). - let weird = owned_deletion_event(30176, "a:b:c"); - assert_eq!( - parse_deletion_coordinate(&weird), - Some((30176, "a:b:c".to_string())) - ); - // Missing d-tag segment / non-numeric kind → None (no-op). - assert_eq!( - parse_deletion_coordinate(&deletion_event("30175:owner")), - None - ); - assert_eq!( - parse_deletion_coordinate(&deletion_event("notakind:owner:d")), - None - ); -} - -#[test] -fn tombstone_removal_predicates_match_apply_fn_keys() { - // The deletion path removes by the SAME per-kind key the apply fns use. - // Persona: by persona_d_tag (slug/id). - let mut personas = vec![local_in_app()]; - let target = persona_d_tag(&personas[0]); - personas.retain(|r| persona_d_tag(r) != target); - assert!(personas.is_empty(), "persona removed by its d-tag"); - - // Team: by id. - let mut teams = vec![local_team()]; - teams.retain(|r| r.id != TEAM_ID); - assert!(teams.is_empty(), "team removed by id"); - - // Managed-agent: by pubkey. A non-matching d-tag is a no-op. - let mut agents = vec![local_agent()]; - agents.retain(|r| r.pubkey != "someoneelse"); - assert_eq!(agents.len(), 1, "non-matching agent tombstone no-ops"); - agents.retain(|r| r.pubkey != AGENT_PUBKEY); - assert!(agents.is_empty(), "agent removed by pubkey"); -} - -// ── Inbound signature gate ────────────────────────────────────────────────── - -#[test] -fn inbound_gate_rejects_tampered_event() { - use nostr::JsonUtil; - // A validly signed event whose content was altered post-signing: the - // pubkey is real, the sig no longer covers the bytes. Must die at the - // gate before any store logic runs. - let keys = nostr::Keys::generate(); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(30175), "{}") - .tags(vec![nostr::Tag::parse(["d", "victim-slug"]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let tampered = event.as_json().replace( - "\"content\":\"{}\"", - "\"content\":\"{\\\"system_prompt\\\":\\\"pwned\\\"}\"", - ); - assert_ne!( - tampered, - event.as_json(), - "string replace must have taken effect — if this fails the test is testing an un-tampered event" - ); - - let err = parse_verified_inbound_event(&tampered).unwrap_err(); - assert!( - err.contains("signature"), - "tampered event must fail the signature gate: {err}" - ); -} - -#[test] -fn inbound_gate_accepts_validly_signed_event() { - use nostr::JsonUtil; - let keys = nostr::Keys::generate(); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(30175), "{}") - .tags(vec![nostr::Tag::parse(["d", "slug"]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let parsed = parse_verified_inbound_event(&event.as_json()).unwrap(); - assert_eq!(parsed.pubkey, keys.public_key()); -} - -/// Item-0 FIX verification: after a "restart" (same retention db, fresh -/// overlay), `hydrate_from_retention` repopulates the overlay from the durable -/// rows — so the resolve sites see relay config instead of stale disk. -#[test] -fn sami_fix_overlay_rehydrates_from_retention_after_restart() { - use crate::managed_agents::{ - private_config_overlay::{hydrate_from_retention, PrivateConfigOverlay}, - retention::open_retention_db, - }; - use buzz_core_pkg::private_managed_agent; - use tempfile::TempDir; - - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("retention.db"); - let owner_keys = nostr::Keys::generate(); - let agent_keys = nostr::Keys::generate(); - - let payload = private_agent_payload(&owner_keys, &agent_keys, "relay name", 4); - let event = private_managed_agent::build_event(&owner_keys, &payload, 20).unwrap(); - - // Session 1: the event lands and is retained durably. - { - let conn = open_retention_db(&db_path).unwrap(); - let mut overlay = PrivateConfigOverlay::default(); - apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay) - .unwrap(); - } - - // Session 2 (restart): hydrate straight from the retained rows — no - // inbound event required. - let conn = open_retention_db(&db_path).unwrap(); - let overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); - let resolved = overlay.resolved_records(&[]); - assert_eq!(resolved.len(), 1, "FIX: overlay rehydrates from retention"); - assert_eq!(resolved[0].name, "relay name"); - assert_eq!(resolved[0].parallelism, 4); - - // NEGATIVE CONTROL: a different owner's keys must hydrate NOTHING — proves - // the query is scoped by owner pubkey and not just returning every row. - let stranger = nostr::Keys::generate(); - assert!( - hydrate_from_retention(&conn, &stranger) - .unwrap() - .resolved_records(&[]) - .is_empty(), - "control: hydration is owner-scoped" - ); -} +// Tombstone authorization, signature-gate, and restart-rehydration +// regressions live in a sibling file to stay under the file-size cap. +#[path = "inbound_security_tests.rs"] +mod security_tests; #[test] fn inbound_persona_rejects_invisible_definition_text() { diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 4bd8f7b4a5..3ea6089844 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -542,614 +542,4 @@ pub fn get_retained_event( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn retention_scope_is_stable_and_separates_relay_and_owner() { - let base = Path::new("/tmp/buzz-retention-test"); - let owner_a = "a".repeat(64); - let owner_b = "b".repeat(64); - let community_a = scoped_retention_db_path(base, "wss://a.example/", &owner_a); - assert_eq!( - community_a, - scoped_retention_db_path(base, "wss://a.example", &owner_a) - ); - assert_ne!( - community_a, - scoped_retention_db_path(base, "wss://b.example", &owner_a) - ); - assert_ne!( - community_a, - scoped_retention_db_path(base, "wss://a.example", &owner_b) - ); - } - - #[test] - fn test_arrival_relay_matching_agrees_with_database_identity() { - let base = Path::new("/tmp/buzz-retention-test"); - let keys = nostr::Keys::generate(); - let owner = keys.public_key().to_hex(); - let scope = |relay: &str| RetentionScope { - db_path: scoped_retention_db_path(base, relay, &owner), - relay_url: relay.to_string(), - owner_keys: keys.clone(), - }; - let community_a = scoped_retention_db_path(base, "wss://a.example", &owner); - - // "Same relay" and "same database" must never disagree: every URL the - // match accepts has to hash to the scope's own db path, and every URL it - // rejects has to hash somewhere else. - for equivalent in ["wss://a.example", "wss://a.example/", " wss://a.example "] { - assert_eq!( - scope_for_arrival(scope("wss://a.example"), equivalent).map(|scope| scope.db_path), - Some(community_a.clone()), - "{equivalent}" - ); - assert_eq!( - scoped_retention_db_path(base, equivalent, &owner), - community_a, - "{equivalent}" - ); - } - - assert!( - scope_for_arrival(scope("wss://b.example"), "wss://a.example").is_none(), - "an event from community A must not be filed while community B is active" - ); - assert_ne!( - scoped_retention_db_path(base, "wss://b.example", &owner), - community_a - ); - } - - #[test] - fn concurrent_open_waits_for_initialization_lock() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("retention.db"); - let first = open_retention_db(&path).unwrap(); - first.execute_batch("BEGIN EXCLUSIVE").unwrap(); - - let second_path = path.clone(); - let second = std::thread::spawn(move || open_retention_db(&second_path)); - std::thread::sleep(std::time::Duration::from_millis(100)); - first.execute_batch("COMMIT").unwrap(); - - assert!(second.join().unwrap().is_ok()); - } - - fn test_db() -> Connection { - open_retention_db(Path::new(":memory:")).unwrap() - } - - fn sample_event() -> RetainedEvent { - RetainedEvent { - kind: 30175, - pubkey: "abc123".to_string(), - d_tag: "test-persona".to_string(), - content: r#"{"display_name":"Test"}"#.to_string(), - created_at: 1000, - raw_event: r#"{"id":"..."}"#.to_string(), - pending_sync: true, - } - } - - #[test] - fn inbound_preflight_does_not_consume_event_before_commit() { - let conn = test_db(); - let mut inbound = sample_event(); - inbound.pending_sync = false; - - assert_eq!( - inbound_event_outcome(&conn, &inbound).unwrap(), - InboundOutcome::Applied - ); - assert!( - get_retained_event(&conn, inbound.kind, &inbound.pubkey, &inbound.d_tag) - .unwrap() - .is_none() - ); - // A failed store/runtime apply can replay the same head because the - // preflight did not advance retention. - assert_eq!( - inbound_event_outcome(&conn, &inbound).unwrap(), - InboundOutcome::Applied - ); - assert_eq!( - retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Applied - ); - assert_eq!( - inbound_event_outcome(&conn, &inbound).unwrap(), - InboundOutcome::Skipped - ); - } - - #[test] - fn retain_and_retrieve() { - let conn = test_db(); - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].d_tag, "test-persona"); - assert_eq!(results[0].created_at, 1000); - assert!(results[0].pending_sync); - } - - #[test] - fn tombstone_retention_keys_are_distinct_across_kinds() { - // A persona slug, team id, and agent pubkey that all happen to equal - // "shared" must occupy DISTINCT kind:5 rows so one tombstone's pending - // publish never clobbers another's (F2c). - let conn = test_db(); - for target_kind in [30175u32, 30176, 30177] { - retain_event( - &conn, - &RetainedEvent { - kind: 5, - pubkey: "owner".to_string(), - d_tag: tombstone_retention_d_tag(target_kind, "shared"), - content: String::new(), - created_at: 1000, - raw_event: format!("{{\"k\":{target_kind}}}"), - pending_sync: true, - }, - ) - .unwrap(); - } - // Three distinct rows survive — no PK collision clobbered any of them. - for target_kind in [30175u32, 30176, 30177] { - let row = get_retained_event( - &conn, - 5, - "owner", - &tombstone_retention_d_tag(target_kind, "shared"), - ) - .unwrap(); - assert!( - row.is_some(), - "tombstone for kind {target_kind} was clobbered" - ); - } - } - - #[test] - fn upsert_replaces_newer() { - let conn = test_db(); - let mut event = sample_event(); - retain_event(&conn, &event).unwrap(); - - event.content = r#"{"display_name":"Updated"}"#.to_string(); - event.created_at = 2000; - retain_event(&conn, &event).unwrap(); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].created_at, 2000); - assert!(results[0].content.contains("Updated")); - } - - #[test] - fn upsert_ignores_older() { - let conn = test_db(); - let mut event = sample_event(); - event.created_at = 2000; - retain_event(&conn, &event).unwrap(); - - event.content = r#"{"display_name":"Old"}"#.to_string(); - event.created_at = 1000; - retain_event(&conn, &event).unwrap(); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].created_at, 2000); - assert!(!results[0].content.contains("Old")); - } - - #[test] - fn pending_sync_query() { - let conn = test_db(); - let mut event = sample_event(); - event.pending_sync = true; - retain_event(&conn, &event).unwrap(); - - let mut event2 = sample_event(); - event2.d_tag = "other".to_string(); - event2.pending_sync = false; - retain_event(&conn, &event2).unwrap(); - - let pending = get_pending_sync(&conn).unwrap(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].d_tag, "test-persona"); - } - - #[test] - fn test_mark_synced_matching_row_clears_flag() { - let conn = test_db(); - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - - mark_synced(&conn, 30175, "abc123", "test-persona", 1000, &event.content).unwrap(); - - let pending = get_pending_sync(&conn).unwrap(); - assert!(pending.is_empty()); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - assert!(!results[0].pending_sync); - } - - #[test] - fn test_mark_synced_stale_version_leaves_flag_set() { - let conn = test_db(); - let published = sample_event(); - retain_event(&conn, &published).unwrap(); - - // A newer edit lands at the same coordinate before the flush loop - // clears the version it published. - let mut newer = sample_event(); - newer.content = r#"{"display_name":"Edited"}"#.to_string(); - newer.created_at = 2000; - retain_event(&conn, &newer).unwrap(); - - // Clearing against the OLD version must not touch the newer pending row. - mark_synced( - &conn, - 30175, - "abc123", - "test-persona", - 1000, - &published.content, - ) - .unwrap(); - - let pending = get_pending_sync(&conn).unwrap(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].created_at, 2000); - } - - #[test] - fn test_delete_retained_event_removes_row() { - let conn = test_db(); - retain_event(&conn, &sample_event()).unwrap(); - - delete_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); - - assert!(get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .is_none()); - } - - #[test] - fn test_delete_retained_event_missing_row_is_noop() { - let conn = test_db(); - delete_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); - } - - #[test] - fn has_retained_personas_works() { - let conn = test_db(); - assert!(!has_retained_personas(&conn, "abc123").unwrap()); - - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - - assert!(has_retained_personas(&conn, "abc123").unwrap()); - assert!(!has_retained_personas(&conn, "other").unwrap()); - } - - #[test] - fn get_retained_event_by_coordinate() { - let conn = test_db(); - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - - let found = get_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); - assert!(found.is_some()); - assert_eq!(found.unwrap().d_tag, "test-persona"); - - let not_found = get_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); - assert!(not_found.is_none()); - } - - #[test] - fn idempotent_retain_same_timestamp() { - let conn = test_db(); - let event = sample_event(); - retain_event(&conn, &event).unwrap(); - retain_event(&conn, &event).unwrap(); - - let results = get_retained_personas(&conn, "abc123").unwrap(); - assert_eq!(results.len(), 1); - } - - #[test] - fn inbound_no_local_row_applies() { - let conn = test_db(); - let mut event = sample_event(); - event.pending_sync = false; - - assert_eq!( - retain_inbound_event(&conn, &event).unwrap(), - InboundOutcome::Applied - ); - - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert_eq!(row.created_at, 1000); - assert!(!row.pending_sync); - } - - #[test] - fn inbound_equal_second_skips_and_preserves_pending() { - let conn = test_db(); - // Pending local edit at t=1000. Same raw-event id as the inbound below - // (an echo / undecidable tie), so the tiebreak cannot decide a winner. - let local = sample_event(); - retain_event(&conn, &local).unwrap(); - - // Inbound at the SAME second with different content. - let inbound = RetainedEvent { - content: r#"{"display_name":"Remote"}"#.to_string(), - pending_sync: false, - ..sample_event() - }; - assert_eq!( - retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Skipped - ); - - // Local pending row is untouched: flag preserved, content unchanged so - // the flush republishes and the relay resolves last-writer-wins. - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert!(row.pending_sync); - assert!(row.content.contains("Test")); - } - - /// Issue-3 regression: two devices retain DISTINCT successors in the same - /// second, then each receives the other's. Without a deterministic - /// equal-second winner both sides skip forever and diverge permanently. - /// The NIP-01 tiebreak (lowest event id wins) makes opposite delivery - /// orders converge on the SAME head — the one the relay itself retains. - #[test] - fn inbound_equal_second_opposite_delivery_orders_converge() { - let event_low = RetainedEvent { - content: r#"{"display_name":"Low"}"#.to_string(), - raw_event: r#"{"id":"0aaa"}"#.to_string(), - pending_sync: false, - ..sample_event() - }; - let event_high = RetainedEvent { - content: r#"{"display_name":"High"}"#.to_string(), - raw_event: r#"{"id":"0bbb"}"#.to_string(), - pending_sync: false, - ..sample_event() - }; - - // Device A: low first, then high. High loses the tie — skipped. - let device_a = test_db(); - assert_eq!( - retain_inbound_event(&device_a, &event_low).unwrap(), - InboundOutcome::Applied - ); - assert_eq!( - retain_inbound_event(&device_a, &event_high).unwrap(), - InboundOutcome::Skipped - ); - - // Device B: high first, then low. Low wins the tie — applied. - let device_b = test_db(); - assert_eq!( - retain_inbound_event(&device_b, &event_high).unwrap(), - InboundOutcome::Applied - ); - assert_eq!( - retain_inbound_event(&device_b, &event_low).unwrap(), - InboundOutcome::Applied - ); - - // Both devices converge on the lexically-lowest id. - for conn in [&device_a, &device_b] { - let row = get_retained_event(conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert!( - row.content.contains("Low"), - "both delivery orders must converge on the lowest event id" - ); - } - } - - /// A pending local edit that WINS the equal-second tie keeps its - /// `pending_sync` (the flush republishes it); one that LOSES is superseded - /// by the relay's head and stops republishing a refused event. - #[test] - fn inbound_equal_second_pending_local_winner_and_loser() { - // Local pending edit with the LOWER id: inbound loses, pending stays. - let conn = test_db(); - let local_low = RetainedEvent { - raw_event: r#"{"id":"0aaa"}"#.to_string(), - ..sample_event() - }; - retain_event(&conn, &local_low).unwrap(); - let inbound_high = RetainedEvent { - content: r#"{"display_name":"Remote"}"#.to_string(), - raw_event: r#"{"id":"0bbb"}"#.to_string(), - pending_sync: false, - ..sample_event() - }; - assert_eq!( - retain_inbound_event(&conn, &inbound_high).unwrap(), - InboundOutcome::Skipped - ); - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert!(row.pending_sync, "the winning local edit keeps its publish"); - - // Local pending edit with the HIGHER id: inbound wins, pending clears. - let conn = test_db(); - let local_high = RetainedEvent { - raw_event: r#"{"id":"0bbb"}"#.to_string(), - ..sample_event() - }; - retain_event(&conn, &local_high).unwrap(); - let inbound_low = RetainedEvent { - content: r#"{"display_name":"Remote"}"#.to_string(), - raw_event: r#"{"id":"0aaa"}"#.to_string(), - pending_sync: false, - ..sample_event() - }; - assert_eq!( - retain_inbound_event(&conn, &inbound_low).unwrap(), - InboundOutcome::Applied - ); - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert!( - !row.pending_sync, - "the losing local edit stops republishing a head the relay refused" - ); - assert!(row.content.contains("Remote")); - } - - #[test] - fn inbound_strictly_newer_applies_and_clears_pending() { - let conn = test_db(); - // Pending local edit at t=1000. - let local = sample_event(); - retain_event(&conn, &local).unwrap(); - - // Inbound strictly newer with different content. - let inbound = RetainedEvent { - content: r#"{"display_name":"Remote"}"#.to_string(), - created_at: 2000, - pending_sync: false, - ..sample_event() - }; - assert_eq!( - retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Applied - ); - - // Inbound wins: content replaced and pending cleared, so the stale - // local edit stops republishing instead of looping. - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert_eq!(row.created_at, 2000); - assert!(!row.pending_sync); - assert!(row.content.contains("Remote")); - } - - #[test] - fn inbound_older_skips() { - let conn = test_db(); - let mut local = sample_event(); - local.created_at = 2000; - retain_event(&conn, &local).unwrap(); - - let inbound = RetainedEvent { - content: r#"{"display_name":"Stale"}"#.to_string(), - created_at: 1000, - pending_sync: false, - ..sample_event() - }; - assert_eq!( - retain_inbound_event(&conn, &inbound).unwrap(), - InboundOutcome::Skipped - ); - - let row = get_retained_event(&conn, 30175, "abc123", "test-persona") - .unwrap() - .unwrap(); - assert_eq!(row.created_at, 2000); - assert!(!row.content.contains("Stale")); - } - - #[test] - fn pending_sync_publishes_tombstones_before_replacements() { - // B5 resurrection race: a kind:5 retained in session N and the same - // coordinate's replacement 30175 retained on the next boot can sit - // pending together. The relay's a-tag deletion ignores timestamps, - // so the tombstone MUST publish first or it wipes the replacement. - let conn = test_db(); - let replacement = RetainedEvent { - kind: 30175, - created_at: 2000, - pending_sync: true, - ..sample_event() - }; - retain_event(&conn, &replacement).unwrap(); - let tombstone = RetainedEvent { - kind: 5, - d_tag: tombstone_retention_d_tag(30175, "test-persona"), - content: String::new(), - created_at: 1000, - pending_sync: true, - ..sample_event() - }; - retain_event(&conn, &tombstone).unwrap(); - - let pending = get_pending_sync(&conn).unwrap(); - assert_eq!(pending.len(), 2); - assert_eq!(pending[0].kind, 5, "tombstone first"); - assert_eq!(pending[1].kind, 30175, "replacement second"); - } - - #[test] - fn deferral_predicate_is_kind_and_pubkey_qualified() { - // Mid-sweep barrier semantics: a failed tombstone defers ONLY the - // replacement at its exact coordinate — same target kind, same pubkey. - use std::collections::HashSet; - - let failed: HashSet<(String, String)> = HashSet::from([( - "abc123".to_string(), - tombstone_retention_d_tag(30175, "test-persona"), - )]); - - // The covered replacement defers. - assert!(deferred_behind_failed_tombstone( - 30175, - "abc123", - "test-persona", - &failed - )); - // Kind-qualified: a coinciding slug under a DIFFERENT kind is a - // distinct coordinate (the cross-kind collision the retention d-tag - // encoding exists to prevent) — never deferred. - assert!(!deferred_behind_failed_tombstone( - 30177, - "abc123", - "test-persona", - &failed - )); - // Never crosses pubkeys. - assert!(!deferred_behind_failed_tombstone( - 30175, - "other-key", - "test-persona", - &failed - )); - // Never defers kind:5 rows, even at a "matching" retention key. - assert!(!deferred_behind_failed_tombstone( - 5, - "abc123", - "test-persona", - &failed - )); - // Unrelated d-tags publish normally. - assert!(!deferred_behind_failed_tombstone( - 30175, - "abc123", - "other-persona", - &failed - )); - } -} +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/retention/tests.rs b/desktop/src-tauri/src/managed_agents/retention/tests.rs new file mode 100644 index 0000000000..fc625e06ff --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/tests.rs @@ -0,0 +1,614 @@ +//! Unit tests for `managed_agents/retention.rs`. +//! +//! Kept in a sibling file so `retention.rs` stays under the 1000-line +//! file-size ratchet; `#[path]`-included from its `tests` module. + +use super::*; + +#[test] +fn retention_scope_is_stable_and_separates_relay_and_owner() { + let base = Path::new("/tmp/buzz-retention-test"); + let owner_a = "a".repeat(64); + let owner_b = "b".repeat(64); + let community_a = scoped_retention_db_path(base, "wss://a.example/", &owner_a); + assert_eq!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://b.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_b) + ); +} + +#[test] +fn test_arrival_relay_matching_agrees_with_database_identity() { + let base = Path::new("/tmp/buzz-retention-test"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let scope = |relay: &str| RetentionScope { + db_path: scoped_retention_db_path(base, relay, &owner), + relay_url: relay.to_string(), + owner_keys: keys.clone(), + }; + let community_a = scoped_retention_db_path(base, "wss://a.example", &owner); + + // "Same relay" and "same database" must never disagree: every URL the + // match accepts has to hash to the scope's own db path, and every URL it + // rejects has to hash somewhere else. + for equivalent in ["wss://a.example", "wss://a.example/", " wss://a.example "] { + assert_eq!( + scope_for_arrival(scope("wss://a.example"), equivalent).map(|scope| scope.db_path), + Some(community_a.clone()), + "{equivalent}" + ); + assert_eq!( + scoped_retention_db_path(base, equivalent, &owner), + community_a, + "{equivalent}" + ); + } + + assert!( + scope_for_arrival(scope("wss://b.example"), "wss://a.example").is_none(), + "an event from community A must not be filed while community B is active" + ); + assert_ne!( + scoped_retention_db_path(base, "wss://b.example", &owner), + community_a + ); +} + +#[test] +fn concurrent_open_waits_for_initialization_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("retention.db"); + let first = open_retention_db(&path).unwrap(); + first.execute_batch("BEGIN EXCLUSIVE").unwrap(); + + let second_path = path.clone(); + let second = std::thread::spawn(move || open_retention_db(&second_path)); + std::thread::sleep(std::time::Duration::from_millis(100)); + first.execute_batch("COMMIT").unwrap(); + + assert!(second.join().unwrap().is_ok()); +} + +fn test_db() -> Connection { + open_retention_db(Path::new(":memory:")).unwrap() +} + +fn sample_event() -> RetainedEvent { + RetainedEvent { + kind: 30175, + pubkey: "abc123".to_string(), + d_tag: "test-persona".to_string(), + content: r#"{"display_name":"Test"}"#.to_string(), + created_at: 1000, + raw_event: r#"{"id":"..."}"#.to_string(), + pending_sync: true, + } +} + +#[test] +fn inbound_preflight_does_not_consume_event_before_commit() { + let conn = test_db(); + let mut inbound = sample_event(); + inbound.pending_sync = false; + + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert!( + get_retained_event(&conn, inbound.kind, &inbound.pubkey, &inbound.d_tag) + .unwrap() + .is_none() + ); + // A failed store/runtime apply can replay the same head because the + // preflight did not advance retention. + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); +} + +#[test] +fn retain_and_retrieve() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].d_tag, "test-persona"); + assert_eq!(results[0].created_at, 1000); + assert!(results[0].pending_sync); +} + +#[test] +fn tombstone_retention_keys_are_distinct_across_kinds() { + // A persona slug, team id, and agent pubkey that all happen to equal + // "shared" must occupy DISTINCT kind:5 rows so one tombstone's pending + // publish never clobbers another's (F2c). + let conn = test_db(); + for target_kind in [30175u32, 30176, 30177] { + retain_event( + &conn, + &RetainedEvent { + kind: 5, + pubkey: "owner".to_string(), + d_tag: tombstone_retention_d_tag(target_kind, "shared"), + content: String::new(), + created_at: 1000, + raw_event: format!("{{\"k\":{target_kind}}}"), + pending_sync: true, + }, + ) + .unwrap(); + } + // Three distinct rows survive — no PK collision clobbered any of them. + for target_kind in [30175u32, 30176, 30177] { + let row = get_retained_event( + &conn, + 5, + "owner", + &tombstone_retention_d_tag(target_kind, "shared"), + ) + .unwrap(); + assert!( + row.is_some(), + "tombstone for kind {target_kind} was clobbered" + ); + } +} + +#[test] +fn upsert_replaces_newer() { + let conn = test_db(); + let mut event = sample_event(); + retain_event(&conn, &event).unwrap(); + + event.content = r#"{"display_name":"Updated"}"#.to_string(); + event.created_at = 2000; + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].created_at, 2000); + assert!(results[0].content.contains("Updated")); +} + +#[test] +fn upsert_ignores_older() { + let conn = test_db(); + let mut event = sample_event(); + event.created_at = 2000; + retain_event(&conn, &event).unwrap(); + + event.content = r#"{"display_name":"Old"}"#.to_string(); + event.created_at = 1000; + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].created_at, 2000); + assert!(!results[0].content.contains("Old")); +} + +#[test] +fn pending_sync_query() { + let conn = test_db(); + let mut event = sample_event(); + event.pending_sync = true; + retain_event(&conn, &event).unwrap(); + + let mut event2 = sample_event(); + event2.d_tag = "other".to_string(); + event2.pending_sync = false; + retain_event(&conn, &event2).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].d_tag, "test-persona"); +} + +#[test] +fn test_mark_synced_matching_row_clears_flag() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + mark_synced(&conn, 30175, "abc123", "test-persona", 1000, &event.content).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert!(pending.is_empty()); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].pending_sync); +} + +#[test] +fn test_mark_synced_stale_version_leaves_flag_set() { + let conn = test_db(); + let published = sample_event(); + retain_event(&conn, &published).unwrap(); + + // A newer edit lands at the same coordinate before the flush loop + // clears the version it published. + let mut newer = sample_event(); + newer.content = r#"{"display_name":"Edited"}"#.to_string(); + newer.created_at = 2000; + retain_event(&conn, &newer).unwrap(); + + // Clearing against the OLD version must not touch the newer pending row. + mark_synced( + &conn, + 30175, + "abc123", + "test-persona", + 1000, + &published.content, + ) + .unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].created_at, 2000); +} + +#[test] +fn test_delete_retained_event_removes_row() { + let conn = test_db(); + retain_event(&conn, &sample_event()).unwrap(); + + delete_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); + + assert!(get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .is_none()); +} + +#[test] +fn test_delete_retained_event_missing_row_is_noop() { + let conn = test_db(); + delete_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); +} + +#[test] +fn has_retained_personas_works() { + let conn = test_db(); + assert!(!has_retained_personas(&conn, "abc123").unwrap()); + + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + assert!(has_retained_personas(&conn, "abc123").unwrap()); + assert!(!has_retained_personas(&conn, "other").unwrap()); +} + +#[test] +fn get_retained_event_by_coordinate() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + let found = get_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().d_tag, "test-persona"); + + let not_found = get_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); + assert!(not_found.is_none()); +} + +#[test] +fn idempotent_retain_same_timestamp() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); +} + +#[test] +fn inbound_no_local_row_applies() { + let conn = test_db(); + let mut event = sample_event(); + event.pending_sync = false; + + assert_eq!( + retain_inbound_event(&conn, &event).unwrap(), + InboundOutcome::Applied + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 1000); + assert!(!row.pending_sync); +} + +#[test] +fn inbound_equal_second_skips_and_preserves_pending() { + let conn = test_db(); + // Pending local edit at t=1000. Same raw-event id as the inbound below + // (an echo / undecidable tie), so the tiebreak cannot decide a winner. + let local = sample_event(); + retain_event(&conn, &local).unwrap(); + + // Inbound at the SAME second with different content. + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); + + // Local pending row is untouched: flag preserved, content unchanged so + // the flush republishes and the relay resolves last-writer-wins. + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!(row.pending_sync); + assert!(row.content.contains("Test")); +} + +/// Issue-3 regression: two devices retain DISTINCT successors in the same +/// second, then each receives the other's. Without a deterministic +/// equal-second winner both sides skip forever and diverge permanently. +/// The NIP-01 tiebreak (lowest event id wins) makes opposite delivery +/// orders converge on the SAME head — the one the relay itself retains. +#[test] +fn inbound_equal_second_opposite_delivery_orders_converge() { + let event_low = RetainedEvent { + content: r#"{"display_name":"Low"}"#.to_string(), + raw_event: r#"{"id":"0aaa"}"#.to_string(), + pending_sync: false, + ..sample_event() + }; + let event_high = RetainedEvent { + content: r#"{"display_name":"High"}"#.to_string(), + raw_event: r#"{"id":"0bbb"}"#.to_string(), + pending_sync: false, + ..sample_event() + }; + + // Device A: low first, then high. High loses the tie — skipped. + let device_a = test_db(); + assert_eq!( + retain_inbound_event(&device_a, &event_low).unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + retain_inbound_event(&device_a, &event_high).unwrap(), + InboundOutcome::Skipped + ); + + // Device B: high first, then low. Low wins the tie — applied. + let device_b = test_db(); + assert_eq!( + retain_inbound_event(&device_b, &event_high).unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + retain_inbound_event(&device_b, &event_low).unwrap(), + InboundOutcome::Applied + ); + + // Both devices converge on the lexically-lowest id. + for conn in [&device_a, &device_b] { + let row = get_retained_event(conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!( + row.content.contains("Low"), + "both delivery orders must converge on the lowest event id" + ); + } +} + +/// A pending local edit that WINS the equal-second tie keeps its +/// `pending_sync` (the flush republishes it); one that LOSES is superseded +/// by the relay's head and stops republishing a refused event. +#[test] +fn inbound_equal_second_pending_local_winner_and_loser() { + // Local pending edit with the LOWER id: inbound loses, pending stays. + let conn = test_db(); + let local_low = RetainedEvent { + raw_event: r#"{"id":"0aaa"}"#.to_string(), + ..sample_event() + }; + retain_event(&conn, &local_low).unwrap(); + let inbound_high = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + raw_event: r#"{"id":"0bbb"}"#.to_string(), + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound_high).unwrap(), + InboundOutcome::Skipped + ); + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!(row.pending_sync, "the winning local edit keeps its publish"); + + // Local pending edit with the HIGHER id: inbound wins, pending clears. + let conn = test_db(); + let local_high = RetainedEvent { + raw_event: r#"{"id":"0bbb"}"#.to_string(), + ..sample_event() + }; + retain_event(&conn, &local_high).unwrap(); + let inbound_low = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + raw_event: r#"{"id":"0aaa"}"#.to_string(), + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound_low).unwrap(), + InboundOutcome::Applied + ); + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert!( + !row.pending_sync, + "the losing local edit stops republishing a head the relay refused" + ); + assert!(row.content.contains("Remote")); +} + +#[test] +fn inbound_strictly_newer_applies_and_clears_pending() { + let conn = test_db(); + // Pending local edit at t=1000. + let local = sample_event(); + retain_event(&conn, &local).unwrap(); + + // Inbound strictly newer with different content. + let inbound = RetainedEvent { + content: r#"{"display_name":"Remote"}"#.to_string(), + created_at: 2000, + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + + // Inbound wins: content replaced and pending cleared, so the stale + // local edit stops republishing instead of looping. + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 2000); + assert!(!row.pending_sync); + assert!(row.content.contains("Remote")); +} + +#[test] +fn inbound_older_skips() { + let conn = test_db(); + let mut local = sample_event(); + local.created_at = 2000; + retain_event(&conn, &local).unwrap(); + + let inbound = RetainedEvent { + content: r#"{"display_name":"Stale"}"#.to_string(), + created_at: 1000, + pending_sync: false, + ..sample_event() + }; + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); + + let row = get_retained_event(&conn, 30175, "abc123", "test-persona") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 2000); + assert!(!row.content.contains("Stale")); +} + +#[test] +fn pending_sync_publishes_tombstones_before_replacements() { + // B5 resurrection race: a kind:5 retained in session N and the same + // coordinate's replacement 30175 retained on the next boot can sit + // pending together. The relay's a-tag deletion ignores timestamps, + // so the tombstone MUST publish first or it wipes the replacement. + let conn = test_db(); + let replacement = RetainedEvent { + kind: 30175, + created_at: 2000, + pending_sync: true, + ..sample_event() + }; + retain_event(&conn, &replacement).unwrap(); + let tombstone = RetainedEvent { + kind: 5, + d_tag: tombstone_retention_d_tag(30175, "test-persona"), + content: String::new(), + created_at: 1000, + pending_sync: true, + ..sample_event() + }; + retain_event(&conn, &tombstone).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].kind, 5, "tombstone first"); + assert_eq!(pending[1].kind, 30175, "replacement second"); +} + +#[test] +fn deferral_predicate_is_kind_and_pubkey_qualified() { + // Mid-sweep barrier semantics: a failed tombstone defers ONLY the + // replacement at its exact coordinate — same target kind, same pubkey. + use std::collections::HashSet; + + let failed: HashSet<(String, String)> = HashSet::from([( + "abc123".to_string(), + tombstone_retention_d_tag(30175, "test-persona"), + )]); + + // The covered replacement defers. + assert!(deferred_behind_failed_tombstone( + 30175, + "abc123", + "test-persona", + &failed + )); + // Kind-qualified: a coinciding slug under a DIFFERENT kind is a + // distinct coordinate (the cross-kind collision the retention d-tag + // encoding exists to prevent) — never deferred. + assert!(!deferred_behind_failed_tombstone( + 30177, + "abc123", + "test-persona", + &failed + )); + // Never crosses pubkeys. + assert!(!deferred_behind_failed_tombstone( + 30175, + "other-key", + "test-persona", + &failed + )); + // Never defers kind:5 rows, even at a "matching" retention key. + assert!(!deferred_behind_failed_tombstone( + 5, + "abc123", + "test-persona", + &failed + )); + // Unrelated d-tags publish normally. + assert!(!deferred_behind_failed_tombstone( + 30175, + "abc123", + "other-persona", + &failed + )); +}