mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
feat(private-repos): require GRASP-08 advertisement for derived membership
Derived private membership previously admitted the NIP-11 owner of any relay referenced by an accepted announcement. A public relay's owner gains nothing legitimate from private membership - their relay enforces no confidentiality for the repositories it mirrors - so minting access for them needlessly widens the trust domain. Approach: parse the GRASP `supported_grasps` extension array from the raw NIP-11 body (the SDK type does not carry it) into a new `RelayLimitHints::grasp08` flag, thread it through the connect-attempt outcome, and only insert into `relay_owners` when the relay advertises "GRASP-08". Absent or malformed documents mean "not a private service". Operator-configured NGIT_PRIVATE_MEMBERS are unaffected. Correctness assumptions: `relay_owners` is the sole source of derived members (`effective_private_members`), so gating insertion gates the whole derivation; removing a stale entry on a non-advertising session keeps a relay that stops advertising GRASP-08 from retaining minted membership past its next reconnect. Test infrastructure: MockRelay can now serve a caller-provided NIP-11 document, `push_to_relay` gained an Authorization-header variant for pushes to private services, and TestRelay gained a persistent-LMDB private constructor. The integration test restarts the relay after promotion because the private NIP-42 gate also applies to the internal self-subscription, so locally published announcements only reach the sync manager through the startup database load; that pre-existing limitation is out of scope here. Validation: new unit tests for the supported_grasps parse; integration test proves a GRASP-08-advertising relay's owner is admitted while an otherwise identical non-advertising relay's owner stays restricted. cargo test --lib grasp08_flag and --test private_mode derived_membership_requires_grasp08_advertising_relay pass.
This commit is contained in:
+13
-2
@@ -1785,6 +1785,7 @@ enum ConnectAttemptOutcome {
|
||||
advertised_default_limit: Option<usize>,
|
||||
advertised_max_subscriptions: Option<usize>,
|
||||
advertised_owner: Option<PublicKey>,
|
||||
advertised_grasp08: bool,
|
||||
},
|
||||
Failed(String),
|
||||
}
|
||||
@@ -2466,7 +2467,11 @@ pub struct SyncManager {
|
||||
private_access: Option<PrivateAccess>,
|
||||
/// Operator-configured members form the permanent base of private access.
|
||||
configured_private_members: HashSet<PublicKey>,
|
||||
/// Latest NIP-11 owner learned for each connected repository relay.
|
||||
/// Latest NIP-11 owner learned for each connected repository relay whose
|
||||
/// NIP-11 also advertises GRASP-08. Only private-service owners can mint
|
||||
/// derived membership: the owner of a public relay gains nothing
|
||||
/// legitimate from private membership, since their relay enforces no
|
||||
/// confidentiality for the repositories it mirrors.
|
||||
relay_owners: HashMap<String, PublicKey>,
|
||||
/// What we've confirmed syncing + connection state
|
||||
relay_sync_index: RelaySyncIndex,
|
||||
@@ -5466,6 +5471,7 @@ impl SyncManager {
|
||||
advertised_default_limit: hints.default_limit,
|
||||
advertised_max_subscriptions: hints.max_subscriptions,
|
||||
advertised_owner: hints.owner,
|
||||
advertised_grasp08: hints.grasp08,
|
||||
}
|
||||
},
|
||||
Err(error) => ConnectAttemptOutcome::Failed(error),
|
||||
@@ -6253,8 +6259,13 @@ impl SyncManager {
|
||||
advertised_default_limit,
|
||||
advertised_max_subscriptions,
|
||||
advertised_owner,
|
||||
advertised_grasp08,
|
||||
} => {
|
||||
match advertised_owner {
|
||||
// Only GRASP-08-advertising relays mint derived private
|
||||
// membership: a public relay's owner gains nothing legitimate
|
||||
// from private membership because their relay enforces no
|
||||
// confidentiality for the mirrored repositories.
|
||||
match advertised_owner.filter(|_| advertised_grasp08) {
|
||||
Some(owner) => {
|
||||
self.relay_owners.insert(result.relay_url.clone(), owner);
|
||||
}
|
||||
|
||||
@@ -442,12 +442,26 @@ pub struct RelayLimitHints {
|
||||
/// grant this identity access only when the relay is referenced by an
|
||||
/// accepted repository announcement.
|
||||
pub owner: Option<PublicKey>,
|
||||
/// Whether the document's `supported_grasps` array (a GRASP extension
|
||||
/// field, parsed from the raw JSON because the SDK's NIP-11 type does not
|
||||
/// carry it) advertises the "GRASP-08" private-service extension.
|
||||
pub grasp08: bool,
|
||||
}
|
||||
|
||||
fn parse_relay_limit_hints(body: &str) -> RelayLimitHints {
|
||||
let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() else {
|
||||
return RelayLimitHints::default();
|
||||
};
|
||||
let grasp08 = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value.get("supported_grasps")?.as_array().map(|grasps| {
|
||||
grasps
|
||||
.iter()
|
||||
.any(|grasp| grasp.as_str() == Some("GRASP-08"))
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let limitation = document.limitation.unwrap_or_default();
|
||||
RelayLimitHints {
|
||||
default_limit: limitation
|
||||
@@ -459,6 +473,7 @@ fn parse_relay_limit_hints(body: &str) -> RelayLimitHints {
|
||||
.and_then(|limit| usize::try_from(limit).ok())
|
||||
.filter(|limit| *limit > 0),
|
||||
owner: document.pubkey,
|
||||
grasp08,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2763,6 +2778,22 @@ mod tests {
|
||||
assert_eq!(hints.max_subscriptions, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grasp08_flag_requires_supported_grasps_entry() {
|
||||
assert!(parse_relay_limit_hints(r#"{"supported_grasps":["GRASP-01","GRASP-08"]}"#).grasp08);
|
||||
assert!(!parse_relay_limit_hints(r#"{"supported_grasps":["GRASP-01"]}"#).grasp08);
|
||||
assert!(!parse_relay_limit_hints(r#"{"name":"relay without grasps"}"#).grasp08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grasp08_flag_defaults_to_false_for_malformed_documents() {
|
||||
// Non-array supported_grasps and unparseable bodies both mean "not a
|
||||
// known private service", never an error.
|
||||
assert!(!parse_relay_limit_hints(r#"{"supported_grasps":"GRASP-08"}"#).grasp08);
|
||||
assert!(!parse_relay_limit_hints(r#"{"supported_grasps":8}"#).grasp08);
|
||||
assert!(!parse_relay_limit_hints("not json at all").grasp08);
|
||||
}
|
||||
|
||||
/// Event-directed connection with the permissive policy used by tests
|
||||
/// that dial loopback fixtures.
|
||||
fn permissive_connection(url: &str, keys: Keys) -> RelayConnection {
|
||||
|
||||
@@ -122,6 +122,24 @@ impl MockRelay {
|
||||
rate_limit: RateLimit,
|
||||
pagination: Option<PaginationConfig>,
|
||||
max_filters: Option<usize>,
|
||||
) -> Self {
|
||||
Self::start_with_options_and_nip11(rate_limit, pagination, max_filters, None).await
|
||||
}
|
||||
|
||||
/// Start a mock relay that serves a caller-provided NIP-11 document for
|
||||
/// `Accept: application/nostr+json` requests.
|
||||
///
|
||||
/// This lets tests control fields the default document never emits, such
|
||||
/// as the GRASP `supported_grasps` extension array or the `pubkey` owner.
|
||||
pub async fn start_with_nip11_document(document: serde_json::Value) -> Self {
|
||||
Self::start_with_options_and_nip11(RateLimit::default(), None, None, Some(document)).await
|
||||
}
|
||||
|
||||
async fn start_with_options_and_nip11(
|
||||
rate_limit: RateLimit,
|
||||
pagination: Option<PaginationConfig>,
|
||||
max_filters: Option<usize>,
|
||||
custom_nip11: Option<serde_json::Value>,
|
||||
) -> Self {
|
||||
// Create and bind listener (eliminates port race condition)
|
||||
let std_listener =
|
||||
@@ -145,6 +163,7 @@ impl MockRelay {
|
||||
pagination,
|
||||
max_filters,
|
||||
Vec::new(),
|
||||
custom_nip11,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -164,10 +183,20 @@ impl MockRelay {
|
||||
let listener = TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("Failed to bind to address");
|
||||
Self::start_with_listener(listener, port, RateLimit::default(), None, None, events).await
|
||||
Self::start_with_listener(
|
||||
listener,
|
||||
port,
|
||||
RateLimit::default(),
|
||||
None,
|
||||
None,
|
||||
events,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Internal method to start the relay with an existing listener.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn start_with_listener(
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
@@ -175,6 +204,7 @@ impl MockRelay {
|
||||
pagination: Option<PaginationConfig>,
|
||||
max_filters: Option<usize>,
|
||||
initial_events: Vec<Event>,
|
||||
custom_nip11: Option<serde_json::Value>,
|
||||
) -> Self {
|
||||
// Create a simple relay with no write policy (accepts all events)
|
||||
let mut builder = LocalRelayBuilder::default().rate_limit(rate_limit);
|
||||
@@ -209,13 +239,22 @@ impl MockRelay {
|
||||
Ok((stream, remote_addr)) => {
|
||||
let relay = server_relay.clone();
|
||||
let pagination = pagination;
|
||||
let custom_nip11 = custom_nip11.clone();
|
||||
let io = TokioIo::new(stream);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let service = service_fn(move |req| {
|
||||
let relay = relay.clone();
|
||||
let custom_nip11 = custom_nip11.clone();
|
||||
async move {
|
||||
handle_request(req, relay, remote_addr, pagination).await
|
||||
handle_request(
|
||||
req,
|
||||
relay,
|
||||
remote_addr,
|
||||
pagination,
|
||||
custom_nip11,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
@@ -302,6 +341,7 @@ async fn handle_request(
|
||||
relay: LocalRelay,
|
||||
addr: SocketAddr,
|
||||
pagination: Option<PaginationConfig>,
|
||||
custom_nip11: Option<serde_json::Value>,
|
||||
) -> Result<Response<Full<Bytes>>, hyper::Error> {
|
||||
// Check for WebSocket upgrade request
|
||||
let is_websocket = req
|
||||
@@ -350,6 +390,13 @@ async fn handle_request(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("application/nostr+json"))
|
||||
{
|
||||
if let Some(document) = custom_nip11 {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/nostr+json")
|
||||
.body(Full::new(Bytes::from(document.to_string())))
|
||||
.unwrap());
|
||||
}
|
||||
let limitation = pagination.map(|config| {
|
||||
serde_json::json!({
|
||||
"default_limit": config.advertised_default_limit,
|
||||
|
||||
@@ -587,6 +587,21 @@ pub fn push_to_relay(
|
||||
relay_domain: &str,
|
||||
npub: &str,
|
||||
repo_id: &str,
|
||||
) -> Result<(), String> {
|
||||
push_to_relay_with_auth_header(local_path, relay_domain, npub, repo_id, None)
|
||||
}
|
||||
|
||||
/// Push a local repository to a relay with an optional `Authorization` header.
|
||||
///
|
||||
/// GRASP-08 private relays require every Smart HTTP request to carry the
|
||||
/// repository-scoped NIP-98 credential, which git can attach via
|
||||
/// `http.extraHeader`. Pass the full header value (e.g. `Nostr <base64>`).
|
||||
pub fn push_to_relay_with_auth_header(
|
||||
local_path: &Path,
|
||||
relay_domain: &str,
|
||||
npub: &str,
|
||||
repo_id: &str,
|
||||
auth_header: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
|
||||
|
||||
@@ -606,7 +621,13 @@ pub fn push_to_relay(
|
||||
}
|
||||
|
||||
// Push all refs
|
||||
let output = git_command()
|
||||
let mut command = git_command();
|
||||
if let Some(header) = auth_header {
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(format!("http.extraHeader=Authorization: {header}"));
|
||||
}
|
||||
let output = command
|
||||
.args(["push", "-u", "origin", "--all"])
|
||||
.current_dir(local_path)
|
||||
.output()
|
||||
|
||||
@@ -155,6 +155,36 @@ impl TestRelay {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Start a GRASP-08 private service with persistent LMDB storage in
|
||||
/// caller-owned directories, so [`Self::restart`] resumes from the same
|
||||
/// events and git data.
|
||||
///
|
||||
/// The private NIP-42 gate applies to the relay's own internal
|
||||
/// self-subscription, so sync targets for locally published
|
||||
/// announcements are derived from the database at startup; tests
|
||||
/// exercising that pipeline publish, then restart.
|
||||
pub async fn start_private_with_lmdb_paths(
|
||||
member: &nostr_sdk::prelude::PublicKey,
|
||||
git_data_path: PathBuf,
|
||||
relay_data_path: PathBuf,
|
||||
) -> Self {
|
||||
Self::start_internal(
|
||||
port::reserve_port(),
|
||||
RelayOptions {
|
||||
private_members: Some(
|
||||
member
|
||||
.to_bech32()
|
||||
.expect("Failed to encode private test member"),
|
||||
),
|
||||
lmdb_backend: true,
|
||||
git_data_path: Some(git_data_path),
|
||||
relay_data_path: Some(relay_data_path),
|
||||
..RelayOptions::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Start relay with sync from another relay (bootstrap relay)
|
||||
///
|
||||
/// # Example
|
||||
|
||||
+216
-1
@@ -3,7 +3,10 @@ mod common;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use common::TestRelay;
|
||||
use common::{
|
||||
create_state_event, create_test_repo_with_commit, push_to_relay_with_auth_header,
|
||||
wait_for_sync_connection, CommitVariant, MockRelay, TestClient, TestRelay,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Kind, Tag, Timestamp, ToBech32};
|
||||
use reqwest::header::{ACCEPT, AUTHORIZATION, WWW_AUTHENTICATE};
|
||||
@@ -428,6 +431,218 @@ async fn private_announcement_admission_requires_member_author() {
|
||||
relay.stop().await;
|
||||
}
|
||||
|
||||
/// Poll until `event_id` is served to an authenticated session using `keys`.
|
||||
async fn wait_for_event_as(
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
event_id: nostr_sdk::prelude::EventId,
|
||||
timeout: Duration,
|
||||
) -> bool {
|
||||
use nostr_sdk::prelude::{Client, Filter, SignerAuthenticator};
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let client = Client::builder()
|
||||
.authenticator(SignerAuthenticator::new(keys.clone()))
|
||||
.build();
|
||||
if client.add_relay(relay_url).await.is_ok() {
|
||||
client.connect().await;
|
||||
let result = client
|
||||
.fetch_events(Filter::new().id(event_id))
|
||||
.timeout(Duration::from_secs(2))
|
||||
.await;
|
||||
client.disconnect().await;
|
||||
if let Ok(events) = result {
|
||||
if !events.is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded probe of whether `keys` is currently an effective member:
|
||||
/// authenticate over a fresh WebSocket session and, when admitted, prove the
|
||||
/// session is bridged by seeing a REQ answered with EOSE.
|
||||
async fn membership_admitted(relay: &TestRelay, keys: &Keys) -> bool {
|
||||
let (mut stream, challenge) = connect_and_challenge(relay).await;
|
||||
send_text(&mut stream, auth_message(keys, &relay.domain(), &challenge)).await;
|
||||
let ok: serde_json::Value =
|
||||
serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON");
|
||||
assert_eq!(ok[0], "OK");
|
||||
if ok[2] != true {
|
||||
return false;
|
||||
}
|
||||
send_text(
|
||||
&mut stream,
|
||||
r#"["REQ","bridge",{"kinds":[1],"limit":1}]"#.to_string(),
|
||||
)
|
||||
.await;
|
||||
loop {
|
||||
let frame: serde_json::Value =
|
||||
serde_json::from_str(&next_text(&mut stream).await).expect("relay JSON");
|
||||
if frame[0] == "EOSE" && frame[1] == "bridge" {
|
||||
return true;
|
||||
}
|
||||
assert_ne!(
|
||||
frame[0], "CLOSED",
|
||||
"admitted member REQ was rejected: {frame}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn derived_membership_requires_grasp08_advertising_relay() {
|
||||
let member = Keys::generate();
|
||||
let owner_plain = Keys::generate();
|
||||
let owner_private = Keys::generate();
|
||||
let git_data_dir = tempfile::tempdir().expect("git data dir");
|
||||
let relay_data_dir = tempfile::tempdir().expect("relay data dir");
|
||||
let relay = TestRelay::start_private_with_lmdb_paths(
|
||||
&member.public_key(),
|
||||
git_data_dir.path().to_path_buf(),
|
||||
relay_data_dir.path().to_path_buf(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Two referenced relays that differ only in whether their NIP-11
|
||||
// advertises the GRASP-08 private-service extension.
|
||||
let plain = MockRelay::start_with_nip11_document(serde_json::json!({
|
||||
"name": "public mirror",
|
||||
"pubkey": owner_plain.public_key().to_hex(),
|
||||
"supported_nips": [1, 11],
|
||||
"supported_grasps": ["GRASP-01"],
|
||||
}))
|
||||
.await;
|
||||
let private_peer = MockRelay::start_with_nip11_document(serde_json::json!({
|
||||
"name": "private peer",
|
||||
"pubkey": owner_private.public_key().to_hex(),
|
||||
"supported_nips": [1, 11],
|
||||
"supported_grasps": ["GRASP-01", "GRASP-08"],
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Member-authored announcement listing our own service (so it is
|
||||
// admitted) plus both mock relays; the state event and git push promote
|
||||
// it out of purgatory so it can mint derived membership.
|
||||
let identifier = "grasp08-derived-membership";
|
||||
let npub = member.public_key().to_bech32().expect("member npub");
|
||||
let clone_url = format!("http://{}/{npub}/{identifier}.git", relay.domain());
|
||||
let relay_urls = vec![
|
||||
format!("ws://{}", relay.domain()),
|
||||
plain.url().to_string(),
|
||||
private_peer.url().to_string(),
|
||||
];
|
||||
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(vec![
|
||||
Tag::identifier(identifier),
|
||||
Tag::custom("clone", vec![clone_url.clone()]),
|
||||
Tag::custom("relays", relay_urls.clone()),
|
||||
])
|
||||
.finalize(&member)
|
||||
.expect("signed announcement");
|
||||
|
||||
let git_dir = tempfile::tempdir().expect("git temp dir");
|
||||
let commit = create_test_repo_with_commit(git_dir.path(), CommitVariant::StateTest)
|
||||
.expect("test repository");
|
||||
let relay_url_refs: Vec<&str> = relay_urls.iter().map(String::as_str).collect();
|
||||
let state_event = create_state_event(
|
||||
&member,
|
||||
identifier,
|
||||
&[("main", &commit)],
|
||||
&[],
|
||||
&[clone_url.as_str()],
|
||||
&relay_url_refs,
|
||||
)
|
||||
.expect("state event");
|
||||
|
||||
// The member passes the inbound NIP-42 gate via the client authenticator
|
||||
// and the inbound NIP-98 gate via the repository-root credential.
|
||||
let client = TestClient::new(relay.url(), member.clone())
|
||||
.await
|
||||
.expect("authenticated member client");
|
||||
client
|
||||
.send_event(&announcement)
|
||||
.await
|
||||
.expect("announcement admitted");
|
||||
client
|
||||
.send_event(&state_event)
|
||||
.await
|
||||
.expect("state event admitted");
|
||||
push_to_relay_with_auth_header(
|
||||
git_dir.path(),
|
||||
&relay.domain(),
|
||||
&npub,
|
||||
identifier,
|
||||
Some(&credential(&member, &clone_url)),
|
||||
)
|
||||
.expect("authenticated git push");
|
||||
client.disconnect().await;
|
||||
|
||||
assert!(
|
||||
wait_for_event_as(
|
||||
relay.url(),
|
||||
&member,
|
||||
announcement.id,
|
||||
Duration::from_secs(30)
|
||||
)
|
||||
.await,
|
||||
"announcement must be promoted out of purgatory"
|
||||
);
|
||||
|
||||
// The internal live self-subscription cannot pass the private NIP-42
|
||||
// gate, so sync targets for locally promoted announcements are derived
|
||||
// from the database at startup; restart to exercise that path.
|
||||
let relay = relay.restart().await;
|
||||
|
||||
wait_for_sync_connection(relay.url(), 2, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("sync connections to both referenced relays");
|
||||
|
||||
// The GRASP-08-advertising relay's owner becomes an effective member.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
if membership_admitted(&relay, &owner_private).await {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"GRASP-08 relay owner was not admitted before the deadline"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
// The plain relay's owner authenticates validly but is never minted.
|
||||
let (mut stream, challenge) = connect_and_challenge(&relay).await;
|
||||
send_text(
|
||||
&mut stream,
|
||||
auth_message(&owner_plain, &relay.domain(), &challenge),
|
||||
)
|
||||
.await;
|
||||
let ok: serde_json::Value =
|
||||
serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON");
|
||||
assert_eq!(ok[0], "OK");
|
||||
assert_eq!(
|
||||
ok[2], false,
|
||||
"non-GRASP-08 relay owner must not gain membership: {ok}"
|
||||
);
|
||||
assert!(
|
||||
ok[3]
|
||||
.as_str()
|
||||
.expect("OK message")
|
||||
.starts_with("restricted:"),
|
||||
"{ok}"
|
||||
);
|
||||
expect_closed(&mut stream).await;
|
||||
|
||||
relay.stop().await;
|
||||
plain.stop().await;
|
||||
private_peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_websocket_bounds_invalid_authentication_attempts() {
|
||||
let member = Keys::generate();
|
||||
|
||||
Reference in New Issue
Block a user