feat(endpoint): add tests and mark tasks 1-7 as completed
- Add 7 registry tests (handler_registry_*) to registry.rs - Add 4 dispatch tests (build_auth_context_*, dispatch_decision_logic_*) to dispatch.rs - Add 6 endpoint tests to endpoint.rs: - debug_for_alknet_endpoint_is_implemented_without_panicking - endpoint_constructs_with_iroh_raw_key_identity (adapted for new API) - iroh_endpoint_runs_accept_loop_and_shutdown (adapted for new API) - with_iroh_sets_field (replaces has_iroh_identity_true_for_raw_key) - without_iroh_field_is_none (replaces has_iroh_identity_false_for_x509) - endpoint_works_without_iroh (replaces has_iroh_identity_false_when_no_identity) - Add async-trait as dev-dependency - All 17 tests pass with iroh feature - All 8 tests pass without features - Workspace tests all pass (no breakage) - Mark tasks 1-7 as completed
This commit is contained in:
1 parent
2755b995c6
commit
5467c30892
12 files changed
+451
-7
No files matched your search
Generated
+1
@@ -103,6 +103,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"alknet-core",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"iroh",
|
||||
"quinn",
|
||||
"rustls",
|
||||
|
||||
@@ -25,3 +25,6 @@ rustls = "0.23"
|
||||
quinn = { version = "0.11", optional = true }
|
||||
iroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }
|
||||
tokio-rustls = { version = "0.26", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
@@ -74,3 +74,165 @@ pub(crate) fn build_auth_context(
|
||||
tls_client_fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use super::build_auth_context;
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use std::collections::HashMap;
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use alknet_core::types::{Connection, HandlerError};
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
use crate::registry::HandlerRegistry;
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
struct DummyHandler {
|
||||
alpn: &'static [u8],
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[async_trait]
|
||||
impl alknet_core::types::ProtocolHandler for DummyHandler {
|
||||
fn alpn(&self) -> &'static [u8] {
|
||||
self.alpn
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_connection: Connection,
|
||||
_auth: &alknet_core::auth::AuthContext,
|
||||
) -> Result<(), HandlerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
fn make_handler(alpn: &'static [u8]) -> Arc<dyn alknet_core::types::ProtocolHandler> {
|
||||
Arc::new(DummyHandler { alpn })
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[test]
|
||||
fn build_auth_context_resolves_identity_from_fingerprint() {
|
||||
struct StaticProvider;
|
||||
impl IdentityProvider for StaticProvider {
|
||||
fn resolve_from_fingerprint(&self, fp: &str) -> Option<Identity> {
|
||||
if fp == "SHA256:known" {
|
||||
Some(Identity {
|
||||
id: "SHA256:known".to_string(),
|
||||
scopes: vec![],
|
||||
resources: HashMap::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticProvider);
|
||||
let auth = build_auth_context(
|
||||
b"alknet/test",
|
||||
None,
|
||||
Some("SHA256:known".to_string()),
|
||||
&provider,
|
||||
);
|
||||
assert_eq!(auth.identity.as_ref().unwrap().id, "SHA256:known");
|
||||
assert_eq!(auth.alpn, b"alknet/test");
|
||||
assert_eq!(auth.tls_client_fingerprint.as_deref(), Some("SHA256:known"));
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[test]
|
||||
fn build_auth_context_no_fingerprint_no_identity() {
|
||||
struct NoProvider;
|
||||
impl IdentityProvider for NoProvider {
|
||||
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let auth = build_auth_context(b"alknet/test", None, None, &provider);
|
||||
assert!(auth.identity.is_none());
|
||||
assert!(auth.tls_client_fingerprint.is_none());
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[test]
|
||||
fn build_auth_context_fingerprint_unknown_identity_none() {
|
||||
struct StaticProvider;
|
||||
impl IdentityProvider for StaticProvider {
|
||||
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticProvider);
|
||||
let auth = build_auth_context(
|
||||
b"alknet/test",
|
||||
None,
|
||||
Some("SHA256:unknown".to_string()),
|
||||
&provider,
|
||||
);
|
||||
assert!(auth.identity.is_none());
|
||||
assert!(auth.tls_client_fingerprint.is_some());
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "quinn", feature = "iroh", feature = "tcp"))]
|
||||
#[test]
|
||||
fn dispatch_decision_logic_lookup_and_auth() {
|
||||
let mut registry = HandlerRegistry::new();
|
||||
registry.register(make_handler(b"alknet/ssh"));
|
||||
registry.register(make_handler(b"alknet/call"));
|
||||
|
||||
struct StaticProvider;
|
||||
impl IdentityProvider for StaticProvider {
|
||||
fn resolve_from_fingerprint(&self, fp: &str) -> Option<Identity> {
|
||||
if fp == "SHA256:caller" {
|
||||
Some(Identity {
|
||||
id: "SHA256:caller".to_string(),
|
||||
scopes: vec!["relay:connect".to_string()],
|
||||
resources: HashMap::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticProvider);
|
||||
|
||||
let ssh_handler = registry.get(b"alknet/ssh").expect("ssh handler registered");
|
||||
assert_eq!(ssh_handler.alpn(), b"alknet/ssh");
|
||||
let auth = build_auth_context(
|
||||
b"alknet/ssh",
|
||||
Some(std::net::SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
1234,
|
||||
)),
|
||||
Some("SHA256:caller".to_string()),
|
||||
&provider,
|
||||
);
|
||||
assert_eq!(auth.identity.as_ref().unwrap().id, "SHA256:caller");
|
||||
assert_eq!(auth.alpn, b"alknet/ssh");
|
||||
|
||||
let unknown = registry.get(b"alknet/unknown");
|
||||
assert!(unknown.is_none(), "unknown ALPN has no handler");
|
||||
}
|
||||
}
|
||||
@@ -176,3 +176,189 @@ impl AlknetEndpoint {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
use alknet_core::config::DynamicConfig;
|
||||
#[cfg(feature = "iroh")]
|
||||
use alknet_core::auth::AuthContext;
|
||||
#[cfg(feature = "iroh")]
|
||||
use alknet_core::types::{Connection, HandlerError};
|
||||
#[cfg(feature = "iroh")]
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
struct DummyHandler {
|
||||
alpn: &'static [u8],
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[async_trait]
|
||||
impl alknet_core::types::ProtocolHandler for DummyHandler {
|
||||
fn alpn(&self) -> &'static [u8] {
|
||||
self.alpn
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_connection: Connection,
|
||||
_auth: &AuthContext,
|
||||
) -> Result<(), HandlerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
fn make_handler(alpn: &'static [u8]) -> Arc<dyn alknet_core::types::ProtocolHandler> {
|
||||
Arc::new(DummyHandler { alpn })
|
||||
}
|
||||
|
||||
struct NoProvider;
|
||||
impl IdentityProvider for NoProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_for_alknet_endpoint_is_implemented_without_panicking() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let registry = HandlerRegistry::new();
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10));
|
||||
let s = format!("{endpoint:?}");
|
||||
assert!(s.contains("AlknetEndpoint"));
|
||||
assert!(s.contains("drain_timeout"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[tokio::test]
|
||||
async fn endpoint_constructs_with_iroh_raw_key_identity() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let mut registry = HandlerRegistry::new();
|
||||
registry.register(make_handler(b"alknet/test"));
|
||||
|
||||
let iroh_endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![b"alknet/test".to_vec()])
|
||||
.relay_mode(iroh::RelayMode::Disabled)
|
||||
.bind()
|
||||
.await
|
||||
.expect("iroh endpoint binds");
|
||||
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10))
|
||||
.with_iroh(iroh_endpoint);
|
||||
assert!(endpoint.shutdown_sender().send(true).is_ok());
|
||||
endpoint.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[tokio::test]
|
||||
async fn iroh_endpoint_runs_accept_loop_and_shutdown() {
|
||||
use std::sync::Mutex;
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
|
||||
let connected = Arc::new(Mutex::new(false));
|
||||
let connected_clone = connected.clone();
|
||||
struct CountingHandler {
|
||||
alpn: &'static [u8],
|
||||
connected: Arc<Mutex<bool>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl alknet_core::types::ProtocolHandler for CountingHandler {
|
||||
fn alpn(&self) -> &'static [u8] {
|
||||
self.alpn
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_conn: Connection,
|
||||
_auth: &AuthContext,
|
||||
) -> Result<(), HandlerError> {
|
||||
*self.connected.lock().unwrap() = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut registry = HandlerRegistry::new();
|
||||
registry.register(Arc::new(CountingHandler {
|
||||
alpn: b"alknet/test",
|
||||
connected: connected_clone,
|
||||
}));
|
||||
|
||||
let iroh_endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![b"alknet/test".to_vec()])
|
||||
.relay_mode(iroh::RelayMode::Disabled)
|
||||
.bind()
|
||||
.await
|
||||
.expect("iroh endpoint binds");
|
||||
|
||||
let endpoint = Arc::new(
|
||||
AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(20))
|
||||
.with_iroh(iroh_endpoint),
|
||||
);
|
||||
|
||||
let run_endpoint = endpoint.clone();
|
||||
let run_task = tokio::spawn(async move {
|
||||
run_endpoint.run().await;
|
||||
});
|
||||
|
||||
let _ = endpoint.shutdown_sender().send(true);
|
||||
endpoint.shutdown().await;
|
||||
let _ = run_task.await;
|
||||
assert!(!*connected.lock().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[test]
|
||||
fn with_iroh_sets_field() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let registry = HandlerRegistry::new();
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let iroh_endpoint = rt.block_on(async {
|
||||
iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![b"alknet/test".to_vec()])
|
||||
.relay_mode(iroh::RelayMode::Disabled)
|
||||
.bind()
|
||||
.await
|
||||
.expect("iroh endpoint binds")
|
||||
});
|
||||
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10))
|
||||
.with_iroh(iroh_endpoint);
|
||||
assert!(endpoint.iroh.is_some());
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[test]
|
||||
fn without_iroh_field_is_none() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let registry = HandlerRegistry::new();
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10));
|
||||
assert!(endpoint.iroh.is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "iroh")]
|
||||
#[test]
|
||||
fn endpoint_works_without_iroh() {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoProvider);
|
||||
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
|
||||
let mut registry = HandlerRegistry::new();
|
||||
registry.register(make_handler(b"alknet/test"));
|
||||
let endpoint = AlknetEndpoint::new(registry, dynamic, provider, Duration::from_millis(10));
|
||||
assert!(endpoint.iroh.is_none());
|
||||
assert!(endpoint.shutdown_sender().send(true).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -59,3 +59,95 @@ impl std::fmt::Debug for HandlerRegistry {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alknet_core::auth::AuthContext;
|
||||
use alknet_core::types::{Connection, HandlerError};
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct DummyHandler {
|
||||
alpn: &'static [u8],
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProtocolHandler for DummyHandler {
|
||||
fn alpn(&self) -> &'static [u8] {
|
||||
self.alpn
|
||||
}
|
||||
async fn handle(
|
||||
&self,
|
||||
_connection: Connection,
|
||||
_auth: &AuthContext,
|
||||
) -> Result<(), HandlerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn make_handler(alpn: &'static [u8]) -> Arc<dyn ProtocolHandler> {
|
||||
Arc::new(DummyHandler { alpn })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_new_is_empty() {
|
||||
let reg = HandlerRegistry::new();
|
||||
assert!(reg.alpn_strings().is_empty());
|
||||
assert!(reg.get(b"alknet/test").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_register_then_get() {
|
||||
let mut reg = HandlerRegistry::new();
|
||||
reg.register(make_handler(b"alknet/test"));
|
||||
assert_eq!(reg.alpn_strings(), vec![b"alknet/test".to_vec()]);
|
||||
assert!(reg.get(b"alknet/test").is_some());
|
||||
assert!(reg.get(b"alknet/other").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_multiple_alpns() {
|
||||
let mut reg = HandlerRegistry::new();
|
||||
reg.register(make_handler(b"alknet/ssh"));
|
||||
reg.register(make_handler(b"alknet/call"));
|
||||
let mut alpns = reg
|
||||
.alpn_strings()
|
||||
.into_iter()
|
||||
.map(|a| String::from_utf8(a).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
alpns.sort();
|
||||
assert_eq!(alpns, vec!["alknet/call", "alknet/ssh"]);
|
||||
assert!(reg.get(b"alknet/ssh").is_some());
|
||||
assert!(reg.get(b"alknet/call").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "ALPN already registered")]
|
||||
fn handler_registry_register_panics_on_duplicate() {
|
||||
let mut reg = HandlerRegistry::new();
|
||||
reg.register(make_handler(b"alknet/test"));
|
||||
reg.register(make_handler(b"alknet/test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_debug_lists_alpns() {
|
||||
let mut reg = HandlerRegistry::new();
|
||||
reg.register(make_handler(b"alknet/test"));
|
||||
let s = format!("{:?}", reg);
|
||||
assert!(s.contains("alknet/test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_default_is_empty() {
|
||||
let reg = HandlerRegistry::default();
|
||||
assert!(reg.alpn_strings().is_empty());
|
||||
assert!(reg.get(b"alknet/test").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_debug_lists_alpns_via_default() {
|
||||
let reg = HandlerRegistry::default();
|
||||
let s = format!("{reg:?}");
|
||||
assert!(s.contains("HandlerRegistry"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: endpoint/accept-iroh
|
||||
name: Implement iroh accept loop and extractors in alknet-endpoint
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [endpoint/dispatch]
|
||||
scope: narrow
|
||||
risk: low
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: endpoint/accept-quinn
|
||||
name: Implement quinn accept loop and extractors in alknet-endpoint
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [endpoint/dispatch]
|
||||
scope: narrow
|
||||
risk: low
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: endpoint/accept-tcp-tls
|
||||
name: Implement TCP+TLS accept loop and extractors in alknet-endpoint (new code)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [endpoint/dispatch]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: endpoint/crate-init
|
||||
name: Initialize alknet-endpoint crate with Cargo.toml, dependencies, and module skeleton
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [tls/review-tls]
|
||||
scope: moderate
|
||||
risk: low
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: endpoint/dispatch
|
||||
name: Implement public dispatch, build_auth_context, and ACME guard
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [endpoint/endpoint-core]
|
||||
scope: narrow
|
||||
risk: low
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: endpoint/endpoint-core
|
||||
name: Implement AlknetEndpoint struct, new, builder methods, run, and shutdown
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [endpoint/registry]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: endpoint/registry
|
||||
name: Extract HandlerRegistry from alknet-core/endpoint.rs into alknet-endpoint
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [endpoint/crate-init]
|
||||
scope: narrow
|
||||
risk: low
|
||||
|
||||
Reference in new issue
Block a user