Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ members = [
"crates/nexum-sdk-test",
"crates/nexum-status-body",
"crates/nexum-tasks",
"crates/nexum-venue-test",
"crates/nexum-world",
"crates/no-std-probe",
"crates/shepherd",
Expand All @@ -20,6 +19,7 @@ members = [
"crates/videre-host",
"crates/videre-macros",
"crates/videre-sdk",
"crates/videre-test",
"modules/ethflow-watcher",
"modules/example",
"modules/examples/balance-tracker",
Expand Down
2 changes: 1 addition & 1 deletion crates/cow-venue/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ serde = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
# The conformance kit: holds the body codec to its published vector set.
nexum-venue-test = { path = "../nexum-venue-test" }
videre-test = { path = "../videre-test" }

[features]
# The body-type + codec slice ships by default; the `client` slice layers
Expand Down
2 changes: 1 addition & 1 deletion crates/cow-venue/src/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub enum CowIntentBody {
#[cfg(test)]
mod tests {
use super::*;
use nexum_venue_test::{CodecVectors, Expectation};
use videre_test::{CodecVectors, Expectation};

use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource};

Expand Down
59 changes: 55 additions & 4 deletions crates/nexum-sdk-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,12 @@ impl MockMessaging {
});
}

/// Confine the mock to `topics`, mirroring the component's
/// `messaging_topics` grant: any other topic fails as
/// [`Fault::Denied`]. Untouched, every topic is allowed.
/// Confine the mock to `topics`, playing the component's
/// `messaging_topics` grant with the host's matching: a topic is
/// admitted when it equals a grant entry or descends from one read
/// as a `/`-bounded path prefix; anything else fails as
/// [`Fault::Denied`]. An empty grant is unscoped, the host's module
/// default, as is an untouched mock.
pub fn scope_topics(&self, topics: impl IntoIterator<Item = impl Into<String>>) {
*self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect());
}
Expand Down Expand Up @@ -423,7 +426,7 @@ impl MockMessaging {
}
}
if let Some(scope) = self.scope.borrow().as_ref()
&& !scope.iter().any(|topic| topic == content_topic)
&& !topic_in_scope(content_topic, scope)
{
return Err(Fault::Denied(format!(
"MockMessaging: {content_topic} is outside the scoped topics"
Expand All @@ -433,6 +436,25 @@ impl MockMessaging {
}
}

/// The host's `messaging_topics` matching: an empty scope admits every
/// topic; otherwise a topic is admitted when it equals a scope entry or
/// descends from one read as a path prefix bounded at `/`, so a grant
/// never leaks into a longer sibling segment.
fn topic_in_scope(topic: &str, scope: &[String]) -> bool {
if scope.is_empty() {
return true;
}
scope.iter().any(|allowed| {
if topic == allowed {
return true;
}
let prefix = allowed.strip_suffix('/').unwrap_or(allowed);
topic
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
})
}

impl MessagingHost for MockMessaging {
fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> {
self.admit(content_topic)?;
Expand Down Expand Up @@ -1361,6 +1383,35 @@ mod tests {
assert_eq!(messaging.publish_count(), 1);
}

#[test]
fn messaging_scope_matches_the_host_grant() {
// A prefix grant admits the family beneath it, bounded at `/`.
let messaging = MockMessaging::default();
messaging.scope_topics(["/nexum/1/"]);
messaging
.publish("/nexum/1/acme-orders/proto", b"x")
.unwrap();
messaging.publish("/nexum/1/twap/proto", b"x").unwrap();
let err = messaging.publish("/nexum/2/acme/proto", b"x").unwrap_err();
assert!(matches!(err, Fault::Denied(_)));

// No trailing slash still bounds on the separator: a grant never
// leaks into a longer sibling segment.
let messaging = MockMessaging::default();
messaging.scope_topics(["/nexum/1/acme"]);
messaging.publish("/nexum/1/acme", b"x").unwrap();
messaging.publish("/nexum/1/acme/orders", b"x").unwrap();
let err = messaging
.publish("/nexum/1/acme-orders/proto", b"x")
.unwrap_err();
assert!(matches!(err, Fault::Denied(_)));

// An empty grant is unscoped, the host's module default.
let messaging = MockMessaging::default();
messaging.scope_topics(Vec::<String>::new());
messaging.publish("/anywhere/at/all", b"x").unwrap();
}

#[test]
fn messaging_fault_injection_fires_by_prefix() {
let messaging = MockMessaging::default();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[package]
name = "nexum-venue-test"
name = "videre-test"
version = "0.1.0"
edition.workspace = true
license.workspace = true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": 1,
"venue": "nexum-venue-test/reference",
"venue": "videre-test/reference",
"goldens": [
{
"name": "v1-small",
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! # nexum-venue-test
//! # videre-test
//!
//! Conformance kit for venue adapters: file-published codec vectors,
//! header-derivation goldens, and an in-memory transport mock, so an
Expand All @@ -25,16 +25,16 @@
//!
//! ```toml
//! [dev-dependencies]
//! nexum-venue-test = { path = "../../crates/nexum-venue-test" }
//! videre-test = { path = "../../crates/videre-test" }
//! ```
//!
//! Hold the adapter to its published fixtures:
//!
//! ```rust
//! use nexum_venue_test::reference::{
//! use videre_test::reference::{
//! CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header,
//! };
//! use nexum_venue_test::{CodecVectors, HeaderGoldens};
//! use videre_test::{CodecVectors, HeaderGoldens};
//!
//! // In a real adapter test these load the venue's own published
//! // files; the kit's reference venue stands in here.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ mod tests {

/// Rebuild the published codec vectors from the reference schema.
fn build_codec_vectors() -> CodecVectors {
let mut vectors = CodecVectors::new("nexum-venue-test/reference-body");
let mut vectors = CodecVectors::new("videre-test/reference-body");

vectors
.push_round_trip("v1-small", &v1_small())
Expand Down Expand Up @@ -218,7 +218,7 @@ mod tests {
/// Rebuild the published header goldens from the reference
/// derivation.
fn build_header_goldens() -> HeaderGoldens {
let mut goldens = HeaderGoldens::new("nexum-venue-test/reference");
let mut goldens = HeaderGoldens::new("videre-test/reference");
goldens
.record(
"v1-small",
Expand Down Expand Up @@ -259,7 +259,7 @@ mod tests {
}

/// Rewrite the published files from the reference schema. Run with
/// `cargo test -p nexum-venue-test -- --ignored regenerate` after a
/// `cargo test -p videre-test -- --ignored regenerate` after a
/// deliberate schema change, then commit the diff; the tests above
/// compare against the compiled-in copy, so they go green on the
/// next build.
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
//! [`MockTransport`] composes the three behind the same seams the SDK
//! wrappers implement ([`ChainHost`], [`MessagingHost`], [`Fetch`]), so
//! adapter logic written against `&impl Seam` runs unchanged in unit
//! tests. Scoping mirrors the host's: [`MockMessaging::scope_topics`]
//! plays the adapter's `messaging_topics` grant and refuses off-scope
//! topics as a typed `denied`, exactly as the host would.
//! tests. Grant play mirrors the host's: [`MockMessaging::scope_topics`]
//! plays the adapter's `messaging_topics` grant with the host's
//! `/`-bounded prefix matching, and [`MockFetch::scope_hosts`] plays the
//! `[capabilities.http].allow` list with the host's exact-or-`*.suffix`
//! matching; both refuse off-grant calls as a typed `denied`, exactly as
//! the host would.

use std::cell::RefCell;
use std::collections::HashMap;
Expand Down Expand Up @@ -92,16 +95,29 @@ struct StoredResponse {
}

/// In-memory [`Fetch`] backed by a `(method, uri)` -> response map.
/// Records every request so tests can assert dispatch shape; an
/// allowlist refusal is programmed as [`FetchError::Denied`] via
/// [`fail_with`](Self::fail_with).
/// Records every request so tests can assert dispatch shape. An
/// optional host scope plays the adapter's `[capabilities.http].allow`
/// grant ([`scope_hosts`](Self::scope_hosts)); one-off refusals can
/// still be programmed via [`fail_with`](Self::fail_with).
#[derive(Default)]
pub struct MockFetch {
responses: RefCell<HashMap<(http::Method, String), Result<StoredResponse, FetchError>>>,
requests: RefCell<Vec<RecordedRequest>>,
scope: RefCell<Option<Vec<String>>>,
}

impl MockFetch {
/// Confine the mock to `hosts`, playing the adapter's
/// `[capabilities.http].allow` grant with the host's matching:
/// case-insensitive, an entry is an exact hostname or a `*.suffix`
/// wildcard, and an off-grant request fails as
/// [`FetchError::Denied`]. An empty grant denies every host, the
/// host's posture for an absent allow list; an untouched mock is
/// unscoped.
pub fn scope_hosts(&self, hosts: impl IntoIterator<Item = impl Into<String>>) {
*self.scope.borrow_mut() = Some(hosts.into_iter().map(Into::into).collect());
}

/// Program a response for the `(method, uri)` pair. Overwrites any
/// prior entry.
///
Expand Down Expand Up @@ -164,6 +180,14 @@ impl Fetch for MockFetch {
body: request.body().clone(),
options,
});
if let Some(scope) = self.scope.borrow().as_ref()
&& !request
.uri()
.host()
.is_some_and(|host| host_allowed(host, scope))
{
return Err(FetchError::Denied);
}
match self.responses.borrow().get(&(method.clone(), uri.clone())) {
Some(Ok(stored)) => Ok(http::Response::builder()
.status(stored.status)
Expand All @@ -177,6 +201,21 @@ impl Fetch for MockFetch {
}
}

/// The host's `[capabilities.http].allow` matching: host-only and
/// case-insensitive, an entry admits its exact hostname or, as
/// `*.suffix`, any strict subdomain of the suffix.
fn host_allowed(host: &str, allowlist: &[String]) -> bool {
let host = host.to_ascii_lowercase();
allowlist.iter().any(|pat| {
let pat = pat.to_ascii_lowercase();
if let Some(suffix) = pat.strip_prefix("*.") {
host.ends_with(&format!(".{suffix}"))
} else {
host == pat
}
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -233,6 +272,50 @@ mod tests {
assert_eq!(fetch.request_count(), 2);
}

#[test]
fn fetch_scope_matches_the_host_grant() {
let fetch = MockFetch::default();
fetch.scope_hosts(["api.acme.example", "*.discord.com"]);
fetch.respond_to(http::Method::GET, "https://api.acme.example/v1", 200, "ok");
fetch.respond_to(http::Method::GET, "https://API.ACME.EXAMPLE/v1", 200, "ok");
fetch.respond_to(http::Method::GET, "https://a.b.discord.com/", 200, "ok");

// Exact entry, case-insensitively; a wildcard admits strict
// subdomains only.
let get = |uri: &str| {
fetch.fetch(
http::Request::builder()
.uri(uri)
.body(Vec::new())
.expect("test request builds"),
)
};
assert!(get("https://api.acme.example/v1").is_ok());
assert!(get("https://API.ACME.EXAMPLE/v1").is_ok());
assert!(get("https://a.b.discord.com/").is_ok());
assert_eq!(
get("https://evil.api.acme.example/").unwrap_err(),
FetchError::Denied,
);
assert_eq!(get("https://discord.com/").unwrap_err(), FetchError::Denied);

// Refused requests are still recorded.
assert_eq!(fetch.request_count(), 5);

// An empty grant denies every host, the host's posture for an
// absent allow list.
let sealed = MockFetch::default();
sealed.scope_hosts(Vec::<String>::new());
sealed.respond_to(http::Method::GET, "https://anywhere.example/", 200, "");
let denied = sealed.fetch(
http::Request::builder()
.uri("https://anywhere.example/")
.body(Vec::new())
.expect("test request builds"),
);
assert_eq!(denied.unwrap_err(), FetchError::Denied);
}

#[test]
fn transport_dispatches_through_every_seam() {
let transport = MockTransport::new();
Expand Down
Loading
Loading