Files
ngit-grasp/tests/lifecycle/replaceable_history.rs
T
DanConwayDev b02ed59c67 fix(relay): keep sessions open on malformed client messages
Production traffic containing unparseable client messages tore down the
entire WebSocket connection. A single bad message - most visibly requests
carrying invalid event IDs, failing deserialization with `Invalid input
length 64` - propagated out of the relay's read loop and closed the
session, so every affected client had to reconnect and re-subscribe. One
malformed frame cost a client all of its live subscriptions, and clients
that retried the same payload produced sustained connection churn.

The defect was upstream in rust-nostr's local relay, not in ngit-grasp,
and was reported as
nostr:nevent1qqsqmmfv6fxk995yr5858eev7z6zmchchgmk90d4rffuuwe6ewcvx0cpz3mhxue69uhhyetvv9ujumn8d96zuer9wckedd82
and fixed by
nostr:nevent1qqs24l0rv6qw3mdqdaj8nj4wlds2gy8a9wrqs3gj5kcmz27c8gm7kagpz3mhxue69uhhyetvv9ujumn8d96zuer9wc7gp6cp
Deserialization failures are now contained inside the WebSocket loop and
answered with a `NOTICE`, leaving the session and its subscriptions
intact.

Upgrade the NostrDevKit crates from 0.45.0-alpha.3 to 0.45.0-alpha.8,
the first published release containing the fix. Inclusion was verified
three ways rather than by release notes: upstream commit
7c0f3aa2cf2fbeb9f0067cb2349579754919eabd is an ancestor of the
`Bump to v0.45.0-alpha.8` commit on upstream master; the alpha.8 crate
downloaded from crates.io contains the repaired match arm in
`src/local_relay/local/inner.rs`; and it also ships the upstream
regression test `test_malformed_client_message_does_not_close_connection`,
which drives a real WebSocket with a short-author REQ and asserts a
subsequent valid REQ still reaches EOSE on the same socket. That upstream
test covers the behaviour directly, so no equivalent test is duplicated
here.

All four rust-nostr crates move together to keep the tree off a mixture
of alpha versions. `nostr-relay-builder` is gone: upstream merged it into
`nostr-sdk` at alpha.4, so its imports become `nostr_sdk::local_relay`
and `nostr_sdk::prelude`, and `nostr-sdk` now enables the `local-relay`
feature. Three further alpha-to-alpha API removals are absorbed:
`nostr::hashes` is no longer re-exported, so the `Sec-WebSocket-Accept`
derivation depends on `bitcoin_hashes` directly - the same crate `nostr`
still uses internally, so no second hash implementation enters the tree;
`BoxedFuture` became crate-private, so `WritePolicy::admit_event` spells
out its `Pin<Box<dyn Future<...>>>` return type; and
`EventBuilder::text_note` was removed in favour of
`EventBuilder::new(Kind::TextNote, ..)`, which affects test code only.

These requirements are pinned exactly as `=0.45.0-alpha.n` rather than
left as caret requirements. Cargo reads `"0.45.0-alpha.3"` as
`^0.45.0-alpha.3`, which admits *any* later prerelease of 0.45.0 even
though prereleases promise no compatibility - exactly the surprise
reported against ngit in
nostr:nevent1qqs0yvj4z302cjsnqx9jpp78jrfujhse0w47adjncwkxr922rjltk8spz3mhxue69uhhyetvv9ujumn8d96zuer9wcr42j8n
where a declared alpha.2 resolved to alpha.7. This upgrade is direct
evidence that the risk is real: alpha.4 deleted a whole crate this
project depended on, which a caret requirement would have accepted
silently. Pinning is safe because it only narrows resolution, and the
committed `Cargo.lock` already selects these versions; what it adds is
protection for builds that do not honour the lockfile - `cargo install`,
and downstream consumers of the `ngit_grasp` library. The pins should be
relaxed to caret requirements once 0.45.0 is released.

Validated in the project Nix development shell with `CARGO_BUILD_JOBS=2`:
`cargo fmt --all --check` clean, `cargo clippy --workspace --all-targets
-D warnings` clean, and `cargo test --workspace --no-fail-fast` at
1937 passed / 32 failed. The 32 failures were confirmed pre-existing by
running the identical suite on unmodified master in this environment: the
same 1937/32 split and a byte-identical set of failing test names, so
this upgrade introduces no regressions. All dependencies remain crates.io
sources, so no `flake.nix` or `nix/module.nix` hashes required updating.
2026-08-01 14:51:28 +00:00

592 lines
20 KiB
Rust

//! Integration tests for durable replaceable/addressable history capture.
#[path = "../common/mod.rs"]
mod common;
use clap::Parser;
use common::{publish_served_repo, TestRelay};
use grasp_audit::{AuditClient, AuditConfig, DETERMINISTIC_COMMIT_HASH};
use ngit_grasp::config::Config;
use ngit_grasp::grasp06::receive::new_repo_init_locks;
use ngit_grasp::nostr::builder::Nip34WritePolicy;
use ngit_grasp::nostr::lifecycle::ReplaceableHistoryStore;
use ngit_grasp::nostr::lifecycle::{HoldingStore, RepositoryLifecycle, Tombstones};
use ngit_grasp::nostr::persistence::SaveContext;
use ngit_grasp::nostr::SharedDatabase;
use ngit_grasp::purgatory::Purgatory;
use nostr_sdk::prelude::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
async fn open_history(relay_data_path: &std::path::Path) -> ReplaceableHistoryStore {
ReplaceableHistoryStore::open_lmdb(relay_data_path)
.await
.expect("open history db")
}
async fn build_announcement_version(
client: &AuditClient,
repo_id: &str,
created_at: Timestamp,
content: &str,
) -> Event {
let relay_url = client
.relay_url()
.await
.expect("client should have relay url");
let relay_domain = relay_url
.trim_start_matches("ws://")
.trim_start_matches("wss://")
.to_string();
let http_url = format!("http://{}", relay_domain);
let npub = client.public_key().to_bech32().expect("pubkey to npub");
EventBuilder::new(Kind::GitRepoAnnouncement, content)
.tags(vec![
Tag::identifier(repo_id),
Tag::custom("name", vec![repo_id.to_string()]),
Tag::custom(
"clone",
vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
),
Tag::custom("relays", vec![relay_url]),
])
.custom_created_at(created_at)
.finalize(client.keys())
.expect("build announcement version")
}
fn build_state_version(
client: &AuditClient,
repo_id: &str,
created_at: Timestamp,
content: &str,
) -> Event {
EventBuilder::new(Kind::RepoState, content)
.tags(vec![
Tag::identifier(repo_id),
Tag::custom(
"refs/heads/main",
vec![DETERMINISTIC_COMMIT_HASH.to_string()],
),
Tag::custom("HEAD", vec!["ref: refs/heads/main".to_string()]),
])
.custom_created_at(created_at)
.finalize(client.keys())
.expect("build state version")
}
/// Build a state version with ngit's one-use nonce tag. Varying the nonce
/// lets the test deterministically produce an ID that wins NIP-01's
/// equal-timestamp tie-break without changing the state coordinate.
fn build_grinded_state_version(
client: &AuditClient,
repo_id: &str,
created_at: Timestamp,
nonce: u64,
) -> Event {
EventBuilder::new(Kind::RepoState, "state-v2")
.tags(vec![
Tag::identifier(repo_id),
Tag::custom(
"refs/heads/main",
vec![DETERMINISTIC_COMMIT_HASH.to_string()],
),
Tag::custom("HEAD", vec!["ref: refs/heads/main".to_string()]),
Tag::custom(
"nonce",
vec![
nonce.to_string(),
"0".to_string(),
"ngit-created-at-tiebreak".to_string(),
],
),
])
.custom_created_at(created_at)
.finalize(client.keys())
.expect("build grinded state version")
}
fn build_policy_for_history_regression(
database: SharedDatabase,
history: ReplaceableHistoryStore,
git_data_path: &std::path::Path,
) -> Nip34WritePolicy {
let config = Config::parse_from([
"ngit-grasp-test",
"--domain",
"test.example.com",
"--archive-all",
"--archive-read-only",
"false",
]);
let purgatory = Arc::new(Purgatory::new(git_data_path.to_path_buf()));
Nip34WritePolicy::new(
database,
Tombstones::in_memory(),
HoldingStore::in_memory(),
RepositoryLifecycle::in_memory(),
history,
git_data_path.to_path_buf(),
purgatory,
config,
new_repo_init_locks(),
)
}
fn build_policy_announcement_version(
keys: &Keys,
repo_id: &str,
created_at: Timestamp,
content: &str,
) -> Event {
let npub = keys.public_key().to_bech32().expect("pubkey to npub");
EventBuilder::new(Kind::GitRepoAnnouncement, content)
.tags(vec![
Tag::identifier(repo_id),
Tag::custom("name", vec![repo_id.to_string()]),
Tag::custom(
"clone",
vec![format!("http://test.example.com/{}/{}.git", npub, repo_id)],
),
Tag::custom("relays", vec!["wss://test.example.com".to_string()]),
])
.custom_created_at(created_at)
.finalize(keys)
.expect("build policy announcement version")
}
#[tokio::test]
async fn newer_30618_supersedes_older_and_preserves_old_in_history() {
let relay = TestRelay::start_with_lmdb().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (_announcement, repo_id) = publish_served_repo(&client, "history-30618").await;
// `publish_served_repo` already saved a 30618 for this coordinate. Nostr
// replaceable ordering is only second-granular, so a same-second "old"
// event can be rejected instead of superseding the fixture event. Start the
// test sequence in a deterministic future second to make both replacements
// strictly newer.
let base_ts = Timestamp::from_secs(Timestamp::now().as_secs() + 1);
let old_state = build_state_version(&client, &repo_id, base_ts, "state-v1");
let new_state = build_state_version(
&client,
&repo_id,
Timestamp::from_secs(base_ts.as_secs() + 30),
"state-v2",
);
client
.send_event(old_state.clone())
.await
.expect("send old state");
client
.send_event(new_state.clone())
.await
.expect("send new state");
tokio::time::sleep(Duration::from_millis(400)).await;
let coordinate = format!("30618:{}:{}", client.public_key().to_hex(), repo_id);
let history = open_history(relay.relay_data_path()).await;
let records = history
.superseded_records_for_coordinate_before(
&coordinate,
// The deterministic future timestamps can be ahead of wall-clock
// `Timestamp::now()`, so query just after the newest test event
// rather than using "now" as the history cutoff.
Timestamp::from_secs(new_state.created_at.as_secs() + 1),
)
.await;
assert!(
records
.iter()
.any(|r| r.superseded_event_id == Some(old_state.id)
&& r.replaced_by == Some(new_state.id)),
"history must contain old 30618 as superseded by the newer 30618"
);
assert!(
history
.event_by_id(&old_state.id)
.await
.expect("lookup old state in history")
.is_some(),
"history payload must persist the superseded 30618 event"
);
relay.stop().await;
}
#[tokio::test]
async fn same_second_30618_replacement_keeps_nip01_lowest_id() {
// Own the directories outside the fixture so the exact same LMDB database
// can be reopened after the relay subprocess is stopped.
let git_data_dir = tempfile::tempdir().expect("create git data directory");
let relay_data_dir = tempfile::tempdir().expect("create relay data directory");
let git_data_path = git_data_dir.path().to_path_buf();
let relay_data_path = relay_data_dir.path().to_path_buf();
let relay = TestRelay::start_with_existing_lmdb_paths(
git_data_path.clone(),
relay_data_path.clone(),
None,
false,
)
.await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (_announcement, repo_id) = publish_served_repo(&client, "same-second-state").await;
// Keep the test sequence after the fixture's initial state event while
// deliberately making the two explicit versions share one timestamp.
let created_at = Timestamp::from_secs(Timestamp::now().as_secs() + 1);
let existing = build_state_version(&client, &repo_id, created_at, "state-v1");
let replacement = (0..100_000)
.map(|nonce| build_grinded_state_version(&client, &repo_id, created_at, nonce))
.find(|candidate| candidate.id < existing.id)
.expect("nonce grinding must produce a lower NIP-01 event ID");
assert_eq!(existing.created_at, replacement.created_at);
assert!(
replacement.id < existing.id,
"ngit's equal-timestamp replacement must have the lower event ID"
);
client
.send_event(existing.clone())
.await
.expect("send existing state");
client
.send_event(replacement.clone())
.await
.expect("send NIP-01-preferred replacement state");
assert!(
!client
.is_event_on_relay(existing.id)
.await
.expect("query existing state by ID"),
"the larger-ID state must be removed after the same-second replacement"
);
assert!(
client
.is_event_on_relay(replacement.id)
.await
.expect("query replacement state by ID"),
"the lower-ID state must be returned as the NIP-01 winner"
);
relay.stop().await;
let restarted =
TestRelay::start_with_existing_lmdb_paths(git_data_path, relay_data_path, None, false)
.await;
let restarted_client = AuditClient::new(restarted.url(), AuditConfig::isolated())
.await
.expect("create client for restarted relay");
assert!(
!restarted_client
.is_event_on_relay(existing.id)
.await
.expect("query existing state by ID after restart"),
"the larger-ID state must remain absent after restart"
);
assert!(
restarted_client
.is_event_on_relay(replacement.id)
.await
.expect("query replacement state by ID after restart"),
"the lower-ID state must remain the NIP-01 winner after restart"
);
restarted.stop().await;
}
#[tokio::test]
async fn newer_30617_supersedes_older_and_preserves_old_in_history() {
let relay = TestRelay::start_with_lmdb().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (first_announcement, repo_id) = publish_served_repo(&client, "history-30617").await;
let newer_announcement = build_announcement_version(
&client,
&repo_id,
Timestamp::from_secs(first_announcement.created_at.as_secs() + 30),
"re-announcement",
)
.await;
client
.send_event(newer_announcement.clone())
.await
.expect("send newer announcement");
tokio::time::sleep(Duration::from_millis(400)).await;
let coordinate = format!("30617:{}:{}", client.public_key().to_hex(), repo_id);
let history = open_history(relay.relay_data_path()).await;
let records = history
.superseded_records_for_coordinate_before(&coordinate, Timestamp::now())
.await;
assert!(
records.iter().any(|r| {
r.superseded_event_id == Some(first_announcement.id)
&& r.replaced_by == Some(newer_announcement.id)
}),
"history must contain old 30617 as superseded by the newer 30617"
);
assert!(
history
.event_by_id(&first_announcement.id)
.await
.expect("lookup old announcement in history")
.is_some(),
"history payload must persist the superseded 30617 event"
);
relay.stop().await;
}
#[tokio::test]
async fn sync_style_admit_then_save_captures_superseded_history_once() {
use nostr_sdk::prelude::{WritePolicy, WritePolicyResult};
let git_dir = tempfile::tempdir().expect("git tempdir");
let db: SharedDatabase = Arc::new(nostr_memory::MemoryDatabase::unbounded());
let history = ReplaceableHistoryStore::in_memory();
let policy = build_policy_for_history_regression(db.clone(), history.clone(), git_dir.path());
let keys = Keys::generate();
let repo_id = format!("history-sync-once-{}", &keys.public_key().to_hex()[..8]);
let old_announcement = build_policy_announcement_version(
&keys,
&repo_id,
Timestamp::from_secs(Timestamp::now().as_secs() + 1),
"old",
);
let new_announcement = build_policy_announcement_version(
&keys,
&repo_id,
Timestamp::from_secs(old_announcement.created_at.as_secs() + 30),
"new",
);
db.save_event(&old_announcement)
.await
.expect("save old announcement");
let dummy_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let result = policy.admit_event(&new_announcement, &dummy_addr).await;
assert!(
matches!(result, WritePolicyResult::Accept),
"new announcement must be accepted before central save"
);
// Cross a timestamp-second boundary before saving. The old persistence hook
// also captured history here, producing a distinct metadata event instead
// of collapsing onto the admission hook's metadata ID.
tokio::time::sleep(Duration::from_millis(1100)).await;
policy
.save_accepted_event(&new_announcement, SaveContext::RelaySync)
.await
.expect("save accepted announcement");
let coordinate = format!("30617:{}:{}", keys.public_key().to_hex(), repo_id);
let records = history
.superseded_records_for_coordinate_before(
&coordinate,
Timestamp::from_secs(new_announcement.created_at.as_secs() + 1),
)
.await;
let matching_records = records
.iter()
.filter(|r| {
r.superseded_event_id == Some(old_announcement.id)
&& r.replaced_by == Some(new_announcement.id)
})
.count();
assert_eq!(
matching_records, 1,
"sync-style admit then save must not duplicate superseded history metadata"
);
}
#[tokio::test]
async fn replaceable_history_survives_restart_lmdb() {
let relay = TestRelay::start_with_lmdb().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (first_announcement, repo_id) = publish_served_repo(&client, "history-restart").await;
let newer_announcement = build_announcement_version(
&client,
&repo_id,
Timestamp::from_secs(first_announcement.created_at.as_secs() + 45),
"restart-reannouncement",
)
.await;
client
.send_event(newer_announcement.clone())
.await
.expect("send newer announcement");
tokio::time::sleep(Duration::from_millis(400)).await;
let coordinate = format!("30617:{}:{}", client.public_key().to_hex(), repo_id);
let history_before_restart = open_history(relay.relay_data_path()).await;
let pre_restart_records = history_before_restart
.superseded_records_for_coordinate_before(&coordinate, Timestamp::now())
.await;
assert!(
pre_restart_records.iter().any(|r| {
r.superseded_event_id == Some(first_announcement.id)
&& r.replaced_by == Some(newer_announcement.id)
}),
"history metadata must exist before restart"
);
let relay_data_path = relay.relay_data_path().clone();
let git_data_path = relay.git_data_path().clone();
let owner_hex = client.public_key().to_hex();
relay.stop().await;
let restarted = TestRelay::start_with_existing_lmdb_paths(
git_data_path,
relay_data_path.clone(),
None,
false,
)
.await;
restarted.stop().await;
let history = open_history(&relay_data_path).await;
let coordinate = format!("30617:{}:{}", owner_hex, repo_id);
let records = history
.superseded_records_for_coordinate_before(&coordinate, Timestamp::now())
.await;
assert!(
records.iter().any(|r| {
r.superseded_event_id == Some(first_announcement.id)
&& r.replaced_by == Some(newer_announcement.id)
}),
"history metadata must survive LMDB restart"
);
assert!(
history
.event_by_id(&first_announcement.id)
.await
.expect("lookup archived announcement after restart")
.is_some(),
"history payload must survive LMDB restart"
);
}
#[tokio::test]
async fn serving_behavior_unchanged_latest_version_is_still_served() {
let relay = TestRelay::start_with_lmdb().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (_announcement, repo_id) = publish_served_repo(&client, "history-serving").await;
// `publish_served_repo` already saved a 30618 for this coordinate. Keep the
// explicit state sequence in future seconds so the relay's second-granular
// replaceable ordering cannot tie it with the fixture event.
let base_ts = Timestamp::from_secs(Timestamp::now().as_secs() + 1);
let old_state = build_state_version(&client, &repo_id, base_ts, "state-old");
let new_state = build_state_version(
&client,
&repo_id,
Timestamp::from_secs(base_ts.as_secs() + 30),
"state-new",
);
client
.send_event(old_state.clone())
.await
.expect("send old state");
client
.send_event(new_state.clone())
.await
.expect("send new state");
tokio::time::sleep(Duration::from_millis(400)).await;
let by_id_old = client
.is_event_on_relay(old_state.id)
.await
.expect("query old state by id");
let by_id_new = client
.is_event_on_relay(new_state.id)
.await
.expect("query new state by id");
assert!(
!by_id_old,
"superseded state must remain non-served in the main relay behavior"
);
assert!(by_id_new, "latest state must still be served");
relay.stop().await;
}
#[tokio::test]
async fn history_not_captured_for_purgatory_only_announcement() {
let relay = TestRelay::start_with_lmdb().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let relay_url = client
.relay_url()
.await
.expect("client should have relay url");
let relay_domain = relay_url
.trim_start_matches("ws://")
.trim_start_matches("wss://")
.to_string();
let npub = client.public_key().to_bech32().expect("pubkey to npub");
let repo_id = format!(
"history-purgatory-only-{}",
&Keys::generate().public_key().to_hex()[..8]
);
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(vec![
Tag::identifier(&repo_id),
Tag::custom("name", vec![repo_id.clone()]),
Tag::custom(
"clone",
vec![format!("http://{}/{}/{}.git", relay_domain, npub, repo_id)],
),
Tag::custom("relays", vec![relay_url]),
])
.finalize(client.keys())
.expect("build purgatory-only announcement");
client
.send_event(announcement)
.await
.expect("send announcement to purgatory");
tokio::time::sleep(Duration::from_millis(300)).await;
let coordinate = format!("30617:{}:{}", client.public_key().to_hex(), repo_id);
let history = open_history(relay.relay_data_path()).await;
let latest = history
.latest_superseded_before(&coordinate, Timestamp::now())
.await;
assert!(
latest.is_none(),
"purgatory-only announcements must not capture superseded history"
);
relay.stop().await;
}