mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
614
desktop/src-tauri/src/managed_agents/retention/tests.rs
Normal file
614
desktop/src-tauri/src/managed_agents/retention/tests.rs
Normal 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
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user