commit 2c1b7c56e99354aec0800de6dbd6f4d4b6018a36
parent e3325eea6146d707aa1460960494e4943e9801db
Author: triesap <tyson@radroots.org>
Date: Sat, 13 Jun 2026 19:28:45 -0700
test: add v2 relay integration coverage
Add deterministic tangle v2 fixture builders and in-process base relay integration coverage for public relay behavior, NIP-11, AUTH, group write authorization, lifecycle, membership, metadata flags, read privacy, secondary privacy surfaces, projection rebuild, conflict ordering, and malformed input smoke tests.
Keep the repo guardrails green by updating guard wording and resolving clippy warnings without compatibility paths.
Validation: cargo fmt --all -- --check; CARGO_TARGET_DIR=.local/build/tangle-rcld12 cargo metadata --format-version 1 --no-deps; CARGO_TARGET_DIR=.local/build/tangle-rcld12 cargo test -p tangle_test_support -p tangle_runtime --test base_relay_v2; CARGO_TARGET_DIR=.local/build/tangle-rcld12 cargo test -p tangle_groups -p tangle_runtime -p tangle_test_support; CARGO_TARGET_DIR=.local/build/tangle-rcld12 cargo test --workspace; CARGO_TARGET_DIR=.local/build/tangle-rcld12 scripts/check.sh; CARGO_TARGET_DIR=.local/build/tangle-rcld12 scripts/test.sh; git diff --check. cargo nextest unavailable: cargo reports no such command.
Diffstat:
14 files changed, 1233 insertions(+), 168 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4441,6 +4441,7 @@ dependencies = [
"serde",
"serde_json",
"tangle_crypto",
+ "tangle_groups",
"tangle_nips",
"tangle_protocol",
"tangle_store",
diff --git a/crates/tangle_groups/src/classification.rs b/crates/tangle_groups/src/classification.rs
@@ -53,14 +53,13 @@ pub fn classify_group_event(
.clone();
return Ok(GroupEventClass::Normal { group_id });
}
- if has_group_identity_tag(event.unsigned().tags()) {
- if let Some(group_tag) =
+ if has_group_identity_tag(event.unsigned().tags())
+ && let Some(group_tag) =
extract_group_tag(event.unsigned().tags(), GroupTagName::H, limits)?
- {
- return Ok(GroupEventClass::Normal {
- group_id: group_tag.group_id().clone(),
- });
- }
+ {
+ return Ok(GroupEventClass::Normal {
+ group_id: group_tag.group_id().clone(),
+ });
}
Ok(GroupEventClass::NonGroup)
}
diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs
@@ -29,7 +29,9 @@ pub use kinds::{
NIP29_MODERATION_KIND_VALUES, NIP29_RELAY_GENERATED_KIND_VALUES,
NIP29_USER_REQUEST_KIND_VALUES,
};
-pub use metadata::{GroupMetadata, SupportedKinds, parse_group_metadata};
+pub use metadata::{
+ GroupMetadata, GroupMetadataFlags, GroupMetadataText, SupportedKinds, parse_group_metadata,
+};
pub use outbox::{
GroupCrashHooks, GroupCrashPoint, GroupOutbox, GroupOutboxEffect, GroupOutboxKey,
GroupOutboxPayload, GroupOutboxRecord, GroupOutboxStatus, OutboxRecoveryReadiness,
diff --git a/crates/tangle_groups/src/metadata.rs b/crates/tangle_groups/src/metadata.rs
@@ -23,24 +23,19 @@ pub struct GroupMetadata {
}
impl GroupMetadata {
- pub fn new(
- name: Option<String>,
- picture: Option<String>,
- about: Option<String>,
- private: bool,
- restricted: bool,
- hidden: bool,
- closed: bool,
+ pub fn from_parts(
+ text: GroupMetadataText,
+ flags: GroupMetadataFlags,
supported_kinds: SupportedKinds,
) -> Self {
Self {
- name,
- picture,
- about,
- private,
- restricted,
- hidden,
- closed,
+ name: text.name,
+ picture: text.picture,
+ about: text.about,
+ private: flags.private,
+ restricted: flags.restricted,
+ hidden: flags.hidden,
+ closed: flags.closed,
supported_kinds,
}
}
@@ -92,6 +87,50 @@ impl GroupMetadata {
}
#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GroupMetadataText {
+ name: Option<String>,
+ picture: Option<String>,
+ about: Option<String>,
+}
+
+impl GroupMetadataText {
+ pub fn new(name: Option<String>, picture: Option<String>, about: Option<String>) -> Self {
+ Self {
+ name,
+ picture,
+ about,
+ }
+ }
+
+ pub fn empty() -> Self {
+ Self {
+ name: None,
+ picture: None,
+ about: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct GroupMetadataFlags {
+ private: bool,
+ restricted: bool,
+ hidden: bool,
+ closed: bool,
+}
+
+impl GroupMetadataFlags {
+ pub fn new(private: bool, restricted: bool, hidden: bool, closed: bool) -> Self {
+ Self {
+ private,
+ restricted,
+ hidden,
+ closed,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SupportedKinds {
UnspecifiedAll,
None,
diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs
@@ -142,12 +142,11 @@ impl<'a> GroupWritePolicy<'a> {
));
}
}
- if kind == KIND_GROUP_EDIT_METADATA {
- if group.metadata().hidden()
- && !self.can_read_group(group_id, Some(event.unsigned().pubkey()))
- {
- return Err(non_enumerating_group_error());
- }
+ if kind == KIND_GROUP_EDIT_METADATA
+ && group.metadata().hidden()
+ && !self.can_read_group(group_id, Some(event.unsigned().pubkey()))
+ {
+ return Err(non_enumerating_group_error());
}
Ok(GroupWriteDecision::Accept)
}
@@ -343,10 +342,10 @@ fn has_role_tag(tags: &[Tag]) -> bool {
fn target_pubkey(event: &Event, tag_name: &str) -> Result<PublicKeyHex, GroupError> {
for tag in event.unsigned().tags() {
- if !tag
+ if tag
.values()
.first()
- .is_some_and(|candidate| candidate == tag_name)
+ .is_none_or(|candidate| candidate != tag_name)
{
continue;
}
@@ -374,11 +373,11 @@ mod tests {
use super::{GroupAuthority, GroupWriteDecision, GroupWritePolicy};
use crate::{
Capability, CapabilitySet, GroupAuthContext, GroupErrorKind, GroupEventClass, GroupId,
- GroupMetadata, GroupProjection, GroupState, KIND_GROUP_CREATE_GROUP,
- KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_GROUP, KIND_GROUP_JOIN_REQUEST,
- KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_REMOVE_USER, MemberState, MemberStatus,
- ProjectedRoleDefinition, ProjectionOrderTuple, RoleDefinition, RoleName, StoreOffset,
- SupportedKinds,
+ GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupProjection, GroupState,
+ KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_GROUP,
+ KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_REMOVE_USER, MemberState,
+ MemberStatus, ProjectedRoleDefinition, ProjectionOrderTuple, RoleDefinition, RoleName,
+ StoreOffset, SupportedKinds,
};
use tangle_protocol::{
Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
@@ -731,14 +730,9 @@ mod tests {
closed: bool,
supported_kinds: SupportedKinds,
) -> GroupMetadata {
- GroupMetadata::new(
- None,
- None,
- None,
- private,
- restricted,
- hidden,
- closed,
+ GroupMetadata::from_parts(
+ GroupMetadataText::empty(),
+ GroupMetadataFlags::new(private, restricted, hidden, closed),
supported_kinds,
)
}
diff --git a/crates/tangle_groups/src/projection.rs b/crates/tangle_groups/src/projection.rs
@@ -2,8 +2,8 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::{
CapabilitySet, GroupError, GroupErrorKind, GroupEventClass, GroupId, GroupLimitsConfig,
- GroupMetadata, RoleDefinition, RoleName, SupportedKinds, classify_group_event,
- parse_group_metadata,
+ GroupMetadata, GroupMetadataFlags, GroupMetadataText, RoleDefinition, RoleName, SupportedKinds,
+ classify_group_event, parse_group_metadata,
};
use serde::{Deserialize, Serialize};
use tangle_protocol::{Event, EventId, Kind, PublicKeyHex, Tag, UnixTimestamp};
@@ -975,11 +975,7 @@ fn prefixed_key(prefix: &str, first: &str, second: Option<&str>) -> Vec<u8> {
fn first_tag_value<'a>(tags: &'a [Tag], name: &str) -> Result<&'a str, GroupError> {
for tag in tags {
- if !tag
- .values()
- .first()
- .is_some_and(|tag_name| tag_name == name)
- {
+ if tag.values().first().is_none_or(|tag_name| tag_name != name) {
continue;
}
let Some((_, value)) = tag.indexed_pair() else {
@@ -1060,14 +1056,9 @@ impl MetadataDocument {
}
fn into_metadata(self) -> Result<GroupMetadata, GroupError> {
- Ok(GroupMetadata::new(
- self.name,
- self.picture,
- self.about,
- self.private,
- self.restricted,
- self.hidden,
- self.closed,
+ Ok(GroupMetadata::from_parts(
+ GroupMetadataText::new(self.name, self.picture, self.about),
+ GroupMetadataFlags::new(self.private, self.restricted, self.hidden, self.closed),
self.supported_kinds.into_supported()?,
))
}
@@ -1429,7 +1420,7 @@ mod tests {
#[test]
fn projection_order_tuple_sorts_by_created_event_and_offset() {
- let mut tuples = vec![
+ let mut tuples = [
tuple(2, "b", 1),
tuple(1, "c", 2),
tuple(1, "a", 3),
@@ -1634,14 +1625,9 @@ mod tests {
let base_tuple = tuple(10, "10", 1);
let state = super::GroupState::new(
GroupId::new("Farm").expect("group"),
- crate::GroupMetadata::new(
- Some("Farmers".to_owned()),
- None,
- None,
- true,
- false,
- false,
- false,
+ crate::GroupMetadata::from_parts(
+ crate::GroupMetadataText::new(Some("Farmers".to_owned()), None, None),
+ crate::GroupMetadataFlags::new(true, false, false, false),
SupportedKinds::UnspecifiedAll,
),
PublicKeyHex::new(&"1".repeat(64)).expect("pubkey"),
diff --git a/crates/tangle_groups/src/read_gate.rs b/crates/tangle_groups/src/read_gate.rs
@@ -91,10 +91,10 @@ impl<'a> GroupReadGate<'a> {
let Some(group) = self.projection.group(group_id) else {
return Ok(GroupReadDecision::Hidden);
};
- if !self
+ if self
.projection
.tombstone(group_id)
- .is_some_and(|tombstone| tombstone.delete_event_id() == event.id())
+ .is_none_or(|tombstone| tombstone.delete_event_id() != event.id())
{
return self.screen_normal_event(group_id, reader);
}
@@ -142,9 +142,10 @@ impl<'a> GroupReadGate<'a> {
mod tests {
use super::{GroupReadDecision, GroupReadGate};
use crate::{
- GroupAuthority, GroupEventDeletion, GroupId, GroupMetadata, GroupProjection, GroupState,
- GroupTombstone, KIND_GROUP_DELETE_GROUP, KIND_GROUP_METADATA, MemberState, MemberStatus,
- ProjectionOrderTuple, StoreOffset, SupportedKinds,
+ GroupAuthority, GroupEventDeletion, GroupId, GroupMetadata, GroupMetadataFlags,
+ GroupMetadataText, GroupProjection, GroupState, GroupTombstone, KIND_GROUP_DELETE_GROUP,
+ KIND_GROUP_METADATA, MemberState, MemberStatus, ProjectionOrderTuple, StoreOffset,
+ SupportedKinds,
};
use tangle_protocol::{
Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
@@ -153,20 +154,7 @@ mod tests {
#[test]
fn read_gate_allows_public_group_events_and_hides_unknown_groups() {
let owner = pubkey("1");
- let projection = projection_with_group(
- "Farm",
- GroupMetadata::new(
- None,
- None,
- None,
- false,
- false,
- false,
- false,
- SupportedKinds::UnspecifiedAll,
- ),
- owner,
- );
+ let projection = projection_with_group("Farm", metadata(false, false, false, false), owner);
let authority = GroupAuthority::empty();
let gate = GroupReadGate::new(&projection, &authority);
@@ -187,20 +175,8 @@ mod tests {
let owner = pubkey("1");
let member = pubkey("2");
let outsider = pubkey("3");
- let mut projection = projection_with_group(
- "Farm",
- GroupMetadata::new(
- None,
- None,
- None,
- true,
- false,
- true,
- false,
- SupportedKinds::UnspecifiedAll,
- ),
- owner.clone(),
- );
+ let mut projection =
+ projection_with_group("Farm", metadata(true, false, true, false), owner.clone());
put_member(&mut projection, "Farm", member.clone());
let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new());
let gate = GroupReadGate::new(&projection, &authority);
@@ -233,20 +209,8 @@ mod tests {
#[test]
fn read_gate_hides_hidden_and_private_snapshots_from_non_members() {
let owner = pubkey("1");
- let projection = projection_with_group(
- "Farm",
- GroupMetadata::new(
- None,
- None,
- None,
- true,
- false,
- true,
- false,
- SupportedKinds::UnspecifiedAll,
- ),
- owner.clone(),
- );
+ let projection =
+ projection_with_group("Farm", metadata(true, false, true, false), owner.clone());
let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new());
let gate = GroupReadGate::new(&projection, &authority);
@@ -273,20 +237,7 @@ mod tests {
#[test]
fn require_visible_uses_non_enumerating_error() {
let owner = pubkey("1");
- let projection = projection_with_group(
- "Farm",
- GroupMetadata::new(
- None,
- None,
- None,
- true,
- false,
- true,
- false,
- SupportedKinds::UnspecifiedAll,
- ),
- owner,
- );
+ let projection = projection_with_group("Farm", metadata(true, false, true, false), owner);
let authority = GroupAuthority::empty();
let gate = GroupReadGate::new(&projection, &authority);
@@ -301,20 +252,8 @@ mod tests {
#[test]
fn read_gate_hides_deleted_target_events() {
let owner = pubkey("1");
- let mut projection = projection_with_group(
- "Farm",
- GroupMetadata::new(
- None,
- None,
- None,
- false,
- false,
- false,
- false,
- SupportedKinds::UnspecifiedAll,
- ),
- owner,
- );
+ let mut projection =
+ projection_with_group("Farm", metadata(false, false, false, false), owner);
let target = event(1, vec![h("Farm")]);
projection.put_event_deletion(GroupEventDeletion::new(
GroupId::new("Farm").expect("group"),
@@ -337,20 +276,8 @@ mod tests {
#[test]
fn read_gate_keeps_group_delete_marker_under_group_visibility_policy() {
let owner = pubkey("1");
- let mut projection = projection_with_group(
- "Farm",
- GroupMetadata::new(
- None,
- None,
- None,
- false,
- false,
- false,
- false,
- SupportedKinds::UnspecifiedAll,
- ),
- owner,
- );
+ let mut projection =
+ projection_with_group("Farm", metadata(false, false, false, false), owner);
let marker = event(KIND_GROUP_DELETE_GROUP, vec![h("Farm")]);
projection.put_tombstone(GroupTombstone::new(
GroupId::new("Farm").expect("group"),
@@ -403,6 +330,14 @@ mod tests {
);
}
+ fn metadata(private: bool, restricted: bool, hidden: bool, closed: bool) -> GroupMetadata {
+ GroupMetadata::from_parts(
+ GroupMetadataText::empty(),
+ GroupMetadataFlags::new(private, restricted, hidden, closed),
+ SupportedKinds::UnspecifiedAll,
+ )
+ }
+
fn event(kind_value: u32, tags: Vec<Tag>) -> Event {
Event::new(
event_id("01"),
diff --git a/crates/tangle_groups/src/tags.rs b/crates/tangle_groups/src/tags.rs
@@ -77,10 +77,10 @@ pub fn extract_group_tag(
) -> Result<Option<GroupTag>, GroupError> {
let mut found: Option<GroupId> = None;
for tag in tags {
- if !tag
+ if tag
.values()
.first()
- .is_some_and(|tag_name| tag_name == name.as_str())
+ .is_none_or(|tag_name| tag_name != name.as_str())
{
continue;
}
diff --git a/crates/tangle_groups/src/write_gate.rs b/crates/tangle_groups/src/write_gate.rs
@@ -89,11 +89,7 @@ fn require_valid_p_tag(event: &Event) -> Result<(), GroupError> {
fn require_indexed_tag_value<'a>(event: &'a Event, name: &str) -> Result<&'a str, GroupError> {
for tag in event.unsigned().tags() {
- if !tag
- .values()
- .first()
- .is_some_and(|tag_name| tag_name == name)
- {
+ if tag.values().first().is_none_or(|tag_name| tag_name != name) {
continue;
}
let Some((_, value)) = tag.indexed_pair() else {
diff --git a/crates/tangle_runtime/src/base_relay.rs b/crates/tangle_runtime/src/base_relay.rs
@@ -1396,7 +1396,7 @@ fn class_group_id(class: &GroupEventClass) -> Option<&GroupId> {
fn delete_target_event_id(event: &Event) -> Result<EventId, GroupError> {
for tag in event.unsigned().tags() {
- if !tag.values().first().is_some_and(|name| name == "e") {
+ if tag.values().first().is_none_or(|name| name != "e") {
continue;
}
let Some((_, value)) = tag.indexed_pair() else {
diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs
@@ -0,0 +1,774 @@
+#![forbid(unsafe_code)]
+
+use std::{fs, panic, path::PathBuf};
+use tangle_crypto::{event_id_matches, verify_event_signature};
+use tangle_groups::{
+ GroupId, GroupRuntimeConfig, KIND_GROUP_ADMINS, KIND_GROUP_DELETE_GROUP, KIND_GROUP_MEMBERS,
+ KIND_GROUP_METADATA, MemberStatus,
+};
+use tangle_protocol::{
+ Event, Filter, RawEventJson, RelayMessage, SubscriptionId, Tag, UnixTimestamp,
+ filter_from_value, parse_client_message, parse_event_json,
+};
+use tangle_runtime::base_relay::{
+ BASE_RELAY_SUPPORTED_NIPS, BaseAuthState, BaseRelay, BaseRelayInfoConfig, CloseResult,
+};
+use tangle_store_pocket::{PocketStoreConfig, PocketSyncPolicy};
+use tangle_test_support::{
+ FixtureKey, TANGLE_V2_RELAY_URL, tangle_v2_auth_event, tangle_v2_delete_group_event,
+ tangle_v2_event, tangle_v2_group_config, tangle_v2_group_create_event, tangle_v2_group_event,
+ tangle_v2_group_metadata_event, tangle_v2_join_event, tangle_v2_leave_event,
+ tangle_v2_put_user_event, tangle_v2_remove_user_event,
+};
+
+#[test]
+fn public_relay_smoke_stores_queries_counts_and_fans_out() {
+ let config = test_store_config("public-smoke");
+ let mut relay = BaseRelay::open(&config, 4).expect("relay");
+ let first =
+ tangle_v2_event(FixtureKey::Member, 1_714_124_433, 1, Vec::new(), "hello").expect("first");
+ let query_id = subscription("public-query");
+
+ assert_accepted(relay.handle_event(first.clone()).expect("event"), &first);
+ assert_event_query(
+ relay
+ .handle_req(query_id.clone(), vec![filter_kind(1)])
+ .expect("query"),
+ &query_id,
+ &[&first],
+ );
+ assert_count(
+ relay.handle_count(subscription("public-count"), vec![filter_kind(1)]),
+ 1,
+ );
+ assert_eq!(relay.handle_close(&query_id), CloseResult::Closed);
+
+ let live_id = subscription("public-live");
+ relay
+ .handle_req(live_id.clone(), vec![filter_kind(1)])
+ .expect("live");
+ let second =
+ tangle_v2_event(FixtureKey::Member, 1_714_124_434, 1, Vec::new(), "again").expect("second");
+ assert_accepted(relay.handle_event(second.clone()).expect("event"), &second);
+
+ assert!(matches!(
+ relay.fanout(&second).as_slice(),
+ [RelayMessage::Event { subscription_id, event }]
+ if subscription_id == &live_id && event.id() == second.id()
+ ));
+}
+
+#[test]
+fn nip11_integration_reports_group_contracts() {
+ let groups = group_config();
+ let document = BaseRelayInfoConfig::new("tangle", groups)
+ .expect("config")
+ .build_document()
+ .expect("document");
+ let disabled = BaseRelayInfoConfig::new("tangle", GroupRuntimeConfig::disabled())
+ .expect("config")
+ .build_document()
+ .expect("disabled");
+
+ assert!(BASE_RELAY_SUPPORTED_NIPS.contains(&1));
+ assert!(document.supported_nips.contains(&29));
+ assert!(document.supported_nips.contains(&42));
+ assert!(document.supported_nips.contains(&45));
+ assert!(document.supported_nips.contains(&70));
+ assert!(!document.supported_nips.contains(&50));
+ assert!(!document.supported_nips.contains(&77));
+ assert!(!document.supported_nips.contains(&99));
+ assert!(document.relay_self().is_some());
+ assert!(!disabled.supported_nips.contains(&29));
+ assert!(disabled.relay_self().is_none());
+}
+
+#[test]
+fn auth_integration_covers_challenge_edges() {
+ let mut auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 20).expect("auth");
+
+ assert_eq!(
+ auth.issue_challenge("challenge-a", UnixTimestamp::new(100))
+ .expect("challenge"),
+ RelayMessage::Auth("challenge-a".to_owned())
+ );
+
+ let owner_event = tangle_v2_auth_event(FixtureKey::Owner, "challenge-a", 105).expect("owner");
+ let admin_event = tangle_v2_auth_event(FixtureKey::Admin, "challenge-a", 110).expect("admin");
+
+ let owner = auth
+ .authenticate(&owner_event, UnixTimestamp::new(105))
+ .expect("owner");
+ let admin = auth
+ .authenticate(&admin_event, UnixTimestamp::new(110))
+ .expect("admin");
+
+ assert_ne!(owner, admin);
+ assert!(auth.authenticated_pubkeys().contains(&owner));
+ assert!(auth.authenticated_pubkeys().contains(&admin));
+ assert_eq!(
+ auth.authenticate(
+ &tangle_v2_auth_event(FixtureKey::Member, "wrong", 111).expect("wrong"),
+ UnixTimestamp::new(111),
+ )
+ .expect_err("wrong")
+ .prefixed_message(),
+ "auth-required: auth challenge does not match"
+ );
+
+ let expired = BaseAuthState::new(TANGLE_V2_RELAY_URL, 1).expect("expired");
+ let mut expired = issue_challenge(expired, "challenge-b", 100);
+ assert_eq!(
+ expired
+ .authenticate(
+ &tangle_v2_auth_event(FixtureKey::Owner, "challenge-b", 101).expect("expired"),
+ UnixTimestamp::new(102),
+ )
+ .expect_err("expired")
+ .prefixed_message(),
+ "auth-required: auth challenge expired"
+ );
+
+ let mut wrong_relay = BaseAuthState::new("wss://other.radroots.test", 20).expect("relay");
+ wrong_relay
+ .issue_challenge("challenge-a", UnixTimestamp::new(100))
+ .expect("challenge");
+ assert_eq!(
+ wrong_relay
+ .authenticate(&owner_event, UnixTimestamp::new(105))
+ .expect_err("relay")
+ .prefixed_message(),
+ "auth-required: auth relay does not match canonical relay URL"
+ );
+}
+
+#[test]
+fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() {
+ let config = test_store_config("group-flows");
+ let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
+ let owner_auth = authenticated(FixtureKey::Owner);
+ let admin_auth = authenticated(FixtureKey::Admin);
+ let member_auth = authenticated(FixtureKey::Member);
+ let outsider_auth = authenticated(FixtureKey::Outsider);
+ let create = tangle_v2_group_create_event(FixtureKey::Owner, "Farm", 1, &[]).expect("create");
+
+ assert_eq!(
+ rejected_message(relay.handle_event(create.clone()).expect("no auth")),
+ "auth-required: group event author must authenticate with AUTH"
+ );
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(create.clone(), &outsider_auth)
+ .expect("wrong auth")
+ ),
+ "auth-required: group event author must authenticate with AUTH"
+ );
+ assert_accepted(
+ relay
+ .handle_event_with_auth(create.clone(), &owner_auth)
+ .expect("create"),
+ &create,
+ );
+
+ let metadata = tangle_v2_group_metadata_event(FixtureKey::Admin, "Farm", "Market", 2, &[])
+ .expect("metadata");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(metadata.clone(), &admin_auth)
+ .expect("metadata"),
+ &metadata,
+ );
+
+ let put =
+ tangle_v2_put_user_event(FixtureKey::Admin, "Farm", FixtureKey::Member, 3).expect("put");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(put.clone(), &admin_auth)
+ .expect("put"),
+ &put,
+ );
+ assert_eq!(
+ relay
+ .group_projection()
+ .expect("projection")
+ .member(&group("Farm"), &FixtureKey::Member.public_key())
+ .expect("member")
+ .status(),
+ MemberStatus::Member
+ );
+
+ let join = tangle_v2_join_event(FixtureKey::Outsider, "Farm", 4).expect("join");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(join.clone(), &outsider_auth)
+ .expect("join"),
+ &join,
+ );
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(
+ tangle_v2_join_event(FixtureKey::Outsider, "Farm", 5).expect("duplicate"),
+ &outsider_auth,
+ )
+ .expect("duplicate")
+ ),
+ "invalid: group member already exists"
+ );
+
+ let leave = tangle_v2_leave_event(FixtureKey::Outsider, "Farm", 6).expect("leave");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(leave.clone(), &outsider_auth)
+ .expect("leave"),
+ &leave,
+ );
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(
+ tangle_v2_leave_event(FixtureKey::Admin, "Farm", 7).expect("admin leave"),
+ &admin_auth,
+ )
+ .expect("admin leave")
+ ),
+ "invalid: group member does not exist"
+ );
+
+ let protected_remove =
+ tangle_v2_remove_user_event(FixtureKey::Admin, "Farm", FixtureKey::Admin, 8)
+ .expect("remove admin");
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(protected_remove, &admin_auth)
+ .expect("remove admin")
+ ),
+ "restricted: permanent group admins cannot be removed"
+ );
+
+ let remove = tangle_v2_remove_user_event(FixtureKey::Admin, "Farm", FixtureKey::Member, 9)
+ .expect("remove member");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(remove.clone(), &admin_auth)
+ .expect("remove member"),
+ &remove,
+ );
+ assert_count(
+ relay.handle_count(
+ subscription("members"),
+ vec![filter_kind(KIND_GROUP_MEMBERS)],
+ ),
+ 1,
+ );
+ assert_eq!(member_auth.authenticated_pubkeys().len(), 1);
+}
+
+#[test]
+fn metadata_flags_and_read_privacy_cover_req_count_and_fanout() {
+ let config = test_store_config("privacy-flags");
+ let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
+ let owner_auth = authenticated(FixtureKey::Owner);
+ let outsider_auth = authenticated(FixtureKey::Outsider);
+
+ accept_group_create(&mut relay, "PrivateFarm", &["private"], 1, &owner_auth);
+ let private_event =
+ tangle_v2_group_event(FixtureKey::Owner, "PrivateFarm", 2, 1, "private harvest")
+ .expect("private");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(private_event.clone(), &owner_auth)
+ .expect("private"),
+ &private_event,
+ );
+
+ let unauth_id = subscription("private-unauth");
+ assert_eq!(
+ relay
+ .handle_req(
+ unauth_id.clone(),
+ vec![filter_group_tag(1, "h", "PrivateFarm")]
+ )
+ .expect("unauth"),
+ vec![RelayMessage::Eose(unauth_id)]
+ );
+ assert_count(
+ relay.handle_count(
+ subscription("private-count-unauth"),
+ vec![filter_group_tag(1, "h", "PrivateFarm")],
+ ),
+ 0,
+ );
+ let owner_query_id = subscription("private-owner");
+ assert_event_query(
+ relay
+ .handle_req_with_auth(
+ owner_query_id.clone(),
+ vec![filter_group_tag(1, "h", "PrivateFarm")],
+ &owner_auth,
+ )
+ .expect("owner"),
+ &owner_query_id,
+ &[&private_event],
+ );
+ assert_eq!(relay.handle_close(&owner_query_id), CloseResult::Closed);
+ assert_count(
+ relay.handle_count_with_auth(
+ subscription("private-count-owner"),
+ vec![filter_group_tag(1, "h", "PrivateFarm")],
+ &owner_auth,
+ ),
+ 1,
+ );
+
+ let live_unauth = subscription("live-private-unauth");
+ let live_owner = subscription("live-private-owner");
+ relay
+ .handle_req(live_unauth, vec![filter_group_tag(1, "h", "PrivateFarm")])
+ .expect("live unauth");
+ relay
+ .handle_req_with_auth(
+ live_owner.clone(),
+ vec![filter_group_tag(1, "h", "PrivateFarm")],
+ &owner_auth,
+ )
+ .expect("live owner");
+ let second_private =
+ tangle_v2_group_event(FixtureKey::Owner, "PrivateFarm", 3, 1, "second").expect("second");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(second_private.clone(), &owner_auth)
+ .expect("second"),
+ &second_private,
+ );
+ assert!(matches!(
+ relay.fanout(&second_private).as_slice(),
+ [RelayMessage::Event { subscription_id, event }]
+ if subscription_id == &live_owner && event.id() == second_private.id()
+ ));
+
+ accept_group_create(&mut relay, "HiddenFarm", &["hidden"], 10, &owner_auth);
+ assert_count(
+ relay.handle_count(
+ subscription("hidden-unauth"),
+ vec![filter_group_tag(KIND_GROUP_METADATA, "d", "HiddenFarm")],
+ ),
+ 0,
+ );
+ assert_count(
+ relay.handle_count_with_auth(
+ subscription("hidden-owner"),
+ vec![filter_group_tag(KIND_GROUP_METADATA, "d", "HiddenFarm")],
+ &owner_auth,
+ ),
+ 1,
+ );
+
+ accept_group_create(
+ &mut relay,
+ "RestrictedFarm",
+ &["restricted"],
+ 20,
+ &owner_auth,
+ );
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(
+ tangle_v2_group_event(FixtureKey::Outsider, "RestrictedFarm", 21, 1, "no")
+ .expect("restricted"),
+ &outsider_auth,
+ )
+ .expect("restricted")
+ ),
+ "restricted: group is unavailable"
+ );
+
+ accept_group_create(&mut relay, "ClosedFarm", &["closed"], 30, &owner_auth);
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(
+ tangle_v2_join_event(FixtureKey::Outsider, "ClosedFarm", 31)
+ .expect("closed join"),
+ &outsider_auth,
+ )
+ .expect("closed join")
+ ),
+ "restricted: group is unavailable"
+ );
+ let closed_normal = tangle_v2_group_event(FixtureKey::Outsider, "ClosedFarm", 32, 1, "visible")
+ .expect("closed normal");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(closed_normal.clone(), &outsider_auth)
+ .expect("closed normal"),
+ &closed_normal,
+ );
+}
+
+#[test]
+fn delete_and_secondary_privacy_surfaces_are_read_gated_or_absent() {
+ let config = test_store_config("delete-privacy");
+ let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
+ let owner_auth = authenticated(FixtureKey::Owner);
+
+ accept_group_create(&mut relay, "DeleteFarm", &[], 1, &owner_auth);
+ let target =
+ tangle_v2_group_event(FixtureKey::Owner, "DeleteFarm", 2, 1, "target").expect("target");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(target.clone(), &owner_auth)
+ .expect("target"),
+ &target,
+ );
+ let delete = tangle_test_support::tangle_v2_delete_event_event(
+ FixtureKey::Owner,
+ "DeleteFarm",
+ &target,
+ 3,
+ )
+ .expect("delete");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(delete.clone(), &owner_auth)
+ .expect("delete"),
+ &delete,
+ );
+
+ assert_count(
+ relay.handle_count(
+ subscription("deleted-target"),
+ vec![filter_group_tag(1, "h", "DeleteFarm")],
+ ),
+ 0,
+ );
+ assert_count(
+ relay.handle_count(
+ subscription("delete-marker"),
+ vec![filter_group_tag(KIND_GROUP_DELETE_GROUP, "h", "DeleteFarm")],
+ ),
+ 0,
+ );
+ let delete_group =
+ tangle_v2_delete_group_event(FixtureKey::Owner, "DeleteFarm", 4).expect("delete group");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(delete_group.clone(), &owner_auth)
+ .expect("delete group"),
+ &delete_group,
+ );
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(
+ tangle_v2_group_event(FixtureKey::Owner, "DeleteFarm", 5, 1, "late")
+ .expect("late"),
+ &owner_auth,
+ )
+ .expect("late")
+ ),
+ "blocked: group is deleted"
+ );
+ assert_count(
+ relay.handle_count(
+ subscription("group-marker"),
+ vec![filter_group_tag(KIND_GROUP_DELETE_GROUP, "h", "DeleteFarm")],
+ ),
+ 1,
+ );
+
+ let document = BaseRelayInfoConfig::new("tangle", group_config())
+ .expect("config")
+ .build_document()
+ .expect("document");
+ assert!(!document.supported_nips.contains(&77));
+ assert!(!document.supported_nips.contains(&86));
+ assert!(!document.supported_nips.contains(&98));
+}
+
+#[test]
+fn projection_rebuild_after_restart_matches_live_state_and_outbox_is_idempotent() {
+ let config = test_store_config("projection-restart");
+ let owner_auth = authenticated(FixtureKey::Owner);
+ {
+ let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
+ accept_group_create(&mut relay, "RestartFarm", &[], 1, &owner_auth);
+ let put = tangle_v2_put_user_event(FixtureKey::Admin, "RestartFarm", FixtureKey::Member, 2)
+ .expect("put");
+ let admin_auth = authenticated(FixtureKey::Admin);
+ assert_accepted(
+ relay
+ .handle_event_with_auth(put.clone(), &admin_auth)
+ .expect("put"),
+ &put,
+ );
+ assert_eq!(count_kind(&relay, KIND_GROUP_METADATA), 1);
+ assert_eq!(count_kind(&relay, KIND_GROUP_ADMINS), 1);
+ assert_eq!(count_kind(&relay, KIND_GROUP_MEMBERS), 1);
+ relay.shutdown().expect("shutdown");
+ }
+
+ let relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("reopen");
+ assert!(
+ relay
+ .group_projection()
+ .expect("projection")
+ .group(&group("RestartFarm"))
+ .is_some()
+ );
+ assert_eq!(
+ relay
+ .group_projection()
+ .expect("projection")
+ .member(&group("RestartFarm"), &FixtureKey::Member.public_key())
+ .expect("member")
+ .status(),
+ MemberStatus::Member
+ );
+ assert_eq!(count_kind(&relay, KIND_GROUP_METADATA), 1);
+ assert_eq!(count_kind(&relay, KIND_GROUP_ADMINS), 1);
+ assert_eq!(count_kind(&relay, KIND_GROUP_MEMBERS), 1);
+
+ let relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("second reopen");
+ assert_eq!(count_kind(&relay, KIND_GROUP_METADATA), 1);
+ assert_eq!(count_kind(&relay, KIND_GROUP_ADMINS), 1);
+ assert_eq!(count_kind(&relay, KIND_GROUP_MEMBERS), 1);
+}
+
+#[test]
+fn same_timestamp_conflicts_are_deterministic_across_ingest_order() {
+ let first = tangle_v2_group_metadata_event(FixtureKey::Owner, "ClockFarm", "Alpha", 100, &[])
+ .expect("first");
+ let second = tangle_v2_group_metadata_event(FixtureKey::Owner, "ClockFarm", "Beta", 100, &[])
+ .expect("second");
+ let expected = if first.id() > second.id() {
+ "Alpha"
+ } else {
+ "Beta"
+ };
+
+ assert_eq!(
+ final_group_name_for_order("conflict-a", [&first, &second]),
+ expected
+ );
+ assert_eq!(
+ final_group_name_for_order("conflict-b", [&second, &first]),
+ expected
+ );
+}
+
+#[test]
+fn malformed_input_fuzz_smoke_rejects_without_panic() {
+ for raw in [
+ "",
+ "[]",
+ "[\"EVENT\"]",
+ "[\"REQ\",\"sub\",{\"#h\":[1]}]",
+ "[\"AUTH\",{}]",
+ "[\"COUNT\",\"sub\",{\"kinds\":[4294967296]}]",
+ ] {
+ panic::catch_unwind(|| {
+ let _ = parse_client_message(raw);
+ })
+ .expect("client parser must not panic");
+ }
+
+ for raw in [
+ "{}",
+ "{\"id\":\"bad\"}",
+ "{\"id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"pubkey\":\"bad\",\"created_at\":0,\"kind\":1,\"tags\":[],\"content\":\"\",\"sig\":\"bad\"}",
+ ] {
+ panic::catch_unwind(|| {
+ if let Ok(raw) = RawEventJson::new(raw) {
+ let _ = parse_event_json(&raw);
+ }
+ })
+ .expect("event parser must not panic");
+ }
+
+ for value in [
+ serde_json::json!({"#h":[1]}),
+ serde_json::json!({"ids":[1]}),
+ serde_json::json!({"authors":[false]}),
+ serde_json::json!({"kinds":["bad"]}),
+ serde_json::json!({"limit":-1}),
+ ] {
+ panic::catch_unwind(|| {
+ let _ = filter_from_value(&value);
+ })
+ .expect("filter parser must not panic");
+ }
+
+ for values in [vec![], vec!["".to_owned()], vec!["h".to_owned()]] {
+ panic::catch_unwind(|| {
+ let _ = Tag::new(values);
+ })
+ .expect("tag parser must not panic");
+ }
+}
+
+fn accept_group_create(
+ relay: &mut BaseRelay,
+ group_id: &str,
+ flags: &[&str],
+ created_at: u64,
+ auth: &BaseAuthState,
+) {
+ let event = tangle_v2_group_create_event(FixtureKey::Owner, group_id, created_at, flags)
+ .expect("event");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(event.clone(), auth)
+ .expect("create"),
+ &event,
+ );
+}
+
+fn final_group_name_for_order(name: &str, edits: [&Event; 2]) -> String {
+ let config = test_store_config(name);
+ let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
+ let auth = authenticated(FixtureKey::Owner);
+ accept_group_create(&mut relay, "ClockFarm", &[], 1, &auth);
+ for edit in edits {
+ assert_accepted(
+ relay
+ .handle_event_with_auth(edit.clone(), &auth)
+ .expect("edit"),
+ edit,
+ );
+ }
+ relay
+ .group_projection()
+ .expect("projection")
+ .group(&group("ClockFarm"))
+ .expect("group")
+ .metadata()
+ .name()
+ .expect("name")
+ .to_owned()
+}
+
+fn test_store_config(name: &str) -> PocketStoreConfig {
+ let root = temp_root(name);
+ let _ = fs::remove_dir_all(&root);
+ PocketStoreConfig::new(
+ root.join("pocket"),
+ 1024 * 1024 * 1024,
+ 128,
+ PocketSyncPolicy::FlushOnShutdown,
+ )
+ .expect("config")
+}
+
+fn temp_root(name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!("tangle-rcld12-{name}-{}", std::process::id()))
+}
+
+fn group_config() -> GroupRuntimeConfig {
+ tangle_v2_group_config(FixtureKey::Owner, &[FixtureKey::Admin]).expect("groups")
+}
+
+fn authenticated(key: FixtureKey) -> BaseAuthState {
+ let auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 60).expect("auth");
+ let mut auth = issue_challenge(auth, "challenge-a", 100);
+ let event = tangle_v2_auth_event(key, "challenge-a", 120).expect("auth event");
+ auth.authenticate(&event, UnixTimestamp::new(120))
+ .expect("authenticate");
+ auth
+}
+
+fn issue_challenge(mut auth: BaseAuthState, challenge: &str, created_at: u64) -> BaseAuthState {
+ auth.issue_challenge(challenge, UnixTimestamp::new(created_at))
+ .expect("challenge");
+ auth
+}
+
+fn assert_accepted(message: RelayMessage, event: &Event) {
+ assert_eq!(
+ message,
+ RelayMessage::Ok {
+ event_id: event.id().clone(),
+ accepted: true,
+ message: String::new()
+ }
+ );
+ assert!(event_id_matches(event));
+ assert_eq!(verify_event_signature(event), Ok(()));
+}
+
+fn rejected_message(message: RelayMessage) -> String {
+ match message {
+ RelayMessage::Ok {
+ accepted: false,
+ message,
+ ..
+ } => message,
+ value => panic!("expected rejected OK, got {value:?}"),
+ }
+}
+
+fn assert_event_query(
+ messages: Vec<RelayMessage>,
+ subscription_id: &SubscriptionId,
+ events: &[&Event],
+) {
+ assert_eq!(messages.len(), events.len() + 1);
+ for (message, expected) in messages.iter().zip(events.iter()) {
+ match message {
+ RelayMessage::Event {
+ subscription_id: actual_subscription,
+ event,
+ } => {
+ assert_eq!(actual_subscription, subscription_id);
+ assert_eq!(event.id(), expected.id());
+ }
+ value => panic!("expected event, got {value:?}"),
+ }
+ }
+ assert_eq!(
+ messages.last(),
+ Some(&RelayMessage::Eose(subscription_id.clone()))
+ );
+}
+
+fn assert_count(
+ message: Result<RelayMessage, tangle_runtime::base_relay::BaseRelayError>,
+ expected: u64,
+) {
+ let RelayMessage::Count { count, .. } = message.expect("count") else {
+ panic!("expected count")
+ };
+ assert_eq!(count, expected);
+}
+
+fn count_kind(relay: &BaseRelay, kind: u32) -> u64 {
+ let RelayMessage::Count { count, .. } = relay
+ .handle_count(subscription("count-kind"), vec![filter_kind(kind)])
+ .expect("count")
+ else {
+ panic!("expected count")
+ };
+ count
+}
+
+fn filter_kind(kind: u32) -> Filter {
+ filter_from_value(&serde_json::json!({"kinds":[kind]})).expect("filter")
+}
+
+fn filter_group_tag(kind: u32, tag_name: &str, tag_value: &str) -> Filter {
+ let mut value = serde_json::Map::new();
+ value.insert("kinds".to_owned(), serde_json::json!([kind]));
+ value.insert(format!("#{tag_name}"), serde_json::json!([tag_value]));
+ filter_from_value(&serde_json::Value::Object(value)).expect("filter")
+}
+
+fn group(value: &str) -> GroupId {
+ GroupId::new(value).expect("group")
+}
+
+fn subscription(value: &str) -> SubscriptionId {
+ SubscriptionId::new(value).expect("subscription")
+}
diff --git a/crates/tangle_store_pocket/src/lib.rs b/crates/tangle_store_pocket/src/lib.rs
@@ -22,6 +22,8 @@ pub type PocketOwnedFilter = OwnedFilter;
pub type PocketPubkey = Pubkey;
pub type PocketScreenResult = ScreenResult;
pub type PocketStore = Store;
+pub type PocketExtraRecord = (Vec<u8>, Vec<u8>);
+pub type PocketExtraRecords = Vec<PocketExtraRecord>;
pub const TANGLE_GROUP_PROJECTION_TABLE: &str = "group_projection";
pub const TANGLE_GROUP_OUTBOX_TABLE: &str = "group_outbox";
@@ -159,7 +161,7 @@ impl PocketStoreHandle {
pub fn scan_extra_records(
&self,
table: &'static str,
- ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, PocketStoreError> {
+ ) -> Result<PocketExtraRecords, PocketStoreError> {
let table_handle = self.extra_table(table)?;
let txn = self.store.read_txn().map_err(|error| {
PocketStoreError::from_extra_table(table, "read transaction", error)
diff --git a/crates/tangle_test_support/Cargo.toml b/crates/tangle_test_support/Cargo.toml
@@ -12,6 +12,7 @@ k256 = { version = "0.13", features = ["schnorr"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tangle_crypto = { path = "../tangle_crypto" }
+tangle_groups = { path = "../tangle_groups" }
tangle_nips = { path = "../tangle_nips" }
tangle_protocol = { path = "../tangle_protocol" }
tangle_store = { path = "../tangle_store" }
diff --git a/crates/tangle_test_support/src/lib.rs b/crates/tangle_test_support/src/lib.rs
@@ -5,7 +5,13 @@ use k256::schnorr::signature::Signer;
use k256::schnorr::{Signature, SigningKey};
use serde::Deserialize;
use std::collections::BTreeMap;
-use tangle_crypto::compute_event_id;
+use tangle_crypto::{RelaySigner, compute_event_id};
+use tangle_groups::{
+ CanonicalRelayUrl, GroupGeneratedEventBuilder, GroupLimitsConfig, GroupOutboxPayload,
+ GroupRedactionConfig, GroupRuntimeConfig, KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT,
+ KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST,
+ KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, RelaySecret,
+};
use tangle_nips::ListingProjection;
use tangle_protocol::{
AddressCoordinate, Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp,
@@ -30,6 +36,10 @@ pub enum FixtureKey {
Seller,
Buyer,
Relay,
+ Owner,
+ Admin,
+ Member,
+ Outsider,
}
impl FixtureKey {
@@ -44,6 +54,10 @@ impl FixtureKey {
Self::Seller => SigningKey::from_bytes(&[7_u8; 32]).expect("seller fixture key"),
Self::Buyer => SigningKey::from_bytes(&[8_u8; 32]).expect("buyer fixture key"),
Self::Relay => SigningKey::from_bytes(&[9_u8; 32]).expect("relay fixture key"),
+ Self::Owner => SigningKey::from_bytes(&[10_u8; 32]).expect("owner fixture key"),
+ Self::Admin => SigningKey::from_bytes(&[11_u8; 32]).expect("admin fixture key"),
+ Self::Member => SigningKey::from_bytes(&[12_u8; 32]).expect("member fixture key"),
+ Self::Outsider => SigningKey::from_bytes(&[13_u8; 32]).expect("outsider fixture key"),
}
}
}
@@ -54,6 +68,10 @@ impl fmt::Display for FixtureKey {
Self::Seller => "seller",
Self::Buyer => "buyer",
Self::Relay => "relay",
+ Self::Owner => "owner",
+ Self::Admin => "admin",
+ Self::Member => "member",
+ Self::Outsider => "outsider",
})
}
}
@@ -98,6 +116,10 @@ impl FixtureEventSpec {
"seller" => Ok(FixtureKey::Seller),
"buyer" => Ok(FixtureKey::Buyer),
"relay" => Ok(FixtureKey::Relay),
+ "owner" => Ok(FixtureKey::Owner),
+ "admin" => Ok(FixtureKey::Admin),
+ "member" => Ok(FixtureKey::Member),
+ "outsider" => Ok(FixtureKey::Outsider),
value => Err(format!("fixture key `{value}` is unsupported")),
}
}
@@ -154,6 +176,251 @@ pub fn build_fixture_event_from_parts(
sign_unsigned_event(fixture_key, unsigned)
}
+pub const TANGLE_V2_RELAY_URL: &str = "wss://relay.radroots.test";
+pub const TANGLE_V2_RELAY_SECRET_HEX: &str =
+ "7777777777777777777777777777777777777777777777777777777777777777";
+
+pub fn tangle_v2_group_config(
+ owner: FixtureKey,
+ admins: &[FixtureKey],
+) -> Result<GroupRuntimeConfig, String> {
+ GroupRuntimeConfig::new(
+ true,
+ Some(CanonicalRelayUrl::new(TANGLE_V2_RELAY_URL).map_err(|error| error.to_string())?),
+ Some(RelaySecret::from_hex(TANGLE_V2_RELAY_SECRET_HEX).map_err(|error| error.to_string())?),
+ vec![owner.public_key()],
+ admins.iter().map(|admin| admin.public_key()).collect(),
+ GroupRedactionConfig::strict(),
+ GroupLimitsConfig::default(),
+ )
+ .map_err(|error| error.to_string())
+}
+
+pub fn tangle_v2_relay_signer() -> Result<RelaySigner, String> {
+ RelaySigner::from_secret_hex(TANGLE_V2_RELAY_SECRET_HEX).map_err(|error| error.to_string())
+}
+
+pub fn tangle_v2_generated_event(payload: &GroupOutboxPayload) -> Result<Event, String> {
+ GroupGeneratedEventBuilder::new(tangle_v2_relay_signer()?)
+ .sign_payload(payload)
+ .map_err(|error| error.to_string())
+}
+
+pub fn tangle_v2_event(
+ fixture_key: FixtureKey,
+ created_at: u64,
+ kind: u64,
+ tags: Vec<Tag>,
+ content: &str,
+) -> Result<Event, String> {
+ let unsigned = UnsignedEvent::new(
+ fixture_key.public_key(),
+ UnixTimestamp::new(created_at),
+ Kind::new(kind)?,
+ tags,
+ content,
+ );
+ sign_unsigned_event(fixture_key, unsigned)
+}
+
+pub fn tangle_v2_auth_event(
+ fixture_key: FixtureKey,
+ challenge: &str,
+ created_at: u64,
+) -> Result<Event, String> {
+ tangle_v2_event(
+ fixture_key,
+ created_at,
+ 22_242,
+ vec![
+ tangle_v2_tag("relay", &[TANGLE_V2_RELAY_URL])?,
+ tangle_v2_tag("challenge", &[challenge])?,
+ ],
+ "",
+ )
+}
+
+pub fn tangle_v2_group_create_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ created_at: u64,
+ flags: &[&str],
+) -> Result<Event, String> {
+ let mut tags = vec![
+ tangle_v2_group_tag(group_id)?,
+ tangle_v2_tag("name", &[group_id])?,
+ ];
+ for flag in flags {
+ tags.push(tangle_v2_tag(flag, &[])?);
+ }
+ tangle_v2_event(
+ fixture_key,
+ created_at,
+ KIND_GROUP_CREATE_GROUP.into(),
+ tags,
+ "",
+ )
+}
+
+pub fn tangle_v2_group_metadata_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ name: &str,
+ created_at: u64,
+ flags: &[&str],
+) -> Result<Event, String> {
+ let mut tags = vec![
+ tangle_v2_group_tag(group_id)?,
+ tangle_v2_tag("name", &[name])?,
+ ];
+ for flag in flags {
+ tags.push(tangle_v2_tag(flag, &[])?);
+ }
+ tangle_v2_event(
+ fixture_key,
+ created_at,
+ KIND_GROUP_EDIT_METADATA.into(),
+ tags,
+ "",
+ )
+}
+
+pub fn tangle_v2_put_user_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ target: FixtureKey,
+ created_at: u64,
+) -> Result<Event, String> {
+ tangle_v2_event(
+ fixture_key,
+ created_at,
+ KIND_GROUP_PUT_USER.into(),
+ vec![
+ tangle_v2_group_tag(group_id)?,
+ tangle_v2_pubkey_tag(target)?,
+ ],
+ "",
+ )
+}
+
+pub fn tangle_v2_remove_user_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ target: FixtureKey,
+ created_at: u64,
+) -> Result<Event, String> {
+ tangle_v2_event(
+ fixture_key,
+ created_at,
+ KIND_GROUP_REMOVE_USER.into(),
+ vec![
+ tangle_v2_group_tag(group_id)?,
+ tangle_v2_pubkey_tag(target)?,
+ ],
+ "",
+ )
+}
+
+pub fn tangle_v2_join_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ created_at: u64,
+) -> Result<Event, String> {
+ tangle_v2_group_event(
+ fixture_key,
+ group_id,
+ created_at,
+ KIND_GROUP_JOIN_REQUEST.into(),
+ "",
+ )
+}
+
+pub fn tangle_v2_leave_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ created_at: u64,
+) -> Result<Event, String> {
+ tangle_v2_group_event(
+ fixture_key,
+ group_id,
+ created_at,
+ KIND_GROUP_LEAVE_REQUEST.into(),
+ "",
+ )
+}
+
+pub fn tangle_v2_delete_event_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ target: &Event,
+ created_at: u64,
+) -> Result<Event, String> {
+ tangle_v2_event(
+ fixture_key,
+ created_at,
+ KIND_GROUP_DELETE_EVENT.into(),
+ vec![
+ tangle_v2_group_tag(group_id)?,
+ tangle_v2_event_tag(target.id())?,
+ ],
+ "",
+ )
+}
+
+pub fn tangle_v2_delete_group_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ created_at: u64,
+) -> Result<Event, String> {
+ tangle_v2_group_event(
+ fixture_key,
+ group_id,
+ created_at,
+ KIND_GROUP_DELETE_GROUP.into(),
+ "",
+ )
+}
+
+pub fn tangle_v2_group_event(
+ fixture_key: FixtureKey,
+ group_id: &str,
+ created_at: u64,
+ kind: u64,
+ content: &str,
+) -> Result<Event, String> {
+ tangle_v2_event(
+ fixture_key,
+ created_at,
+ kind,
+ vec![tangle_v2_group_tag(group_id)?],
+ content,
+ )
+}
+
+pub fn tangle_v2_group_tag(group_id: &str) -> Result<Tag, String> {
+ tangle_v2_tag("h", &[group_id])
+}
+
+pub fn tangle_v2_address_group_tag(group_id: &str) -> Result<Tag, String> {
+ tangle_v2_tag("d", &[group_id])
+}
+
+pub fn tangle_v2_pubkey_tag(fixture_key: FixtureKey) -> Result<Tag, String> {
+ let pubkey = fixture_key.public_key();
+ tangle_v2_tag("p", &[pubkey.as_str()])
+}
+
+pub fn tangle_v2_event_tag(event_id: &EventId) -> Result<Tag, String> {
+ tangle_v2_tag("e", &[event_id.as_str()])
+}
+
+pub fn tangle_v2_tag(name: &str, values: &[&str]) -> Result<Tag, String> {
+ let mut parts = Vec::with_capacity(values.len() + 1);
+ parts.push(name.to_owned());
+ parts.extend(values.iter().map(|value| (*value).to_owned()));
+ Tag::new(parts)
+}
+
pub fn fixture_event_json(event: &Event) -> serde_json::Value {
event_to_value(event)
}
@@ -269,9 +536,13 @@ mod tests {
use super::{
FixtureKey, InMemoryRepository, auth_event_spec, build_fixture_event, deletion_event_spec,
fixed_hex_bytes, fixture_event_json, fixture_spec_from_json,
- projection_ineligible_listing_spec, valid_public_listing_spec,
+ projection_ineligible_listing_spec, tangle_v2_auth_event, tangle_v2_generated_event,
+ tangle_v2_group_config, tangle_v2_group_create_event, tangle_v2_group_event,
+ tangle_v2_group_metadata_event, tangle_v2_join_event, tangle_v2_put_user_event,
+ valid_public_listing_spec,
};
use tangle_crypto::{event_id_matches, verify_event_signature};
+ use tangle_groups::{GroupOutboxPayload, KIND_GROUP_CREATE_GROUP, KIND_GROUP_METADATA};
use tangle_nips::{
DeletionTarget, ListingProjectionEvaluation, evaluate_listing_projection,
parse_deletion_request, parse_relay_auth_event,
@@ -306,6 +577,10 @@ mod tests {
assert_eq!(FixtureKey::Seller.to_string(), "seller");
assert_eq!(FixtureKey::Buyer.to_string(), "buyer");
assert_eq!(FixtureKey::Relay.to_string(), "relay");
+ assert_eq!(FixtureKey::Owner.to_string(), "owner");
+ assert_eq!(FixtureKey::Admin.to_string(), "admin");
+ assert_eq!(FixtureKey::Member.to_string(), "member");
+ assert_eq!(FixtureKey::Outsider.to_string(), "outsider");
assert_eq!(
FixtureKey::Seller.public_key().as_str(),
"989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f"
@@ -317,6 +592,67 @@ mod tests {
}
#[test]
+ fn tangle_v2_builders_create_deterministic_signed_events() {
+ let first =
+ tangle_v2_group_create_event(FixtureKey::Owner, "Farm", 1_714_124_433, &["private"])
+ .expect("first");
+ let second =
+ tangle_v2_group_create_event(FixtureKey::Owner, "Farm", 1_714_124_433, &["private"])
+ .expect("second");
+ let auth =
+ tangle_v2_auth_event(FixtureKey::Owner, "challenge-001", 1_714_124_434).expect("auth");
+
+ assert_eq!(first.id(), second.id());
+ assert_eq!(verify_event_signature(&first), Ok(()));
+ assert_eq!(verify_event_signature(&auth), Ok(()));
+ assert_eq!(first.unsigned().kind().as_u32(), KIND_GROUP_CREATE_GROUP);
+ assert_eq!(
+ parse_relay_auth_event(&auth)
+ .expect("auth parse")
+ .expect("auth")
+ .challenge(),
+ "challenge-001"
+ );
+ }
+
+ #[test]
+ fn tangle_v2_builders_cover_group_config_and_generated_events() {
+ let config =
+ tangle_v2_group_config(FixtureKey::Owner, &[FixtureKey::Admin]).expect("config");
+ let metadata = tangle_v2_group_metadata_event(
+ FixtureKey::Owner,
+ "Farm",
+ "Market",
+ 1_714_124_435,
+ &["hidden"],
+ )
+ .expect("metadata");
+ let put =
+ tangle_v2_put_user_event(FixtureKey::Admin, "Farm", FixtureKey::Member, 1_714_124_436)
+ .expect("put");
+ let join = tangle_v2_join_event(FixtureKey::Member, "Farm", 1_714_124_437).expect("join");
+ let normal = tangle_v2_group_event(FixtureKey::Member, "Farm", 1_714_124_438, 1, "harvest")
+ .expect("normal");
+ let payload = GroupOutboxPayload::new(
+ KIND_GROUP_METADATA,
+ UnixTimestamp::new(1_714_124_439),
+ vec![vec!["d".to_owned(), "Farm".to_owned()]],
+ "",
+ );
+ let generated = tangle_v2_generated_event(&payload).expect("generated");
+
+ assert!(config.enabled());
+ assert_eq!(config.owner_pubkeys(), &[FixtureKey::Owner.public_key()]);
+ assert_eq!(config.admin_pubkeys(), &[FixtureKey::Admin.public_key()]);
+ assert_eq!(verify_event_signature(&metadata), Ok(()));
+ assert_eq!(verify_event_signature(&put), Ok(()));
+ assert_eq!(verify_event_signature(&join), Ok(()));
+ assert_eq!(verify_event_signature(&normal), Ok(()));
+ assert_eq!(verify_event_signature(&generated), Ok(()));
+ assert_eq!(generated.unsigned().kind().as_u32(), KIND_GROUP_METADATA);
+ }
+
+ #[test]
fn fixture_builder_signs_verifiable_public_listing_events() {
let event = build_fixture_event(&valid_public_listing_spec()).expect("event");
let json = fixture_event_json(&event);