chore(desktop): satisfy file-size ratchet for review-fix growth

- Extract retention.rs inline test module to retention/tests.rs
  (1155 -> 545 lines).
- Extract the tombstone/signature-gate/rehydration regressions from
  inbound_tests.rs into inbound_security_tests.rs (1302 -> 967 lines).
- Trim two doc-comment lines in app_state.rs (1080 -> 1079, its exact
  ratchet allowance).

Test code moves verbatim; no behavior change.

Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
Brain
2026-08-20 13:18:53 -06:00
parent 6c7bb84f01
commit e4db7b376a
5 changed files with 970 additions and 951 deletions

View File

@@ -53,7 +53,6 @@ pub struct AppState {
pub huddle_state: Mutex<HuddleState>,
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<Option<AppHandle>>,
/// Port of the localhost media streaming proxy (set during setup).

View File

@@ -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"
);
}

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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
));
}