update
This commit is contained in:
@@ -731,15 +731,12 @@ fn seal_edition(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::cord03::{self, build_message, seal_rumor};
|
||||
use crate::cord04::fold;
|
||||
use crate::cord04::pins;
|
||||
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
|
||||
use crate::derive::{channel_group_key, grant_locator};
|
||||
use crate::store::{CommunityState, load_state, save_state};
|
||||
use crate::store::CommunityState;
|
||||
use crate::{Extra, RoleId};
|
||||
|
||||
const AT: u64 = 1_700_000_000;
|
||||
@@ -768,66 +765,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genesis_reopens_for_a_second_holder() {
|
||||
let owner = Keys::generate();
|
||||
let community_metadata = CommunityMetadata {
|
||||
name: "coop".to_owned(),
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
|
||||
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
|
||||
assert!(minted.identity.verify(), "identity is self-certifying");
|
||||
|
||||
// Only what an invite hands over: the roots, the community id and the owner salt.
|
||||
let (read, signer) = holder(&minted);
|
||||
let editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||
|
||||
assert_eq!(editions.len(), 2);
|
||||
|
||||
let community = &editions[0];
|
||||
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
|
||||
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
|
||||
assert_eq!(community.author, owner.public_key());
|
||||
assert_eq!((community.version, community.prev), (1, None));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<CommunityMetadata>(&community.content)
|
||||
.expect("parses")
|
||||
.name,
|
||||
"coop"
|
||||
);
|
||||
|
||||
let channel = &editions[1];
|
||||
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
|
||||
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
|
||||
|
||||
for edition in &editions {
|
||||
let folded = fold(&[EditionMeta::from(edition)], 0, None);
|
||||
assert_eq!(folded.head, Some(0));
|
||||
assert!(
|
||||
folded.anchored && !folded.gap,
|
||||
"genesis anchors at its floor"
|
||||
);
|
||||
}
|
||||
|
||||
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
|
||||
|
||||
smol::block_on(async {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
save_state(&database, &state).await.expect("saves");
|
||||
let loaded = load_state(&database, &minted.identity.community_id)
|
||||
.await
|
||||
.expect("loads")
|
||||
.expect("present");
|
||||
|
||||
assert_eq!(loaded.community_root, minted.community_root);
|
||||
assert_eq!(loaded.control_root, Some(minted.control_root));
|
||||
assert_eq!(loaded.channels.len(), 1);
|
||||
assert_eq!(loaded.heads.len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_channel_edits_reach_a_second_client() {
|
||||
let owner = Keys::generate();
|
||||
|
||||
+11
-168
@@ -27,10 +27,12 @@ const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
|
||||
pub async fn cache_rumor(
|
||||
database: &dyn NostrDatabase,
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
opened: &OpenedStream,
|
||||
) -> Result<bool> {
|
||||
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||
|
||||
if cord03::expiration_of(&opened.rumor)?
|
||||
.is_some_and(|expiration| expiration <= Timestamp::now())
|
||||
{
|
||||
@@ -45,23 +47,19 @@ pub async fn cache_rumor(
|
||||
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
|
||||
Tag::public_key(opened.author),
|
||||
];
|
||||
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
|
||||
.tags(tags)
|
||||
.custom_created_at(at)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn purge_expired(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
now: Timestamp,
|
||||
) -> Result<usize> {
|
||||
pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result<usize> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
@@ -69,7 +67,7 @@ pub async fn purge_expired(
|
||||
|
||||
let mut expired = Vec::new();
|
||||
|
||||
for event in database.query(filter).await? {
|
||||
for event in client.database().query(filter).await? {
|
||||
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
|
||||
continue;
|
||||
};
|
||||
@@ -86,7 +84,7 @@ pub async fn purge_expired(
|
||||
let purged = expired.len();
|
||||
|
||||
if purged > 0 {
|
||||
database.delete(Filter::new().ids(expired)).await?;
|
||||
client.database().delete(Filter::new().ids(expired)).await?;
|
||||
}
|
||||
|
||||
Ok(purged)
|
||||
@@ -288,16 +286,13 @@ fn state_identifier(id: &CommunityId) -> String {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
|
||||
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
|
||||
where
|
||||
D: NostrDatabase + ?Sized,
|
||||
{
|
||||
pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -319,7 +314,6 @@ where
|
||||
|
||||
pub async fn backfill(
|
||||
client: &Client,
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
held: &[(Epoch, [u8; 32])],
|
||||
until: Option<Timestamp>,
|
||||
@@ -342,7 +336,7 @@ pub async fn backfill(
|
||||
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
|
||||
|
||||
for (opened, rumor) in fresh {
|
||||
if cache_rumor(database, channel, &opened).await? {
|
||||
if cache_rumor(client, channel, &opened).await? {
|
||||
found.push(rumor);
|
||||
}
|
||||
}
|
||||
@@ -416,13 +410,8 @@ async fn fetch_page(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::Epoch;
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
|
||||
};
|
||||
use crate::cord03::{build_message, seal_rumor};
|
||||
use crate::derive::channel_group_key;
|
||||
|
||||
@@ -499,150 +488,4 @@ mod tests {
|
||||
["after the rekey", "still before", "before the rekey"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumors_read_back_after_a_restart() {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
let channel = ChannelId::from_bytes([0xabu8; 32]);
|
||||
let author = Keys::generate();
|
||||
|
||||
smol::block_on(async {
|
||||
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||
|
||||
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
|
||||
let rumor = build_rumor_ms(
|
||||
9,
|
||||
author.public_key(),
|
||||
content,
|
||||
channel_binding_tags(&channel, Epoch(0)),
|
||||
at_ms,
|
||||
);
|
||||
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
|
||||
let (wrap, _) = wrap_seal(
|
||||
&seal,
|
||||
&group,
|
||||
KIND_WRAP,
|
||||
Timestamp::from_secs(at_ms / 1000),
|
||||
&[],
|
||||
)
|
||||
.expect("wraps");
|
||||
|
||||
let opened = open_wrap(&wrap, &group).expect("opens");
|
||||
cache_rumor(&database, &channel, &opened)
|
||||
.await
|
||||
.expect("caches");
|
||||
}
|
||||
|
||||
// The group key is gone; only the local cache stands in for it.
|
||||
let rumors = query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(rumors.len(), 2, "both messages come back");
|
||||
assert_eq!(rumors[0].content, "second", "newest first");
|
||||
assert_eq!(rumors[1].content, "first");
|
||||
|
||||
// A page boundary in message time, not in cache time.
|
||||
let until = Timestamp::from_secs(1_500);
|
||||
let page = query_rumors(&database, &channel, Some(until), 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].content, "first");
|
||||
|
||||
let capped = query_rumors(&database, &channel, None, 1)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(capped.len(), 1);
|
||||
assert_eq!(capped[0].content, "second");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
let channel = ChannelId::from_bytes([0x77u8; 32]);
|
||||
let author = Keys::generate();
|
||||
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||
let now = Timestamp::now().as_secs();
|
||||
|
||||
smol::block_on(async {
|
||||
// A live timer is stored; one that already elapsed is refused at ingest.
|
||||
assert!(
|
||||
cache(
|
||||
&database,
|
||||
&group,
|
||||
&channel,
|
||||
&author,
|
||||
"live",
|
||||
Some(3_600),
|
||||
now
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!cache(
|
||||
&database,
|
||||
&group,
|
||||
&channel,
|
||||
&author,
|
||||
"gone",
|
||||
Some(1),
|
||||
now - 120
|
||||
)
|
||||
.await
|
||||
);
|
||||
|
||||
let stored = query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].content, "live");
|
||||
|
||||
// Hiding is not disappearing: the sweep removes the row itself,
|
||||
// judged on the rumor's own signed tag.
|
||||
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200))
|
||||
.await
|
||||
.expect("sweeps");
|
||||
assert_eq!(purged, 1);
|
||||
assert!(
|
||||
query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries")
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// An untimed rumor is never swept, whatever the clock says.
|
||||
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await);
|
||||
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400))
|
||||
.await
|
||||
.expect("sweeps");
|
||||
assert_eq!(purged, 0);
|
||||
});
|
||||
}
|
||||
|
||||
async fn cache(
|
||||
database: &MemoryDatabase,
|
||||
group: &GroupKey,
|
||||
channel: &ChannelId,
|
||||
author: &Keys,
|
||||
content: &str,
|
||||
timer: Option<u64>,
|
||||
at_secs: u64,
|
||||
) -> bool {
|
||||
let rumor = build_message(
|
||||
author.public_key(),
|
||||
channel,
|
||||
Epoch(0),
|
||||
content,
|
||||
None,
|
||||
at_secs * 1_000,
|
||||
timer,
|
||||
);
|
||||
let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals");
|
||||
let opened = open_wrap(&wrap, group).expect("opens");
|
||||
|
||||
cache_rumor(database, channel, &opened)
|
||||
.await
|
||||
.expect("caches")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user