From 1311fa6266dbf24abf60c5a35f526c83f2d28b5e Mon Sep 17 00:00:00 2001 From: jabdikadyr Date: Wed, 16 Sep 2026 17:03:53 +0600 Subject: [PATCH] fix(sdk): close conformance gaps in the agent, directory and service --- Cargo.lock | 2 + crates/odp-agent/Cargo.toml | 5 +- crates/odp-agent/README.md | 16 + crates/odp-agent/src/agent.rs | 148 ++- crates/odp-agent/src/cache.rs | 69 +- crates/odp-agent/src/capabilities.rs | 21 +- crates/odp-agent/src/client.rs | 454 +++++++-- crates/odp-agent/src/destinations.rs | 182 ++++ crates/odp-agent/src/details.rs | 47 + crates/odp-agent/src/lib.rs | 2 + crates/odp-agent/src/schema.rs | 287 +++++- crates/odp-agent/tests/action_conformance.rs | 178 ++++ crates/odp-agent/tests/caching_conformance.rs | 572 +++++++++++ .../odp-agent/tests/capability_conformance.rs | 358 +++++++ crates/odp-agent/tests/catalog_conformance.rs | 663 +++++++++++++ .../tests/destination_conformance.rs | 205 ++++ crates/odp-agent/tests/schema_conformance.rs | 621 ++++++++++++ crates/odp-agent/tests/support/mod.rs | 139 +++ .../odp-agent/tests/transport_conformance.rs | 536 +++++++++++ crates/odp-core/src/addresses.rs | 209 ++++ crates/odp-core/src/lib.rs | 2 + crates/odp-core/src/models.rs | 22 +- crates/odp-core/src/references.rs | 14 +- crates/odp-core/src/validation.rs | 261 ++++- crates/odp-core/tests/agent_conformance.rs | 530 ++++++++++ .../odp-core/tests/capability_conformance.rs | 452 +++++++++ crates/odp-core/tests/document_conformance.rs | 331 +++++++ crates/odp-core/tests/problem_conformance.rs | 207 ++++ .../odp-core/tests/reference_conformance.rs | 505 ++++++++++ .../tests/representation_conformance.rs | 265 +++++ crates/odp-core/tests/support/mod.rs | 132 +++ crates/odp-directory/Cargo.toml | 3 +- crates/odp-directory/src/client.rs | 564 ++++++++--- crates/odp-directory/src/models.rs | 25 +- crates/odp-directory/src/results.rs | 5 +- crates/odp-directory/src/transport.rs | 194 +++- crates/odp-directory/tests/mixed.rs | 5 +- .../tests/pagination_conformance.rs | 320 ++++++ .../odp-directory/tests/reqwest_transport.rs | 288 ++++++ .../odp-directory/tests/search_conformance.rs | 909 ++++++++++++++++++ crates/odp-directory/tests/support/mod.rs | 147 +++ .../tests/transport_conformance.rs | 435 +++++++++ crates/odp-service/README.md | 10 + crates/odp-service/src/service.rs | 581 +++++++++-- crates/odp-service/src/static_catalog.rs | 171 +++- .../odp-service/tests/builder_conformance.rs | 600 ++++++++++++ .../odp-service/tests/catalog_conformance.rs | 633 ++++++++++++ crates/odp-service/tests/http_conformance.rs | 533 ++++++++++ crates/odp-service/tests/support/mod.rs | 322 +++++++ .../odp-service/tests/variant_conformance.rs | 295 ++++++ .../src/bin/odp_conformance_adapter.rs | 30 +- .../src/bin/odp_node_interop.rs | 2 +- 52 files changed, 13096 insertions(+), 411 deletions(-) create mode 100644 crates/odp-agent/src/destinations.rs create mode 100644 crates/odp-agent/tests/action_conformance.rs create mode 100644 crates/odp-agent/tests/caching_conformance.rs create mode 100644 crates/odp-agent/tests/capability_conformance.rs create mode 100644 crates/odp-agent/tests/catalog_conformance.rs create mode 100644 crates/odp-agent/tests/destination_conformance.rs create mode 100644 crates/odp-agent/tests/schema_conformance.rs create mode 100644 crates/odp-agent/tests/support/mod.rs create mode 100644 crates/odp-agent/tests/transport_conformance.rs create mode 100644 crates/odp-core/src/addresses.rs create mode 100644 crates/odp-core/tests/agent_conformance.rs create mode 100644 crates/odp-core/tests/capability_conformance.rs create mode 100644 crates/odp-core/tests/document_conformance.rs create mode 100644 crates/odp-core/tests/problem_conformance.rs create mode 100644 crates/odp-core/tests/reference_conformance.rs create mode 100644 crates/odp-core/tests/representation_conformance.rs create mode 100644 crates/odp-core/tests/support/mod.rs create mode 100644 crates/odp-directory/tests/pagination_conformance.rs create mode 100644 crates/odp-directory/tests/reqwest_transport.rs create mode 100644 crates/odp-directory/tests/search_conformance.rs create mode 100644 crates/odp-directory/tests/support/mod.rs create mode 100644 crates/odp-directory/tests/transport_conformance.rs create mode 100644 crates/odp-service/tests/builder_conformance.rs create mode 100644 crates/odp-service/tests/catalog_conformance.rs create mode 100644 crates/odp-service/tests/http_conformance.rs create mode 100644 crates/odp-service/tests/support/mod.rs create mode 100644 crates/odp-service/tests/variant_conformance.rs diff --git a/Cargo.lock b/Cargo.lock index c46d02c..bd5e15b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,6 +1042,7 @@ version = "0.1.1" dependencies = [ "async-trait", "futures", + "getrandom 0.4.3", "httpdate", "jsonschema", "odp-core", @@ -1092,6 +1093,7 @@ dependencies = [ "serde", "serde_json", "thiserror", + "tiny_http", "tokio", "url", ] diff --git a/crates/odp-agent/Cargo.toml b/crates/odp-agent/Cargo.toml index 0ad0ccd..3fbdab5 100644 --- a/crates/odp-agent/Cargo.toml +++ b/crates/odp-agent/Cargo.toml @@ -14,7 +14,9 @@ categories.workspace = true readme = "README.md" [dependencies] +async-trait.workspace = true futures.workspace = true +getrandom.workspace = true httpdate.workspace = true jsonschema.workspace = true odp-core = { version = "0.1.1", path = "../odp-core" } @@ -23,11 +25,10 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true thiserror.workspace = true +tokio.workspace = true url.workspace = true [dev-dependencies] -async-trait.workspace = true -tokio.workspace = true [lints] workspace = true diff --git a/crates/odp-agent/README.md b/crates/odp-agent/README.md index 6d37f12..3c8562c 100644 --- a/crates/odp-agent/README.md +++ b/crates/odp-agent/README.md @@ -46,6 +46,20 @@ is validated. `ServiceClient` uses an in-memory cache by default; callers can in set an authentication-aware cache partition, or override the Service Document, Collection, and Offering fallback lifetimes. +Each client has an isolated cache partition, including when sharing a `Cache`. Use +`with_cache_partition` only to share responses between clients with the same authentication context. +Manual `continue_offerings` and `continue_collections` calls use HTTP freshness headers without a +fallback lifetime: an opaque continuation URL does not identify whether it originated in a search. +The automatic traversal methods retain the originating operation's fallback policy. + +`ServiceClient::new` permits public destinations only. Use `ServiceClient::for_local_development` +for localhost examples. The default transport pins validated addresses for each request, disables +proxies, and checks the connected peer. An injected transport owns its network policy; wrapping it +in `SecureTransport` requires implementing `Transport::send_to` with equivalent address pinning and +peer verification. Its default implementation refuses the request rather than silently bypassing +those checks. Implement `send_limited` to enforce response limits while streaming; its default +implementation can only check the completed response. + `get_offering_details` bundles an Offering with its validated Attribute Schema, validates the Offering attributes, and normalizes usable Action targets. `resolve_action` resolves an Action's request schema or unique OpenAPI 3.1 operation without invoking the target. Supporting documents @@ -54,6 +68,8 @@ resolution accepts JSON Schema Draft 2020-12 and is limited to 256 KiB per docum eight reference levels, and one MiB for the complete graph. OpenAPI documents are limited to one MiB. These are fixed SDK safety ceilings. Cross-document schema composition uses `$ref`; `$dynamicRef` accepts only a fragment reference such as `#node`. +Returned schemas include their referenced resources in `$defs` with absolute identifiers, so a +caller can use them without additional network access. ## Search across Services diff --git a/crates/odp-agent/src/agent.rs b/crates/odp-agent/src/agent.rs index 4ab28d0..6138f72 100644 --- a/crates/odp-agent/src/agent.rs +++ b/crates/odp-agent/src/agent.rs @@ -184,7 +184,7 @@ mod tests { impl Transport for ServiceTransport { async fn send(&self, request: HttpRequest) -> Result { if request.url.ends_with("/.well-known/odp") { - return Ok(odp_response(br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"}]}"#)); + return Ok(odp_response(br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-offerings"}]}"#)); } let id = if request.url.starts_with("https://one.example") { "one" @@ -243,4 +243,150 @@ mod tests { assert_eq!(events[0].service.name, "One"); assert_eq!(events[1].service.name, "Two"); } + + /// A search request drives the search operation; a bare request just lists. + #[tokio::test] + async fn searches_a_service_only_when_the_request_asks_a_question() { + let recorder = Arc::new(RecordingFactory::default()); + let directory = + DirectoryClient::with_transport(Environment::Production, Arc::new(DirectoryTransport)); + let agent = Agent::with_clients(directory, recorder.clone()); + + agent + .search_offerings_across_services(&FederatedSearchRequest { + offerings: OfferingSearchRequest { + query: "rubber".to_owned(), + ..OfferingSearchRequest::default() + }, + ..FederatedSearchRequest::default() + }) + .await + .unwrap(); + + let urls = recorder.urls(); + assert!( + urls.iter().any(|url| url.contains("/offerings/search")), + "{urls:?}" + ); + } + + /// FED-04: one Service that cannot answer is reported, and the others still report offerings. + #[tokio::test] + async fn reports_a_failing_service_without_losing_the_others() { + struct HalfBroken; + + impl ServiceClientFactory for HalfBroken { + fn create(&self, service: &DirectoryService) -> Result { + if service.service_origin.starts_with("https://one.example") { + return Err(AgentError::InvalidRequest("no client for One".to_owned())); + } + ServiceClient::with_transport(&service.service_origin, Arc::new(ServiceTransport)) + } + } + + let directory = + DirectoryClient::with_transport(Environment::Production, Arc::new(DirectoryTransport)); + let agent = Agent::with_clients(directory, Arc::new(HalfBroken)); + let events = agent + .search_offerings_across_services(&FederatedSearchRequest::default()) + .await + .unwrap(); + + assert_eq!(events.len(), 2); + assert_eq!(events[0].service.name, "One"); + assert!(events[0].offering.is_none()); + assert!(events[0].issue.is_some()); + assert!(events[1].offering.is_some()); + assert!(events[1].issue.is_none()); + } + + /// Each bound has a ceiling, so a request asking for more is refused before anything is sent. + #[tokio::test] + async fn refuses_a_request_that_asks_for_more_than_the_bounds_allow() { + let directory = + DirectoryClient::with_transport(Environment::Production, Arc::new(DirectoryTransport)); + let agent = Agent::with_clients(directory, Arc::new(Factory)); + + for (request, name) in [ + ( + FederatedSearchRequest { + max_services: 101, + ..FederatedSearchRequest::default() + }, + "max_services", + ), + ( + FederatedSearchRequest { + max_offerings_per_service: 101, + ..FederatedSearchRequest::default() + }, + "max_offerings_per_service", + ), + ( + FederatedSearchRequest { + concurrency: 17, + ..FederatedSearchRequest::default() + }, + "concurrency", + ), + ] { + let error = agent + .search_offerings_across_services(&request) + .await + .unwrap_err(); + assert!(error.to_string().contains(name), "{name}: {error}"); + } + } + + #[test] + fn builds_an_agent_for_an_environment_it_keeps() { + let agent = Agent::new(Environment::Sandbox).unwrap(); + assert_eq!(agent.environment(), Environment::Sandbox); + } + + /// The default factory reaches the Service Origin the Directory listed. + #[test] + fn builds_a_default_client_for_a_listed_service() { + let service: DirectoryService = serde_json::from_str( + r#"{"description":"One","indexed_at":"2026-08-25T00:00:00Z","language":"en","localizations":["en"],"name":"One","operations":[],"service_origin":"https://plants.example"}"#, + ) + .unwrap(); + let client = DefaultServiceClientFactory.create(&service).unwrap(); + assert_eq!(client.service_origin(), "https://plants.example"); + } + + /// A factory that records the URLs its clients are asked for. + #[derive(Default)] + struct RecordingFactory { + urls: Arc>>, + } + + impl RecordingFactory { + fn urls(&self) -> Vec { + self.urls.lock().unwrap().clone() + } + } + + impl ServiceClientFactory for RecordingFactory { + fn create(&self, service: &DirectoryService) -> Result { + ServiceClient::with_transport( + &service.service_origin, + Arc::new(RecordingTransport { + urls: self.urls.clone(), + }), + ) + } + } + + struct RecordingTransport { + urls: Arc>>, + } + + #[async_trait] + impl Transport for RecordingTransport { + async fn send(&self, request: HttpRequest) -> Result { + self.urls.lock().unwrap().push(request.url.clone()); + ServiceTransport.send(request).await + } + } } diff --git a/crates/odp-agent/src/cache.rs b/crates/odp-agent/src/cache.rs index 090898e..8afb377 100644 --- a/crates/odp-agent/src/cache.rs +++ b/crates/odp-agent/src/cache.rs @@ -21,15 +21,48 @@ pub trait Cache: Send + Sync { fn set(&self, key: String, record: CacheRecord) -> Result<(), String>; } -#[derive(Default)] +const DEFAULT_CAPACITY: usize = 256; + +/// An in-memory cache bounded by entry count, so a long-lived Agent cannot grow without limit. +/// When it is full the least recently stored record makes room for the new one. pub struct MemoryCache { + capacity: usize, records: RwLock>, } +impl Default for MemoryCache { + fn default() -> Self { + Self::with_capacity(DEFAULT_CAPACITY) + } +} + impl MemoryCache { + #[must_use] pub fn new() -> Self { Self::default() } + + /// A cache holding at most `capacity` records. A capacity of zero keeps nothing. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + Self { + capacity, + records: RwLock::new(BTreeMap::new()), + } + } + + #[must_use] + pub fn len(&self) -> usize { + self.records + .read() + .map(|records| records.len()) + .unwrap_or_default() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } } impl Cache for MemoryCache { @@ -51,26 +84,54 @@ impl Cache for MemoryCache { } fn set(&self, key: String, record: CacheRecord) -> Result<(), String> { - self.records + let mut records = self + .records .write() - .map_err(|_| "memory cache lock is poisoned".to_owned())? - .insert(key, record); + .map_err(|_| "memory cache lock is poisoned".to_owned())?; + if self.capacity == 0 { + return Ok(()); + } + if !records.contains_key(&key) && records.len() >= self.capacity { + // Capacity is at least one here, so evicting just enough always leaves room. + let mut by_age = records + .iter() + .map(|(name, value)| (value.stored_at, name.clone())) + .collect::>(); + by_age.sort(); + for (_, name) in by_age.into_iter().take(records.len() + 1 - self.capacity) { + records.remove(&name); + } + } + records.insert(key, record); Ok(()) } } +/// CCH-02: the freshness an Agent assumes when a response supplies none. +/// +/// CCH-03 makes each resource class independently configurable, so every class the draft names has +/// its own field rather than borrowing a neighbour's. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CacheFallbacks { + /// Filter and Sort Definitions. + pub capabilities: Duration, pub collection: Duration, pub offering: Duration, + /// Attribute Schema documents. + pub schema: Duration, + /// Search responses, which describe one request and are not reused for the next. + pub search: Duration, pub service_document: Duration, } impl Default for CacheFallbacks { fn default() -> Self { Self { + capabilities: Duration::from_secs(60 * 60), collection: Duration::from_secs(60 * 60), offering: Duration::from_secs(5 * 60), + schema: Duration::from_secs(24 * 60 * 60), + search: Duration::ZERO, service_document: Duration::from_secs(4 * 60 * 60), } } diff --git a/crates/odp-agent/src/capabilities.rs b/crates/odp-agent/src/capabilities.rs index 87a7997..8a329ef 100644 --- a/crates/odp-agent/src/capabilities.rs +++ b/crates/odp-agent/src/capabilities.rs @@ -6,7 +6,7 @@ use odp_core::{ }; use url::Url; -use crate::{AgentError, CacheFallbacks, ServiceClient}; +use crate::{AgentError, ServiceClient}; const MAXIMUM_CAPABILITY_PAGES: usize = 16; const MAXIMUM_FILTERS: usize = 1_024; @@ -272,7 +272,7 @@ impl ServiceClient { let data = self .linked_odp( target, - CacheFallbacks::default().collection, + self.cache_fallbacks().capabilities, validate_filter_page, ) .await?; @@ -307,7 +307,7 @@ impl ServiceClient { let data = self .linked_odp( target, - CacheFallbacks::default().collection, + self.cache_fallbacks().capabilities, validate_sort_page, ) .await?; @@ -419,4 +419,19 @@ mod tests { assert_eq!(catalog.filters["price"].title, "Price"); assert_eq!(catalog.sorts["price-lowest"].filters[0].id, "price"); } + + /// A validated document never carries one, so the helper's own guard is checked here. + #[test] + fn refuses_a_capability_reference_that_is_not_an_http_url() { + for reference in ["mailto:filters@plants.example", "file:///filters.json"] { + let error = resolve_reference(reference, "https://plants.example").unwrap_err(); + assert!(error.to_string().contains("HTTP"), "{reference}: {error}"); + } + } + + #[test] + fn refuses_a_capability_reference_it_has_no_base_for() { + let error = resolve_reference("/filters", "not a base").unwrap_err(); + assert!(matches!(error, AgentError::InvalidRequest(_)), "{error}"); + } } diff --git a/crates/odp-agent/src/client.rs b/crates/odp-agent/src/client.rs index 351ce95..597b041 100644 --- a/crates/odp-agent/src/client.rs +++ b/crates/odp-agent/src/client.rs @@ -7,19 +7,23 @@ use std::{ use odp_core::{ Collection, CollectionSearchRequest, Offering, OfferingPage, OfferingSearchRequest, Operation, Page, ParseError, Representation, ServiceDocument, build_operation_url, derive_service_origin, - normalize_agent_response, parse_agent_service_document, parse_collection, parse_offering, - parse_offering_search_response, parse_page, parse_problem_response, resolve_continuation, + normalize_agent_response, parse_agent_collection, parse_agent_offering, + parse_agent_offering_search_response, parse_agent_service_document, parse_page, + parse_problem_response, resolve_continuation, }; use odp_directory::{HttpRequest, ReqwestTransport, Transport, TransportError}; use sha2::{Digest, Sha256}; use thiserror::Error; use url::Url; -use crate::{Cache, CacheFallbacks, CacheRecord, default_cache}; +use crate::{Cache, CacheFallbacks, CacheRecord, SecureTransport, default_cache}; const MEDIA_TYPE: &str = "application/odp+json"; +const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; const MAX_DOCUMENT_BYTES: usize = 65_536; const MAX_RESOURCE_BYTES: usize = 524_288; +/// ERR-21: a Problem Details response is read far more tightly than a successful one. +const MAX_PROBLEM_BYTES: usize = 16_384; const MAX_REDIRECTS: usize = 5; const MAX_TRAVERSAL_ITEMS: usize = 10_000; const MAX_TRAVERSAL_PAGES: usize = 16; @@ -87,20 +91,43 @@ pub struct ServiceClient { } impl ServiceClient { + /// A client that reaches public destinations only (SEC-08). pub fn new(service_url: &str) -> Result { - Self::with_transport(service_url, Arc::new(ReqwestTransport::new()?)) + Self::with_transport( + service_url, + Arc::new(SecureTransport::new(Arc::new(ReqwestTransport::new()?))), + ) } + /// A client that also accepts a Service on loopback, for local development. + /// + /// The relaxation reaches loopback and nothing else: a name resolving anywhere else in a + /// private network is still refused. + pub fn for_local_development(service_url: &str) -> Result { + let transport: Arc = Arc::new(SecureTransport::for_local_development( + Arc::new(ReqwestTransport::new()?), + )); + let mut client = Self::with_transport(service_url, transport.clone())?; + client.supporting_transport = transport; + Ok(client) + } + + /// A client over a caller-supplied transport. The caller owns its destination policy: a + /// transport that reaches the network should be wrapped in [`SecureTransport`]. pub fn with_transport( service_url: &str, transport: Arc, ) -> Result { - let supporting_transport = Arc::new(ReqwestTransport::new()?); + let mut partition = [0_u8; 32]; + getrandom::fill(&mut partition) + .map_err(|error| AgentError::InvalidRequest(error.to_string()))?; + let supporting_transport: Arc = + Arc::new(SecureTransport::new(Arc::new(ReqwestTransport::new()?))); Ok(Self { accept_language: None, cache: default_cache(), cache_fallbacks: CacheFallbacks::default(), - cache_partition: "anonymous".to_owned(), + cache_partition: sha256_hex(&partition), service_origin: derive_service_origin(service_url) .map_err(|error| AgentError::InvalidRequest(error.to_string()))?, supporting_transport, @@ -163,7 +190,7 @@ impl ServiceClient { limit: usize, ) -> Result, AgentError> { let page = self - .get_page(Operation::ListCollections, None, representation, limit) + .get_collection_page(Operation::ListCollections, representation, limit) .await?; validate_collections(page) } @@ -239,6 +266,14 @@ impl ServiceClient { } pub async fn continue_collections(&self, next: &str) -> Result, AgentError> { + self.continue_collections_with(next, Duration::ZERO).await + } + + async fn continue_collections_with( + &self, + next: &str, + fallback: Duration, + ) -> Result, AgentError> { let target = resolve_continuation(next, &self.service_origin) .map_err(|error| AgentError::InvalidRequest(error.to_string()))?; let response = self @@ -247,7 +282,7 @@ impl ServiceClient { target, Vec::new(), MAX_RESOURCE_BYTES, - self.cache_fallbacks.collection, + fallback, validate_collection_page_bytes, ) .await?; @@ -257,6 +292,14 @@ impl ServiceClient { pub async fn continue_offerings( &self, next: &str, + ) -> Result, AgentError> { + self.continue_offerings_with(next, Duration::ZERO).await + } + + async fn continue_offerings_with( + &self, + next: &str, + fallback: Duration, ) -> Result, AgentError> { let target = resolve_continuation(next, &self.service_origin) .map_err(|error| AgentError::InvalidRequest(error.to_string()))?; @@ -266,7 +309,7 @@ impl ServiceClient { target, Vec::new(), MAX_RESOURCE_BYTES, - self.cache_fallbacks.offering, + fallback, validate_offering_page_bytes, ) .await?; @@ -280,7 +323,8 @@ impl ServiceClient { options: TraversalOptions, ) -> Result, AgentError> { let mut page = self.list_collections(representation, limit).await?; - self.collect_collections(&mut page, options).await + self.collect_collections(&mut page, options, self.cache_fallbacks.collection) + .await } pub async fn list_all_offerings( @@ -290,7 +334,8 @@ impl ServiceClient { options: TraversalOptions, ) -> Result, AgentError> { let mut page = self.list_offerings(representation, limit).await?; - self.collect_offerings(&mut page, options).await + self.collect_offerings(&mut page, options, self.cache_fallbacks.offering) + .await } pub async fn search_all_offerings( @@ -300,13 +345,15 @@ impl ServiceClient { options: TraversalOptions, ) -> Result, AgentError> { let mut page = self.search_offerings(request, representation).await?; - self.collect_offerings(&mut page, options).await + self.collect_offerings(&mut page, options, self.cache_fallbacks.search) + .await } async fn collect_collections( &self, page: &mut Page, options: TraversalOptions, + fallback: Duration, ) -> Result, AgentError> { let (maximum_items, maximum_pages) = traversal_bounds(options)?; let mut result = Vec::new(); @@ -316,7 +363,7 @@ impl ServiceClient { return Ok(result); } if page_number + 1 < maximum_pages { - *page = self.continue_collections(&page.next).await?; + *page = self.continue_collections_with(&page.next, fallback).await?; } } Ok(result) @@ -326,6 +373,7 @@ impl ServiceClient { &self, page: &mut OfferingPage, options: TraversalOptions, + fallback: Duration, ) -> Result, AgentError> { let (maximum_items, maximum_pages) = traversal_bounds(options)?; let mut result = Vec::new(); @@ -335,31 +383,25 @@ impl ServiceClient { return Ok(result); } if page_number + 1 < maximum_pages { - *page = self.continue_offerings(&page.next).await?; + *page = self.continue_offerings_with(&page.next, fallback).await?; } } Ok(result) } - async fn get_page( + async fn get_collection_page( &self, operation: Operation, - id: Option<&str>, representation: Representation, limit: usize, - ) -> Result, AgentError> { + ) -> Result, AgentError> { let data = self - .get_page_bytes(operation, id, representation, limit) + .get_page_bytes(operation, None, representation, limit) .await?; - let kind = if matches!( - operation, - Operation::ListCollections | Operation::SearchCollections - ) { - "collection-page" - } else { - "offering-page" - }; - Ok(parse_page(&normalize_agent_response(&data, kind)?)?) + Ok(parse_page(&normalize_agent_response( + &data, + "collection-page", + )?)?) } async fn get_offering_page( @@ -447,11 +489,7 @@ impl ServiceClient { target .query_pairs_mut() .append_pair("representation", representation_name(representation)); - let fallback = if operation == Operation::SearchCollections { - self.cache_fallbacks.collection - } else { - self.cache_fallbacks.offering - }; + let fallback = self.cache_fallbacks.search; let validator = response_validator(operation); Ok(self .request_cached( @@ -489,7 +527,11 @@ impl ServiceClient { fallback: Duration, validate: fn(&[u8]) -> Result<(), AgentError>, ) -> Result { - let key = self.cache_key(method, target.as_str(), &body); + let key = format!( + "{}\n{}", + self.cache_key(method, target.as_str(), &body), + fallback.as_nanos() + ); let request_origin = derive_service_origin(target.as_str()) .map_err(|error| AgentError::InvalidRequest(error.to_string()))?; let cached = self.cache.get(&key).map_err(AgentError::Cache)?; @@ -505,7 +547,7 @@ impl ServiceClient { } let mut conditional = BTreeMap::new(); let mut request_target = target; - if let Some(record) = &cached { + if let Some(record) = cached.as_ref().filter(|_| method == "GET") { if let Ok(cached_target) = Url::parse(&record.final_url) { if derive_service_origin(cached_target.as_str()) .ok() @@ -523,7 +565,14 @@ impl ServiceClient { } } let raw = self - .request_raw(method, request_target, body, conditional, &request_origin) + .request_raw( + method, + request_target, + body, + conditional, + &request_origin, + maximum_bytes, + ) .await?; if raw.status == 304 { let Some(mut record) = cached else { @@ -585,8 +634,10 @@ impl ServiceClient { mut body: Vec, conditional: BTreeMap, redirect_origin: &str, + maximum_bytes: usize, ) -> Result { - for redirect in 0..=MAX_REDIRECTS { + let mut redirect = 0_usize; + loop { let mut headers = BTreeMap::from([("accept".to_owned(), MEDIA_TYPE.to_owned())]); if let Some(language) = &self.accept_language { headers.insert("accept-language".to_owned(), language.clone()); @@ -597,12 +648,15 @@ impl ServiceClient { headers.extend(conditional.clone()); let response = self .transport - .send(HttpRequest { - body: body.clone(), - headers, - method: method.to_owned(), - url: target.to_string(), - }) + .send_limited( + HttpRequest { + body: body.clone(), + headers, + method: method.to_owned(), + url: target.to_string(), + }, + maximum_bytes, + ) .await?; if matches!(response.status, 301 | 302 | 303 | 307 | 308) { if redirect == MAX_REDIRECTS { @@ -628,6 +682,7 @@ impl ServiceClient { body.clear(); } target = next; + redirect += 1; continue; } return Ok(RawResponse { @@ -637,9 +692,6 @@ impl ServiceClient { status: response.status, }); } - Err(AgentError::InvalidResponse( - "ODP response exceeded its redirect limit".to_owned(), - )) } fn cache_key(&self, method: &str, target: &str, body: &[u8]) -> String { @@ -676,6 +728,10 @@ impl ServiceClient { .body) } + pub(crate) const fn cache_fallbacks(&self) -> CacheFallbacks { + self.cache_fallbacks + } + pub(crate) async fn supporting_json( &self, target: &str, @@ -683,23 +739,64 @@ impl ServiceClient { accept: &str, media_types: &[&str], maximum_bytes: usize, + fallback: Duration, ) -> Result { + Ok(self + .supporting_resource( + target, + resource_class, + accept, + media_types, + maximum_bytes, + fallback, + ) + .await? + .value) + } + + pub(crate) async fn supporting_resource( + &self, + target: &str, + resource_class: &str, + accept: &str, + media_types: &[&str], + maximum_bytes: usize, + fallback: Duration, + ) -> Result { let mut current = Url::parse(target).map_err(|error| AgentError::InvalidRequest(error.to_string()))?; - if current.scheme() != "https" || current.host_str().is_none() { + if current.scheme() != "https" + || current.host_str().is_none() + || !current.username().is_empty() + || current.password().is_some() + { return Err(AgentError::InvalidRequest( "ODP supporting document URL must use HTTPS".to_owned(), )); } - let key = format!("anonymous:{resource_class}\nGET\n{target}\n{accept}"); + let key = format!( + "{}:{resource_class}\nGET\n{target}\n{accept}", + self.cache_partition + ); let cached = self.cache.get(&key).map_err(AgentError::Cache)?; let now = SystemTime::now(); if let Some(record) = &cached { if now < record.expires_at { - return decode_json_object(&record.body); + return supporting_document(&record.body, &record.final_url, resource_class); + } + } + let mut redirects = 0_usize; + if let Some(record) = &cached { + if let Ok(final_url) = Url::parse(&record.final_url) { + if final_url.origin() == current.origin() + && final_url.username().is_empty() + && final_url.password().is_none() + { + current = final_url; + } } } - for redirects in 0..=MAX_REDIRECTS { + loop { let mut headers = BTreeMap::from([("accept".to_owned(), accept.to_owned())]); if let Some(record) = &cached { if let Some(etag) = &record.etag { @@ -711,12 +808,15 @@ impl ServiceClient { } let response = self .supporting_transport - .send(HttpRequest { - body: Vec::new(), - headers, - method: "GET".to_owned(), - url: current.to_string(), - }) + .send_limited( + HttpRequest { + body: Vec::new(), + headers, + method: "GET".to_owned(), + url: current.to_string(), + }, + maximum_bytes, + ) .await?; if matches!(response.status, 301 | 302 | 303 | 307 | 308) { if redirects == MAX_REDIRECTS { @@ -732,12 +832,17 @@ impl ServiceClient { let next = current .join(location) .map_err(|error| AgentError::InvalidResponse(error.to_string()))?; - if next.scheme() != "https" || next.host_str().is_none() { + if next.origin() != current.origin() + || !next.username().is_empty() + || next.password().is_some() + { return Err(AgentError::InvalidResponse( - "ODP supporting document redirect must use HTTPS".to_owned(), + "ODP supporting document redirect changed origin or included credentials" + .to_owned(), )); } current = next; + redirects += 1; continue; } if response.status == 304 { @@ -751,14 +856,14 @@ impl ServiceClient { self.cache.delete(&key).map_err(AgentError::Cache)?; } else { record.expires_at = - revalidated_expiration(&response.headers, &record, Duration::ZERO, now); + revalidated_expiration(&response.headers, &record, fallback, now); record.stored_at = now; record.final_url = current.to_string(); self.cache .set(key.clone(), record.clone()) .map_err(AgentError::Cache)?; } - return decode_json_object(&record.body); + return supporting_document(&record.body, &record.final_url, resource_class); } if !(200..300).contains(&response.status) { return Err(AgentError::Request { @@ -766,7 +871,13 @@ impl ServiceClient { status: response.status, }); } - if response.body.len() > maximum_bytes { + let declared = response + .headers + .get("content-length") + .and_then(|value| value.trim().parse::().ok()); + if declared.is_some_and(|value| value > maximum_bytes) + || response.body.len() > maximum_bytes + { return Err(AgentError::InvalidResponse( "ODP supporting document exceeds its byte limit".to_owned(), )); @@ -784,8 +895,8 @@ impl ServiceClient { "ODP supporting document has an unsupported media type".to_owned(), )); } - let document = decode_json_object(&response.body)?; - if !cacheable("GET", &response.headers, Duration::ZERO) { + let document = supporting_document(&response.body, current.as_str(), resource_class)?; + if !cacheable("GET", &response.headers, fallback) { self.cache.delete(&key).map_err(AgentError::Cache)?; } else { self.cache @@ -794,7 +905,7 @@ impl ServiceClient { CacheRecord { body: response.body, etag: response.headers.get("etag").cloned(), - expires_at: expiration(&response.headers, Duration::ZERO, now), + expires_at: expiration(&response.headers, fallback, now), final_url: current.to_string(), last_modified: response.headers.get("last-modified").cloned(), status: response.status, @@ -805,12 +916,29 @@ impl ServiceClient { } return Ok(document); } - Err(AgentError::InvalidResponse( - "ODP supporting document exceeded its redirect limit".to_owned(), - )) } } +pub(crate) struct SupportingDocument { + pub value: serde_json::Value, + pub final_url: String, + pub bytes: usize, +} + +fn supporting_document( + body: &[u8], + final_url: &str, + resource_class: &str, +) -> Result { + let value = decode_json_object(body)?; + odp_core::validate_json_depth(&value, if resource_class == "openapi" { 32 } else { 16 })?; + Ok(SupportingDocument { + value, + final_url: final_url.to_owned(), + bytes: body.len(), + }) +} + fn sha256_hex(data: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let digest = Sha256::digest(data); @@ -822,22 +950,10 @@ fn sha256_hex(data: &[u8]) -> String { encoded } -fn parse_agent_collection(data: &[u8]) -> Result { - parse_collection(&normalize_agent_response(data, "collection")?) -} - -fn parse_agent_offering(data: &[u8]) -> Result { - parse_offering(&normalize_agent_response(data, "offering")?) -} - fn parse_agent_collection_page(data: &[u8]) -> Result, ParseError> { parse_page(&normalize_agent_response(data, "collection-page")?) } -fn parse_agent_offering_search_response(data: &[u8]) -> Result, ParseError> { - parse_offering_search_response(&normalize_agent_response(data, "offering-page")?) -} - fn validate_collections(page: Page) -> Result, AgentError> { for collection in &page.items { let mut inherited = collection.clone(); @@ -934,34 +1050,21 @@ struct RawResponse { } fn consume(response: RawResponse, maximum_bytes: usize) -> Result { - if response.body.len() > maximum_bytes { - return Err(AgentError::InvalidResponse( - "ODP response exceeds its byte limit".to_owned(), - )); - } - if !(200..300).contains(&response.status) { - let problem = normalize_agent_response(&response.body, "problem") - .unwrap_or_else(|_| response.body.clone()); - let message = parse_problem_response(&problem, response.status) - .map(|problem| { - if problem.detail.is_empty() { - problem.title - } else { - problem.detail - } - }) - .unwrap_or_else(|_| String::from_utf8_lossy(&response.body).into_owned()); + let failed = !(200..300).contains(&response.status); + // ERR-21 gives a Problem Details response its own, much smaller limit. + let limit = if failed { + MAX_PROBLEM_BYTES + } else { + maximum_bytes + }; + require_within_limit(&response, limit)?; + if failed { return Err(AgentError::Request { - message, + message: failure_message(&response), status: response.status, }); } - let content_type = response - .headers - .get("content-type") - .map(|value| value.split(';').next().unwrap_or_default().trim()) - .unwrap_or_default(); - if !content_type.eq_ignore_ascii_case(MEDIA_TYPE) { + if !media_type_essence(&response.headers).eq_ignore_ascii_case(MEDIA_TYPE) { return Err(AgentError::InvalidResponse(format!( "ODP response must use {MEDIA_TYPE}" ))); @@ -969,6 +1072,74 @@ fn consume(response: RawResponse, maximum_bytes: usize) -> Result Result<(), AgentError> { + let declared = response + .headers + .get("content-length") + .and_then(|value| value.trim().parse::().ok()); + if declared.is_some_and(|value| value > limit) || response.body.len() > limit { + return Err(AgentError::InvalidResponse( + "ODP response exceeds its byte limit".to_owned(), + )); + } + Ok(()) +} + +/// Describes a failed request without repeating whatever the response happened to contain. +/// +/// Only a structured field of an RFC 9457 document is quoted, and only after the control characters +/// that would let it forge a log line are removed. A body of any other media type says nothing +/// about the request that this Agent should carry into its own errors. +fn failure_message(response: &RawResponse) -> String { + const ABSENT: &str = "the Service supplied no Problem Details"; + if !media_type_essence(&response.headers).eq_ignore_ascii_case(PROBLEM_MEDIA_TYPE) { + return ABSENT.to_owned(); + } + let Ok(problem) = normalize_agent_response(&response.body, "problem") + .and_then(|document| parse_problem_response(&document, response.status)) + else { + return ABSENT.to_owned(); + }; + let detail = printable(if problem.detail.is_empty() { + &problem.title + } else { + &problem.detail + }); + if detail.is_empty() { + ABSENT.to_owned() + } else { + detail + } +} + +fn media_type_essence(headers: &BTreeMap) -> &str { + headers + .get("content-type") + .map(|value| value.split(';').next().unwrap_or_default().trim()) + .unwrap_or_default() +} + +/// Flattens a quoted string so it cannot forge a log line: a control character becomes a space and +/// runs of whitespace collapse. Its length needs no bound here, because a document whose `detail` +/// runs past the ERR-04 limit is not a Problem Details document and is never quoted at all. +fn printable(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + fn expiration( headers: &BTreeMap, fallback: Duration, @@ -1278,4 +1449,83 @@ mod tests { .is_err() ); } + + /// A supporting document is a JSON object; an array or a scalar is not one. + #[test] + fn refuses_a_supporting_document_that_is_not_an_object() { + for body in [b"[1,2]".as_slice(), b"\"text\"", b"7"] { + let error = decode_json_object(body).unwrap_err(); + assert!(error.to_string().contains("JSON object"), "{error}"); + } + assert!(decode_json_object(b"{}").is_ok()); + } + + /// ERR-14: a Problem Details body with nothing readable in it is not quoted back. + #[test] + fn quotes_nothing_from_a_problem_with_no_readable_text() { + let absent = "the Service supplied no Problem Details"; + assert_eq!( + failure_message(&problem_response( + br#"{"odp_version":"1.0","status":500,"title":"\u0001\u0002"}"#, + PROBLEM_MEDIA_TYPE, + )), + absent, + "a title of control characters reads as nothing" + ); + assert_eq!( + failure_message(&problem_response( + "{\"detail\":\"\\u{a0}\",\"odp_version\":\"1.0\",\"status\":500,\"title\":\"Oh\"}" + .as_bytes(), + PROBLEM_MEDIA_TYPE, + )), + absent, + "a detail of nothing but spacing reads as nothing" + ); + assert_eq!( + failure_message(&problem_response(b"not json", PROBLEM_MEDIA_TYPE)), + absent + ); + assert_eq!( + failure_message(&problem_response(b"{}", "text/plain")), + absent + ); + } + + fn problem_response(body: &[u8], media_type: &str) -> RawResponse { + RawResponse { + body: body.to_vec(), + final_url: "https://plants.example/odp/offerings".to_owned(), + headers: BTreeMap::from([("content-type".to_owned(), media_type.to_owned())]), + status: 500, + } + } + + /// SEC-05: a supporting document is fetched over HTTPS, and the check precedes the request. + #[tokio::test] + async fn refuses_a_supporting_target_that_is_not_https() { + let transport = Arc::new(MockTransport { + responses: Mutex::new(VecDeque::new()), + }); + let client = ServiceClient::with_transport("https://plants.example", transport.clone()) + .unwrap() + .with_supporting_transport(transport.clone()); + + for target in [ + "http://localhost:8080/schema.json", + "http://plants.example/s.json", + ] { + let error = client + .supporting_json( + target, + "attribute-schema", + "application/schema+json", + &["application/schema+json"], + 1024, + Duration::from_secs(60), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("HTTPS"), "{target}: {error}"); + } + } } diff --git a/crates/odp-agent/src/destinations.rs b/crates/odp-agent/src/destinations.rs new file mode 100644 index 0000000..e6e3300 --- /dev/null +++ b/crates/odp-agent/src/destinations.rs @@ -0,0 +1,182 @@ +//! SEC-08: where an Agent is willing to connect. +//! +//! A Service Origin and every reference inside a Service's documents are written by somebody else. +//! Before a request is sent, the destination is resolved and every address it resolves to is +//! judged against [`odp_core::is_public`], so a name a third party controls cannot point an Agent +//! at an address its own network treats as internal. + +use std::{ + net::{IpAddr, SocketAddr, ToSocketAddrs}, + sync::Arc, +}; + +use async_trait::async_trait; +use odp_core::is_public; +use odp_directory::{HttpRequest, HttpResponse, Transport, TransportError}; +use url::Url; + +/// A transport that resolves each destination and refuses one the public internet does not route. +/// +/// `allow_local_network` exists for local development, where a Service runs on loopback. It permits +/// a loopback host and nothing else, so it cannot be used to reach the rest of a private network. +pub struct SecureTransport { + allow_local_network: bool, + inner: Arc, +} + +impl SecureTransport { + #[must_use] + pub fn new(inner: Arc) -> Self { + Self { + allow_local_network: false, + inner, + } + } + + #[must_use] + pub fn for_local_development(inner: Arc) -> Self { + Self { + allow_local_network: true, + inner, + } + } +} + +#[async_trait] +impl Transport for SecureTransport { + async fn send(&self, request: HttpRequest) -> Result { + self.send_limited(request, 2 * 1024 * 1024).await + } + + async fn send_limited( + &self, + request: HttpRequest, + maximum_bytes: usize, + ) -> Result { + let addresses = public_destinations(&request.url, self.allow_local_network).await?; + self.inner.send_to(request, &addresses, maximum_bytes).await + } +} + +/// Resolves a request target and judges every address it answers with, not only the first. +pub async fn require_public_destination( + target: &str, + allow_local_network: bool, +) -> Result<(), TransportError> { + public_destinations(target, allow_local_network) + .await + .map(|_| ()) +} + +async fn public_destinations( + target: &str, + allow_local_network: bool, +) -> Result, TransportError> { + let url = Url::parse(target).map_err(|error| TransportError { + message: error.to_string(), + })?; + let host = url.host_str().ok_or_else(|| TransportError { + message: "ODP request target must name a host".to_owned(), + })?; + let port = url.port_or_known_default().unwrap_or(443); + let addresses = resolve(host, port).await?; + if addresses.is_empty() { + return Err(TransportError { + message: format!("ODP request host {host} did not resolve"), + }); + } + let local = is_local_development_host(host); + for address in &addresses { + if local && allow_local_network { + if !address.is_loopback() { + return Err(TransportError { + message: "ODP local-development host resolved outside the loopback network" + .to_owned(), + }); + } + } else if !is_public(*address) { + return Err(TransportError { + message: format!("ODP request host {host} resolved to a non-public address"), + }); + } + } + Ok(addresses + .into_iter() + .map(|address| SocketAddr::new(address, port)) + .collect()) +} + +fn is_local_development_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host == "127.0.0.1" + || host == "::1" + || host == "[::1]" +} + +async fn resolve(host: &str, port: u16) -> Result, TransportError> { + // A bracketed IPv6 literal, and any literal, needs no name service at all. + let literal = host.trim_start_matches('[').trim_end_matches(']'); + if let Ok(address) = literal.parse::() { + return Ok(vec![address]); + } + let owned = host.to_owned(); + tokio::task::spawn_blocking(move || { + (owned.as_str(), port) + .to_socket_addrs() + .map(|addresses| addresses.map(|address| address.ip()).collect::>()) + }) + .await + .map_err(|error| TransportError { + message: error.to_string(), + })? + .map_err(|error| TransportError { + message: format!("ODP request host did not resolve: {error}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn refuses_a_literal_destination_the_internet_does_not_route() { + for target in [ + "https://169.254.169.254/latest/meta-data", + "https://127.0.0.1/.well-known/odp", + "https://[::1]/.well-known/odp", + "https://10.0.0.1/", + ] { + let error = require_public_destination(target, false).await.unwrap_err(); + assert!( + error.message.contains("non-public"), + "{target}: {}", + error.message + ); + } + } + + #[tokio::test] + async fn permits_loopback_only_when_local_development_is_allowed() { + require_public_destination("http://127.0.0.1:8080/.well-known/odp", true) + .await + .unwrap(); + require_public_destination("https://[::1]/.well-known/odp", true) + .await + .unwrap(); + // A private address that is not loopback stays refused even then. + assert!( + require_public_destination("https://10.0.0.1/", true) + .await + .is_err() + ); + } + + #[tokio::test] + async fn refuses_a_target_without_a_host() { + assert!( + require_public_destination("not a url", false) + .await + .is_err() + ); + } +} diff --git a/crates/odp-agent/src/details.rs b/crates/odp-agent/src/details.rs index 5762708..a28d323 100644 --- a/crates/odp-agent/src/details.rs +++ b/crates/odp-agent/src/details.rs @@ -176,6 +176,9 @@ impl ServiceClient { "application/vnd.oai.openapi+json;version=3.1, application/json;q=0.9", &["application/vnd.oai.openapi+json", "application/json"], MAXIMUM_OPENAPI_BYTES, + // CCH-02 names no fallback for an OpenAPI document, so one is only cached when the + // Service says how long it stays fresh. + std::time::Duration::ZERO, ) .await?; let version = document @@ -343,4 +346,48 @@ mod tests { Some("https://plants.example/downloads/plant.pdf") ); } + + /// The Offering schema keeps these shapes out of a parsed document, so they are checked + /// here: the helpers also run against a Service Origin, which no schema validates. + #[test] + fn refuses_an_action_target_that_is_not_an_http_url() { + for reference in ["mailto:sales@plants.example", "file:///etc/passwd"] { + let error = resolve_http_reference(reference, "https://plants.example").unwrap_err(); + assert!(error.to_string().contains("HTTP"), "{reference}: {error}"); + } + } + + #[test] + fn refuses_a_supporting_document_that_is_not_served_over_https() { + let error = + resolve_https_reference("http://plants.example/s.json", "https://plants.example") + .unwrap_err(); + assert!(error.to_string().contains("HTTPS"), "{error}"); + } + + #[test] + fn refuses_a_reference_it_has_no_base_for() { + let error = resolve_http_reference("/plants", "not a base").unwrap_err(); + assert!(matches!(error, AgentError::InvalidRequest(_)), "{error}"); + } + + /// An Action describing no target at all is passed over quietly: there is nothing to call. + #[test] + fn passes_over_an_action_that_names_no_target() { + let action = Action { + authentication: AuthenticationRequirement::NotRequired, + description: "Ask us".to_owned(), + http: None, + id: "ask".to_owned(), + openapi: None, + rel: ActionRelation::Other("contact".to_owned()), + }; + let (actions, issues) = normalize_actions( + std::slice::from_ref(&action), + "https://plants.example", + "https://plants.example/openapi.json", + ); + assert!(actions.is_empty()); + assert!(issues.is_empty()); + } } diff --git a/crates/odp-agent/src/lib.rs b/crates/odp-agent/src/lib.rs index bc977c0..c7b4027 100644 --- a/crates/odp-agent/src/lib.rs +++ b/crates/odp-agent/src/lib.rs @@ -4,6 +4,7 @@ mod agent; mod cache; mod capabilities; mod client; +mod destinations; mod details; mod schema; @@ -11,4 +12,5 @@ pub use agent::*; pub use cache::*; pub use capabilities::*; pub use client::*; +pub use destinations::*; pub use details::*; diff --git a/crates/odp-agent/src/schema.rs b/crates/odp-agent/src/schema.rs index 5f8b528..97661b1 100644 --- a/crates/odp-agent/src/schema.rs +++ b/crates/odp-agent/src/schema.rs @@ -11,7 +11,15 @@ const MAXIMUM_DEPTH: usize = 8; const MAXIMUM_DOCUMENT_BYTES: usize = 262_144; const MAXIMUM_DOCUMENTS: usize = 16; const MAXIMUM_GRAPH_BYTES: usize = 1_048_576; -const STANDARD_VOCABULARY: &str = "https://json-schema.org/draft/2020-12/vocab/"; +const VOCABULARIES: &[&str] = &[ + "core", + "applicator", + "unevaluated", + "validation", + "meta-data", + "format-annotation", + "content", +]; pub(crate) async fn resolve_schema( client: &ServiceClient, @@ -37,27 +45,47 @@ pub(crate) async fn resolve_schema( "ODP Attribute Schema graph exceeds eight reference levels".to_owned(), )); } - let document = client - .supporting_json( + let resource = client + .supporting_resource( url.as_str(), "attribute-schema", "application/schema+json", &["application/schema+json"], MAXIMUM_DOCUMENT_BYTES, + client.cache_fallbacks().schema, ) .await?; + let mut document = resource.value; require_schema(&document)?; - graph_bytes = graph_bytes.saturating_add( - serde_json::to_vec(&document) - .map_err(|error| AgentError::InvalidResponse(error.to_string()))? - .len(), - ); + graph_bytes = graph_bytes.saturating_add(resource.bytes); if graph_bytes > MAXIMUM_GRAPH_BYTES { return Err(AgentError::InvalidResponse( "ODP Attribute Schema graph exceeds its byte limit".to_owned(), )); } - for reference_url in schema_references(&document, &url)? { + let final_url = Url::parse(&resource.final_url) + .map_err(|error| AgentError::InvalidResponse(error.to_string()))?; + if let Some(object) = document.as_object_mut() { + if object.get("$id").is_some_and(|id| !id.is_string()) { + return Err(AgentError::InvalidResponse( + "Schema $id must be a string".to_owned(), + )); + } + let identifier = object.get("$id").and_then(Value::as_str).unwrap_or(""); + let identifier = final_url + .join(identifier) + .map_err(|error| AgentError::InvalidResponse(error.to_string()))?; + if identifier + .fragment() + .is_some_and(|fragment| !fragment.is_empty()) + { + return Err(AgentError::InvalidResponse( + "Schema $id must not contain a fragment".to_owned(), + )); + } + object.insert("$id".to_owned(), Value::String(identifier.to_string())); + } + for reference_url in schema_references(&document, &final_url)? { pending.push_back((reference_url, depth + 1)); } documents.insert(url.to_string(), document); @@ -90,7 +118,130 @@ pub(crate) async fn resolve_schema( .map(|value| validator.is_valid(&value)) .unwrap_or(false) }); - Ok((root.clone(), valid)) + Ok((bundle(root, &documents)?, valid)) +} + +fn bundle(root: &Value, documents: &BTreeMap) -> Result { + let mut bundled = root.clone(); + let root_id = root.get("$id").and_then(Value::as_str).unwrap_or_default(); + let aliases = documents + .iter() + .map(|(retrieval, value)| { + ( + retrieval.clone(), + value + .get("$id") + .and_then(Value::as_str) + .unwrap_or(retrieval) + .to_owned(), + ) + }) + .collect::>(); + rewrite_references( + &mut bundled, + &Url::parse(root_id).map_err(|error| AgentError::InvalidResponse(error.to_string()))?, + &aliases, + )?; + let object = bundled.as_object_mut().ok_or_else(|| { + AgentError::InvalidResponse("Attribute Schema must be an object".to_owned()) + })?; + let definitions = object + .entry("$defs") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| AgentError::InvalidResponse("Schema $defs must be an object".to_owned()))?; + let mut resources = BTreeSet::from([root_id.to_owned()]); + for (retrieval_url, document) in documents { + let identifier = document + .get("$id") + .and_then(Value::as_str) + .unwrap_or(retrieval_url); + if resources.insert(identifier.to_owned()) { + let mut document = document.clone(); + rewrite_references( + &mut document, + &Url::parse(identifier) + .map_err(|error| AgentError::InvalidResponse(error.to_string()))?, + &aliases, + )?; + insert_definition(definitions, document); + } + } + Ok(bundled) +} + +fn rewrite_references( + value: &mut Value, + base: &Url, + aliases: &BTreeMap, +) -> Result<(), AgentError> { + let Some(values) = value.as_object_mut() else { + return Ok(()); + }; + let base = match values.get("$id").and_then(Value::as_str) { + Some(id) => base + .join(id) + .map_err(|error| AgentError::InvalidResponse(error.to_string()))?, + None => base.clone(), + }; + if let Some(reference) = values.get_mut("$ref") { + if let Some(text) = reference.as_str() { + let mut target = base + .join(text) + .map_err(|error| AgentError::InvalidResponse(error.to_string()))?; + let fragment = target.fragment().map(str::to_owned); + target.set_fragment(None); + if let Some(canonical) = aliases.get(target.as_str()) { + target = Url::parse(canonical) + .map_err(|error| AgentError::InvalidResponse(error.to_string()))?; + } + target.set_fragment(fragment.as_deref()); + *reference = Value::String(target.to_string()); + } + } + for (name, child) in values { + match name.as_str() { + "$defs" | "properties" | "patternProperties" | "dependentSchemas" => { + if let Some(map) = child.as_object_mut() { + for child in map.values_mut() { + rewrite_references(child, &base, aliases)?; + } + } + } + "allOf" | "anyOf" | "oneOf" | "prefixItems" => { + if let Some(array) = child.as_array_mut() { + for child in array { + rewrite_references(child, &base, aliases)?; + } + } + } + "not" + | "if" + | "then" + | "else" + | "items" + | "contains" + | "additionalProperties" + | "propertyNames" + | "unevaluatedItems" + | "unevaluatedProperties" + | "contentSchema" => rewrite_references(child, &base, aliases)?, + _ => {} + } + } + Ok(()) +} + +fn insert_definition(definitions: &mut Map, value: Value) { + let mut index = definitions.len(); + loop { + let name = format!("odp_resource_{index}"); + if !definitions.contains_key(&name) { + definitions.insert(name, value); + return; + } + index += 1; + } } fn document_url(value: &str) -> Result { @@ -117,32 +268,32 @@ fn require_schema(document: &Value) -> Result<(), AgentError> { } let mut pending = vec![document]; while let Some(value) = pending.pop() { - match value { - Value::Array(values) => pending.extend(values), - Value::Object(values) => { - if let Some(reference) = values.get("$dynamicRef") { - if reference - .as_str() - .is_none_or(|reference| !reference.starts_with('#')) - { - return Err(AgentError::InvalidResponse( - "ODP Attribute Schema $dynamicRef must be a fragment-only reference" - .to_owned(), - )); - } + if let Value::Object(values) = value { + if let Some(reference) = values.get("$dynamicRef") { + if reference + .as_str() + .is_none_or(|reference| !reference.starts_with('#')) + { + return Err(AgentError::InvalidResponse( + "ODP Attribute Schema $dynamicRef must be a fragment-only reference" + .to_owned(), + )); } - if let Some(vocabulary) = values.get("$vocabulary").and_then(Value::as_object) { - for (url, required) in vocabulary { - if required == &Value::Bool(true) && !url.starts_with(STANDARD_VOCABULARY) { - return Err(AgentError::InvalidResponse(format!( - "ODP Attribute Schema requires unsupported vocabulary {url}" - ))); - } + } + if let Some(vocabulary) = values.get("$vocabulary").and_then(Value::as_object) { + for (url, required) in vocabulary { + if required == &Value::Bool(true) + && !url + .strip_prefix("https://json-schema.org/draft/2020-12/vocab/") + .is_some_and(|name| VOCABULARIES.contains(&name)) + { + return Err(AgentError::InvalidResponse(format!( + "ODP Attribute Schema requires unsupported vocabulary {url}" + ))); } } - pending.extend(values.values()); } - _ => {} + pending.extend(subschemas(values)); } } Ok(()) @@ -153,34 +304,64 @@ fn schema_references(document: &Value, retrieval_url: &Url) -> Result, let mut local_resources = BTreeSet::from([retrieval_url.to_string()]); let mut pending = vec![(document, retrieval_url.clone())]; while let Some((value, inherited_base)) = pending.pop() { - match value { - Value::Array(values) => { - pending.extend(values.iter().map(|value| (value, inherited_base.clone()))); - } - Value::Object(values) => { - let mut base = inherited_base; - if let Some(identifier) = values.get("$id").and_then(Value::as_str) { - base = base - .join(identifier) - .map_err(|error| AgentError::InvalidResponse(error.to_string()))?; - base.set_fragment(None); - local_resources.insert(base.to_string()); - } - add_reference(values, "$ref", &base, &mut references)?; - pending.extend( - values - .iter() - .filter(|(name, _)| *name != "$ref") - .map(|(_, value)| (value, base.clone())), - ); + if let Value::Object(values) = value { + let mut base = inherited_base; + if let Some(identifier) = values.get("$id").and_then(Value::as_str) { + base = base + .join(identifier) + .map_err(|error| AgentError::InvalidResponse(error.to_string()))?; + base.set_fragment(None); + local_resources.insert(base.to_string()); } - _ => {} + add_reference(values, "$ref", &base, &mut references)?; + pending.extend( + subschemas(values) + .into_iter() + .map(|value| (value, base.clone())), + ); } } references.retain(|reference| !local_resources.contains(reference.as_str())); Ok(references) } +fn subschemas(values: &Map) -> Vec<&Value> { + let mut children = Vec::new(); + for name in [ + "$defs", + "properties", + "patternProperties", + "dependentSchemas", + ] { + if let Some(Value::Object(map)) = values.get(name) { + children.extend(map.values()); + } + } + for name in ["allOf", "anyOf", "oneOf", "prefixItems"] { + if let Some(Value::Array(array)) = values.get(name) { + children.extend(array); + } + } + for name in [ + "not", + "if", + "then", + "else", + "items", + "contains", + "additionalProperties", + "propertyNames", + "unevaluatedItems", + "unevaluatedProperties", + "contentSchema", + ] { + if let Some(value) = values.get(name) { + children.push(value); + } + } + children +} + fn add_reference( values: &Map, name: &str, diff --git a/crates/odp-agent/tests/action_conformance.rs b/crates/odp-agent/tests/action_conformance.rs new file mode 100644 index 0000000..d7e3df3 --- /dev/null +++ b/crates/odp-agent/tests/action_conformance.rs @@ -0,0 +1,178 @@ +//! Resolving an Action: the target an Agent would call, and the documents describing it. + +mod support; + +use odp_agent::AgentError; +use support::{ODP_JSON, SCHEMA_JSON, SERVICE_DOCUMENT, Stub, client, response}; + +const OPENAPI_JSON: &str = "application/vnd.oai.openapi+json"; + +/// A Service Document naming a Service-wide OpenAPI document. +const DOCUMENT_WITH_OPENAPI: &[u8] = br#"{"description":"Plants","http":{"endpoint_base":"/odp","openapi":{"url":"https://plants.example/openapi.json"}},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"}]}"#; + +const OPENAPI_OFFERING: &[u8] = br#"{"actions":[{"authentication":"not-required","id":"buy","openapi":{"operation_id":"purchasePlant"},"rel":"purchase"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + +/// A Service serving the Service Document, one Offering, and one supporting document. +fn serving( + document: &'static [u8], + offering: &'static [u8], + supporting: Vec, + media: &'static str, +) -> std::sync::Arc { + Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else if request.url.contains("/odp/") { + response(200, offering, ODP_JSON) + } else { + response(200, &supporting, media) + }) + }) +} + +// -- OpenAPI Actions ---------------------------------------------------------------------- + +/// OFR-66: the named operation is read out of the OpenAPI document, and nothing is invoked. +#[tokio::test] +async fn resolves_an_openapi_operation_by_its_identifier() { + let openapi = br#"{"openapi":"3.1.0","info":{"title":"Plants","version":"1"},"paths":{"/buy":{"post":{"operationId":"purchasePlant","summary":"Buy"},"get":{"operationId":"listPlants"}}}}"#; + let stub = serving( + DOCUMENT_WITH_OPENAPI, + OPENAPI_OFFERING, + openapi.to_vec(), + OPENAPI_JSON, + ); + + let resolved = client(&stub) + .resolve_action("plant-1", "buy") + .await + .unwrap(); + assert_eq!( + resolved + .operation + .as_ref() + .and_then(|value| value.get("summary")) + .and_then(|value| value.as_str()), + Some("Buy") + ); + assert!(resolved.openapi_document.is_some()); + assert!(resolved.request_schema.is_none()); +} + +/// ODP names OpenAPI 3.1, so a 3.0 document describes a contract this Agent cannot read. +#[tokio::test] +async fn refuses_an_openapi_document_of_another_version() { + let openapi = br#"{"openapi":"3.0.3","info":{"title":"Plants","version":"1"},"paths":{"/buy":{"post":{"operationId":"purchasePlant"}}}}"#; + let stub = serving( + DOCUMENT_WITH_OPENAPI, + OPENAPI_OFFERING, + openapi.to_vec(), + OPENAPI_JSON, + ); + + let error = client(&stub) + .resolve_action("plant-1", "buy") + .await + .unwrap_err(); + assert!(error.to_string().contains("OpenAPI 3.1"), "{error}"); +} + +#[tokio::test] +async fn refuses_an_openapi_document_with_no_paths() { + let openapi = br#"{"openapi":"3.1.0","info":{"title":"Plants","version":"1"}}"#; + let stub = serving( + DOCUMENT_WITH_OPENAPI, + OPENAPI_OFFERING, + openapi.to_vec(), + OPENAPI_JSON, + ); + + let error = client(&stub) + .resolve_action("plant-1", "buy") + .await + .unwrap_err(); + assert!(error.to_string().contains("paths"), "{error}"); +} + +/// An `operationId` is unique across an OpenAPI document, so zero or two matches is unusable. +#[tokio::test] +async fn refuses_an_operation_identifier_that_does_not_resolve_exactly_once() { + for paths in [ + r#"{"/other":{"post":{"operationId":"somethingElse"}}}"#, + r#"{"/buy":{"post":{"operationId":"purchasePlant"}},"/buy-again":{"put":{"operationId":"purchasePlant"}}}"#, + ] { + let openapi = format!( + r#"{{"openapi":"3.1.0","info":{{"title":"P","version":"1"}},"paths":{paths}}}"# + ); + let stub = serving( + DOCUMENT_WITH_OPENAPI, + OPENAPI_OFFERING, + openapi.into_bytes(), + OPENAPI_JSON, + ); + + let error = client(&stub) + .resolve_action("plant-1", "buy") + .await + .unwrap_err(); + assert!( + error.to_string().contains("exactly once"), + "{paths}: {error}" + ); + } +} + +/// An Action naming its own OpenAPI document uses that one, not the Service-wide document. +#[tokio::test] +async fn prefers_the_document_the_action_names() { + let offering = br#"{"actions":[{"authentication":"not-required","id":"buy","openapi":{"operation_id":"purchasePlant","url":"https://plants.example/buy-api.json"},"rel":"purchase"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let openapi = br#"{"openapi":"3.1.0","info":{"title":"Plants","version":"1"},"paths":{"/buy":{"post":{"operationId":"purchasePlant"}}}}"#; + let stub = serving( + DOCUMENT_WITH_OPENAPI, + offering, + openapi.to_vec(), + OPENAPI_JSON, + ); + + client(&stub) + .resolve_action("plant-1", "buy") + .await + .unwrap(); + assert!( + stub.requests() + .iter() + .any(|request| request.url == "https://plants.example/buy-api.json"), + "{:?}", + stub.requests() + ); +} + +// -- HTTP Actions ------------------------------------------------------------------------- + +/// An HTTP Action naming a request schema has it fetched, so a caller can build the body. +#[tokio::test] +async fn resolves_the_request_schema_of_an_http_action() { + let offering = br#"{"actions":[{"authentication":"not-required","http":{"href":"/buy","method":"POST","request":{"content_type":"application/json","schema":{"url":"https://plants.example/buy.json"}}},"id":"buy","rel":"purchase"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let schema = br#"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}"#; + let stub = serving(SERVICE_DOCUMENT, offering, schema.to_vec(), SCHEMA_JSON); + + let resolved = client(&stub) + .resolve_action("plant-1", "buy") + .await + .unwrap(); + assert!(resolved.request_schema.is_some()); + assert!(resolved.openapi_document.is_none()); + assert!(resolved.operation.is_none()); +} + +/// Naming an Action the Offering does not describe is the caller's mistake, not the Service's. +#[tokio::test] +async fn refuses_to_resolve_an_action_the_offering_does_not_describe() { + let stub = Stub::serving(br#"{"id":"plant-1","name":"Plant","odp_version":"1.0"}"#); + + let error = client(&stub) + .resolve_action("plant-1", "buy") + .await + .unwrap_err(); + assert!(matches!(error, AgentError::InvalidRequest(_)), "{error}"); +} diff --git a/crates/odp-agent/tests/caching_conformance.rs b/crates/odp-agent/tests/caching_conformance.rs new file mode 100644 index 0000000..53cc15c --- /dev/null +++ b/crates/odp-agent/tests/caching_conformance.rs @@ -0,0 +1,572 @@ +//! CCH: what the Agent stores, what it revalidates, and what it keeps apart. + +mod support; + +use std::{sync::Arc, time::Duration}; + +use odp_agent::{AgentError, CacheFallbacks, Freshness, MemoryCache, ServiceClient}; +use odp_core::Representation; +use support::{ + ODP_JSON, OFFERING_PAGE, ORIGIN, SCHEMA_JSON, SERVICE_DOCUMENT, Stub, bare, client, response, + scripted, with_headers, +}; + +/// CCH-02: a response supplying no freshness is kept for the class's fallback lifetime. +#[tokio::test] +async fn serves_a_fresh_document_from_the_cache() { + let stub = Stub::serving(OFFERING_PAGE); + let client = client(&stub); + + assert_eq!( + client.inspect().await.unwrap().freshness, + Freshness::Fetched + ); + assert_eq!(client.inspect().await.unwrap().freshness, Freshness::Fresh); + assert_eq!(stub.count(), 1, "the second read never left the Agent"); +} + +/// CCH-03: every class the draft names is configurable on its own. +#[tokio::test] +async fn keeps_a_fallback_for_every_resource_class() { + let fallbacks = CacheFallbacks::default(); + + assert_eq!(fallbacks.service_document, Duration::from_secs(4 * 60 * 60)); + assert_eq!(fallbacks.collection, Duration::from_secs(60 * 60)); + assert_eq!(fallbacks.offering, Duration::from_secs(5 * 60)); + assert_eq!(fallbacks.capabilities, Duration::from_secs(60 * 60)); + assert_eq!(fallbacks.schema, Duration::from_secs(24 * 60 * 60)); + assert_eq!( + fallbacks.search, + Duration::ZERO, + "a search answers one request and is not reused for the next" + ); +} + +/// A fallback of zero keeps nothing, so the next read reaches the Service again. +#[tokio::test] +async fn stores_nothing_for_a_class_whose_fallback_is_zero() { + let stub = Stub::serving(OFFERING_PAGE); + let client = client(&stub).with_cache_fallbacks(CacheFallbacks { + service_document: Duration::ZERO, + ..CacheFallbacks::default() + }); + + client.inspect().await.unwrap(); + client.inspect().await.unwrap(); + assert_eq!(stub.count(), 2); +} + +/// CCH-04: a fallback never overrides what the Service said. +#[tokio::test] +async fn lets_the_service_override_the_fallback() { + let stub = Stub::new(|_| { + Ok(with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("cache-control", "no-store")], + )) + }); + let client = client(&stub); + + client.inspect().await.unwrap(); + client.inspect().await.unwrap(); + assert_eq!( + stub.count(), + 2, + "no-store is not cached whatever the fallback" + ); +} + +#[tokio::test] +async fn reads_freshness_from_the_directives_the_service_sent() { + for (directive, reached) in [ + ("max-age=600", 1), + ("max-age=0", 2), + ("no-cache", 2), + ("no-store", 2), + ] { + let stub = Stub::new(move |_| { + Ok(with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("cache-control", directive)], + )) + }); + let client = client(&stub); + client.inspect().await.unwrap(); + let _ = client.inspect().await; + assert_eq!(stub.count(), reached, "{directive}"); + } +} + +/// A stored response ages by the time it already spent in an intermediary. +#[tokio::test] +async fn subtracts_the_age_a_response_arrived_with() { + let stub = Stub::new(|_| { + Ok(with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("cache-control", "max-age=60"), ("age", "120")], + )) + }); + let client = client(&stub); + + client.inspect().await.unwrap(); + let _ = client.inspect().await; + assert_eq!(stub.count(), 2, "it arrived already stale"); +} + +#[tokio::test] +async fn reads_an_expires_date_when_no_directive_says_otherwise() { + let stub = Stub::new(|_| { + Ok(with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("expires", "Sun, 06 Nov 1994 08:49:37 GMT")], + )) + }); + let client = client(&stub); + + client.inspect().await.unwrap(); + let _ = client.inspect().await; + assert_eq!(stub.count(), 2, "a date in the past is already stale"); +} + +/// CCH-01: a stale entry is revalidated rather than discarded. +#[tokio::test] +async fn revalidates_a_stale_entry_with_its_validators() { + let stub = scripted(vec![ + with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[ + ("cache-control", "max-age=0"), + ("etag", "\"v1\""), + ("last-modified", "Sun, 06 Nov 1994 08:49:37 GMT"), + ], + ), + bare(304, &[("cache-control", "max-age=600")]), + ]); + let client = client(&stub); + + client.inspect().await.unwrap(); + let second = client.inspect().await.unwrap(); + assert_eq!(second.freshness, Freshness::Revalidated); + assert_eq!(second.document.name, "Plants"); + + let conditional = stub.last(); + assert_eq!( + conditional.headers.get("if-none-match").map(String::as_str), + Some("\"v1\"") + ); + assert_eq!( + conditional + .headers + .get("if-modified-since") + .map(String::as_str), + Some("Sun, 06 Nov 1994 08:49:37 GMT") + ); + + let third = client.inspect().await.unwrap(); + assert_eq!(third.freshness, Freshness::Fresh, "the 304 refreshed it"); +} + +/// A 304 telling the Agent to store nothing drops the entry it revalidated. +#[tokio::test] +async fn drops_an_entry_a_revalidation_told_it_not_to_store() { + let stub = scripted(vec![ + with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("cache-control", "max-age=0"), ("etag", "\"v1\"")], + ), + bare(304, &[("cache-control", "no-store")]), + with_headers(200, SERVICE_DOCUMENT, ODP_JSON, &[("etag", "\"v2\"")]), + ]); + let client = client(&stub); + + client.inspect().await.unwrap(); + assert_eq!( + client.inspect().await.unwrap().freshness, + Freshness::Revalidated + ); + assert_eq!( + client.inspect().await.unwrap().freshness, + Freshness::Fetched, + "the entry was dropped, so the next read fetched" + ); +} + +/// A 304 for something the Agent never stored describes nothing it can serve. +#[tokio::test] +async fn refuses_a_revalidation_of_something_it_never_stored() { + let stub = Stub::new(|_| Ok(bare(304, &[]))); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::InvalidResponse(message) if message.contains("304")), + "{error}" + ); +} + +/// A validator-less 304 keeps the lifetime the stored entry already had. +#[tokio::test] +async fn keeps_the_stored_lifetime_when_a_revalidation_states_none() { + let stub = scripted(vec![ + with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("cache-control", "max-age=0"), ("etag", "\"v1\"")], + ), + bare(304, &[]), + ]); + let client = client(&stub); + + client.inspect().await.unwrap(); + assert_eq!( + client.inspect().await.unwrap().freshness, + Freshness::Revalidated + ); + let _ = client.inspect().await; + assert_eq!(stub.count(), 3, "a zero lifetime stayed zero"); +} + +/// A Vary the Agent cannot honour means the stored entry could answer the wrong request. +#[tokio::test] +async fn stores_nothing_it_cannot_vary_on() { + for (vary, reached) in [ + ("Accept-Language", 1), + ("accept, content-type", 1), + ("Authorization", 2), + ("*", 2), + ] { + let stub = Stub::new(move |_| { + Ok(with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("vary", vary)], + )) + }); + let client = client(&stub); + client.inspect().await.unwrap(); + let _ = client.inspect().await; + assert_eq!(stub.count(), reached, "{vary}"); + } +} + +/// CCH-06: a request in another language is another request. +#[tokio::test] +async fn keeps_each_language_in_its_own_entry() { + let stub = Stub::serving(OFFERING_PAGE); + let cache = Arc::new(MemoryCache::new()); + let english = client(&stub).with_cache(cache.clone()); + let french = client(&stub) + .with_cache(cache.clone()) + .with_accept_language("fr"); + + english.inspect().await.unwrap(); + french.inspect().await.unwrap(); + assert_eq!(stub.count(), 2); + assert_eq!(english.inspect().await.unwrap().freshness, Freshness::Fresh); +} + +/// CCH-05/06: an authenticated representation is never reused for another context. +#[tokio::test] +async fn partitions_every_authentication_context() { + let stub = Stub::serving(OFFERING_PAGE); + let cache = Arc::new(MemoryCache::new()); + let anonymous = client(&stub).with_cache(cache.clone()); + let authenticated = client(&stub) + .with_cache(cache.clone()) + .with_cache_partition("bearer-abc"); + + anonymous.inspect().await.unwrap(); + assert_eq!( + authenticated.inspect().await.unwrap().freshness, + Freshness::Fetched, + "the anonymous entry did not answer for the authenticated context" + ); + assert_eq!( + anonymous.inspect().await.unwrap().freshness, + Freshness::Fresh + ); +} + +#[tokio::test] +async fn shared_cache_is_isolated_by_default_and_shared_only_explicitly() { + let cache = Arc::new(MemoryCache::new()); + let first = Stub::serving(OFFERING_PAGE); + let second = Stub::serving(OFFERING_PAGE); + client(&first) + .with_cache(cache.clone()) + .inspect() + .await + .unwrap(); + client(&second) + .with_cache(cache.clone()) + .inspect() + .await + .unwrap(); + assert_eq!(second.count(), 1); + let first = client(&first) + .with_cache(cache.clone()) + .with_cache_partition("same-identity"); + let second = client(&second) + .with_cache(cache) + .with_cache_partition("same-identity"); + first.inspect().await.unwrap(); + assert_eq!(second.inspect().await.unwrap().freshness, Freshness::Fresh); + assert_eq!( + second.clone().inspect().await.unwrap().freshness, + Freshness::Fresh + ); +} + +#[tokio::test] +async fn manual_continuations_do_not_assume_catalog_freshness() { + let stub = Stub::serving(OFFERING_PAGE); + let client = client(&stub); + client + .continue_offerings("/odp/offerings?cursor=search") + .await + .unwrap(); + client + .continue_offerings("/odp/offerings?cursor=search") + .await + .unwrap(); + assert_eq!(stub.catalog_requests().len(), 2); +} + +/// The same partitioning covers supporting documents, which are fetched on their own path. +#[tokio::test] +async fn partitions_supporting_documents_too() { + let schema = br#"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}"#; + let offering = br#"{"attributes":{"size":"L"},"id":"plant-1","name":"Plant","odp_version":"1.0","schema":{"url":"https://schemas.example/plant.json"}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else if request.url.contains("schemas.example") { + with_headers( + 200, + schema, + SCHEMA_JSON, + &[("cache-control", "max-age=86400")], + ) + } else { + response(200, offering, ODP_JSON) + }) + }); + let cache = Arc::new(MemoryCache::new()); + let anonymous = client(&stub).with_cache(cache.clone()); + let authenticated = client(&stub) + .with_cache(cache.clone()) + .with_cache_partition("bearer-abc"); + + anonymous.get_offering_details("plant-1").await.unwrap(); + let before = schema_fetches(&stub); + authenticated.get_offering_details("plant-1").await.unwrap(); + assert_eq!( + schema_fetches(&stub), + before + 1, + "the authenticated context fetched the schema for itself" + ); + + let again = schema_fetches(&stub); + anonymous.get_offering_details("plant-1").await.unwrap(); + assert_eq!( + schema_fetches(&stub), + again, + "its own entry was still fresh" + ); +} + +/// An Attribute Schema is long-lived, so the Agent keeps one rather than fetching it each time. +#[tokio::test] +async fn keeps_an_attribute_schema_for_its_fallback_lifetime() { + let schema = br#"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}"#; + let offering = br#"{"attributes":{"size":"L"},"id":"plant-1","name":"Plant","odp_version":"1.0","schema":{"url":"https://schemas.example/plant.json"}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else if request.url.contains("schemas.example") { + response(200, schema, SCHEMA_JSON) + } else { + response(200, offering, ODP_JSON) + }) + }); + let client = client(&stub); + + client.get_offering_details("plant-1").await.unwrap(); + let before = schema_fetches(&stub); + client.get_offering_details("plant-1").await.unwrap(); + assert_eq!( + schema_fetches(&stub), + before, + "the schema was stored, not refetched" + ); +} + +/// A search describes one request, so a second identical search asks the Service again. +#[tokio::test] +async fn stores_no_search_response_of_its_own_accord() { + let stub = Stub::serving(OFFERING_PAGE); + let client = client(&stub); + let request = odp_core::OfferingSearchRequest { + odp_version: "1.0".to_owned(), + query: "plants".to_owned(), + ..odp_core::OfferingSearchRequest::default() + }; + + client + .search_offerings(&request, Representation::Terse) + .await + .unwrap(); + let before = stub.catalog_requests().len(); + client + .search_offerings(&request, Representation::Terse) + .await + .unwrap(); + assert_eq!(stub.catalog_requests().len(), before + 1); +} + +#[tokio::test] +async fn stale_post_searches_are_repeated_without_get_preconditions() { + let stub = Stub::new(|request| { + if request.url.ends_with("/.well-known/odp") { + return Ok(response(200, SERVICE_DOCUMENT, ODP_JSON)); + } + assert_eq!(request.method, "POST"); + assert!(!request.headers.contains_key("if-none-match")); + assert!(!request.headers.contains_key("if-modified-since")); + Ok(with_headers( + 200, + OFFERING_PAGE, + ODP_JSON, + &[("cache-control", "max-age=0"), ("etag", "search-v1")], + )) + }); + let client = client(&stub); + let request = odp_core::OfferingSearchRequest { + odp_version: "1.0".to_owned(), + query: "plants".to_owned(), + ..Default::default() + }; + for _ in 0..2 { + client + .search_offerings(&request, Representation::Terse) + .await + .unwrap(); + } + assert_eq!(stub.catalog_requests().len(), 2); +} + +// -- the cache itself -------------------------------------------------------------------- + +/// A cache that grows without bound is a liability in a long-lived Agent. +#[tokio::test] +async fn bounds_what_the_memory_cache_keeps() { + let cache = Arc::new(MemoryCache::with_capacity(2)); + let client = ServiceClient::with_transport(ORIGIN, Stub::serving(OFFERING_PAGE)) + .unwrap() + .with_cache(cache.clone()); + + for language in ["en", "fr", "de", "es"] { + client + .clone() + .with_accept_language(language) + .inspect() + .await + .unwrap(); + } + assert_eq!(cache.len(), 2); + assert!(!cache.is_empty()); +} + +#[tokio::test] +async fn keeps_nothing_at_all_when_it_has_no_capacity() { + let cache = Arc::new(MemoryCache::with_capacity(0)); + let stub = Stub::serving(OFFERING_PAGE); + let client = client(&stub).with_cache(cache.clone()); + + client.inspect().await.unwrap(); + client.inspect().await.unwrap(); + assert_eq!(cache.len(), 0); + assert!(cache.is_empty()); + assert_eq!(stub.count(), 2); +} + +#[tokio::test] +async fn replaces_an_entry_it_already_holds() { + let cache = Arc::new(MemoryCache::with_capacity(1)); + let stub = scripted(vec![ + with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("cache-control", "max-age=0")], + ), + with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("cache-control", "max-age=600")], + ), + ]); + let client = client(&stub).with_cache(cache.clone()); + + client.inspect().await.unwrap(); + client.inspect().await.unwrap(); + assert_eq!(cache.len(), 1); + assert_eq!(client.inspect().await.unwrap().freshness, Freshness::Fresh); +} + +/// CCH-05: a document reached through a redirect is revalidated where it was actually found. +#[tokio::test] +async fn revalidates_a_document_where_the_redirect_left_it() { + let stub = Stub::new(|request| { + Ok(if request.url.ends_with("/.well-known/odp") { + bare(308, &[("location", "https://plants.example/odp/document")]) + } else if request.headers.contains_key("if-none-match") { + bare(304, &[("cache-control", "max-age=600")]) + } else { + with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("cache-control", "max-age=0"), ("etag", "\"v1\"")], + ) + }) + }); + let client = client(&stub); + + client.inspect().await.unwrap(); + assert_eq!( + client.inspect().await.unwrap().freshness, + Freshness::Revalidated + ); + + let last = stub.last(); + assert_eq!(last.url, "https://plants.example/odp/document"); + assert!( + !stub + .requests() + .iter() + .skip(2) + .any(|request| request.url.ends_with("/.well-known/odp")), + "the redirect was not walked a second time" + ); +} + +fn schema_fetches(stub: &Arc) -> usize { + stub.requests() + .into_iter() + .filter(|request| request.url.contains("schemas.example")) + .count() +} diff --git a/crates/odp-agent/tests/capability_conformance.rs b/crates/odp-agent/tests/capability_conformance.rs new file mode 100644 index 0000000..7ffe8ef --- /dev/null +++ b/crates/odp-agent/tests/capability_conformance.rs @@ -0,0 +1,358 @@ +//! FLT-52/53/54/64: the bounds an Agent puts on a Service's declared search capabilities. + +mod support; + +use std::sync::Arc; + +use support::{ODP_JSON, OFFERING_PAGE, Stub, client, response}; + +const OPERATIONS: &str = r#""operations":[{"authentication":"not-required","name":"get-collection"},{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-offerings"}]"#; + +/// A Service Document carrying the given `search_capabilities` object. +fn document(capabilities: &str) -> String { + format!( + r#"{{"description":"Plants","http":{{"endpoint_base":"/odp"}},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0",{OPERATIONS},"search_capabilities":{capabilities}}}"# + ) +} + +/// A Service answering the Service Document, then `pages` by URL substring, then an Offering page. +fn serving(document: String, pages: Vec<(&'static str, String)>) -> Arc { + Stub::new(move |request| { + if request.url.ends_with("/.well-known/odp") { + return Ok(response(200, document.as_bytes(), ODP_JSON)); + } + for (marker, body) in &pages { + if request.url.contains(marker) { + return Ok(response(200, body.as_bytes(), ODP_JSON)); + } + } + Ok(response(200, OFFERING_PAGE, ODP_JSON)) + }) +} + +fn filter(id: &str) -> String { + format!( + r#"{{"description":"d","id":"{id}","operators":["eq"],"title":"{id}","type":"string"}}"# + ) +} + +fn sort(id: &str, filter_id: &str) -> String { + format!( + r#"{{"description":"d","id":"{id}","keys":[{{"direction":"ascending","filter_id":"{filter_id}","missing":"last"}}],"title":"{id}"}}"# + ) +} + +fn joined(values: impl Iterator) -> String { + values.collect::>().join(",") +} + +// -- linked sources ----------------------------------------------------------------------- + +/// FLT-53: a linked filter source that points back at itself never terminates, so it is refused. +#[tokio::test] +async fn refuses_a_linked_filter_source_that_loops() { + let page = format!( + r#"{{"items":[{}],"next":"/odp/filters","odp_version":"1.0"}}"#, + filter("colour") + ); + let stub = serving( + document(r#"{"filters":{"linked":{"href":"/odp/filters"}}}"#), + vec![("/odp/filters", page)], + ); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert!(catalog.filters.is_empty()); + assert_eq!(catalog.issues.len(), 1); + assert!( + catalog.issues[0].message.contains("loop"), + "{:?}", + catalog.issues + ); +} + +/// FLT-52: a linked source is read for at most 16 pages; a seventeenth is not followed. +#[tokio::test] +async fn stops_a_linked_filter_source_at_sixteen_pages() { + let stub = Stub::new(move |request| { + if request.url.ends_with("/.well-known/odp") { + return Ok(response( + 200, + document(r#"{"filters":{"linked":{"href":"/odp/filters?page=0"}}}"#).as_bytes(), + ODP_JSON, + )); + } + let page: usize = request + .url + .split("page=") + .nth(1) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + let body = format!( + r#"{{"items":[{}],"next":"/odp/filters?page={}","odp_version":"1.0"}}"#, + filter(&format!("filter-{page}")), + page + 1 + ); + Ok(response(200, body.as_bytes(), ODP_JSON)) + }); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert!(catalog.filters.is_empty(), "the source was never complete"); + assert_eq!(catalog.issues.len(), 1); + assert!( + catalog.issues[0].message.contains("16 pages"), + "{:?}", + catalog.issues + ); + assert_eq!( + stub.catalog_requests().len(), + 16, + "a seventeenth page was not fetched" + ); +} + +#[tokio::test] +async fn stops_a_linked_sort_source_at_sixteen_pages() { + let stub = Stub::new(move |request| { + if request.url.ends_with("/.well-known/odp") { + return Ok(response( + 200, + document(r#"{"sorts":{"linked":{"href":"/odp/sorts?page=0"}}}"#).as_bytes(), + ODP_JSON, + )); + } + let page: usize = request + .url + .split("page=") + .nth(1) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + let body = format!( + r#"{{"items":[{}],"next":"/odp/sorts?page={}","odp_version":"1.0"}}"#, + sort(&format!("sort-{page}"), "colour"), + page + 1 + ); + Ok(response(200, body.as_bytes(), ODP_JSON)) + }); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert!(catalog.sorts.is_empty()); + assert!( + catalog + .issues + .iter() + .any(|issue| issue.message.contains("16 pages")), + "{:?}", + catalog.issues + ); +} + +/// A linked sort source that ends is read to its end and no further. +#[tokio::test] +async fn follows_a_linked_sort_source_to_its_last_page() { + let first = format!( + r#"{{"items":[{}],"next":"/odp/sorts?cursor=c2","odp_version":"1.0"}}"#, + sort("cheapest", "colour") + ); + let last = format!( + r#"{{"items":[{}],"odp_version":"1.0"}}"#, + sort("newest", "colour") + ); + let capabilities = format!( + r#"{{"filters":{{"inline":[{}]}},"sorts":{{"linked":{{"href":"/odp/sorts"}}}}}}"#, + filter("colour") + ); + let stub = serving( + document(&capabilities), + vec![("cursor=c2", last), ("/odp/sorts", first)], + ); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert_eq!(catalog.sorts.len(), 2, "{:?}", catalog.issues); + assert!(catalog.issues.is_empty(), "{:?}", catalog.issues); + assert_eq!(stub.catalog_requests().len(), 2); +} + +/// A linked source the Service cannot serve is an issue about that source, not a failed catalog. +#[tokio::test] +async fn reports_a_linked_filter_source_the_service_cannot_serve() { + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response( + 200, + document(r#"{"filters":{"linked":{"href":"/odp/filters"}}}"#).as_bytes(), + ODP_JSON, + ) + } else { + response( + 503, + br#"{"status":503,"title":"Unavailable"}"#, + "application/problem+json", + ) + }) + }); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert!(catalog.filters.is_empty()); + assert_eq!(catalog.issues.len(), 1); +} + +// -- effective bounds --------------------------------------------------------------------- + +/// FLT-54: more than 1024 effective filters is more than an Agent will hold. +#[tokio::test] +async fn refuses_more_filters_than_the_effective_limit_allows() { + let stub = paged_source( + r#"{"filters":{"linked":{"href":"/odp/filters?page=0"}}}"#, + 11, + &|page| joined((0..100).map(move |index| filter(&format!("filter-{page}-{index}")))), + ); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert!(catalog.filters.is_empty()); + assert!( + catalog + .issues + .iter() + .any(|issue| issue.message.contains("1024")), + "{:?}", + catalog.issues + ); +} + +/// FLT-54: and more than 128 effective sorts likewise. +#[tokio::test] +async fn refuses_more_sorts_than_the_effective_limit_allows() { + let stub = paged_source( + r#"{"sorts":{"linked":{"href":"/odp/sorts?page=0"}}}"#, + 2, + &|page| joined((0..100).map(move |index| sort(&format!("sort-{page}-{index}"), "colour"))), + ); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert!(catalog.sorts.is_empty()); + assert!( + catalog + .issues + .iter() + .any(|issue| issue.message.contains("128")), + "{:?}", + catalog.issues + ); +} + +/// A Service serving `pages` pages of a linked capability source, the last of them final. +fn paged_source( + capabilities: &str, + pages: usize, + items: &'static (dyn Fn(usize) -> String + Send + Sync), +) -> Arc { + let document = document(capabilities); + Stub::new(move |request| { + if request.url.ends_with("/.well-known/odp") { + return Ok(response(200, document.as_bytes(), ODP_JSON)); + } + let page: usize = request + .url + .split("page=") + .nth(1) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + let next = if page + 1 < pages { + let path = request.url.split('?').next().unwrap_or_default().to_owned(); + format!(r#""next":"{path}?page={}","#, page + 1) + } else { + String::new() + }; + let body = format!(r#"{{"items":[{}],{next}"odp_version":"1.0"}}"#, items(page)); + Ok(response(200, body.as_bytes(), ODP_JSON)) + }) +} + +/// FLT-64: a sort identifier two effective sources both claim is available from neither. +#[tokio::test] +async fn drops_a_sort_two_sources_both_claim() { + let capabilities = format!( + r#"{{"filters":{{"inline":[{}]}},"sorts":{{"inline":[{}]}}}}"#, + filter("colour"), + sort("cheapest", "colour") + ); + let collection = format!( + r#"{{"id":"plants","name":"Plants","odp_version":"1.0","search_capabilities":{{"sorts":{{"inline":[{},{}]}}}}}}"#, + sort("cheapest", "colour"), + sort("newest", "colour") + ); + let stub = serving( + document(&capabilities), + vec![("/odp/collections/plants", collection)], + ); + + let catalog = client(&stub) + .get_offering_search_capabilities(Some("plants")) + .await + .unwrap(); + assert!(!catalog.sorts.contains_key("cheapest"), "claimed twice"); + assert!(catalog.sorts.contains_key("newest")); + assert!( + catalog + .issues + .iter() + .any(|issue| issue.message.contains("Duplicate sorts: cheapest")), + "{:?}", + catalog.issues + ); +} + +/// A linked source that ends exactly at the sixteenth page is complete, not over its bound. +#[tokio::test] +async fn reads_a_linked_filter_source_of_exactly_sixteen_pages() { + let stub = paged_source( + r#"{"filters":{"linked":{"href":"/odp/filters?page=0"}}}"#, + 16, + &|page| filter(&format!("filter-{page}")), + ); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert_eq!(catalog.filters.len(), 16); + assert!(catalog.issues.is_empty(), "{:?}", catalog.issues); +} + +/// The same bound applies to a linked sort source. +#[tokio::test] +async fn reads_a_linked_sort_source_of_exactly_sixteen_pages() { + let stub = paged_source( + r#"{"filters":{"inline":[{"description":"d","id":"colour","operators":["eq"],"title":"Colour","type":"string"}]},"sorts":{"linked":{"href":"/odp/sorts?page=0"}}}"#, + 16, + &|page| sort(&format!("sort-{page}"), "colour"), + ); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert_eq!(catalog.sorts.len(), 16); + assert!(catalog.issues.is_empty(), "{:?}", catalog.issues); +} diff --git a/crates/odp-agent/tests/catalog_conformance.rs b/crates/odp-agent/tests/catalog_conformance.rs new file mode 100644 index 0000000..ac3cf6c --- /dev/null +++ b/crates/odp-agent/tests/catalog_conformance.rs @@ -0,0 +1,663 @@ +//! What the Agent makes of the documents a Service returns: versions, pages, Actions, schemas. + +mod support; + +use std::sync::Arc; + +use odp_agent::{AgentError, OfferingIssueScope, ServiceClient, TraversalOptions}; +use odp_core::Representation; +use support::{ + COLLECTION_PAGE, ODP_JSON, OFFERING_PAGE, ORIGIN, SCHEMA_JSON, SERVICE_DOCUMENT, Stub, client, + response, scripted, +}; + +// -- versions and representations --------------------------------------------------------- + +/// VER-04: an item inherits the version of the document carrying it. +#[tokio::test] +async fn reads_an_item_that_inherits_the_page_version() { + let stub = Stub::serving(OFFERING_PAGE); + let page = client(&stub) + .list_offerings(Representation::Terse, 0) + .await + .unwrap(); + + assert_eq!(page.odp_version, "1.0"); + assert_eq!(page.items[0].id, "plant-1"); +} + +/// VER-05: a document declaring a version this Agent does not support is rejected. +#[tokio::test] +async fn refuses_a_version_it_does_not_support() { + for version in ["2.0", "0.9", "", "1"] { + let body = format!( + r#"{{"items":[{{"id":"plant-1","name":"Rubber Plant"}}],"odp_version":"{version}"}}"# + ); + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else { + response(200, body.as_bytes(), ODP_JSON) + }) + }); + assert!( + client(&stub) + .list_offerings(Representation::Terse, 0) + .await + .is_err(), + "{version}" + ); + } +} + +/// A page whose items do not describe the resource they claim to is not a page this Agent reads. +#[tokio::test] +async fn refuses_a_page_whose_items_are_not_the_resource() { + let stub = Stub::serving(br#"{"items":[{"name":"No identifier"}],"odp_version":"1.0"}"#); + assert!( + client(&stub) + .list_offerings(Representation::Terse, 0) + .await + .is_err() + ); +} + +#[tokio::test] +async fn refuses_a_collection_page_whose_items_are_not_collections() { + let stub = Stub::serving(br#"{"items":[{"id":"plants"}],"odp_version":"1.0"}"#); + assert!( + client(&stub) + .list_collections(Representation::Terse, 0) + .await + .is_err() + ); +} + +#[tokio::test] +async fn refuses_a_body_that_is_not_json_at_all() { + let stub = Stub::new(|_| Ok(response(200, b"not json", ODP_JSON))); + assert!(client(&stub).inspect().await.is_err()); +} + +// -- pagination --------------------------------------------------------------------------- + +/// PAG-07: a continuation stays on the origin the operation started from. +#[tokio::test] +async fn refuses_a_continuation_that_leaves_the_service_origin() { + let stub = Stub::serving(OFFERING_PAGE); + for next in [ + "https://elsewhere.example/odp/offerings?c=2", + "//elsewhere.example/x", + "http://plants.example/odp/offerings", + ] { + let error = client(&stub).continue_offerings(next).await.unwrap_err(); + assert!( + matches!(error, AgentError::InvalidRequest(_)), + "{next}: {error}" + ); + } +} + +#[tokio::test] +async fn follows_a_continuation_to_the_end_of_the_sequence() { + let stub = scripted(vec![ + response(200, SERVICE_DOCUMENT, ODP_JSON), + response( + 200, + br#"{"items":[{"id":"plant-1","name":"One"}],"next":"/odp/offerings?cursor=c2","odp_version":"1.0"}"#, + ODP_JSON, + ), + response( + 200, + br#"{"items":[{"id":"plant-2","name":"Two"}],"odp_version":"1.0"}"#, + ODP_JSON, + ), + ]); + let offerings = client(&stub) + .list_all_offerings(Representation::Terse, 0, TraversalOptions::default()) + .await + .unwrap(); + + assert_eq!( + offerings + .iter() + .map(|value| value.id.as_str()) + .collect::>(), + ["plant-1", "plant-2"] + ); +} + +#[tokio::test] +async fn follows_a_collection_continuation_too() { + let stub = scripted(vec![ + response(200, SERVICE_DOCUMENT, ODP_JSON), + response( + 200, + br#"{"items":[{"id":"plants","name":"Plants"}],"next":"/odp/collections?cursor=c2","odp_version":"1.0"}"#, + ODP_JSON, + ), + response( + 200, + br#"{"items":[{"id":"trees","name":"Trees"}],"odp_version":"1.0"}"#, + ODP_JSON, + ), + ]); + let collections = client(&stub) + .list_all_collections(Representation::Terse, 0, TraversalOptions::default()) + .await + .unwrap(); + + assert_eq!(collections.len(), 2); +} + +/// A traversal stops at the bounds the caller set, however much the Service offers. +#[tokio::test] +async fn stops_a_traversal_at_the_bounds_it_was_given() { + let stub = Stub::new(|request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else { + response( + 200, + br#"{"items":[{"id":"plant-1","name":"One"},{"id":"plant-2","name":"Two"}],"next":"/odp/offerings?cursor=more","odp_version":"1.0"}"#, + ODP_JSON, + ) + }) + }); + + let offerings = client(&stub) + .list_all_offerings( + Representation::Terse, + 0, + TraversalOptions { + max_items: 3, + max_pages: 0, + }, + ) + .await + .unwrap(); + assert_eq!(offerings.len(), 3); + + let stub = Stub::new(|request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else { + response( + 200, + br#"{"items":[{"id":"plant-1","name":"One"}],"next":"/odp/offerings?cursor=more","odp_version":"1.0"}"#, + ODP_JSON, + ) + }) + }); + let offerings = client(&stub) + .list_all_offerings( + Representation::Terse, + 0, + TraversalOptions { + max_items: 0, + max_pages: 2, + }, + ) + .await + .unwrap(); + assert_eq!(offerings.len(), 2, "two pages of one item each"); +} + +#[tokio::test] +async fn refuses_a_traversal_past_the_bounds_the_protocol_allows() { + let stub = Stub::serving(OFFERING_PAGE); + for options in [ + TraversalOptions { + max_items: 10_001, + max_pages: 0, + }, + TraversalOptions { + max_items: 0, + max_pages: 17, + }, + ] { + let error = client(&stub) + .list_all_offerings(Representation::Terse, 0, options) + .await + .unwrap_err(); + assert!(matches!(error, AgentError::InvalidRequest(_)), "{error}"); + } +} + +#[tokio::test] +async fn searches_every_page_of_a_result() { + let stub = scripted(vec![ + response(200, SERVICE_DOCUMENT, ODP_JSON), + response( + 200, + br#"{"items":[{"id":"plant-1","name":"One"}],"next":"/odp/offerings/search?cursor=c2","odp_version":"1.0"}"#, + ODP_JSON, + ), + response( + 200, + br#"{"items":[{"id":"plant-2","name":"Two"}],"odp_version":"1.0"}"#, + ODP_JSON, + ), + ]); + let offerings = client(&stub) + .search_all_offerings( + &odp_core::OfferingSearchRequest { + odp_version: "1.0".to_owned(), + query: "plants".to_owned(), + ..odp_core::OfferingSearchRequest::default() + }, + Representation::Terse, + TraversalOptions::default(), + ) + .await + .unwrap(); + + assert_eq!(offerings.len(), 2); +} + +// -- Offering details --------------------------------------------------------------------- + +/// OFR-62: an Action target is resolved against the Service Origin without being invoked. +#[tokio::test] +async fn resolves_an_action_target_without_invoking_it() { + let offering = br#"{"actions":[{"authentication":"not-required","description":"Download","http":{"href":"/downloads/plant.pdf","method":"GET","response_content_types":["application/pdf"]},"id":"download","rel":"download"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let stub = Stub::serving(offering); + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + + assert!(details.issues.is_empty(), "{:?}", details.issues); + assert_eq!( + details.actions[0] + .http + .as_ref() + .map(|http| http.url.as_str()), + Some("https://plants.example/downloads/plant.pdf") + ); + assert!( + !stub + .requests() + .iter() + .any(|request| request.url.contains("/downloads/")), + "the Action was described, not performed" + ); +} + +/// OFR-57: an Action identifier is unique within its Offering, so a repeat is reported, not used. +#[tokio::test] +async fn reports_a_duplicate_action_identifier_once() { + let offering = br#"{"actions":[{"authentication":"not-required","http":{"href":"/a","method":"GET"},"id":"download","rel":"download"},{"authentication":"not-required","http":{"href":"/b","method":"GET"},"id":"download","rel":"download"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let stub = Stub::serving(offering); + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + + assert!(details.actions.is_empty()); + assert_eq!(details.issues.len(), 1); + assert_eq!(details.issues[0].scope, OfferingIssueScope::Action); + assert_eq!(details.issues[0].action_id.as_deref(), Some("download")); +} + +/// OFR-68: an OpenAPI Action with no URL of its own uses the Service-wide document. +#[tokio::test] +async fn falls_back_to_the_service_openapi_document() { + let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp","openapi":{"url":"https://plants.example/openapi.json"}},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"}]}"#; + let offering = br#"{"actions":[{"authentication":"not-required","id":"buy","openapi":{"operation_id":"purchasePlant"},"rel":"purchase"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else { + response(200, offering, ODP_JSON) + }) + }); + + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert_eq!( + details.actions[0] + .openapi + .as_ref() + .map(|value| value.url.as_str()), + Some("https://plants.example/openapi.json") + ); +} + +/// An Action with no target at all, and no document to fall back to, is reported as an issue. +#[tokio::test] +async fn reports_an_openapi_action_with_no_document() { + let offering = br#"{"actions":[{"authentication":"not-required","id":"buy","openapi":{"operation_id":"purchasePlant"},"rel":"purchase"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let stub = Stub::serving(offering); + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + + assert!(details.actions.is_empty()); + assert_eq!(details.issues.len(), 1); +} + +/// OFR-41: attributes that do not match their schema are withheld and reported. +#[tokio::test] +async fn withholds_attributes_that_do_not_match_their_schema() { + let schema = br#"{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"size":{"type":"integer"}},"required":["size"],"type":"object"}"#; + let offering = br#"{"attributes":{"size":"large"},"id":"plant-1","name":"Plant","odp_version":"1.0","schema":{"url":"https://schemas.example/plant.json"}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else if request.url.contains("schemas.example") { + response(200, schema, SCHEMA_JSON) + } else { + response(200, offering, ODP_JSON) + }) + }); + + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert!(details.offering.attributes.is_empty()); + assert_eq!(details.issues[0].scope, OfferingIssueScope::Attributes); + assert!(details.attribute_schema.is_some()); +} + +/// ERR-30: a schema the Agent cannot retrieve is a scoped issue, not a failed Offering. +#[tokio::test] +async fn reports_an_unreachable_schema_without_failing_the_offering() { + let offering = br#"{"attributes":{"size":"L"},"id":"plant-1","name":"Plant","odp_version":"1.0","schema":{"url":"https://schemas.example/plant.json"}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else if request.url.contains("schemas.example") { + response(404, b"{}", "application/problem+json") + } else { + response(200, offering, ODP_JSON) + }) + }); + + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert_eq!(details.offering.id, "plant-1"); + assert!(details.offering.attributes.is_empty()); + assert_eq!(details.issues[0].scope, OfferingIssueScope::AttributeSchema); +} + +#[tokio::test] +async fn resolves_an_action_a_caller_names() { + let offering = br#"{"actions":[{"authentication":"not-required","http":{"href":"/downloads/plant.pdf","method":"GET"},"id":"download","rel":"download"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let stub = Stub::serving(offering); + let client = client(&stub); + + let resolved = client.resolve_action("plant-1", "download").await.unwrap(); + assert_eq!(resolved.action.id, "download"); + assert!(resolved.openapi_document.is_none()); + + let error = client + .resolve_action("plant-1", "absent") + .await + .unwrap_err(); + assert!(matches!(error, AgentError::InvalidRequest(_)), "{error}"); +} + +// -- search capabilities ------------------------------------------------------------------ + +/// FLT-49: capabilities belong to a Service that advertises the search operation. +#[tokio::test] +async fn reports_collection_capabilities_the_service_cannot_honour() { + let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-collection"},{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"}]}"#; + let collection = br#"{"id":"plants","name":"Plants","odp_version":"1.0","search_capabilities":{"filters":{"inline":[{"description":"d","id":"colour","operators":["eq"],"title":"Colour","type":"string"}]}}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else { + response(200, collection, ODP_JSON) + }) + }); + + let catalog = client(&stub) + .get_collection_search_capabilities("plants") + .await + .unwrap(); + assert!(catalog.filters.is_empty()); + assert_eq!(catalog.issues.len(), 1); +} + +/// FLT-64: an identifier published by two effective sources is available from neither. +#[tokio::test] +async fn drops_a_definition_two_sources_both_claim() { + let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-collection"},{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-offerings"}],"search_capabilities":{"filters":{"inline":[{"description":"d","id":"colour","operators":["eq"],"title":"Colour","type":"string"}]}}}"#; + let collection = br#"{"id":"plants","name":"Plants","odp_version":"1.0","search_capabilities":{"filters":{"inline":[{"description":"d","id":"colour","operators":["eq"],"title":"Colour","type":"string"},{"description":"d","id":"height","operators":["gte"],"title":"Height","type":"integer"}]}}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else { + response(200, collection, ODP_JSON) + }) + }); + + let catalog = client(&stub) + .get_offering_search_capabilities(Some("plants")) + .await + .unwrap(); + assert!(!catalog.filters.contains_key("colour"), "claimed twice"); + assert!(catalog.filters.contains_key("height")); + assert_eq!(catalog.issues.len(), 1); +} + +/// FLT-41: a sort key resolves to a filter the Agent actually has. +#[tokio::test] +async fn reports_a_sort_whose_filter_is_unavailable() { + let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-offerings"}],"search_capabilities":{"sorts":{"inline":[{"description":"d","id":"tallest","keys":[{"direction":"descending","filter_id":"height","missing":"last"}],"title":"Tallest"}]}}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else { + response(200, OFFERING_PAGE, ODP_JSON) + }) + }); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert!(catalog.sorts.is_empty()); + assert_eq!(catalog.issues.len(), 1); +} + +#[tokio::test] +async fn resolves_a_sort_against_the_filters_it_names() { + let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-offerings"}],"search_capabilities":{"filters":{"inline":[{"description":"d","id":"height","operators":["gte"],"title":"Height","type":"integer"}]},"sorts":{"inline":[{"description":"d","id":"tallest","keys":[{"direction":"descending","filter_id":"height","missing":"last"}],"title":"Tallest"}]}}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else { + response(200, OFFERING_PAGE, ODP_JSON) + }) + }); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert_eq!(catalog.sorts["tallest"].filters.len(), 1); + assert!(catalog.issues.is_empty()); +} + +/// FLT-52/53: a linked source is followed page by page, and a loop in it is refused. +#[tokio::test] +async fn follows_a_linked_capability_source() { + let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-offerings"}],"search_capabilities":{"filters":{"linked":{"href":"/odp/filters"}}}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else if request.url.contains("cursor=c2") { + response( + 200, + br#"{"items":[{"description":"d","id":"height","operators":["gte"],"title":"Height","type":"integer"}],"odp_version":"1.0"}"#, + ODP_JSON, + ) + } else { + response( + 200, + br#"{"items":[{"description":"d","id":"colour","operators":["eq"],"title":"Colour","type":"string"}],"next":"/odp/filters?cursor=c2","odp_version":"1.0"}"#, + ODP_JSON, + ) + }) + }); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert_eq!(catalog.filters.len(), 2); + assert!(catalog.issues.is_empty()); +} + +#[tokio::test] +async fn refuses_a_linked_source_that_loops() { + let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-offerings"}],"search_capabilities":{"sorts":{"linked":{"href":"/odp/sorts"}}}}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else { + response( + 200, + br#"{"items":[{"description":"d","id":"tallest","keys":[{"direction":"descending","filter_id":"height","missing":"last"}],"title":"Tallest"}],"next":"/odp/sorts","odp_version":"1.0"}"#, + ODP_JSON, + ) + }) + }); + + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + assert_eq!(catalog.issues.len(), 1); + assert!(catalog.issues[0].message.contains("loop")); +} + +/// A Service advertising no capabilities at all reports none, and no issues either. +#[tokio::test] +async fn reports_no_capabilities_when_the_service_advertises_none() { + let stub = Stub::serving(COLLECTION_PAGE); + let catalog = client(&stub) + .get_offering_search_capabilities(None) + .await + .unwrap(); + + assert!(catalog.filters.is_empty()); + assert!(catalog.sorts.is_empty()); + assert!(catalog.issues.is_empty()); +} + +#[tokio::test] +async fn keeps_the_client_shareable_across_tasks() { + let stub = Stub::serving(OFFERING_PAGE); + let client = Arc::new(ServiceClient::with_transport(ORIGIN, stub.clone()).unwrap()); + let second = Arc::clone(&client); + + let task = tokio::spawn(async move { second.inspect().await.map(|value| value.document.name) }); + client.inspect().await.unwrap(); + + assert_eq!(task.await.unwrap().unwrap(), "Plants"); +} + +// -- collections -------------------------------------------------------------------------- + +/// PAG-07: a Collection continuation is followed the same way an Offering one is. +#[tokio::test] +async fn continues_a_collection_sequence_to_its_end() { + let stub = Stub::new(|request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else if request.url.contains("cursor=c2") { + response( + 200, + br#"{"items":[{"id":"shrubs","name":"Shrubs"}],"odp_version":"1.0"}"#, + ODP_JSON, + ) + } else { + response( + 200, + br#"{"items":[{"id":"plants","name":"Plants"}],"next":"/odp/collections?cursor=c2","odp_version":"1.0"}"#, + ODP_JSON, + ) + }) + }); + let client = client(&stub); + + let first = client + .list_collections(Representation::Terse, 0) + .await + .unwrap(); + assert_eq!(first.items.len(), 1); + + let second = client.continue_collections(&first.next).await.unwrap(); + assert_eq!(second.items[0].id, "shrubs"); + assert!(second.next.is_empty()); +} + +/// A whole Collection sequence is gathered in one call, within the bounds it was given. +#[tokio::test] +async fn gathers_every_collection_across_pages() { + let stub = Stub::new(|request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else if request.url.contains("cursor=c3") { + response( + 200, + br#"{"items":[{"id":"trees","name":"Trees"}],"odp_version":"1.0"}"#, + ODP_JSON, + ) + } else if request.url.contains("cursor=c2") { + response( + 200, + br#"{"items":[{"id":"shrubs","name":"Shrubs"}],"next":"/odp/collections?cursor=c3","odp_version":"1.0"}"#, + ODP_JSON, + ) + } else { + response( + 200, + br#"{"items":[{"id":"plants","name":"Plants"}],"next":"/odp/collections?cursor=c2","odp_version":"1.0"}"#, + ODP_JSON, + ) + }) + }); + + let all = client(&stub) + .list_all_collections(Representation::Terse, 0, TraversalOptions::default()) + .await + .unwrap(); + assert_eq!(all.len(), 3); + + let bounded = client(&stub) + .list_all_collections( + Representation::Terse, + 0, + TraversalOptions { + max_items: 10, + max_pages: 2, + }, + ) + .await + .unwrap(); + assert_eq!(bounded.len(), 2, "the third page was outside the bound"); +} + +/// SEC-05: a schema URL on loopback HTTP is a reference this Agent will not follow. +#[tokio::test] +async fn reports_an_attribute_schema_url_it_will_not_follow() { + let offering = br#"{"attributes":{"size":"L"},"id":"plant-1","name":"Plant","odp_version":"1.0","schema":{"url":"http://localhost:8080/plant.json"}}"#; + let stub = Stub::serving(offering); + + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert_eq!(details.offering.id, "plant-1"); + assert!(details.offering.attributes.is_empty()); + assert_eq!(details.issues.len(), 1); + assert_eq!(details.issues[0].scope, OfferingIssueScope::AttributeSchema); + assert!( + details.issues[0].message.contains("HTTPS"), + "{:?}", + details.issues + ); +} + +/// The same applies to an Action's request schema. +#[tokio::test] +async fn refuses_a_request_schema_url_it_will_not_follow() { + let offering = br#"{"actions":[{"authentication":"not-required","http":{"href":"/buy","method":"POST","request":{"content_type":"application/json","schema":{"url":"http://localhost:8080/buy.json"}}},"id":"buy","rel":"purchase"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let stub = Stub::serving(offering); + + let error = client(&stub) + .resolve_action("plant-1", "buy") + .await + .unwrap_err(); + assert!(error.to_string().contains("HTTPS"), "{error}"); +} diff --git a/crates/odp-agent/tests/destination_conformance.rs b/crates/odp-agent/tests/destination_conformance.rs new file mode 100644 index 0000000..6ff53fe --- /dev/null +++ b/crates/odp-agent/tests/destination_conformance.rs @@ -0,0 +1,205 @@ +//! SEC-08: the destinations an Agent is willing to reach. + +mod support; + +use odp_agent::{SecureTransport, ServiceClient}; +use odp_directory::{HttpRequest, Transport}; +use support::{ODP_JSON, OFFERING_PAGE, SERVICE_DOCUMENT, Stub, response}; + +struct PinnedStub(std::sync::Arc); + +#[async_trait::async_trait] +impl Transport for PinnedStub { + async fn send( + &self, + _: HttpRequest, + ) -> Result { + panic!("SecureTransport must use pinned destinations"); + } + + async fn send_to( + &self, + request: HttpRequest, + addresses: &[std::net::SocketAddr], + _: usize, + ) -> Result { + assert!(!addresses.is_empty()); + let url = url::Url::parse(&request.url).unwrap(); + assert!( + addresses + .iter() + .all(|address| address.port() == url.port_or_known_default().unwrap()) + ); + if url.host_str() == Some("93.184.216.34") { + assert_eq!(addresses[0].ip().to_string(), "93.184.216.34"); + } else { + assert!(addresses.iter().all(|address| address.ip().is_loopback())); + } + self.0.send(request).await + } +} + +/// A Service Origin is written by somebody else, so the address behind it is checked first. +#[tokio::test] +async fn refuses_a_service_the_public_internet_does_not_route() { + for origin in [ + "https://169.254.169.254", + "https://127.0.0.1", + "https://10.0.0.1", + "https://192.168.1.1", + "https://172.16.9.9", + "https://[::1]", + "https://[fd00::1]", + "https://[64:ff9b::a9fe:a9fe]", + ] { + let client = ServiceClient::new(origin).expect("the URL itself is well formed"); + let error = client.inspect().await.unwrap_err(); + assert!( + error.to_string().contains("non-public"), + "{origin}: {error}" + ); + } +} + +/// The guard runs before the request is sent, not after the answer comes back. +#[tokio::test] +async fn refuses_the_destination_before_it_sends_anything() { + let inner = Stub::serving(SERVICE_DOCUMENT); + let guarded = SecureTransport::new(inner.clone()); + + let error = guarded + .send(HttpRequest { + body: Vec::new(), + headers: Default::default(), + method: "GET".to_owned(), + url: "https://169.254.169.254/latest/meta-data".to_owned(), + }) + .await + .unwrap_err(); + + assert!(error.message.contains("non-public"), "{}", error.message); + assert_eq!(inner.count(), 0, "nothing reached the inner transport"); +} + +#[tokio::test] +async fn reaches_a_public_destination() { + let inner = Stub::serving(OFFERING_PAGE); + let guarded = SecureTransport::new(std::sync::Arc::new(PinnedStub(inner.clone()))); + + guarded + .send(HttpRequest { + body: Vec::new(), + headers: Default::default(), + method: "GET".to_owned(), + url: "https://93.184.216.34/.well-known/odp".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(inner.count(), 1); +} + +/// Local development reaches loopback and nothing else. +#[tokio::test] +async fn permits_loopback_only_for_local_development() { + let inner = Stub::new(|_| Ok(response(200, SERVICE_DOCUMENT, ODP_JSON))); + let guarded = + SecureTransport::for_local_development(std::sync::Arc::new(PinnedStub(inner.clone()))); + + for url in [ + "http://127.0.0.1:8080/.well-known/odp", + "http://localhost:8080/.well-known/odp", + "https://[::1]/.well-known/odp", + ] { + guarded + .send(HttpRequest { + body: Vec::new(), + headers: Default::default(), + method: "GET".to_owned(), + url: url.to_owned(), + }) + .await + .unwrap_or_else(|error| panic!("{url}: {error}")); + } + + let error = guarded + .send(HttpRequest { + body: Vec::new(), + headers: Default::default(), + method: "GET".to_owned(), + url: "https://169.254.169.254/".to_owned(), + }) + .await + .unwrap_err(); + assert!(error.message.contains("non-public"), "{}", error.message); + assert_eq!(inner.count(), 3, "only the loopback requests were sent"); +} + +#[tokio::test] +async fn builds_a_local_development_client_over_loopback() { + let client = ServiceClient::for_local_development("http://127.0.0.1:4103/.well-known/odp"); + assert!(client.is_ok()); +} + +#[tokio::test] +async fn refuses_a_target_that_names_no_host() { + let inner = Stub::serving(SERVICE_DOCUMENT); + let guarded = SecureTransport::new(inner.clone()); + + for url in ["not a url", "file:///etc/passwd"] { + assert!( + guarded + .send(HttpRequest { + body: Vec::new(), + headers: Default::default(), + method: "GET".to_owned(), + url: url.to_owned(), + }) + .await + .is_err(), + "{url}" + ); + } + assert_eq!(inner.count(), 0); +} + +/// A caller-supplied transport keeps its own destination policy, so a stub stays a stub. +#[tokio::test] +async fn leaves_a_caller_supplied_transport_alone() { + let stub = Stub::serving(OFFERING_PAGE); + let client = ServiceClient::with_transport("https://plants.example", stub.clone()).unwrap(); + + assert_eq!(client.inspect().await.unwrap().document.name, "Plants"); + assert_eq!(stub.count(), 1); +} + +/// A host that resolves nowhere is refused rather than attempted. +#[tokio::test] +async fn refuses_a_host_that_does_not_resolve() { + let inner = Stub::serving(SERVICE_DOCUMENT); + let guarded = SecureTransport::new(inner.clone()); + + let error = guarded + .send(HttpRequest { + body: Vec::new(), + headers: Default::default(), + method: "GET".to_owned(), + url: "https://invalid.invalid/.well-known/odp".to_owned(), + }) + .await + .unwrap_err(); + + assert!(error.message.contains("resolve"), "{}", error.message); + assert_eq!(inner.count(), 0); +} + +/// The table itself lives in `odp-core`, so every crate judges an address the same way. +#[test] +fn judges_addresses_without_resolving_anything() { + use odp_core::is_public; + + assert!(is_public("8.8.8.8".parse().unwrap())); + assert!(is_public("2001:4860:4860::8888".parse().unwrap())); + assert!(!is_public("169.254.169.254".parse().unwrap())); + assert!(!is_public("::ffff:10.0.0.1".parse().unwrap())); +} diff --git a/crates/odp-agent/tests/schema_conformance.rs b/crates/odp-agent/tests/schema_conformance.rs new file mode 100644 index 0000000..f0ddea7 --- /dev/null +++ b/crates/odp-agent/tests/schema_conformance.rs @@ -0,0 +1,621 @@ +//! ERR-21 and SEC-05 as they apply to Attribute Schemas and the supporting documents around them. + +mod support; + +use std::sync::{Arc, Mutex}; + +use odp_agent::{CacheFallbacks, OfferingIssueScope, ServiceClient}; +use odp_directory::{HttpRequest, HttpResponse, TransportError}; +use support::{ + ODP_JSON, SCHEMA_JSON, SERVICE_DOCUMENT, Stub, bare, client, response, with_headers, +}; + +const DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; +const CORE_VOCABULARY: &str = "https://json-schema.org/draft/2020-12/vocab/core"; + +#[tokio::test] +async fn example_values_are_not_interpreted_as_schema_keywords() { + let stub = serving(|_| { + response(200, &serde_json::to_vec(&serde_json::json!({ + "$schema":DIALECT,"type":"object","examples":[{"$ref":"https://unrelated.example/value","$dynamicRef":"not-a-schema-reference","$vocabulary":{"unknown":true}}] + })).unwrap(), SCHEMA_JSON) + }); + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert!(details.issues.is_empty(), "{:?}", details.issues); + assert_eq!( + stub.requests() + .iter() + .filter(|r| r.url.contains("schemas.example")) + .count(), + 1 + ); +} + +#[tokio::test] +async fn unsupported_standard_namespace_vocabulary_is_still_unsupported() { + let stub = serving(|_| { + response(200, &serde_json::to_vec(&serde_json::json!({"$schema":DIALECT,"$vocabulary":{"https://json-schema.org/draft/2020-12/vocab/not-implemented":true}})).unwrap(), SCHEMA_JSON) + }); + assert!(schema_issue(&stub).await.contains("not-implemented")); +} + +#[tokio::test] +async fn malformed_identifiers_are_not_repaired_during_bundling() { + for id in [ + serde_json::json!(42), + serde_json::json!("https://schemas.example/root#fragment"), + ] { + let stub = serving(move |_| { + response( + 200, + &serde_json::to_vec(&serde_json::json!({"$schema":DIALECT,"$id":id})).unwrap(), + SCHEMA_JSON, + ) + }); + assert!(schema_issue(&stub).await.contains("$id")); + } +} + +#[tokio::test] +async fn revalidates_a_redirected_schema_at_its_final_url() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let calls = AtomicUsize::new(0); + let stub = serving(move |url| match url { + "https://schemas.example/root.json" => bare(302, &[("location", "/moved/root.json")]), + "https://schemas.example/moved/root.json" => { + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + with_headers( + 200, + &leaf(), + SCHEMA_JSON, + &[("cache-control", "max-age=0"), ("etag", "schema-v1")], + ) + } else { + bare(304, &[]) + } + } + other => panic!("Unexpected schema request {other}"), + }); + let client = client(&stub); + let first = client.get_offering_details("plant-1").await.unwrap(); + let second = client.get_offering_details("plant-1").await.unwrap(); + assert!(first.issues.is_empty()); + assert!(second.issues.is_empty()); + assert_eq!(first.attribute_schema, second.attribute_schema); + assert_eq!( + stub.requests() + .iter() + .filter(|r| r.url == "https://schemas.example/root.json") + .count(), + 1 + ); +} + +#[tokio::test] +async fn resolves_from_final_url_and_returns_a_self_contained_schema() { + let stub = serving(|url| { + match url { + "https://schemas.example/root.json" => bare(302, &[("location", "/nested/root.json")]), + "https://schemas.example/nested/root.json" => response(200, &serde_json::to_vec(&serde_json::json!({"$schema":DIALECT,"$ref":"child.json"})).unwrap(), SCHEMA_JSON), + "https://schemas.example/nested/child.json" => response(200, &serde_json::to_vec(&serde_json::json!({"$schema":DIALECT,"type":"object","properties":{"size":{"type":"integer"}}})).unwrap(), SCHEMA_JSON), + other => panic!("Unexpected schema request {other}"), + } + }); + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert!(details.issues.is_empty(), "{:?}", details.issues); + let bundled = details.attribute_schema.unwrap(); + // jsonschema has no HTTP retrieval feature enabled in this workspace. + let validator = jsonschema::validator_for(&bundled).unwrap(); + assert!(validator.is_valid(&serde_json::json!({"size":1}))); + assert!(!validator.is_valid(&serde_json::json!({"size":"wrong"}))); +} + +#[tokio::test] +async fn bundled_references_preserve_fragments_across_redirects() { + let stub = serving(|url| { + match url { + "https://schemas.example/root.json" => response(200, &serde_json::to_vec(&serde_json::json!({"$schema":DIALECT,"type":"object","properties":{"size":{"$ref":"child.json#/$defs/size"}}})).unwrap(), SCHEMA_JSON), + "https://schemas.example/child.json" => bare(302, &[("location", "/moved/child.json")]), + "https://schemas.example/moved/child.json" => response(200, &serde_json::to_vec(&serde_json::json!({"$schema":DIALECT,"$defs":{"size":{"type":"integer"}}})).unwrap(), SCHEMA_JSON), + other => panic!("Unexpected schema request {other}"), + } + }); + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert!(details.issues.is_empty(), "{:?}", details.issues); + let validator = jsonschema::validator_for(&details.attribute_schema.unwrap()).unwrap(); + assert!(validator.is_valid(&serde_json::json!({"size":1}))); + assert!(!validator.is_valid(&serde_json::json!({"size":"wrong"}))); +} + +#[tokio::test] +async fn supporting_redirects_cannot_change_origin() { + let stub = serving(|_| bare(302, &[("location", "https://another.example/schema")])); + assert!(schema_issue(&stub).await.contains("origin")); + assert!( + !stub + .requests() + .iter() + .any(|r| r.url.contains("another.example")) + ); +} + +/// An Offering whose attributes are described by a schema at `https://schemas.example/root.json`. +const OFFERING: &[u8] = br#"{"attributes":{"size":1},"id":"plant-1","name":"Plant","odp_version":"1.0","schema":{"url":"https://schemas.example/root.json"}}"#; + +fn schema(body: String) -> Vec { + body.into_bytes() +} + +/// A leaf schema that references nothing. +fn leaf() -> Vec { + schema(format!(r#"{{"$schema":"{DIALECT}","type":"object"}}"#)) +} + +/// A Service answering the Service Document, the Offering, and schemas from a reply function. +fn serving(schemas: impl Fn(&str) -> HttpResponse + Send + Sync + 'static) -> Arc { + Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else if request.url.contains("schemas.example") { + schemas(&request.url) + } else { + response(200, OFFERING, ODP_JSON) + }) + }) +} + +/// The single issue an Offering reports about its Attribute Schema. +async fn schema_issue(stub: &Arc) -> String { + let details = client(stub).get_offering_details("plant-1").await.unwrap(); + assert_eq!( + details.issues.len(), + 1, + "expected one schema issue: {:?}", + details.issues + ); + assert_eq!(details.issues[0].scope, OfferingIssueScope::AttributeSchema); + details.issues[0].message.clone() +} + +// -- the shape of a schema ---------------------------------------------------------------- + +/// A document that does not declare the 2020-12 dialect is not a schema this Agent can apply. +#[tokio::test] +async fn refuses_a_document_that_declares_no_dialect() { + let stub = serving(|_| response(200, br#"{"type":"object"}"#, SCHEMA_JSON)); + assert!( + schema_issue(&stub).await.contains("Draft 2020-12"), + "the dialect is what is missing" + ); +} + +/// A schema requiring a vocabulary this Agent does not implement cannot be evaluated correctly. +#[tokio::test] +async fn refuses_a_schema_requiring_an_unsupported_vocabulary() { + let body = schema(format!( + r#"{{"$schema":"{DIALECT}","$vocabulary":{{"{CORE_VOCABULARY}":true,"https://vocab.example/custom":true}},"type":"object"}}"# + )); + let stub = serving(move |_| response(200, &body, SCHEMA_JSON)); + assert!( + schema_issue(&stub).await.contains("vocab.example/custom"), + "the unsupported vocabulary is named" + ); +} + +/// A vocabulary the schema only prefers is not a reason to refuse it. +#[tokio::test] +async fn accepts_a_vocabulary_the_schema_does_not_require() { + let body = schema(format!( + r#"{{"$schema":"{DIALECT}","$vocabulary":{{"{CORE_VOCABULARY}":true,"https://vocab.example/custom":false}},"properties":{{"size":{{"type":"integer"}}}},"type":"object"}}"# + )); + let stub = serving(move |_| response(200, &body, SCHEMA_JSON)); + + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert!(details.issues.is_empty(), "{:?}", details.issues); + assert_eq!(details.offering.attributes.len(), 1); +} + +// -- the bounds of a schema graph --------------------------------------------------------- + +/// A graph of more than 16 documents is more than an Agent will assemble. +#[tokio::test] +async fn refuses_a_schema_graph_of_more_than_sixteen_documents() { + let references = (0..16) + .map(|index| format!(r#"{{"$ref":"https://schemas.example/leaf-{index}.json"}}"#)) + .collect::>() + .join(","); + let root = schema(format!( + r#"{{"$schema":"{DIALECT}","allOf":[{references}],"type":"object"}}"# + )); + let other = leaf(); + let stub = serving(move |url| { + response( + 200, + if url.contains("root") { &root } else { &other }, + SCHEMA_JSON, + ) + }); + + assert!(schema_issue(&stub).await.contains("16 documents")); +} + +/// A chain of references deeper than eight levels is refused at the level that crosses the bound. +#[tokio::test] +async fn refuses_a_schema_graph_deeper_than_eight_levels() { + let stub = serving(|url| { + let level: usize = url + .split("level-") + .nth(1) + .and_then(|value| value.split('.').next()) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + let body = schema(format!( + r#"{{"$schema":"{DIALECT}","allOf":[{{"$ref":"https://schemas.example/level-{}.json"}}],"type":"object"}}"#, + level + 1 + )); + response(200, &body, SCHEMA_JSON) + }); + + assert!(schema_issue(&stub).await.contains("eight reference levels")); +} + +/// The graph as a whole is bounded too, however few documents carry it. +#[tokio::test] +async fn refuses_a_schema_graph_over_its_byte_limit() { + let padding = "x".repeat(200_000); + let references = (0..6) + .map(|index| format!(r#"{{"$ref":"https://schemas.example/leaf-{index}.json"}}"#)) + .collect::>() + .join(","); + let root = schema(format!( + r#"{{"$schema":"{DIALECT}","allOf":[{references}],"description":"{padding}","type":"object"}}"# + )); + let big = schema(format!( + r#"{{"$schema":"{DIALECT}","description":"{padding}","type":"object"}}"# + )); + let stub = serving(move |url| { + response( + 200, + if url.contains("root") { &root } else { &big }, + SCHEMA_JSON, + ) + }); + + assert!(schema_issue(&stub).await.contains("byte limit")); +} + +/// A single document over its own limit is refused before the graph is even assembled. +#[tokio::test] +async fn refuses_a_schema_document_over_its_byte_limit() { + let body = schema(format!( + r#"{{"$schema":"{DIALECT}","description":"{}","type":"object"}}"#, + "x".repeat(262_145) + )); + let stub = serving(move |_| response(200, &body, SCHEMA_JSON)); + + assert!(schema_issue(&stub).await.contains("byte limit")); +} + +/// ERR-20: a declared Content-Length over the limit is refused without reading the body. +#[tokio::test] +async fn refuses_a_schema_that_declares_more_than_it_may_send() { + let stub = + serving(|_| with_headers(200, &leaf(), SCHEMA_JSON, &[("content-length", "262145")])); + + assert!(schema_issue(&stub).await.contains("byte limit")); +} + +/// A schema served as something other than a schema is not read as one. +#[tokio::test] +async fn refuses_a_schema_of_an_unsupported_media_type() { + let stub = serving(|_| response(200, &leaf(), "text/html")); + assert!(schema_issue(&stub).await.contains("media type")); +} + +/// The same document reached twice in one graph is fetched once. +#[tokio::test] +async fn fetches_a_shared_schema_document_once() { + let root = schema(format!( + r#"{{"$schema":"{DIALECT}","allOf":[{{"$ref":"https://schemas.example/shared.json"}},{{"$ref":"https://schemas.example/shared.json#/$defs/sized"}}],"type":"object"}}"# + )); + let shared = schema(format!( + r#"{{"$schema":"{DIALECT}","$defs":{{"sized":{{"properties":{{"size":{{"type":"integer"}}}},"type":"object"}}}},"type":"object"}}"# + )); + let stub = serving(move |url| { + response( + 200, + if url.contains("root") { &root } else { &shared }, + SCHEMA_JSON, + ) + }); + + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert!(details.issues.is_empty(), "{:?}", details.issues); + assert_eq!( + stub.requests() + .iter() + .filter(|request| request.url.contains("shared.json")) + .count(), + 1 + ); +} + +/// SEC-05: a schema reference over plain HTTP is not followed. +#[tokio::test] +async fn refuses_a_schema_reference_that_is_not_https() { + let root = schema(format!( + r#"{{"$schema":"{DIALECT}","allOf":[{{"$ref":"http://schemas.example/leaf.json"}}],"type":"object"}}"# + )); + let stub = serving(move |_| response(200, &root, SCHEMA_JSON)); + + assert!(schema_issue(&stub).await.contains("HTTPS")); +} + +// -- caching a supporting document --------------------------------------------------------- + +/// CCH-02: a schema stays fresh for as long as the Service says, and is not re-fetched. +#[tokio::test] +async fn serves_a_fresh_schema_from_the_cache() { + let stub = serving(|_| { + with_headers( + 200, + &leaf(), + SCHEMA_JSON, + &[("cache-control", "max-age=3600")], + ) + }); + let client = client(&stub); + + client.get_offering_details("plant-1").await.unwrap(); + client.get_offering_details("plant-1").await.unwrap(); + + assert_eq!( + stub.requests() + .iter() + .filter(|request| request.url.contains("schemas.example")) + .count(), + 1, + "the second read came from the cache" + ); +} + +/// CCH-05: a stale schema is revalidated, and a 304 refreshes what is already held. +#[tokio::test] +async fn revalidates_a_stale_schema_with_its_entity_tag() { + let seen = Arc::new(Mutex::new(Vec::::new())); + let recorder = seen.clone(); + let stub = Stub::new(move |request| { + if request.url.ends_with("/.well-known/odp") { + return Ok(response(200, SERVICE_DOCUMENT, ODP_JSON)); + } + if !request.url.contains("schemas.example") { + return Ok(response(200, OFFERING, ODP_JSON)); + } + recorder.lock().unwrap().push(request.clone()); + Ok(if request.headers.contains_key("if-none-match") { + bare(304, &[("cache-control", "max-age=60")]) + } else { + with_headers( + 200, + &leaf(), + SCHEMA_JSON, + &[("cache-control", "max-age=0"), ("etag", "\"v1\"")], + ) + }) + }); + let client = client(&stub); + + client.get_offering_details("plant-1").await.unwrap(); + let details = client.get_offering_details("plant-1").await.unwrap(); + + assert!(details.issues.is_empty(), "{:?}", details.issues); + let requests = seen.lock().unwrap().clone(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1].headers.get("if-none-match").map(String::as_str), + Some("\"v1\"") + ); +} + +/// CCH-05: with no entity tag to offer, a stale schema is revalidated by its modification date. +#[tokio::test] +async fn revalidates_a_stale_schema_by_its_modification_date() { + let seen = Arc::new(Mutex::new(Vec::::new())); + let recorder = seen.clone(); + let stub = Stub::new(move |request| { + if request.url.ends_with("/.well-known/odp") { + return Ok(response(200, SERVICE_DOCUMENT, ODP_JSON)); + } + if !request.url.contains("schemas.example") { + return Ok(response(200, OFFERING, ODP_JSON)); + } + recorder.lock().unwrap().push(request.clone()); + Ok(if request.headers.contains_key("if-modified-since") { + bare(304, &[("cache-control", "max-age=60")]) + } else { + with_headers( + 200, + &leaf(), + SCHEMA_JSON, + &[ + ("cache-control", "max-age=0"), + ("last-modified", "Tue, 08 Sep 2026 00:00:00 GMT"), + ], + ) + }) + }); + let client = client(&stub); + + client.get_offering_details("plant-1").await.unwrap(); + let details = client.get_offering_details("plant-1").await.unwrap(); + + assert!(details.issues.is_empty(), "{:?}", details.issues); + let requests = seen.lock().unwrap().clone(); + assert_eq!(requests.len(), 2); + assert!(!requests[1].headers.contains_key("if-none-match")); + assert_eq!( + requests[1] + .headers + .get("if-modified-since") + .map(String::as_str), + Some("Tue, 08 Sep 2026 00:00:00 GMT") + ); +} + +/// A 304 with nothing held is a Service mistake the Agent will not guess around. +#[tokio::test] +async fn refuses_a_schema_revalidation_it_never_asked_for() { + let stub = serving(|_| bare(304, &[])); + assert!(schema_issue(&stub).await.contains("304")); +} + +/// CCH-06: `no-store` on a revalidation drops what was held rather than extending it. +#[tokio::test] +async fn drops_a_held_schema_the_service_asks_it_to_forget() { + let stub = Stub::new(move |request| { + if request.url.ends_with("/.well-known/odp") { + return Ok(response(200, SERVICE_DOCUMENT, ODP_JSON)); + } + if !request.url.contains("schemas.example") { + return Ok(response(200, OFFERING, ODP_JSON)); + } + Ok(if request.headers.contains_key("if-none-match") { + bare(304, &[("cache-control", "no-store")]) + } else { + with_headers( + 200, + &leaf(), + SCHEMA_JSON, + &[("cache-control", "max-age=0"), ("etag", "\"v1\"")], + ) + }) + }); + let client = client(&stub); + + client.get_offering_details("plant-1").await.unwrap(); + client.get_offering_details("plant-1").await.unwrap(); + // The third read has nothing to revalidate against, so it is a plain request again. + client.get_offering_details("plant-1").await.unwrap(); + + let conditional = stub + .requests() + .iter() + .filter(|request| { + request.url.contains("schemas.example") && request.headers.contains_key("if-none-match") + }) + .count(); + assert_eq!(conditional, 1, "the held representation was forgotten"); +} + +/// CCH-06: `no-store` on the response itself keeps nothing at all. +#[tokio::test] +async fn keeps_nothing_of_a_schema_marked_no_store() { + let stub = + serving(|_| with_headers(200, &leaf(), SCHEMA_JSON, &[("cache-control", "no-store")])); + let client = client(&stub); + + client.get_offering_details("plant-1").await.unwrap(); + client.get_offering_details("plant-1").await.unwrap(); + + assert!( + stub.requests() + .iter() + .filter(|request| request.url.contains("schemas.example")) + .all(|request| !request.headers.contains_key("if-none-match")), + "nothing was held to revalidate" + ); +} + +/// ERR-24: a supporting document follows redirects, and only over HTTPS. +#[tokio::test] +async fn follows_a_schema_redirect_to_its_target() { + let stub = serving(|url| { + if url.contains("root") { + bare( + 308, + &[("location", "https://schemas.example/moved/leaf.json")], + ) + } else { + response(200, &leaf(), SCHEMA_JSON) + } + }); + + let details = client(&stub).get_offering_details("plant-1").await.unwrap(); + assert!(details.issues.is_empty(), "{:?}", details.issues); + assert!( + stub.requests() + .iter() + .any(|request| request.url.contains("/moved/")) + ); +} + +#[tokio::test] +async fn refuses_a_schema_redirect_that_leaves_https() { + let stub = serving(|_| bare(302, &[("location", "http://schemas.example/root.json")])); + assert!(schema_issue(&stub).await.contains("origin")); +} + +#[tokio::test] +async fn refuses_a_schema_redirect_that_names_no_target() { + let stub = serving(|_| bare(307, &[])); + assert!(schema_issue(&stub).await.contains("Location")); +} + +/// ERR-25: the sixth redirect is one too many. +#[tokio::test] +async fn refuses_a_sixth_schema_redirect() { + let stub = serving(|url| { + let step: usize = url + .split("step=") + .nth(1) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + bare( + 308, + &[( + "location", + Box::leak( + format!("https://schemas.example/root.json?step={}", step + 1).into_boxed_str(), + ), + )], + ) + }); + + assert!(schema_issue(&stub).await.contains("five redirects")); +} + +/// A supporting document is fetched over HTTPS, so a client on loopback cannot reach one. +#[tokio::test] +async fn refuses_a_supporting_document_url_that_is_not_https() { + let offering = br#"{"actions":[{"authentication":"not-required","http":{"href":"/buy","method":"POST","request":{"content_type":"application/json","schema":{"url":"https://schemas.example/buy.json"}}},"id":"buy","rel":"purchase"}],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else { + response(200, offering, ODP_JSON) + }) + }); + // A transport that refuses everything proves the URL check runs before anything is sent. + let client = ServiceClient::with_transport("https://plants.example", stub.clone()) + .unwrap() + .with_supporting_transport(Arc::new(Refusing)) + .with_cache_fallbacks(CacheFallbacks::default()); + + let error = client + .resolve_action("plant-1", "buy") + .await + .unwrap_err() + .to_string(); + assert!(error.contains("refused"), "{error}"); +} + +struct Refusing; + +#[async_trait::async_trait] +impl odp_directory::Transport for Refusing { + async fn send(&self, _request: HttpRequest) -> Result { + Err(TransportError { + message: "refused".to_owned(), + }) + } +} diff --git a/crates/odp-agent/tests/support/mod.rs b/crates/odp-agent/tests/support/mod.rs new file mode 100644 index 0000000..3c042eb --- /dev/null +++ b/crates/odp-agent/tests/support/mod.rs @@ -0,0 +1,139 @@ +//! The fixtures the Agent conformance tests are written against. +//! +//! Each test binary compiles this module separately, so not every binary uses every fixture. +#![allow(dead_code)] + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use odp_agent::ServiceClient; +use odp_directory::{HttpRequest, HttpResponse, Transport, TransportError}; + +pub const ODP_JSON: &str = "application/odp+json"; +pub const PROBLEM_JSON: &str = "application/problem+json"; +pub const SCHEMA_JSON: &str = "application/schema+json"; +pub const ORIGIN: &str = "https://plants.example"; + +/// A Service Document advertising every operation this crate can drive. +pub const SERVICE_DOCUMENT: &[u8] = br#"{"description":"Plants for agents.","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-collection"},{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-collection-offerings"},{"authentication":"not-required","name":"list-collections"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-collections"},{"authentication":"not-required","name":"search-offerings"}]}"#; + +pub const OFFERING: &[u8] = br#"{"id":"plant-1","name":"Rubber Plant","odp_version":"1.0"}"#; +pub const OFFERING_PAGE: &[u8] = + br#"{"items":[{"id":"plant-1","name":"Rubber Plant"}],"odp_version":"1.0"}"#; +pub const COLLECTION: &[u8] = br#"{"id":"plants","name":"Plants","odp_version":"1.0"}"#; +pub const COLLECTION_PAGE: &[u8] = + br#"{"items":[{"id":"plants","name":"Plants"}],"odp_version":"1.0"}"#; + +/// How a [`Stub`] answers one request. +type Reply = dyn Fn(&HttpRequest) -> Result + Send + Sync; + +/// One scripted exchange, and a record of everything the Agent asked for. +pub struct Stub { + reply: Box, + requests: Mutex>, +} + +impl Stub { + pub fn new( + reply: impl Fn(&HttpRequest) -> Result + Send + Sync + 'static, + ) -> Arc { + Arc::new(Self { + reply: Box::new(reply), + requests: Mutex::new(Vec::new()), + }) + } + + /// A Service that answers the Service Document and one other document for everything else. + pub fn serving(body: &'static [u8]) -> Arc { + Self::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else { + response(200, body, ODP_JSON) + }) + }) + } + + pub fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + pub fn count(&self) -> usize { + self.requests.lock().unwrap().len() + } + + /// Every request except the Service Document, which every operation fetches first. + pub fn catalog_requests(&self) -> Vec { + self.requests() + .into_iter() + .filter(|request| !request.url.ends_with("/.well-known/odp")) + .collect() + } + + pub fn last(&self) -> HttpRequest { + self.requests().last().cloned().expect("a request") + } +} + +#[async_trait] +impl Transport for Stub { + async fn send(&self, request: HttpRequest) -> Result { + self.requests.lock().unwrap().push(request.clone()); + (self.reply)(&request) + } +} + +pub fn response(status: u16, body: &[u8], content_type: &str) -> HttpResponse { + HttpResponse { + body: body.to_vec(), + headers: BTreeMap::from([("content-type".to_owned(), content_type.to_owned())]), + status, + } +} + +pub fn with_headers( + status: u16, + body: &[u8], + content_type: &str, + extra: &[(&str, &str)], +) -> HttpResponse { + let mut value = response(status, body, content_type); + for (name, setting) in extra { + value + .headers + .insert((*name).to_owned(), (*setting).to_owned()); + } + value +} + +/// A response carrying no body at all, as a redirect or a 304 does. +pub fn bare(status: u16, extra: &[(&str, &str)]) -> HttpResponse { + HttpResponse { + body: Vec::new(), + headers: extra + .iter() + .map(|(name, setting)| ((*name).to_owned(), (*setting).to_owned())) + .collect(), + status, + } +} + +pub fn client(stub: &Arc) -> ServiceClient { + ServiceClient::with_transport(ORIGIN, stub.clone()) + .unwrap() + .with_supporting_transport(stub.clone()) +} + +/// A Service whose replies are taken from a script, one per request, repeating the last. +pub fn scripted(steps: Vec) -> Arc { + let cursor = Mutex::new(0_usize); + Stub::new(move |_| { + let mut index = cursor.lock().unwrap(); + let step = steps[(*index).min(steps.len() - 1)].clone(); + *index += 1; + Ok(step) + }) +} diff --git a/crates/odp-agent/tests/transport_conformance.rs b/crates/odp-agent/tests/transport_conformance.rs new file mode 100644 index 0000000..8f2287c --- /dev/null +++ b/crates/odp-agent/tests/transport_conformance.rs @@ -0,0 +1,536 @@ +//! What the Agent puts on the wire, and what it refuses to read back. + +mod support; + +use std::sync::Arc; + +use odp_agent::{AgentError, ServiceClient}; +use odp_core::Representation; +use support::{ + COLLECTION, COLLECTION_PAGE, ODP_JSON, OFFERING, OFFERING_PAGE, ORIGIN, PROBLEM_JSON, + SERVICE_DOCUMENT, Stub, bare, client, response, scripted, with_headers, +}; + +/// MED-02: an Agent says what it will take, and MED-05 what it is sending. +#[tokio::test] +async fn states_the_media_type_it_accepts() { + let stub = Stub::serving(OFFERING_PAGE); + client(&stub) + .list_offerings(Representation::Terse, 10) + .await + .unwrap(); + + let request = stub.last(); + assert_eq!( + request.headers.get("accept").map(String::as_str), + Some(ODP_JSON) + ); + assert_eq!(request.method, "GET"); + assert!(!request.headers.contains_key("content-type")); +} + +#[tokio::test] +async fn declares_the_media_type_of_a_search_body() { + let stub = Stub::serving(OFFERING_PAGE); + client(&stub) + .search_offerings(&search("plants"), Representation::Terse) + .await + .unwrap(); + + let request = stub.last(); + assert_eq!(request.method, "POST"); + assert_eq!( + request.headers.get("content-type").map(String::as_str), + Some(ODP_JSON) + ); + assert!(!request.body.is_empty()); +} + +#[tokio::test] +async fn sends_the_language_the_caller_asked_for() { + let stub = Stub::serving(OFFERING_PAGE); + client(&stub) + .with_accept_language("fr-CA") + .list_offerings(Representation::Terse, 0) + .await + .unwrap(); + + assert_eq!( + stub.last() + .headers + .get("accept-language") + .map(String::as_str), + Some("fr-CA") + ); +} + +/// MED-08: a successful response with a missing or different media type is not an ODP response. +#[tokio::test] +async fn refuses_a_successful_response_of_another_media_type() { + for content_type in ["application/json", "text/html", "", "application/odp+jsonx"] { + let stub = Stub::new(move |_| Ok(response(200, SERVICE_DOCUMENT, content_type))); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::InvalidResponse(message) if message.contains(ODP_JSON)), + "{content_type}: {error}" + ); + } +} + +/// MED-10: a parameter is syntactically valid and carries no meaning of its own. +#[tokio::test] +async fn ignores_media_type_parameters() { + let stub = Stub::new(|_| { + Ok(response( + 200, + SERVICE_DOCUMENT, + "APPLICATION/ODP+JSON; charset=utf-8", + )) + }); + assert_eq!( + client(&stub).inspect().await.unwrap().document.name, + "Plants" + ); +} + +/// SVC-73: the representation the caller asked for is the one the request states. +#[tokio::test] +async fn states_the_representation_and_limit_it_was_asked_for() { + let stub = Stub::serving(OFFERING_PAGE); + client(&stub) + .list_offerings(Representation::Full, 25) + .await + .unwrap(); + assert!(stub.last().url.contains("representation=full")); + assert!(stub.last().url.contains("limit=25")); + + let stub = Stub::serving(OFFERING_PAGE); + client(&stub) + .list_offerings(Representation::Terse, 0) + .await + .unwrap(); + assert!(stub.last().url.contains("representation=terse")); + assert!( + !stub.last().url.contains("limit="), + "an absent limit is not sent" + ); +} + +/// ROLE-04: an Agent asks only for what the Service Document advertises. +#[tokio::test] +async fn refuses_an_operation_the_service_does_not_advertise() { + let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"}]}"#; + let stub = Stub::new(move |request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, document, ODP_JSON) + } else { + response(200, COLLECTION_PAGE, ODP_JSON) + }) + }); + + let error = client(&stub) + .list_collections(Representation::Terse, 0) + .await + .unwrap_err(); + assert!( + matches!(error, AgentError::UnsupportedOperation(_)), + "{error}" + ); + assert_eq!(stub.catalog_requests().len(), 0, "nothing was requested"); +} + +// -- redirects --------------------------------------------------------------------------- + +/// RFC 9110 15.4: a 303, and a 301 or 302 answering a POST, continue as a GET with no body. +#[tokio::test] +async fn continues_a_redirected_search_as_a_get() { + for status in [301, 302, 303] { + let stub = scripted(vec![ + response(200, SERVICE_DOCUMENT, ODP_JSON), + bare(status, &[("location", "/odp/offerings/search/v2")]), + response(200, OFFERING_PAGE, ODP_JSON), + ]); + client(&stub) + .search_offerings(&search("plants"), Representation::Terse) + .await + .unwrap(); + + let last = stub.last(); + assert_eq!(last.method, "GET", "{status}"); + assert!(last.body.is_empty(), "{status}"); + } +} + +/// A 307 or 308 keeps the method and the body it was answering. +#[tokio::test] +async fn keeps_the_method_across_a_preserving_redirect() { + for status in [307, 308] { + let stub = scripted(vec![ + response(200, SERVICE_DOCUMENT, ODP_JSON), + bare(status, &[("location", "/odp/offerings/search/v2")]), + response(200, OFFERING_PAGE, ODP_JSON), + ]); + client(&stub) + .search_offerings(&search("plants"), Representation::Terse) + .await + .unwrap(); + + let last = stub.last(); + assert_eq!(last.method, "POST", "{status}"); + assert!(!last.body.is_empty(), "{status}"); + } +} + +/// ERR-24: a redirect keeps the scheme, host and port of the request it answers. +#[tokio::test] +async fn refuses_a_redirect_that_leaves_the_service_origin() { + for location in [ + "https://elsewhere.example/odp/offerings", + "https://plants.example:8443/odp/offerings", + "http://plants.example/odp/offerings", + ] { + let stub = scripted(vec![ + response(200, SERVICE_DOCUMENT, ODP_JSON), + bare(302, &[("location", location)]), + response(200, OFFERING_PAGE, ODP_JSON), + ]); + let error = client(&stub) + .list_offerings(Representation::Terse, 0) + .await + .unwrap_err(); + assert!( + matches!(&error, AgentError::InvalidResponse(message) if message.contains("origin")), + "{location}: {error}" + ); + } +} + +/// ERR-25: the sixth redirect is refused. +#[tokio::test] +async fn refuses_a_sixth_redirect() { + let stub = scripted(vec![ + response(200, SERVICE_DOCUMENT, ODP_JSON), + bare(302, &[("location", "/odp/offerings?page=next")]), + ]); + let error = client(&stub) + .list_offerings(Representation::Terse, 0) + .await + .unwrap_err(); + assert!( + matches!(&error, AgentError::InvalidResponse(message) if message.contains("redirect")), + "{error}" + ); +} + +#[tokio::test] +async fn refuses_a_redirect_that_names_nowhere() { + let stub = scripted(vec![ + response(200, SERVICE_DOCUMENT, ODP_JSON), + bare(302, &[]), + ]); + let error = client(&stub) + .list_offerings(Representation::Terse, 0) + .await + .unwrap_err(); + assert!( + matches!(&error, AgentError::InvalidResponse(message) if message.contains("Location")), + "{error}" + ); +} + +// -- limits ------------------------------------------------------------------------------ + +/// ERR-21: a Service Document is read to 65,536 bytes and a page to 524,288. +#[tokio::test] +async fn refuses_a_body_past_its_limit() { + let padded = format!( + r#"{{"description":"{}","http":{{"endpoint_base":"/odp"}},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{{"authentication":"not-required","name":"get-offering"}},{{"authentication":"not-required","name":"list-offerings"}}]}}"#, + "d".repeat(70_000) + ); + let stub = Stub::new(move |_| Ok(response(200, padded.as_bytes(), ODP_JSON))); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::Transport(error) if error.message.contains("byte limit")), + "{error}" + ); +} + +/// ERR-20: a declared length past the limit is refused before the body is read at all. +#[tokio::test] +async fn refuses_a_declared_length_past_its_limit() { + let stub = Stub::new(|_| { + Ok(with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("content-length", "99999999")], + )) + }); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::InvalidResponse(message) if message.contains("byte limit")), + "{error}" + ); +} + +#[tokio::test] +async fn accepts_a_declared_length_within_its_limit() { + let stub = Stub::new(|_| { + Ok(with_headers( + 200, + SERVICE_DOCUMENT, + ODP_JSON, + &[("content-length", &SERVICE_DOCUMENT.len().to_string())], + )) + }); + assert_eq!( + client(&stub).inspect().await.unwrap().document.name, + "Plants" + ); +} + +/// ERR-21 gives a Problem Details response its own, much smaller limit. +#[tokio::test] +async fn refuses_a_problem_body_past_its_limit() { + let body = format!( + r#"{{"code":"X","detail":"{}","status":500,"title":"T","type":"https://offeringprotocol.org/problems/x"}}"#, + "d".repeat(20_000) + ); + let stub = Stub::new(move |_| Ok(response(500, body.as_bytes(), PROBLEM_JSON))); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::Transport(error) if error.message.contains("byte limit")), + "{error}" + ); +} + +// -- failures ---------------------------------------------------------------------------- + +/// A failure quotes a structured field of an RFC 9457 document, and nothing else. +#[tokio::test] +async fn quotes_only_the_problem_details_of_a_failure() { + let stub = Stub::new(|_| { + Ok(response( + 404, + br#"{"code":"NOT_FOUND","detail":"Offering plant-9 does not exist","status":404,"title":"Not found","type":"https://offeringprotocol.org/problems/not-found"}"#, + PROBLEM_JSON, + )) + }); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::Request { message, status } + if message == "Offering plant-9 does not exist" && *status == 404), + "{error}" + ); +} + +#[tokio::test] +async fn falls_back_to_the_problem_title() { + let stub = Stub::new(|_| { + Ok(response( + 500, + br#"{"code":"INTERNAL_ERROR","status":500,"title":"Internal error","type":"https://offeringprotocol.org/problems/internal-error"}"#, + PROBLEM_JSON, + )) + }); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::Request { message, .. } if message == "Internal error"), + "{error}" + ); +} + +/// A body of any other media type says nothing this Agent should carry into its own errors. +#[tokio::test] +async fn repeats_nothing_of_a_body_that_is_not_problem_details() { + for (body, content_type) in [ + ( + &b"upstream failed\nGET /admin HTTP/1.1 token=secret"[..], + "text/html", + ), + (&b"not json at all"[..], PROBLEM_JSON), + (&b"{}"[..], PROBLEM_JSON), + (&b""[..], ""), + ] { + let stub = Stub::new(move |_| Ok(response(503, body, content_type))); + let error = client(&stub).inspect().await.unwrap_err(); + let message = error.to_string(); + assert!(message.contains("503"), "{message}"); + assert!(!message.contains("secret"), "{message}"); + assert!(!message.contains("admin"), "{message}"); + assert!(!message.contains(""), "{message}"); + } +} + +/// A quoted field cannot forge a log line, however the Service wrote it. +#[tokio::test] +async fn flattens_the_text_it_quotes() { + let stub = Stub::new(|_| { + Ok(response( + 400, + br#"{"code":"INVALID_REQUEST","detail":"forged\n\tINFO ok","status":400,"title":"T","type":"https://offeringprotocol.org/problems/invalid-request"}"#, + PROBLEM_JSON, + )) + }); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::Request { message, .. } if message == "forged INFO ok"), + "{error}" + ); +} + +/// ERR-04 bounds what a Service may write, so a longer detail is not a Problem Details document +/// and nothing of it is quoted. +#[tokio::test] +async fn quotes_a_detail_up_to_its_limit_and_nothing_longer() { + let at_limit = format!( + r#"{{"code":"X","detail":"{}","status":400,"title":"T","type":"https://offeringprotocol.org/problems/x"}}"#, + "d".repeat(2_048) + ); + let stub = Stub::new(move |_| Ok(response(400, at_limit.as_bytes(), PROBLEM_JSON))); + let error = client(&stub).inspect().await.unwrap_err(); + let AgentError::Request { message, .. } = error else { + panic!("expected a request failure"); + }; + assert_eq!(message.chars().count(), 2_048); + + let past_limit = format!( + r#"{{"code":"X","detail":"{}","status":400,"title":"T","type":"https://offeringprotocol.org/problems/x"}}"#, + "d".repeat(2_049) + ); + let stub = Stub::new(move |_| Ok(response(400, past_limit.as_bytes(), PROBLEM_JSON))); + let error = client(&stub).inspect().await.unwrap_err(); + assert!( + matches!(&error, AgentError::Request { message, .. } if !message.contains("dddd")), + "{error}" + ); +} + +#[tokio::test] +async fn reports_a_transport_failure_as_such() { + let stub = Stub::new(|_| { + Err(odp_directory::TransportError { + message: "no route to host".to_owned(), + }) + }); + assert!(matches!( + client(&stub).inspect().await.unwrap_err(), + AgentError::Transport(_) + )); +} + +// -- construction ------------------------------------------------------------------------ + +#[tokio::test] +async fn refuses_a_service_url_it_cannot_use() { + for url in [ + "http://plants.example", + "not a url", + "https://user@plants.example", + "ftp://plants.example", + ] { + assert!(ServiceClient::new(url).is_err(), "{url}"); + } +} + +#[tokio::test] +async fn derives_the_service_origin_from_the_document_url() { + let stub = Stub::serving(OFFERING); + let client = + ServiceClient::with_transport("https://PLANTS.example:443/.well-known/odp", stub.clone()) + .unwrap(); + let inspection = client.inspect().await.unwrap(); + + assert_eq!(inspection.service_origin, ORIGIN); + assert_eq!( + inspection.requested_url, + format!("{ORIGIN}/.well-known/odp") + ); +} + +#[tokio::test] +async fn reaches_every_advertised_operation() { + let stub = Stub::new(|request| { + Ok(if request.url.ends_with("/.well-known/odp") { + response(200, SERVICE_DOCUMENT, ODP_JSON) + } else if request.url.contains("/collections/plants/offerings") { + response(200, OFFERING_PAGE, ODP_JSON) + } else if request.url.contains("/collections/plants") { + response(200, COLLECTION, ODP_JSON) + } else if request.url.contains("/collections") { + response(200, COLLECTION_PAGE, ODP_JSON) + } else if request.url.contains("/offerings/plant-1") { + response(200, OFFERING, ODP_JSON) + } else { + response(200, OFFERING_PAGE, ODP_JSON) + }) + }); + let client = Arc::new(client(&stub)); + + assert_eq!(client.get_offering("plant-1").await.unwrap().id, "plant-1"); + assert_eq!(client.get_collection("plants").await.unwrap().id, "plants"); + assert_eq!( + client + .list_offerings(Representation::Terse, 0) + .await + .unwrap() + .items + .len(), + 1 + ); + assert_eq!( + client + .list_collections(Representation::Terse, 0) + .await + .unwrap() + .items + .len(), + 1 + ); + assert_eq!( + client + .list_collection_offerings("plants", Representation::Terse, 0) + .await + .unwrap() + .items + .len(), + 1 + ); + assert_eq!( + client + .search_collections( + &odp_core::CollectionSearchRequest { + query: "plants".to_owned(), + ..odp_core::CollectionSearchRequest::default() + }, + Representation::Terse + ) + .await + .unwrap() + .items + .len(), + 1 + ); +} + +/// IDN-08: an identifier the protocol could not carry is refused before a request is sent. +#[tokio::test] +async fn refuses_an_identifier_that_is_not_local() { + let stub = Stub::serving(OFFERING); + for id in ["", ".", "..", "a b", "a/b", "a?b", &"a".repeat(129)] { + let error = client(&stub).get_offering(id).await.unwrap_err(); + assert!( + matches!(error, AgentError::InvalidRequest(_)), + "{id}: {error}" + ); + } + assert_eq!(stub.catalog_requests().len(), 0); +} + +pub fn search(query: &str) -> odp_core::OfferingSearchRequest { + odp_core::OfferingSearchRequest { + odp_version: "1.0".to_owned(), + query: query.to_owned(), + ..odp_core::OfferingSearchRequest::default() + } +} diff --git a/crates/odp-core/src/addresses.rs b/crates/odp-core/src/addresses.rs new file mode 100644 index 0000000..1037852 --- /dev/null +++ b/crates/odp-core/src/addresses.rs @@ -0,0 +1,209 @@ +//! SEC-08: the addresses the public internet does not route. +//! +//! A Service Origin, a Directory result and every reference inside a Service's documents are +//! written by somebody else. Judging an address against the IANA special-purpose registries is +//! how an SDK keeps a name a third party controls from naming an address its own network treats +//! as internal, so every crate in this workspace judges them the same way, from this one table. +//! +//! The IPv6 transition ranges matter as much as the obvious ones: each embeds an IPv4 address, so +//! without them a name resolving to `64:ff9b::a9fe:a9fe` reaches link-local 169.254.169.254. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +/// One special-purpose prefix: the network address and how many leading bits identify it. +struct Prefix { + bits: u8, + network: IpAddr, +} + +const fn v4(a: u8, b: u8, c: u8, d: u8, bits: u8) -> Prefix { + Prefix { + bits, + network: IpAddr::V4(Ipv4Addr::new(a, b, c, d)), + } +} + +const fn v6(segments: [u16; 8], bits: u8) -> Prefix { + Prefix { + bits, + network: IpAddr::V6(Ipv6Addr::new( + segments[0], + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + segments[6], + segments[7], + )), + } +} + +/// RFC 6890 and its successors: the addresses the public internet does not route. +static NON_PUBLIC: &[Prefix] = &[ + v4(0, 0, 0, 0, 8), + v4(10, 0, 0, 0, 8), + v4(100, 64, 0, 0, 10), + v4(127, 0, 0, 0, 8), + v4(169, 254, 0, 0, 16), + v4(172, 16, 0, 0, 12), + v4(192, 0, 0, 0, 24), + v4(192, 0, 2, 0, 24), + v4(192, 31, 196, 0, 24), + v4(192, 88, 99, 0, 24), + v4(192, 168, 0, 0, 16), + v4(192, 175, 48, 0, 24), + v4(198, 18, 0, 0, 15), + v4(198, 51, 100, 0, 24), + v4(203, 0, 113, 0, 24), + v4(224, 0, 0, 0, 4), + v4(240, 0, 0, 0, 4), + v6([0, 0, 0, 0, 0, 0, 0, 0], 96), + v6([0x64, 0xff9b, 0, 0, 0, 0, 0, 0], 96), + v6([0x64, 0xff9b, 1, 0, 0, 0, 0, 0], 48), + v6([0x100, 0, 0, 0, 0, 0, 0, 0], 64), + v6([0x2001, 0, 0, 0, 0, 0, 0, 0], 32), + v6([0x2001, 2, 0, 0, 0, 0, 0, 0], 48), + v6([0x2001, 3, 0, 0, 0, 0, 0, 0], 32), + v6([0x2001, 4, 0x112, 0, 0, 0, 0, 0], 48), + v6([0x2001, 0x10, 0, 0, 0, 0, 0, 0], 28), + v6([0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28), + v6([0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28), + v6([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0], 32), + v6([0x2002, 0, 0, 0, 0, 0, 0, 0], 16), + v6([0x2620, 0x4f, 0x8000, 0, 0, 0, 0, 0], 48), + v6([0x5f00, 0, 0, 0, 0, 0, 0, 0], 16), + v6([0xfc00, 0, 0, 0, 0, 0, 0, 0], 7), + v6([0xfe80, 0, 0, 0, 0, 0, 0, 0], 10), + v6([0xfec0, 0, 0, 0, 0, 0, 0, 0], 10), + v6([0xff00, 0, 0, 0, 0, 0, 0, 0], 8), +]; + +/// True when the address falls in no special-purpose range, so the public internet routes it. +#[must_use] +pub fn is_public(address: IpAddr) -> bool { + let address = unmap(address); + !NON_PUBLIC.iter().any(|prefix| contains(prefix, address)) +} + +/// An IPv4-mapped IPv6 address is the IPv4 address it carries, and is judged as one. +fn unmap(address: IpAddr) -> IpAddr { + match address { + IpAddr::V6(value) => value.to_ipv4_mapped().map_or(IpAddr::V6(value), IpAddr::V4), + value => value, + } +} + +fn contains(prefix: &Prefix, address: IpAddr) -> bool { + match (prefix.network, address) { + (IpAddr::V4(network), IpAddr::V4(value)) => { + matches_bits(&network.octets(), &value.octets(), prefix.bits) + } + (IpAddr::V6(network), IpAddr::V6(value)) => { + matches_bits(&network.octets(), &value.octets(), prefix.bits) + } + _ => false, + } +} + +fn matches_bits(network: &[u8], address: &[u8], bits: u8) -> bool { + let whole = usize::from(bits / 8); + if network[..whole] != address[..whole] { + return false; + } + let remainder = bits % 8; + if remainder == 0 { + return true; + } + let mask = 0xffu8 << (8 - remainder); + network[whole] & mask == address[whole] & mask +} + +#[cfg(test)] +mod tests { + use super::*; + + fn address(value: &str) -> IpAddr { + value.parse().unwrap() + } + + #[test] + fn routes_a_public_address() { + for value in ["8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "2606:4700::1"] { + assert!(is_public(address(value)), "{value}"); + } + } + + #[test] + fn refuses_every_special_purpose_range() { + for value in [ + "0.0.0.0", + "10.1.2.3", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "192.0.0.1", + "192.0.2.1", + "192.168.1.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "240.0.0.1", + "::1", + "::", + "fc00::1", + "fd12::1", + "fe80::1", + "fec0::1", + "ff02::1", + "2001:db8::1", + ] { + assert!(!is_public(address(value)), "{value}"); + } + } + + /// Each transition range embeds an IPv4 address, so a public-looking one can still be internal. + #[test] + fn refuses_the_ipv6_transition_ranges() { + for value in [ + "64:ff9b::a9fe:a9fe", + "64:ff9b:1::1", + "2002:a9fe:a9fe::1", + "2001::1", + "2001:2::1", + "::ffff:169.254.169.254", + "::ffff:127.0.0.1", + "100::1", + "5f00::1", + "2620:4f:8000::1", + ] { + assert!(!is_public(address(value)), "{value}"); + } + } + + /// The table is written with two constructors, and a prefix means nothing unless they put the + /// network and its length where `contains` reads them. + #[test] + fn builds_a_prefix_from_the_network_and_its_length() { + let ten = v4(10, 0, 0, 0, 8); + assert_eq!(ten.bits, 8); + assert!(contains(&ten, address("10.255.0.1"))); + assert!(!contains(&ten, address("11.0.0.1"))); + // A prefix and an address of different families describe different networks. + assert!(!contains(&ten, address("::1"))); + + let documentation = v6([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0], 32); + assert_eq!(documentation.bits, 32); + assert!(contains(&documentation, address("2001:db8::1"))); + assert!(!contains(&documentation, address("2001:db9::1"))); + assert!(!contains(&documentation, address("10.0.0.1"))); + } + + #[test] + fn reads_an_ipv4_mapped_address_as_the_address_it_carries() { + assert!(is_public(address("::ffff:8.8.8.8"))); + assert!(!is_public(address("::ffff:10.0.0.1"))); + } +} diff --git a/crates/odp-core/src/lib.rs b/crates/odp-core/src/lib.rs index 0e2b97d..3965648 100644 --- a/crates/odp-core/src/lib.rs +++ b/crates/odp-core/src/lib.rs @@ -1,10 +1,12 @@ #![doc = include_str!("../README.md")] +mod addresses; mod identity; mod models; mod references; mod validation; +pub use addresses::*; pub use models::*; pub use references::*; pub use validation::*; diff --git a/crates/odp-core/src/models.rs b/crates/odp-core/src/models.rs index 6af9252..a5f8c26 100644 --- a/crates/odp-core/src/models.rs +++ b/crates/odp-core/src/models.rs @@ -500,7 +500,13 @@ pub struct CollectionSearchRequest { #[serde(default, skip_serializing_if = "is_zero_usize")] pub limit: usize, pub odp_version: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + /// COL-06: an omitted `parent_id` applies no hierarchy constraint, while a JSON `null` asks + /// for root Collections. `None` is the first, `Some(None)` the second. + #[serde( + default, + deserialize_with = "explicit_null", + skip_serializing_if = "Option::is_none" + )] pub parent_id: Option>, #[serde(default, skip_serializing_if = "String::is_empty")] pub query: String, @@ -533,6 +539,20 @@ fn is_zero_usize(value: &usize) -> bool { *value == 0 } +/// Reads a member that was present as `Some`, even when what it carried was `null`. +/// +/// `Option>` on its own cannot tell the two apart: serde reads a JSON `null` straight +/// into the outer `None`, the same answer an absent member gives. Only the inner `Option` is read +/// here, so `#[serde(default)]` supplies `None` when the member is absent and this supplies +/// `Some(None)` when it is present and null. +fn explicit_null<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct FilterExpression { pub id: String, diff --git a/crates/odp-core/src/references.rs b/crates/odp-core/src/references.rs index 03d3940..862509f 100644 --- a/crates/odp-core/src/references.rs +++ b/crates/odp-core/src/references.rs @@ -1,7 +1,5 @@ -use std::{net::IpAddr, str::FromStr}; - use thiserror::Error; -use url::Url; +use url::{Host, Url}; use crate::Operation; @@ -140,9 +138,13 @@ fn parse_url(value: &str) -> Result { } fn require_secure_url(url: &Url) -> Result<(), ReferenceError> { - let host = url.host_str().ok_or(ReferenceError::MissingHost)?; - let loopback = host.eq_ignore_ascii_case("localhost") - || IpAddr::from_str(host).is_ok_and(|address| address.is_loopback()); + // `host_str` spells an IPv6 literal with its brackets, which is not an address any parser + // reads, so the host is taken apart rather than parsed back out of its URL spelling. + let loopback = match url.host().ok_or(ReferenceError::MissingHost)? { + Host::Domain(name) => name.eq_ignore_ascii_case("localhost"), + Host::Ipv4(address) => address.is_loopback(), + Host::Ipv6(address) => address.is_loopback(), + }; if url.scheme() != "https" && !(url.scheme() == "http" && loopback) { return Err(ReferenceError::InsecureUrl); } diff --git a/crates/odp-core/src/validation.rs b/crates/odp-core/src/validation.rs index edf014e..02304d6 100644 --- a/crates/odp-core/src/validation.rs +++ b/crates/odp-core/src/validation.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, sync::OnceLock}; +use std::{borrow::Cow, collections::BTreeMap, sync::OnceLock}; use include_dir::{Dir, include_dir}; use jsonschema::{Registry, Validator}; @@ -9,8 +9,8 @@ use thiserror::Error; use crate::{ Collection, CollectionSearchRequest, FilterDefinition, FilterOperator, FilterType, Offering, - OfferingPage, OfferingSearchRequest, Operation, Page, ProblemDetails, ResourceIdentity, - ServiceDocument, SortDefinition, + OfferingPage, OfferingSearchRequest, Operation, Page, ProblemDetails, RefinementGroup, + ResourceIdentity, ServiceDocument, SortDefinition, }; static SCHEMA_FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/schemas"); @@ -62,13 +62,11 @@ pub fn normalize_agent_response(data: &[u8], kind: &str) -> Result, Pars document_type: "Agent response".to_owned(), issues: vec![issue("", "json", &error.to_string())], })?; + validate_json_depth(&raw, if kind == "service-document" { 8 } else { 16 })?; normalize_agent_document(&mut raw, kind); - serde_json::to_vec(&raw) - .map_err(|error| ValidationError { - document_type: "Agent response".to_owned(), - issues: vec![issue("", "json", &error.to_string())], - }) - .map_err(ParseError::from) + // Re-encoding cannot fail, so it reports no error to handle: a `Value` holds only what JSON + // can spell, and its `Display` writes that spelling directly rather than through a `Result`. + Ok(raw.to_string().into_bytes()) } fn normalize_agent_document(document: &mut Value, kind: &str) { @@ -273,7 +271,8 @@ fn normalize_branding(document: &mut Value) { .and_then(Value::as_str) .is_some_and(|image_type| !recognized.contains(&image_type)); if unknown { - branding.remove(member); + object.remove("branding"); + return; } else if let Some(Value::Object(image)) = branding.get_mut(member) { image.retain(|key, _value| matches!(key.as_str(), "src" | "type")); } @@ -325,6 +324,7 @@ fn normalize_offering(document: &mut Value) { .is_some_and(|schema| schema.keys().any(|key| key != "url")) { object.remove("schema"); + object.remove("attributes"); } let known_prices = ["fixed", "free", "metered", "quote", "range", "starting_at"]; let unknown_price = object @@ -537,22 +537,133 @@ pub fn parse_collection(data: &[u8]) -> Result { "collection.schema.json", "Collection", |value: &Collection| { - representation_issues(&value.language, &value.localizations, &value.images) + let mut issues = collection_representation_issues(value); + issues.extend(collection_issues(value)); + issues }, ) } +/// Reads a Collection an Agent received. +/// +/// ROLE-03: discovery metadata an Agent can still use is not withheld over a defect it can work +/// around, so the invariants `collection_issues` states -- which describe a hierarchy the Agent +/// simply does not walk -- do not refuse the document here. A Service, which MUST NOT publish one, +/// goes through [`parse_collection`]. +pub fn parse_agent_collection(data: &[u8]) -> Result { + parse( + &normalize_agent_response(data, "collection")?, + "collection.schema.json", + "Collection", + collection_representation_issues, + ) +} + +fn collection_representation_issues(value: &Collection) -> Vec { + representation_issues(&value.language, &value.localizations, &value.images) +} + +/// The Collection rules a JSON Schema cannot state: they compare one member against another. +fn collection_issues(value: &Collection) -> Vec { + // COL-20: a Collection naming itself as a parent is a one-node cycle, so anything walking the + // hierarchy upwards from it would never reach a root. + if value.parent_ids.contains(&value.id) { + vec![issue( + "/parent_ids", + "self-parent", + "must not name the Collection itself", + )] + } else { + Vec::new() + } +} + pub fn parse_offering(data: &[u8]) -> Result { parse( data, "offering.schema.json", "Offering", |value: &Offering| { - representation_issues(&value.language, &value.localizations, &value.images) + let mut issues = offering_representation_issues(value); + issues.extend(offering_issues(value)); + issues }, ) } +/// Reads an Offering an Agent received. +/// +/// ROLE-03: a defect an Agent can describe to its caller is a note about that Offering rather than +/// a reason to discard it, so the invariants `offering_issues` states are left for the Agent to +/// report against the Actions or price they concern. A Service, which MUST NOT publish one, goes +/// through [`parse_offering`]. +pub fn parse_agent_offering(data: &[u8]) -> Result { + parse( + &normalize_agent_response(data, "offering")?, + "offering.schema.json", + "Offering", + offering_representation_issues, + ) +} + +fn offering_representation_issues(value: &Offering) -> Vec { + representation_issues(&value.language, &value.localizations, &value.images) +} + +/// The Offering rules a JSON Schema cannot state: they compare one member against another. +fn offering_issues(value: &Offering) -> Vec { + let mut issues = Vec::new(); + // OFR-57: an Action identifier is unique within its Offering, so a repeat leaves an Agent + // unable to say which Action a caller meant. + let mut identifiers = std::collections::BTreeSet::new(); + if value + .actions + .iter() + .any(|action| !identifiers.insert(&action.id)) + { + issues.push(issue( + "/actions", + "unique-action-id", + "must contain unique Action identifiers", + )); + } + // OFR-49: a range whose minimum is above its maximum describes no price at all. + if let Some(price) = &value.price { + if price.price_type == crate::PriceType::Range + && compare_decimals(&price.minimum, &price.maximum).is_gt() + { + issues.push(issue( + "/price/minimum", + "price-range", + "must be less than or equal to maximum", + )); + } + } + issues +} + +/// Orders two ODP monetary values, which are decimal strings rather than JSON numbers (OFR-48). +/// +/// The schema already fixes the shape as digits with an optional fractional part, so the two are +/// compared digit by digit: the longer whole part is larger, and otherwise the first difference +/// decides. Parsing to a float would lose precision the decimal form exists to keep. +fn compare_decimals(left: &str, right: &str) -> std::cmp::Ordering { + fn split(value: &str) -> (&str, &str) { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + ( + whole.trim_start_matches('0'), + fraction.trim_end_matches('0'), + ) + } + let (left_whole, left_fraction) = split(left); + let (right_whole, right_fraction) = split(right); + left_whole + .len() + .cmp(&right_whole.len()) + .then_with(|| left_whole.cmp(right_whole)) + .then_with(|| left_fraction.cmp(right_fraction)) +} + pub fn parse_problem_details(data: &[u8]) -> Result { parse( data, @@ -603,13 +714,76 @@ pub fn parse_offering_search_request(data: &[u8]) -> Result Result, ParseError> { - parse_without_refinement( + parse( data, "offering-search-response.schema.json", "Offering search response", + |value: &OfferingPage| refinement_issues(&value.refinements), ) } +/// Reads an Offering-search response an Agent received. +/// +/// A Refinement Group the Agent cannot use is not a reason to discard the Offering results beside +/// it, so the invariants `refinement_issues` states do not refuse the page here. A Service, which +/// MUST NOT publish one, goes through [`parse_offering_search_response`]. +pub fn parse_agent_offering_search_response( + data: &[u8], +) -> Result, ParseError> { + parse_without_refinement( + &normalize_agent_response(data, "offering-page")?, + "offering-search-response.schema.json", + "Offering search response", + ) +} + +/// The Refinement rules a JSON Schema cannot state: they compare one member against another. +fn refinement_issues(groups: &[RefinementGroup]) -> Vec { + let mut issues = Vec::new(); + // FLT-30: `filter_id` is unique among the returned groups, so a repeat leaves an Agent unable + // to say which group belongs to that Filter Definition. + let mut identifiers = std::collections::BTreeSet::new(); + if groups + .iter() + .any(|group| !identifiers.insert(&group.filter_id)) + { + issues.push(issue( + "/refinements", + "unique-filter-id", + "must contain unique Refinement Group identifiers", + )); + } + // FLT-32: bucket values are unique within a group. The schema's `uniqueItems` compares whole + // buckets, so it passes two buckets that name one value with differing counts -- exactly the + // case that leaves an Agent with two counts for the same candidate and no way to choose. + for (index, group) in groups.iter().enumerate() { + let mut values = std::collections::BTreeSet::new(); + if group + .values + .iter() + .any(|bucket| !values.insert(bucket_key(&bucket.value))) + { + issues.push(issue( + &format!("/refinements/{index}/values"), + "unique-bucket-value", + "must contain unique Refinement Bucket values", + )); + } + } + issues +} + +// String values need their Filter Definition before decimal equality can be applied. +fn bucket_key(value: &Value) -> String { + match value { + Value::String(text) => format!("s{text}"), + Value::Number(number) => number + .as_f64() + .map_or_else(|| format!("n{number}"), |float| format!("n{float}")), + other => format!("o{other}"), + } +} + pub fn parse_filter_definition(data: &[u8]) -> Result { parse( data, @@ -644,12 +818,35 @@ pub fn validate_value( schema_name: &str, document_type: &str, ) -> Result<(), ParseError> { + validate_json_depth( + value, + if schema_name == "service-document.schema.json" { + 8 + } else { + 16 + }, + )?; + let mut compatible = Cow::Borrowed(value); + if value + .get("odp_version") + .and_then(Value::as_str) + .is_some_and(|version| { + version != crate::VERSION + && version.strip_prefix("1.").is_some_and(|minor| { + !minor.is_empty() + && (minor == "0" || !minor.starts_with('0')) + && minor.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) + { + compatible.to_mut()["odp_version"] = Value::String(crate::VERSION.to_owned()); + } let schemas = schemas()?; let validator = schemas.validators.get(schema_name).ok_or_else(|| { ParseError::SchemaInitialization(format!("missing bundled schema {schema_name}")) })?; let issues = validator - .iter_errors(value) + .iter_errors(&compatible) .map(|error| { let schema_path = error.schema_path().to_string(); ValidationIssue { @@ -676,6 +873,31 @@ pub fn validate_value( } } +/// Checks JSON nesting from a top-level depth of one before protocol data is exposed. +pub fn validate_json_depth(value: &Value, maximum: usize) -> Result<(), ParseError> { + let mut pending = vec![(value, 1)]; + while let Some((value, depth)) = pending.pop() { + if !value.is_object() && !value.is_array() { + continue; + } + if depth > maximum { + return Err(ValidationError { + document_type: "JSON document".to_owned(), + issues: vec![issue("", "depth", "exceeds the JSON nesting depth limit")], + } + .into()); + } + match value { + Value::Object(values) => { + pending.extend(values.values().map(|value| (value, depth + 1))) + } + Value::Array(values) => pending.extend(values.iter().map(|value| (value, depth + 1))), + _ => {} + } + } + Ok(()) +} + fn parse_without_refinement( data: &[u8], schema_name: &str, @@ -918,11 +1140,18 @@ fn filter_definition_issues(value: &FilterDefinition) -> Vec { "contains an operator incompatible with the Filter type", )); } - if value.filter_type == FilterType::Boolean && value.unit.is_some() { + // FLT-10: only a numeric Filter carries a unit. A unit on a string, date, date-time or + // boolean Filter describes a dimension its values do not have, so an Agent reading it would + // convert or label values that were never quantities. + if !matches!( + value.filter_type, + FilterType::Decimal | FilterType::Integer | FilterType::Number + ) && value.unit.is_some() + { issues.push(issue( "/unit", "unit-type", - "must not appear on a boolean Filter", + "must not appear on a non-numeric Filter", )); } issues diff --git a/crates/odp-core/tests/agent_conformance.rs b/crates/odp-core/tests/agent_conformance.rs new file mode 100644 index 0000000..8298bca --- /dev/null +++ b/crates/odp-core/tests/agent_conformance.rs @@ -0,0 +1,530 @@ +//! ROLE-04 and CNF-09: what an Agent does with a member a later ODP version defined. +//! +//! An Agent that refused every document carrying something it did not recognize would stop working +//! the day a Service adopted a newer ODP. So the Agent entry points drop what they cannot read and +//! keep the rest, and these tests pin down exactly how much gets dropped: the unreadable descriptor, +//! not the document around it. + +mod support; + +use odp_core::{ParseError, normalize_agent_response, parse_agent_service_document}; +use serde_json::{Value, json}; +use support::{amend, encode, service_document}; + +fn normalize(kind: &str, document: &Value) -> Value { + let encoded = normalize_agent_response(&encode(document), kind).expect("readable JSON"); + serde_json::from_slice(&encoded).expect("normalization returns JSON") +} + +fn read(changes: Value) -> Result { + parse_agent_service_document(&encode(&amend(service_document(), changes))) +} + +// -- Service Document ------------------------------------------------------------------------------ + +/// An operation ODP does not name is dropped, and the operations beside it are kept. +#[test] +fn drops_an_operation_odp_does_not_name() { + let document = read(json!({"operations": [ + {"authentication": "not-required", "name": "list-offerings"}, + {"authentication": "not-required", "name": "get-offering"}, + {"authentication": "not-required", "name": "rent-offering"} + ]})) + .unwrap(); + + assert_eq!(document.operations.len(), 2); +} + +/// An authentication requirement ODP does not name leaves the Agent unable to say whether it must +/// sign in, so that descriptor goes rather than being guessed at. +#[test] +fn drops_an_operation_whose_authentication_it_cannot_read() { + let document = read(json!({"operations": [ + {"authentication": "not-required", "name": "list-offerings"}, + {"authentication": "not-required", "name": "get-offering"}, + {"authentication": "biometric", "name": "list-collections"} + ]})) + .unwrap(); + + assert_eq!(document.operations.len(), 2); +} + +/// A descriptor carrying a member ODP does not define describes something this Agent cannot honour. +#[test] +fn drops_a_descriptor_that_carries_more_than_odp_defines() { + let normalized = normalize( + "service-document", + &amend( + service_document(), + json!({"operations": [ + {"authentication": "not-required", "name": "list-offerings"}, + {"authentication": "not-required", "name": "get-offering", "quota": 10} + ]}), + ), + ); + + assert_eq!(normalized["operations"].as_array().unwrap().len(), 1); +} + +/// With every descriptor dropped the member itself goes, rather than being left empty. +#[test] +fn removes_a_member_whose_every_entry_was_dropped() { + let normalized = normalize( + "service-document", + &amend( + service_document(), + json!({"mcp": [{"type": "sse", "url": "https://plants.example/mcp"}]}), + ), + ); + + assert!(normalized.get("mcp").is_none()); +} + +/// A protocol ODP does not name is dropped from its category, and an emptied category with it. +#[test] +fn drops_a_protocol_odp_does_not_name() { + let normalized = normalize( + "service-document", + &amend( + service_document(), + json!({"protocols": { + "payments": [ + {"authentication": "not-required", "name": "mpp", "options": ["card"]}, + {"authentication": "not-required", "name": "swift"} + ], + "trust": [{"name": "web-of-trust"}] + }}), + ), + ); + + assert_eq!( + normalized["protocols"]["payments"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert!(normalized["protocols"].get("trust").is_none()); +} + +/// With no category left the whole member goes. +#[test] +fn removes_protocols_whose_every_category_was_dropped() { + let normalized = normalize( + "service-document", + &amend( + service_document(), + json!({"protocols": {"enrollment": [{"name": "oauth"}]}}), + ), + ); + + assert!(normalized.get("protocols").is_none()); +} + +/// A payment option ODP does not name is dropped from its descriptor, not from the protocol. +#[test] +fn drops_a_payment_option_odp_does_not_name() { + let document = read(json!({"protocols": {"payments": [ + {"authentication": "not-required", "name": "mpp", "options": ["card", "cheque"]} + ]}})) + .unwrap(); + + let payments = &document.protocols.unwrap().payments; + assert_eq!(payments.len(), 1); + assert_eq!(payments[0].options.len(), 1); +} + +/// A branding image in a format the Agent cannot render is dropped; the other one stays. +#[test] +fn drops_branding_it_cannot_render() { + let normalized = normalize( + "service-document", + &amend( + service_document(), + json!({"branding": { + "icon": {"src": "/icon.png", "type": "image/png"}, + "logo": {"src": "/logo.tiff", "type": "image/tiff"}, + "banner": {"src": "/banner.png"} + }}), + ), + ); + + assert!(normalized.get("branding").is_none()); +} + +#[test] +fn removes_branding_it_can_render_nothing_of() { + let normalized = normalize( + "service-document", + &amend( + service_document(), + json!({"branding": {"icon": {"src": "/icon.tiff", "type": "image/tiff"}}}), + ), + ); + + assert!(normalized.get("branding").is_none()); +} + +/// An inline Filter Definition the Agent cannot evaluate is dropped from the advertisement. +#[test] +fn drops_an_inline_definition_it_cannot_evaluate() { + let capabilities = |filters: Value| { + normalize( + "service-document", + &amend( + service_document(), + json!({"search_capabilities": {"filters": filters}}), + ), + ) + }; + + let kept = capabilities(json!({"inline": [ + support::filter_definition("string"), + amend(support::filter_definition("string"), json!({"id": "colour", "type": "colour"})) + ]})); + assert_eq!( + kept["search_capabilities"]["filters"]["inline"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let emptied = capabilities(json!({"inline": [ + amend(support::filter_definition("string"), json!({"operators": ["startswith"]})) + ]})); + assert!(emptied.get("search_capabilities").is_none()); +} + +/// A unit system ODP does not name is a dimension the Agent cannot convert, so that definition goes. +#[test] +fn drops_an_inline_definition_measured_in_a_system_it_does_not_know() { + let normalized = normalize( + "service-document", + &amend( + service_document(), + json!({"search_capabilities": {"filters": {"inline": [ + amend( + support::filter_definition("integer"), + json!({"unit": {"system": "imperial", "code": "lb"}}) + ) + ]}}}), + ), + ); + + assert!(normalized.get("search_capabilities").is_none()); +} + +/// A Sort Definition that orders in a direction the Agent cannot follow is dropped the same way. +#[test] +fn drops_an_inline_sort_it_cannot_follow() { + let normalized = normalize( + "service-document", + &amend( + service_document(), + json!({"search_capabilities": {"sorts": {"inline": [ + amend( + support::sort_definition(), + json!({"keys": [ + {"filter_id": "price", "direction": "sideways", "missing": "last"} + ]}) + ) + ]}}}), + ), + ); + + assert!(normalized.get("search_capabilities").is_none()); +} + +// -- Offering -------------------------------------------------------------------------------------- + +/// An Action the Agent cannot perform exactly as written is dropped rather than approximated. +#[test] +fn drops_an_action_it_cannot_perform_as_written() { + for change in [ + json!({"authentication": "biometric"}), + json!({"quota": 10}), + json!({"http": {"href": "/buy", "method": "DELETE"}}), + json!({"http": {"href": "/buy", "method": "POST", "retries": 3}}), + json!({"http": { + "href": "/buy", "method": "POST", "request": {"content_type": "text/csv", "encoding": "gzip"} + }}), + json!({"http": { + "href": "/buy", + "method": "POST", + "request": {"content_type": "application/json", "schema": {"url": "/s.json", "draft": 7}} + }}), + json!({"openapi": {"operation_id": "buy", "server": "https://plants.example"}}), + ] { + let offering = amend( + support::offering(), + json!({"actions": [amend(support::action("buy"), change.clone())]}), + ); + let normalized = normalize("offering", &offering); + + assert!(normalized.get("actions").is_none(), "{change}"); + } +} + +/// A method ODP does define is left alone. +#[test] +fn keeps_an_action_it_can_perform() { + for method in ["GET", "POST"] { + let offering = amend( + support::offering(), + json!({"actions": [amend( + support::action("buy"), + json!({"http": {"href": "/buy", "method": method}}) + )]}), + ); + + assert_eq!( + normalize("offering", &offering)["actions"] + .as_array() + .unwrap() + .len(), + 1, + "{method}" + ); + } +} + +/// An Attribute Schema reference carrying more than a URL points at something else entirely. +#[test] +fn drops_an_attribute_schema_reference_it_cannot_follow() { + let offering = amend( + support::offering(), + json!({"schema": {"url": "https://plants.example/schema.json", "draft": 7}}), + ); + + assert!(normalize("offering", &offering).get("schema").is_none()); +} + +/// An image in a format the Agent cannot render is dropped, and unknown members are stripped from +/// the ones it keeps. +#[test] +fn drops_an_image_it_cannot_render_and_strips_the_rest() { + let offering = amend( + support::offering(), + json!({"images": [ + {"src": "/a.tiff", "type": "image/tiff"}, + {"src": "/b.png", "type": "image/png", "focal_point": "centre"} + ]}), + ); + let normalized = normalize("offering", &offering); + + let images = normalized["images"].as_array().unwrap(); + assert_eq!(images.len(), 1); + assert!(images[0].get("focal_point").is_none()); + assert_eq!(images[0]["src"], "/b.png"); +} + +/// A Collection is normalized the same way an Offering is, minus the Offering-only members. +#[test] +fn normalizes_a_collection_the_same_way() { + let collection = amend( + support::collection(), + json!({"images": [{"src": "/a.tiff", "type": "image/tiff"}]}), + ); + + assert!(normalize("collection", &collection).get("images").is_none()); +} + +// -- pages and problems ------------------------------------------------------------------------------ + +/// A page is normalized item by item, so one unreadable member does not cost the whole page. +#[test] +fn normalizes_every_item_on_a_page() { + for (kind, item) in [ + ( + "offering-page", + amend(support::offering(), json!({"price": {"type": "auction"}})), + ), + ( + "collection-page", + amend( + support::collection(), + json!({"images": [{"src": "/a.tiff", "type": "image/tiff"}]}), + ), + ), + ] { + let normalized = normalize(kind, &support::page(json!([item]))); + let first = &normalized["items"][0]; + + assert!( + first.get("price").is_none() && first.get("images").is_none(), + "{kind}" + ); + assert_eq!(first["id"], normalized["items"][0]["id"]); + } +} + +/// A capability page drops the definitions the Agent cannot use and keeps the others. +#[test] +fn drops_definitions_it_cannot_use_from_a_capability_page() { + let filters = normalize( + "filter-page", + &support::page(json!([ + support::filter_definition("string"), + amend( + support::filter_definition("string"), + json!({"type": "colour"}) + ) + ])), + ); + assert_eq!(filters["items"].as_array().unwrap().len(), 1); + + let sorts = normalize( + "sort-page", + &support::page(json!([ + support::sort_definition(), + amend( + support::sort_definition(), + json!({"keys": [{"filter_id": "p", "direction": "ascending", "missing": "middle"}]}) + ) + ])), + ); + assert_eq!(sorts["items"].as_array().unwrap().len(), 1); +} + +/// PRB-14: a parameter located somewhere ODP does not name cannot be pointed at, so it is dropped +/// while the problem itself is still reported. +#[test] +fn drops_an_invalid_parameter_it_cannot_locate() { + let problem = amend( + support::problem(), + json!({"invalid_params": [ + {"in": "query", "name": "limit", "reason": "too large"}, + {"in": "cookie", "name": "session", "reason": "expired"} + ]}), + ); + let normalized = normalize("problem", &problem); + + assert_eq!(normalized["invalid_params"].as_array().unwrap().len(), 1); +} + +// -- boundaries -------------------------------------------------------------------------------------- + +/// A body that is not JSON is not something normalization can repair. +#[test] +fn refuses_a_body_that_is_not_json() { + let error = normalize_agent_response(b"not json", "offering").unwrap_err(); + assert!(matches!(error, ParseError::Validation(_))); +} + +/// A kind with nothing to normalize, and a document of the wrong shape, are both left as they are. +#[test] +fn leaves_alone_what_it_has_no_rule_for() { + assert_eq!( + normalize("resource-identity", &json!({"id": "plant-1"}))["id"], + "plant-1" + ); + assert_eq!(normalize("offering", &json!([1, 2])), json!([1, 2])); + assert_eq!(normalize("service-document", &json!("text")), json!("text")); + assert_eq!( + normalize("offering-page", &json!({"items": "none"}))["items"], + "none" + ); +} + +/// A member of the right name but the wrong type is not a list to filter. +#[test] +fn leaves_a_member_that_is_not_a_list_alone() { + for document in [ + json!({"operations": "all", "mcp": 3, "branding": "none", "search_capabilities": 1}), + json!({"protocols": {"payments": "all"}}), + ] { + assert_eq!(normalize("service-document", &document), document); + } + + let offering = json!({"actions": "all", "images": 3, "schema": "none", "price": 1}); + assert_eq!(normalize("offering", &offering), offering); +} + +/// A member whose entries the Agent can all read is left exactly as it was, and one whose entries +/// it can read none of goes entirely. These are the two ends of the same filter. +#[test] +fn removes_a_member_it_emptied_and_keeps_one_it_did_not() { + for (member, entry) in [ + ( + "operations", + json!({"authentication": "biometric", "name": "list-offerings"}), + ), + ( + "mcp", + json!({"type": "streamable-http", "url": "/mcp", "retries": 3}), + ), + ] { + let emptied = normalize( + "service-document", + &amend(service_document(), json!({member: [entry]})), + ); + assert!(emptied.get(member).is_none(), "{member}"); + } +} + +/// A payment descriptor that is not an object, or that lists no options, has no options to filter. +#[test] +fn leaves_a_payment_descriptor_it_cannot_read_alone() { + for payments in [ + json!(["mpp"]), + json!([{"authentication": "not-required", "name": "mpp"}]), + json!([{"authentication": "not-required", "name": "mpp", "options": "all"}]), + ] { + let document = amend( + service_document(), + json!({"protocols": {"payments": payments}}), + ); + assert_eq!( + normalize("service-document", &document)["protocols"]["payments"], + payments + ); + } +} + +/// An options list the Agent can read nothing in is dropped rather than left empty, because an +/// empty list would claim the protocol supports nothing. +#[test] +fn removes_an_options_list_it_emptied() { + let document = amend( + service_document(), + json!({"protocols": {"payments": [ + {"authentication": "not-required", "name": "mpp", "options": ["cheque"]} + ]}}), + ); + let normalized = normalize("service-document", &document); + + assert!( + normalized["protocols"]["payments"][0] + .get("options") + .is_none() + ); +} + +/// A definition that is not an object is not one this Agent can find fault with either. +#[test] +fn leaves_a_definition_it_cannot_read_alone() { + let page = support::page(json!(["weight", 3])); + assert_eq!( + normalize("filter-page", &page)["items"], + json!(["weight", 3]) + ); + assert_eq!(normalize("sort-page", &page)["items"], json!(["weight", 3])); +} + +/// A problem whose `invalid_params` is not a list is left as it stands. +#[test] +fn leaves_invalid_parameters_that_are_not_a_list_alone() { + let problem = amend(support::problem(), json!({"invalid_params": "limit"})); + assert_eq!(normalize("problem", &problem)["invalid_params"], "limit"); +} + +/// An image entry that is not an object has no members to strip. +#[test] +fn leaves_an_image_that_is_not_an_object_alone() { + let offering = amend(support::offering(), json!({"images": ["/a.png"]})); + assert_eq!( + normalize("offering", &offering)["images"], + json!(["/a.png"]) + ); +} diff --git a/crates/odp-core/tests/capability_conformance.rs b/crates/odp-core/tests/capability_conformance.rs new file mode 100644 index 0000000..0445751 --- /dev/null +++ b/crates/odp-core/tests/capability_conformance.rs @@ -0,0 +1,452 @@ +//! Filter and Sort Definitions, the search requests that reference them, and the Refinement +//! Groups a search response answers with. + +mod support; + +use odp_core::{ + FilterOperator, FilterType, ParseError, SortDirection, parse_agent_offering_search_response, + parse_collection_search_request, parse_filter_definition, parse_filter_definition_page, + parse_offering_search_request, parse_offering_search_response, parse_page, + parse_sort_definition, parse_sort_definition_page, +}; +use serde_json::{Value, json}; +use support::{amend, assert_rejected_for, encode, filter_definition, page, sort_definition}; + +fn read_filter(changes: Value) -> Result { + parse_filter_definition(&encode(&amend(filter_definition("integer"), changes))) +} + +fn kilograms() -> Value { + json!({"system": "ucum", "code": "kg"}) +} + +#[test] +fn reads_a_conformant_filter_definition() { + let definition = read_filter(json!({"operators": ["eq", "gte"], "refinable": true})).unwrap(); + + assert_eq!(definition.id, "weight"); + assert_eq!(definition.filter_type, FilterType::Integer); + assert_eq!(definition.operators[1], FilterOperator::GreaterThanOrEqual); + assert!(definition.refinable); +} + +// -- units ----------------------------------------------------------------------------------------- + +/// FLT-10: only a numeric Filter carries a unit, because only a numeric Filter has a dimension. +#[test] +fn refuses_a_unit_on_a_filter_that_measures_nothing() { + for filter_type in ["boolean", "date", "date-time", "string"] { + assert_rejected_for( + parse_filter_definition(&encode(&amend( + filter_definition(filter_type), + json!({"unit": kilograms()}), + ))), + "unit-type", + ); + } +} + +#[test] +fn accepts_a_unit_on_every_numeric_filter() { + for filter_type in ["decimal", "integer", "number"] { + assert!( + parse_filter_definition(&encode(&amend( + filter_definition(filter_type), + json!({"unit": kilograms()}), + ))) + .is_ok(), + "{filter_type}" + ); + } +} + +/// A non-numeric Filter that claims no unit is exactly what the rule asks for. +#[test] +fn accepts_every_filter_type_without_a_unit() { + for filter_type in [ + "boolean", + "date", + "date-time", + "decimal", + "integer", + "number", + "string", + ] { + assert!( + parse_filter_definition(&encode(&filter_definition(filter_type))).is_ok(), + "{filter_type}" + ); + } +} + +/// FLT-14: a unit is defined inline, under a system ODP names. +#[test] +fn refuses_a_unit_from_a_system_odp_does_not_name() { + assert!(read_filter(json!({"unit": {"system": "imperial", "code": "lb"}})).is_err()); + assert!( + read_filter(json!({ + "unit": {"system": "service", "code": "pot-size", "title": "Pot size"} + })) + .is_ok() + ); +} + +// -- operators ------------------------------------------------------------------------------------- + +/// FLT-11: an ordering operator on a type that has no order cannot be evaluated. +#[test] +fn refuses_an_ordering_operator_on_an_unordered_type() { + for filter_type in ["boolean", "string"] { + for operator in ["gt", "gte", "lt", "lte"] { + assert_rejected_for( + parse_filter_definition(&encode(&amend( + filter_definition(filter_type), + json!({"operators": ["eq", operator]}), + ))), + "operator-type", + ); + } + } +} + +/// Dates and numbers are ordered, so the same operators are fine there. +#[test] +fn accepts_an_ordering_operator_on_an_ordered_type() { + for filter_type in ["date", "date-time", "decimal", "integer", "number"] { + assert!( + parse_filter_definition(&encode(&amend( + filter_definition(filter_type), + json!({"operators": ["gt", "lte"]}), + ))) + .is_ok(), + "{filter_type}" + ); + } +} + +/// FLT-12: a refinable definition counts values, which needs an equality operator to count by. +#[test] +fn refuses_a_refinable_definition_that_cannot_compare_values() { + assert!(read_filter(json!({"operators": ["exists"], "refinable": true})).is_err()); + assert!(read_filter(json!({"operators": ["eq"], "refinable": true})).is_ok()); + assert!(read_filter(json!({"operators": ["in"], "refinable": true})).is_ok()); +} + +/// FLT-09: an operator ODP does not define is not one a Service can advertise. +#[test] +fn refuses_an_operator_odp_does_not_define() { + assert!(read_filter(json!({"operators": ["startswith"]})).is_err()); + assert!(read_filter(json!({"operators": []})).is_err()); +} + +// -- Sort Definitions -------------------------------------------------------------------------------- + +#[test] +fn reads_a_conformant_sort_definition() { + let definition = parse_sort_definition(&encode(&sort_definition())).unwrap(); + + assert_eq!(definition.id, "cheapest"); + assert_eq!(definition.keys[0].direction, SortDirection::Ascending); +} + +/// SRT-05: a recipe orders by between one and three keys. +#[test] +fn refuses_a_recipe_with_no_keys_or_too_many() { + let key = + |filter_id| json!({"filter_id": filter_id, "direction": "ascending", "missing": "last"}); + + assert!( + parse_sort_definition(&encode(&amend(sort_definition(), json!({"keys": []})))).is_err() + ); + assert!( + parse_sort_definition(&encode(&amend( + sort_definition(), + json!({"keys": [key("a"), key("b"), key("c"), key("d")]}), + ))) + .is_err() + ); +} + +/// SRT-07: a key orders in a direction ODP names, and says where missing values go. +#[test] +fn refuses_a_key_that_orders_in_no_direction_odp_names() { + for key in [ + json!({"filter_id": "price", "direction": "sideways", "missing": "last"}), + json!({"filter_id": "price", "direction": "ascending", "missing": "middle"}), + json!({"filter_id": "price", "direction": "ascending"}), + ] { + assert!( + parse_sort_definition(&encode(&amend(sort_definition(), json!({"keys": [key]})))) + .is_err() + ); + } +} + +// -- capability pages ---------------------------------------------------------------------------------- + +#[test] +fn reads_a_page_of_each_kind_of_definition() { + let filters = + parse_filter_definition_page(&encode(&page(json!([filter_definition("string")])))).unwrap(); + assert_eq!(filters.items[0].id, "weight"); + assert!(filters.next.is_empty()); + + let sorts = parse_sort_definition_page(&encode(&page(json!([sort_definition()])))).unwrap(); + assert_eq!(sorts.items[0].keys.len(), 1); +} + +/// PAG-04: a page carries at most 100 items, whatever the caller asked for. +#[test] +fn refuses_a_page_beyond_the_item_limit() { + let items: Vec = (0..101) + .map(|index| { + amend( + filter_definition("string"), + json!({"id": format!("f{index}")}), + ) + }) + .collect(); + + assert!(parse_filter_definition_page(&encode(&page(json!(items)))).is_err()); +} + +/// PAG-06: a continuation is a reference the Agent can follow back to the same Service. +#[test] +fn reads_a_page_that_offers_a_continuation() { + let value = amend( + page(json!([])), + json!({"next": "/odp/offerings?cursor=2", "auth_expands": true}), + ); + let read = parse_page::(&encode(&value)).unwrap(); + + assert_eq!(read.next, "/odp/offerings?cursor=2"); + assert!(read.auth_expands); +} + +/// `auth_expands` says the signed-in view is wider; saying it is not is saying nothing. +#[test] +fn refuses_a_page_that_denies_expansion_rather_than_omitting_it() { + let value = amend(page(json!([])), json!({"auth_expands": false})); + assert!(parse_page::(&encode(&value)).is_err()); +} + +// -- search requests ------------------------------------------------------------------------------------- + +#[test] +fn reads_a_conformant_offering_search_request() { + let request = parse_offering_search_request( + br#"{"odp_version":"1.0","query":"monstera","limit":20,"collection_id":"plants","include_descendants":true,"sort":"cheapest","refinements":["colour"],"filters":[{"id":"weight","operator":"gte","value":2}]}"#, + ) + .unwrap(); + + assert_eq!(request.query, "monstera"); + assert_eq!(request.limit, 20); + assert_eq!(request.refinements, ["colour"]); + assert_eq!( + request.filters[0].operator, + FilterOperator::GreaterThanOrEqual + ); +} + +#[test] +fn reads_a_conformant_collection_search_request() { + let request = parse_collection_search_request( + br#"{"odp_version":"1.0","query":"plants","parent_id":"garden","limit":5}"#, + ) + .unwrap(); + + assert_eq!(request.parent_id, Some(Some("garden".to_owned()))); + assert_eq!(request.limit, 5); +} + +/// COL-31: a null parent asks for the Collections at the root, which is not the same question as +/// asking with no parent at all. +#[test] +fn tells_a_null_parent_apart_from_an_absent_one() { + let rooted = + parse_collection_search_request(br#"{"odp_version":"1.0","parent_id":null}"#).unwrap(); + let anywhere = + parse_collection_search_request(br#"{"odp_version":"1.0","query":"plants"}"#).unwrap(); + + assert_eq!(rooted.parent_id, Some(None)); + assert_eq!(anywhere.parent_id, None); +} + +/// A Collection search asks by query or by parent; asking neither asks nothing. +#[test] +fn refuses_a_collection_search_that_asks_nothing() { + assert!(parse_collection_search_request(br#"{"odp_version":"1.0"}"#).is_err()); +} + +/// FLT-25: a Filter Expression names its definition, an operator, and a value to compare against. +#[test] +fn refuses_a_filter_expression_that_compares_nothing() { + for filters in [ + json!([{"id": "weight", "operator": "gte"}]), + json!([{"id": "weight", "value": 2}]), + json!([{"operator": "gte", "value": 2}]), + json!([{"id": "weight", "operator": "gte", "value": []}]), + json!([{"id": "weight", "operator": "gte", "value": {"amount": 2}}]), + ] { + let request = json!({"odp_version": "1.0", "filters": filters}); + assert!( + parse_offering_search_request(&encode(&request)).is_err(), + "{filters}" + ); + } +} + +/// PAG-03: a request cannot ask for more than a page holds. +#[test] +fn refuses_a_limit_beyond_what_a_page_holds() { + let request = json!({"odp_version": "1.0", "limit": 101}); + assert!(parse_offering_search_request(&encode(&request)).is_err()); +} + +// -- refinements --------------------------------------------------------------------------------------------- + +fn search_response(refinements: Value) -> Vec { + encode(&json!({"odp_version": "1.0", "items": [], "refinements": refinements})) +} + +fn group(filter_id: &str, values: Value) -> Value { + json!({"filter_id": filter_id, "values": values}) +} + +#[test] +fn reads_a_conformant_search_response() { + let page = parse_offering_search_response(&search_response(json!([group( + "colour", + json!([{"value": "green", "count": 4}, {"value": "red", "count": 2, "count_relation": "lower_bound"}]) + )]))) + .unwrap(); + + assert_eq!(page.refinements[0].filter_id, "colour"); + assert_eq!(page.refinements[0].values[0].count, 4); + assert_eq!(page.refinements[0].values[1].count_relation, "lower_bound"); +} + +/// FLT-30: `filter_id` is unique among the returned groups, so a repeat leaves an Agent unable to +/// say which group belongs to that Filter Definition. +#[test] +fn refuses_two_groups_for_one_filter() { + assert_rejected_for( + parse_offering_search_response(&search_response(json!([ + group("colour", json!([{"value": "green", "count": 4}])), + group("colour", json!([{"value": "red", "count": 2}])) + ]))), + "unique-filter-id", + ); +} + +/// FLT-32: bucket values are unique within a group, so two counts never describe one candidate. +#[test] +fn refuses_a_repeated_bucket_value() { + for values in [ + json!([{"value": "green", "count": 4}, {"value": "green", "count": 2}]), + json!([{"value": true, "count": 4}, {"value": true, "count": 2}]), + json!([{"value": 3, "count": 4}, {"value": 3.0, "count": 2}]), + ] { + assert_rejected_for( + parse_offering_search_response(&search_response(json!([group("colour", values)]))), + "unique-bucket-value", + ); + } +} + +#[test] +fn preserves_distinct_strings_without_a_filter_definition() { + for values in [ + json!([{"value": "1.0", "count": 4}, {"value": "1.00", "count": 2}]), + json!([{"value": "0", "count": 4}, {"value": "0.0", "count": 2}]), + json!([{"value": "12", "count": 4}, {"value": "12.000", "count": 2}]), + ] { + assert!( + parse_offering_search_response(&search_response(json!([group("weight", values)]))) + .is_ok() + ); + } +} + +/// Two decimals that differ are still two values, and so is a string that only looks decimal. +#[test] +fn keeps_bucket_values_that_differ_apart() { + for values in [ + json!([{"value": "1.0", "count": 4}, {"value": "2.0", "count": 2}]), + json!([{"value": "1.01", "count": 4}, {"value": "1.1", "count": 2}]), + json!([{"value": "10", "count": 4}, {"value": "1.0", "count": 2}]), + // Neither is a decimal -- a leading zero and a trailing period are not ODP decimals -- so + // both are compared as the strings they are. + json!([{"value": "01", "count": 4}, {"value": "1", "count": 2}]), + json!([{"value": "1.", "count": 4}, {"value": "1", "count": 2}]), + json!([{"value": "-1.0", "count": 4}, {"value": "1.0", "count": 2}]), + json!([{"value": true, "count": 4}, {"value": false, "count": 2}]), + json!([{"value": 1, "count": 4}, {"value": "1", "count": 2}]), + ] { + assert!( + parse_offering_search_response(&search_response(json!([group( + "weight", + values.clone() + )]))) + .is_ok(), + "{values}" + ); + } +} + +/// FLT-31: a response carries at most 16 groups, each of at most 32 buckets. +#[test] +fn refuses_more_groups_or_buckets_than_a_response_carries() { + let groups: Vec = (0..17) + .map(|index| group(&format!("f{index}"), json!([{"value": "a", "count": 1}]))) + .collect(); + assert!(parse_offering_search_response(&search_response(json!(groups))).is_err()); + + let buckets: Vec = (0..33) + .map(|index| json!({"value": index, "count": 1})) + .collect(); + assert!( + parse_offering_search_response(&search_response(json!([group("colour", json!(buckets))]))) + .is_err() + ); +} + +/// FLT-33: a count is a non-negative integer, and `count_relation` says only that it is a floor. +#[test] +fn refuses_a_count_that_is_not_a_count() { + for bucket in [ + json!({"value": "green", "count": -1}), + json!({"value": "green", "count": 1.5}), + json!({"value": "green"}), + json!({"value": "green", "count": 1, "count_relation": "estimate"}), + json!({"value": ["green"], "count": 1}), + ] { + assert!( + parse_offering_search_response(&search_response(json!([group( + "colour", + json!([bucket]) + )]))) + .is_err(), + "{bucket}" + ); + } +} + +/// ROLE-03: a Refinement Group an Agent cannot use is no reason to discard the Offering results +/// beside it, so the Agent entry point reads the page and leaves the group to report. +#[test] +fn hands_an_agent_a_page_a_service_must_not_publish() { + let data = search_response(json!([ + group("colour", json!([{"value": "green", "count": 4}])), + group("colour", json!([{"value": "red", "count": 2}])) + ])); + + assert!(parse_offering_search_response(&data).is_err()); + assert_eq!( + parse_agent_offering_search_response(&data) + .unwrap() + .refinements + .len(), + 2 + ); +} diff --git a/crates/odp-core/tests/document_conformance.rs b/crates/odp-core/tests/document_conformance.rs new file mode 100644 index 0000000..60ace06 --- /dev/null +++ b/crates/odp-core/tests/document_conformance.rs @@ -0,0 +1,331 @@ +//! The Service Document rules a JSON Schema cannot state, and the schema boundaries around them. + +mod support; + +#[test] +fn accepts_compatible_minor_versions_without_rewriting_the_input() { + for version in ["1.0", "1.1", "1.7"] { + let document = support::amend( + support::service_document(), + serde_json::json!({"odp_version":version}), + ); + let parsed = odp_core::parse_service_document(&support::encode(&document)).unwrap(); + assert_eq!(parsed.odp_version, version); + } +} + +#[test] +fn enforces_document_depth_before_discarding_unknown_members() { + let mut document = support::service_document(); + let mut nested = serde_json::json!(0); + for _ in 0..8 { + nested = serde_json::json!({"nested":nested}); + } + document["extension"] = nested; + assert!(odp_core::parse_service_document(&support::encode(&document)).is_err()); + assert!(odp_core::parse_agent_service_document(&support::encode(&document)).is_err()); +} + +#[test] +fn depth_counts_containers_not_scalar_fields() { + let mut value = serde_json::json!({"leaf":1}); + for _ in 0..7 { + value = serde_json::json!({"nested":value}); + } + assert!(odp_core::validate_json_depth(&value, 8).is_ok()); + assert!(odp_core::validate_json_depth(&value, 7).is_err()); +} + +#[test] +fn unsupported_branding_does_not_reject_the_service() { + for member in ["icon", "logo"] { + let mut document = support::service_document(); + document["branding"] = serde_json::json!({"icon":{"src":"/icon.png","type":"image/png"},"logo":{"src":"/logo.png","type":"image/png"}}); + document["branding"][member]["type"] = serde_json::json!("image/future"); + let parsed = odp_core::parse_agent_service_document(&support::encode(&document)).unwrap(); + assert!(parsed.branding.is_none()); + } +} + +#[test] +fn unsupported_schema_reference_only_removes_attributes() { + let document = support::amend( + support::offering(), + serde_json::json!({"schema":{"url":"/schema","future":true},"attributes":{"size":1}}), + ); + let parsed = odp_core::parse_agent_offering(&support::encode(&document)).unwrap(); + assert_eq!(parsed.id, "plant-1"); + assert!(parsed.schema.is_none()); + assert!(parsed.attributes.is_empty()); +} + +use odp_core::{ + Operation, ParseError, Representation, VERSION, parse_resource_identity, + parse_service_document, validate_value, +}; +use serde_json::json; +use support::{amend, assert_rejected_for, encode, service_document}; + +fn parse(changes: serde_json::Value) -> Result { + parse_service_document(&encode(&amend(service_document(), changes))) +} + +#[test] +fn reads_a_conformant_service_document() { + let document = parse(json!({})).unwrap(); + + assert_eq!(document.odp_version, VERSION); + assert_eq!(document.name, "Plants"); + assert_eq!(document.language, "en"); + assert_eq!(document.http.endpoint_base, "/odp"); + assert_eq!(document.operations.len(), 2); + assert_eq!(document.operations[0].name, Operation::ListOfferings); +} + +/// SVC-11: a Service Document describes a Service, not a resource, so it carries no identifier. +#[test] +fn refuses_an_identifier() { + assert_rejected_for(parse(json!({"id": "plants"})), "prohibited"); +} + +/// SVC-11: nor a `web_url`, which belongs to a Collection or an Offering. +#[test] +fn refuses_a_resource_web_url() { + assert_rejected_for( + parse(json!({"web_url": "https://plants.example"})), + "prohibited", + ); +} + +/// Both prohibited members are reported together rather than one per round trip. +#[test] +fn reports_every_prohibited_member_at_once() { + let issues = support::issues(parse(json!({"id": "x", "web_url": "https://a.example"}))); + assert_eq!(issues.len(), 2); + assert_eq!(issues[0].path, "/id"); + assert_eq!(issues[1].path, "/web_url"); +} + +// -- language and localizations ----------------------------------------------------------------- + +/// SVC-55: the default language is a language tag. +#[test] +fn refuses_a_default_language_that_is_not_a_tag() { + assert_rejected_for( + parse(json!({"language": "not a tag", "localizations": ["not a tag"]})), + "language-tag", + ); +} + +/// A tag that repeats a variant subtag is not well-formed, whatever its length. +#[test] +fn refuses_a_tag_that_repeats_a_subtag() { + for tag in ["de-1901-1901", "en-a-bbb-a-ccc"] { + assert_rejected_for( + parse(json!({"language": tag, "localizations": [tag]})), + "language-tag", + ); + } +} + +/// SVC-56: the default language is one of the localizations the Service advertises. +#[test] +fn refuses_localizations_that_omit_the_default_language() { + assert_rejected_for( + parse(json!({"language": "en", "localizations": ["fr", "de"]})), + "contains-default-language", + ); +} + +/// Language tags are case-insensitive, so the default language may be spelled either way. +#[test] +fn matches_the_default_language_without_regard_to_case() { + assert!(parse(json!({"language": "en-GB", "localizations": ["EN-gb"]})).is_ok()); +} + +/// Two spellings of one tag advertise one localization twice. +#[test] +fn refuses_localizations_that_repeat_a_tag() { + assert_rejected_for( + parse(json!({"language": "en", "localizations": ["en", "EN"]})), + "unique-language-tag", + ); +} + +/// A malformed tag in the list is reported on its own; the membership rule needs a readable list. +#[test] +fn reports_a_malformed_localization_before_membership() { + let issues = support::issues(parse(json!({"localizations": ["not a tag"]}))); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].keyword, "language-tag"); + assert_eq!(issues[0].path, "/localizations"); +} + +// -- keywords ----------------------------------------------------------------------------------- + +/// SVC-21: keywords are budgeted together, so a Service cannot spend the budget on one long list. +#[test] +fn refuses_keywords_beyond_the_shared_budget() { + // 32 distinct keywords of 64 code points each is 2048, twice what the Service may spend. + let keywords: Vec = (0..32).map(|index| format!("{index:0>64}")).collect(); + assert_rejected_for(parse(json!({"keywords": keywords})), "max-code-points"); +} + +/// The budget counts code points, not bytes, so a multi-byte keyword is not charged twice. +#[test] +fn counts_keyword_code_points_rather_than_bytes() { + // 16 distinct keywords of 64 code points each is the whole budget, and four times as many + // bytes. + let keywords: Vec = (b'a'..b'q') + .map(|letter| format!("{}{}", char::from(letter), "\u{1f33f}".repeat(63))) + .collect(); + assert!(parse(json!({"keywords": keywords})).is_ok()); +} + +// -- search capabilities ------------------------------------------------------------------------ + +/// SVC-70: advertised search capabilities describe an operation the Service actually offers. +#[test] +fn refuses_search_capabilities_without_the_search_operation() { + assert_rejected_for( + parse(json!({ + "search_capabilities": {"filters": {"inline": [support::filter_definition("string")]}} + })), + "operation-support", + ); +} + +#[test] +fn accepts_search_capabilities_beside_the_search_operation() { + assert!( + parse(json!({ + "operations": [ + {"authentication": "not-required", "name": "list-offerings"}, + {"authentication": "not-required", "name": "get-offering"}, + {"authentication": "not-required", "name": "search-offerings"} + ], + "search_capabilities": {"filters": {"inline": [support::filter_definition("string")]}} + })) + .is_ok() + ); +} + +// -- schema boundaries --------------------------------------------------------------------------- + +/// A Service Document that is not JSON at all is refused before any rule is applied. +#[test] +fn refuses_a_body_that_is_not_json() { + let issues = support::issues(parse_service_document(b"not json")); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].keyword, "json"); +} + +/// SVC-04: the version member names the version of ODP this document speaks. +#[test] +fn refuses_another_protocol_version() { + assert_rejected_for(parse(json!({"odp_version": "2.0"})), "const"); +} + +/// SVC-64: the endpoint base is an origin-relative absolute path, so it carries no query. +#[test] +fn refuses_an_endpoint_base_that_is_not_a_path() { + for base in ["odp", "//elsewhere.example/odp", "/odp?x=1", "/odp#a"] { + assert!( + parse(json!({"http": {"endpoint_base": base}})).is_err(), + "{base}" + ); + } +} + +/// SVC-45: a Service advertises each payment protocol once. +#[test] +fn refuses_a_repeated_payment_protocol() { + assert!( + parse(json!({"protocols": {"payments": [ + {"name": "mpp", "options": ["card"]}, + {"name": "mpp", "options": ["solana"]} + ]}})) + .is_err() + ); +} + +/// SVC-30: an MCP endpoint descriptor names a transport ODP defines. +#[test] +fn refuses_an_unknown_mcp_transport() { + assert!(parse(json!({"mcp": [{"type": "sse", "url": "https://plants.example/mcp"}]})).is_err()); + assert!( + parse(json!({ + "mcp": [{"type": "streamable-http", "url": "https://plants.example/mcp"}] + })) + .is_ok() + ); +} + +// -- resource identity --------------------------------------------------------------------------- + +/// A Resource Identity names the Service, the kind of resource, and the resource. +#[test] +fn reads_a_resource_identity() { + let identity = parse_resource_identity( + br#"{"service":"https://plants.example","type":"offering","id":"plant-1"}"#, + ) + .unwrap(); + + assert_eq!(identity.id, "plant-1"); + assert_eq!(identity.service, "https://plants.example"); +} + +#[test] +fn refuses_a_resource_identity_of_an_unknown_kind() { + assert!( + parse_resource_identity( + br#"{"service":"https://plants.example","type":"basket","id":"plant-1"}"# + ) + .is_err() + ); +} + +// -- the shared validator ------------------------------------------------------------------------- + +/// `validate_value` is the same check without the decode step, for a document already in hand. +#[test] +fn validates_a_value_against_a_bundled_schema() { + let document = service_document(); + assert!( + validate_value( + &document, + "service-document.schema.json", + "Service Document" + ) + .is_ok() + ); + + let broken = amend(document, json!({"odp_version": "0.9"})); + let issues = support::issues(validate_value( + &broken, + "service-document.schema.json", + "Service Document", + )); + assert_eq!(issues[0].path, "/odp_version"); +} + +/// Asking for a schema that was never bundled is a programming error, not a document defect. +#[test] +fn reports_a_schema_that_was_never_bundled() { + let error = validate_value(&json!({}), "basket.schema.json", "Basket").unwrap_err(); + assert!(matches!(error, ParseError::SchemaInitialization(_))); + assert!(error.to_string().contains("basket.schema.json")); +} + +/// The representation enumeration is the one the query parameter accepts. +#[test] +fn round_trips_the_representation_enumeration() { + assert_eq!( + serde_json::from_str::("\"full\"").unwrap(), + Representation::Full + ); + assert_eq!( + serde_json::to_string(&Representation::Terse).unwrap(), + "\"terse\"" + ); +} diff --git a/crates/odp-core/tests/problem_conformance.rs b/crates/odp-core/tests/problem_conformance.rs new file mode 100644 index 0000000..41e61ea --- /dev/null +++ b/crates/odp-core/tests/problem_conformance.rs @@ -0,0 +1,207 @@ +//! PRB-*: the Problem Details a Service answers a refused request with, and what a Service that +//! serializes a document has to put on the wire. + +mod support; + +use odp_core::{ + Collection, CollectionSearchRequest, Offering, Operation, OperationDescriptor, Page, + ResourceImage, parse_problem_details, parse_problem_response, +}; +use serde_json::{Value, json}; +use support::{amend, assert_rejected_for, encode, problem}; + +fn read(changes: Value) -> Result { + parse_problem_details(&encode(&amend(problem(), changes))) +} + +#[test] +fn reads_a_conformant_problem() { + let value = read(json!({ + "detail": "The limit was above 100.", + "instance": "/odp/offerings", + "invalid_params": [{"in": "query", "name": "limit", "reason": "too large"}] + })) + .unwrap(); + + assert_eq!(value.code, "INVALID_REQUEST"); + assert_eq!(value.status, 400); + assert_eq!(value.invalid_params[0].name, "limit"); +} + +/// PRB-04: the type URI and the code name one problem, so a document whose two disagree tells the +/// Agent two different things. +#[test] +fn refuses_a_type_that_does_not_match_the_code() { + assert_rejected_for( + read(json!({"type": "https://offeringprotocol.org/problems/not-found"})), + "problem-type", + ); +} + +/// The code is spelled in capitals with underscores and the URI in lower case with hyphens, and one +/// is derived from the other. +#[test] +fn derives_the_type_from_the_code() { + for (code, problem_type) in [ + ( + "NOT_FOUND", + "https://offeringprotocol.org/problems/not-found", + ), + ( + "UNSUPPORTED_OPERATION", + "https://offeringprotocol.org/problems/unsupported-operation", + ), + ( + "STALE_CONTINUATION", + "https://offeringprotocol.org/problems/stale-continuation", + ), + ] { + assert!( + read(json!({"code": code, "type": problem_type})).is_ok(), + "{code}" + ); + } +} + +/// PRB-11: the status inside the document is the status the response carried, so an Agent reading +/// one need not reconcile two answers. +#[test] +fn refuses_a_status_that_contradicts_the_response() { + let data = encode(&problem()); + + assert_eq!(parse_problem_response(&data, 400).unwrap().status, 400); + assert_rejected_for(parse_problem_response(&data, 404), "http-status"); +} + +/// A body that is not a Problem Details document at all is reported as such rather than as a +/// mismatched status. +#[test] +fn refuses_a_response_body_that_is_not_a_problem() { + assert!(parse_problem_response(b"not json", 400).is_err()); + assert!(parse_problem_response(br#"{"title":"Oops"}"#, 400).is_err()); +} + +/// PRB-13: a code is an upper-case identifier, and a status is an HTTP status. +#[test] +fn refuses_a_code_or_status_that_is_neither() { + for changes in [ + json!({"code": "invalid_request"}), + json!({"code": "1_INVALID"}), + json!({"status": 42}), + json!({"status": "400"}), + ] { + assert!(read(changes.clone()).is_err(), "{changes}"); + } +} + +/// PRB-14: an invalid parameter says where it was found, what it was called, and what was wrong. +#[test] +fn refuses_an_invalid_parameter_that_points_nowhere() { + for parameter in [ + json!({"in": "query", "name": "limit"}), + json!({"in": "query", "reason": "too large"}), + json!({"name": "limit", "reason": "too large"}), + json!({"in": "cookie", "name": "session", "reason": "expired"}), + // Outside a body the name is a parameter name, so it cannot be empty. + json!({"in": "query", "name": "", "reason": "too large"}), + ] { + assert!( + read(json!({"invalid_params": [parameter.clone()]})).is_err(), + "{parameter}" + ); + } +} + +/// In a body the name is a JSON Pointer, and the pointer to the whole body is the empty string. +#[test] +fn reads_a_body_parameter_named_by_pointer() { + for name in ["", "/filters/0/value", "/a~0b", "/a~1b"] { + assert!( + read(json!({"invalid_params": [ + {"in": "body", "name": name, "reason": "wrong"} + ]})) + .is_ok(), + "{name:?}" + ); + } + assert!( + read(json!({"invalid_params": [ + {"in": "body", "name": "filters", "reason": "wrong"} + ]})) + .is_err() + ); +} + +// -- serialization ----------------------------------------------------------------------------------- + +/// A Service builds these documents rather than reading them, so what the models leave off the wire +/// matters as much as what they read from it: a member that was absent stays absent, rather than +/// being written back as the default that stood in for it. +#[test] +fn leaves_an_absent_member_off_the_wire() { + for document in [ + json!({"odp_version": "1.0", "id": "plant-1", "name": "Monstera"}), + json!({"odp_version": "1.0", "id": "plant-1", "name": "Monstera", "images": [{"src": "/a.png"}]}), + ] { + let read: Offering = serde_json::from_value(document.clone()).unwrap(); + assert_eq!(serde_json::to_value(&read).unwrap(), document); + } + + let collection = json!({"odp_version": "1.0", "id": "plants", "name": "Plants"}); + let read: Collection = serde_json::from_value(collection.clone()).unwrap(); + assert_eq!(serde_json::to_value(&read).unwrap(), collection); +} + +/// A page says which ODP it speaks and what it carries, and says nothing about a continuation it +/// does not offer. +#[test] +fn writes_a_page_that_offers_no_continuation() { + let document = json!({"odp_version": "1.0", "items": []}); + let read: Page = serde_json::from_value(document.clone()).unwrap(); + let encoded = serde_json::to_value(&read).unwrap(); + + assert_eq!(encoded, document); + assert!(encoded.get("next").is_none()); + assert!(encoded.get("auth_expands").is_none()); +} + +/// COL-06: a null parent survives the round trip as a null, because it asks a question an absent +/// member does not, while an absent one is written back absent. +#[test] +fn keeps_a_null_parent_on_the_wire() { + for document in [ + json!({"odp_version": "1.0", "parent_id": null}), + json!({"odp_version": "1.0", "parent_id": "garden"}), + json!({"odp_version": "1.0", "query": "plants"}), + ] { + let read: CollectionSearchRequest = serde_json::from_value(document.clone()).unwrap(); + assert_eq!(serde_json::to_value(&read).unwrap(), document); + } +} + +/// An Operation Descriptor is written with the wire spelling ODP fixes, not the Rust one. +#[test] +fn writes_an_operation_with_its_wire_spelling() { + let descriptor = OperationDescriptor { + authentication: odp_core::AuthenticationRequirement::NotRequired, + name: Operation::ListCollectionOfferings, + }; + + assert_eq!( + serde_json::to_value(&descriptor).unwrap(), + json!({"authentication": "not-required", "name": "list-collection-offerings"}) + ); +} + +/// A dimension nobody measured is not a dimension of zero, so it is left off. +#[test] +fn leaves_an_unmeasured_image_dimension_off_the_wire() { + let document = json!({"src": "/a.png", "type": "image/png"}); + let read: ResourceImage = serde_json::from_value(document.clone()).unwrap(); + + assert_eq!(serde_json::to_value(&read).unwrap(), document); + + let measured = json!({"src": "/a.png", "width": 800, "height": 600}); + let read: ResourceImage = serde_json::from_value(measured.clone()).unwrap(); + assert_eq!(serde_json::to_value(&read).unwrap(), measured); +} diff --git a/crates/odp-core/tests/reference_conformance.rs b/crates/odp-core/tests/reference_conformance.rs new file mode 100644 index 0000000..2b94732 --- /dev/null +++ b/crates/odp-core/tests/reference_conformance.rs @@ -0,0 +1,505 @@ +//! SEC-05 and the URL rules: where a reference written by somebody else is allowed to point. + +use odp_core::{ + Operation, ReferenceError, ResourceIdentity, ResourceType, build_operation_url, + derive_service_origin, is_local_resource_identifier, is_public, operation_method, + resolve_continuation, resolve_resource_reference, +}; + +// -- Service Origin --------------------------------------------------------------------------------- + +/// SVC-07: the Service Origin is the scheme, host and port of the Service Document URL, and the +/// canonical spelling of those, so two spellings of one Service are one Service. +#[test] +fn canonicalizes_a_service_origin() { + for (url, origin) in [ + ( + "https://plants.example/.well-known/odp", + "https://plants.example", + ), + ( + "https://PLANTS.example/.well-known/odp", + "https://plants.example", + ), + ( + "https://plants.example:443/.well-known/odp", + "https://plants.example", + ), + ( + "https://plants.example:8443/.well-known/odp", + "https://plants.example:8443", + ), + ( + "http://localhost:3000/.well-known/odp", + "http://localhost:3000", + ), + ( + "http://127.0.0.1:3000/.well-known/odp", + "http://127.0.0.1:3000", + ), + ] { + assert_eq!(derive_service_origin(url).unwrap(), origin, "{url}"); + } +} + +/// SEC-05: ODP travels over HTTPS, and the one exception is a loopback host a developer is running +/// against. +#[test] +fn refuses_a_service_document_url_that_is_not_secure() { + assert_eq!( + derive_service_origin("http://plants.example/.well-known/odp"), + Err(ReferenceError::InsecureUrl) + ); + // A host merely beginning with a loopback name is another host entirely. + assert_eq!( + derive_service_origin("http://localhost.plants.example/.well-known/odp"), + Err(ReferenceError::InsecureUrl) + ); + assert!(derive_service_origin("http://[::1]:3000/.well-known/odp").is_ok()); +} + +/// Credentials in a URL are a way to make one host look like another, so they are refused outright. +#[test] +fn refuses_credentials_in_a_service_document_url() { + for url in [ + "https://user@plants.example/.well-known/odp", + "https://user:secret@plants.example/.well-known/odp", + ] { + assert_eq!( + derive_service_origin(url), + Err(ReferenceError::UserInformation), + "{url}" + ); + } +} + +#[test] +fn refuses_a_service_document_url_that_is_not_a_url() { + assert!(matches!( + derive_service_origin("not a url"), + Err(ReferenceError::InvalidUrl(_)) + )); + assert_eq!( + derive_service_origin("mailto:plants@example.com"), + Err(ReferenceError::MissingHost) + ); +} + +// -- resource references ------------------------------------------------------------------------------ + +/// REF-01: a reference is an origin-relative absolute path or a secure absolute URL, so a relative +/// path that would resolve against whatever came before it is not one. +#[test] +fn resolves_a_reference_the_spec_allows() { + for (reference, resolved) in [ + ( + "/odp/offerings/plant-1", + "https://plants.example/odp/offerings/plant-1", + ), + ( + "/odp/offerings?limit=10", + "https://plants.example/odp/offerings?limit=10", + ), + ("https://cdn.example/a.png", "https://cdn.example/a.png"), + ] { + assert_eq!( + resolve_resource_reference(reference, "https://plants.example") + .unwrap() + .as_str(), + resolved + ); + } +} + +#[test] +fn refuses_a_reference_that_resolves_against_nothing_fixed() { + for reference in ["offerings/plant-1", "./a.png", "../a.png", "a.png"] { + assert_eq!( + resolve_resource_reference(reference, "https://plants.example"), + Err(ReferenceError::InvalidReference), + "{reference}" + ); + } +} + +/// A scheme-relative reference takes its scheme from the page that carried it, which is exactly the +/// ambiguity ODP removes. +#[test] +fn refuses_a_scheme_relative_reference() { + assert_eq!( + resolve_resource_reference("//cdn.example/a.png", "https://plants.example"), + Err(ReferenceError::SchemeRelativeReference) + ); +} + +/// A fragment addresses part of a document, and ODP references address documents. +#[test] +fn refuses_a_reference_carrying_a_fragment() { + assert_eq!( + resolve_resource_reference("/odp/offerings#first", "https://plants.example"), + Err(ReferenceError::Fragment) + ); +} + +#[test] +fn refuses_a_reference_carrying_credentials() { + assert_eq!( + resolve_resource_reference("https://user@cdn.example/a.png", "https://plants.example"), + Err(ReferenceError::UserInformation) + ); +} + +/// SEC-05 again, from the other end: an absolute reference to a plaintext host is refused, and a +/// loopback one is the exception. +#[test] +fn refuses_an_absolute_reference_that_is_not_secure() { + assert_eq!( + resolve_resource_reference("http://cdn.example/a.png", "https://plants.example"), + Err(ReferenceError::InvalidReference) + ); + for reference in [ + "http://localhost:3000/a.png", + "http://127.0.0.1:3000/a.png", + "http://[::1]:3000/a.png", + ] { + assert!( + resolve_resource_reference(reference, "http://localhost:3000").is_ok(), + "{reference}" + ); + } +} + +/// A host that merely starts with a loopback name is refused when the resolution is done, whatever +/// the prefix looked like. +#[test] +fn refuses_a_host_that_only_looks_like_loopback() { + for reference in [ + "http://localhost.plants.example/a.png", + "http://127.0.0.1.plants.example/a.png", + ] { + assert_eq!( + resolve_resource_reference(reference, "https://plants.example"), + Err(ReferenceError::InsecureUrl), + "{reference}" + ); + } +} + +#[test] +fn refuses_a_service_origin_that_is_not_a_url() { + assert!(matches!( + resolve_resource_reference("/odp/offerings", "not a url"), + Err(ReferenceError::InvalidUrl(_)) + )); +} + +// -- continuations ------------------------------------------------------------------------------------- + +/// PAG-08: a continuation stays on the Service that issued it, so following one cannot be redirected +/// into somebody else's catalog. +#[test] +fn keeps_a_continuation_on_the_service_that_issued_it() { + assert_eq!( + resolve_continuation("/odp/offerings?cursor=2", "https://plants.example") + .unwrap() + .as_str(), + "https://plants.example/odp/offerings?cursor=2" + ); + assert_eq!( + resolve_continuation("https://plants.example/next", "https://plants.example") + .unwrap() + .as_str(), + "https://plants.example/next" + ); + assert_eq!( + resolve_continuation("https://elsewhere.example/next", "https://plants.example"), + Err(ReferenceError::CrossOriginContinuation) + ); +} + +// -- operation URLs -------------------------------------------------------------------------------------- + +/// SVC-65: every operation hangs off the advertised endpoint base at the path ODP fixes for it. +#[test] +fn builds_the_path_odp_fixes_for_each_operation() { + for (operation, id, path) in [ + (Operation::ListCollections, None, "/odp/collections"), + ( + Operation::SearchCollections, + None, + "/odp/collections/search", + ), + ( + Operation::GetCollection, + Some("plants"), + "/odp/collections/plants", + ), + ( + Operation::ListCollectionOfferings, + Some("plants"), + "/odp/collections/plants/offerings", + ), + (Operation::ListOfferings, None, "/odp/offerings"), + (Operation::SearchOfferings, None, "/odp/offerings/search"), + ( + Operation::GetOffering, + Some("plant-1"), + "/odp/offerings/plant-1", + ), + ] { + assert_eq!( + build_operation_url("/odp", operation, "https://plants.example", id) + .unwrap() + .path(), + path, + "{operation:?}" + ); + } +} + +/// A base of `/` and a base with a trailing slash name the same place. +#[test] +fn reads_every_spelling_of_one_endpoint_base() { + for base in ["/odp", "/odp/"] { + assert_eq!( + build_operation_url( + base, + Operation::ListOfferings, + "https://plants.example", + None + ) + .unwrap() + .path(), + "/odp/offerings", + "{base}" + ); + } + assert_eq!( + build_operation_url( + "/", + Operation::ListOfferings, + "https://plants.example", + None + ) + .unwrap() + .path(), + "/offerings" + ); +} + +/// SVC-64: the endpoint base is origin-relative, so it cannot send the Agent to another host. +#[test] +fn refuses_an_endpoint_base_that_leaves_the_origin() { + for base in [ + "odp", + "//elsewhere.example/odp", + "https://elsewhere.example/odp", + ] { + assert_eq!( + build_operation_url( + base, + Operation::ListOfferings, + "https://plants.example", + None + ), + Err(ReferenceError::InvalidEndpointBase), + "{base}" + ); + } +} + +/// An operation that addresses one resource needs a valid identifier, and one that addresses a +/// collection of them takes none. +#[test] +fn refuses_an_identifier_the_operation_cannot_use() { + for operation in [ + Operation::GetCollection, + Operation::GetOffering, + Operation::ListCollectionOfferings, + ] { + for id in [ + None, + Some(""), + Some("."), + Some(".."), + Some("a/b"), + Some("a?b"), + ] { + assert_eq!( + build_operation_url("/odp", operation, "https://plants.example", id), + Err(ReferenceError::InvalidResourceIdentifier(operation)), + "{operation:?} {id:?}" + ); + } + } + + for operation in [ + Operation::ListCollections, + Operation::ListOfferings, + Operation::SearchCollections, + Operation::SearchOfferings, + ] { + assert_eq!( + build_operation_url("/odp", operation, "https://plants.example", Some("plant-1")), + Err(ReferenceError::UnexpectedResourceIdentifier(operation)), + "{operation:?}" + ); + } +} + +/// REF-04: an identifier is a bounded run of unreserved characters, so it cannot carry a path +/// segment, a query, or a traversal. +#[test] +fn reads_a_local_resource_identifier() { + for id in [ + "a", + "plant-1", + "plant_1", + "plant.1", + "plant~1", + &"a".repeat(128), + ] { + assert!(is_local_resource_identifier(id), "{id}"); + } + for id in [ + "", + ".", + "..", + "a/b", + "a b", + "a%2Fb", + "a#b", + "\u{1f33f}", + &"a".repeat(129), + ] { + assert!(!is_local_resource_identifier(id), "{id}"); + } +} + +/// SVC-66: a search is a POST because its request is a document; everything else is a GET. +#[test] +fn names_the_method_each_operation_uses() { + for operation in [Operation::SearchCollections, Operation::SearchOfferings] { + assert_eq!(operation_method(operation), "POST", "{operation:?}"); + } + for operation in [ + Operation::GetCollection, + Operation::GetOffering, + Operation::ListCollectionOfferings, + Operation::ListCollections, + Operation::ListOfferings, + ] { + assert_eq!(operation_method(operation), "GET", "{operation:?}"); + } +} + +// -- resource identity ----------------------------------------------------------------------------------- + +/// REF-10: one resource is identified by its Service, its kind and its identifier together, so the +/// same identifier at two Services is two resources. +#[test] +fn composes_an_identity_that_two_services_cannot_collide_on() { + let here = ResourceIdentity::new( + "https://plants.example/.well-known/odp", + ResourceType::Offering, + "plant-1", + ) + .unwrap(); + let there = ResourceIdentity::new( + "https://other.example/.well-known/odp", + ResourceType::Offering, + "plant-1", + ) + .unwrap(); + let collection = ResourceIdentity::new( + "https://plants.example/.well-known/odp", + ResourceType::Collection, + "plant-1", + ) + .unwrap(); + + assert_eq!(here.key(), "https://plants.example\0offering\0plant-1"); + assert_eq!( + collection.key(), + "https://plants.example\0collection\0plant-1" + ); + assert_ne!(here.key(), there.key()); + assert_ne!(here.key(), collection.key()); +} + +/// An identity is built from the same identifier rule a URL is, and names the operation that would +/// have addressed the resource. +#[test] +fn refuses_an_identity_whose_identifier_addresses_nothing() { + assert_eq!( + ResourceIdentity::new( + "https://plants.example/.well-known/odp", + ResourceType::Offering, + "a/b" + ), + Err(ReferenceError::InvalidResourceIdentifier( + Operation::GetOffering + )) + ); + assert_eq!( + ResourceIdentity::new( + "https://plants.example/.well-known/odp", + ResourceType::Collection, + ".." + ), + Err(ReferenceError::InvalidResourceIdentifier( + Operation::GetCollection + )) + ); +} + +#[test] +fn refuses_an_identity_at_a_service_that_is_not_secure() { + assert_eq!( + ResourceIdentity::new( + "http://plants.example/.well-known/odp", + ResourceType::Offering, + "plant-1" + ), + Err(ReferenceError::InsecureUrl) + ); +} + +// -- addresses --------------------------------------------------------------------------------------------- + +/// SEC-08: an address the public internet does not route is one a name a third party controls +/// should not be able to reach. +#[test] +fn judges_an_address_by_the_special_purpose_registries() { + for value in ["8.8.8.8", "203.1.113.1", "2606:4700::1111"] { + assert!(is_public(value.parse().unwrap()), "{value}"); + } + for value in [ + "169.254.169.254", + "10.0.0.1", + "::ffff:169.254.169.254", + "64:ff9b::a9fe:a9fe", + ] { + assert!(!is_public(value.parse().unwrap()), "{value}"); + } +} + +/// Every error this module reports says what was wrong in words a caller can pass on. +#[test] +fn describes_every_failure_it_reports() { + for error in [ + ReferenceError::InvalidUrl("relative URL without a base".to_owned()), + ReferenceError::MissingHost, + ReferenceError::UserInformation, + ReferenceError::InsecureUrl, + ReferenceError::InvalidReference, + ReferenceError::SchemeRelativeReference, + ReferenceError::Fragment, + ReferenceError::CrossOriginContinuation, + ReferenceError::InvalidEndpointBase, + ReferenceError::InvalidResourceIdentifier(Operation::GetOffering), + ReferenceError::UnexpectedResourceIdentifier(Operation::ListOfferings), + ] { + assert!(!error.to_string().is_empty(), "{error:?}"); + } +} diff --git a/crates/odp-core/tests/representation_conformance.rs b/crates/odp-core/tests/representation_conformance.rs new file mode 100644 index 0000000..aa57273 --- /dev/null +++ b/crates/odp-core/tests/representation_conformance.rs @@ -0,0 +1,265 @@ +//! The Offering and Collection rules that compare one member of a representation against another. + +mod support; + +use odp_core::{ + ActionRelation, ParseError, PriceType, parse_agent_collection, parse_agent_offering, + parse_collection, parse_offering, +}; +use serde_json::json; +use support::{action, amend, assert_rejected_for, collection, encode, offering}; + +fn read_offering(changes: serde_json::Value) -> Result { + parse_offering(&encode(&amend(offering(), changes))) +} + +fn read_collection(changes: serde_json::Value) -> Result { + parse_collection(&encode(&amend(collection(), changes))) +} + +#[test] +fn reads_a_conformant_offering() { + let value = read_offering(json!({ + "description": "A big green plant.", + "actions": [action("buy")], + "price": {"type": "fixed", "amount": "39.00", "currency": "USD"} + })) + .unwrap(); + + assert_eq!(value.id, "plant-1"); + assert_eq!(value.name, "Monstera"); + assert_eq!(value.actions[0].rel, ActionRelation::Purchase); + assert_eq!(value.price.unwrap().price_type, PriceType::Fixed); +} + +#[test] +fn reads_a_conformant_collection() { + let value = read_collection(json!({"parent_ids": ["garden"]})).unwrap(); + + assert_eq!(value.id, "plants"); + assert_eq!(value.parent_ids, ["garden"]); +} + +// -- Action identifiers --------------------------------------------------------------------------- + +/// OFR-57: an Action identifier is unique within its Offering, so a repeat leaves a caller unable +/// to say which Action it meant. +#[test] +fn refuses_a_repeated_action_identifier() { + assert_rejected_for( + read_offering(json!({"actions": [action("buy"), action("buy")]})), + "unique-action-id", + ); +} + +#[test] +fn accepts_actions_that_are_told_apart() { + assert!(read_offering(json!({"actions": [action("buy"), action("rent")]})).is_ok()); +} + +// -- price ---------------------------------------------------------------------------------------- + +/// OFR-49: a range whose minimum is above its maximum describes no price at all. +#[test] +fn refuses_an_inverted_price_range() { + let range = |minimum, maximum| { + read_offering(json!({"price": { + "type": "range", "currency": "USD", "minimum": minimum, "maximum": maximum + }})) + }; + + assert_rejected_for(range("99.00", "5.00"), "price-range"); + assert_rejected_for(range("10", "9.99"), "price-range"); + assert!(range("5.00", "99.00").is_ok()); +} + +/// OFR-48: a price is a decimal string, so the bounds are ordered numerically, not lexically. +#[test] +fn orders_a_price_range_numerically() { + let range = |minimum, maximum| { + read_offering(json!({"price": { + "type": "range", "currency": "USD", "minimum": minimum, "maximum": maximum + }})) + .is_ok() + }; + + // Lexically "9.00" sorts after "10.00"; numerically it does not. + assert!(range("9.00", "10.00")); + // Trailing and leading zeros do not change a value, so these bounds are equal. + assert!(range("5.0", "5.00")); + assert!(range("0", "0.000")); + assert!(range("1.5", "1.50")); + assert!(!range("1.51", "1.5")); +} + +/// A price type that carries no bounds has nothing to order. +#[test] +fn leaves_a_price_without_bounds_alone() { + assert!(read_offering(json!({"price": {"type": "free"}})).is_ok()); + assert!(read_offering(json!({"price": {"type": "quote"}})).is_ok()); + assert!( + read_offering(json!({ + "price": {"type": "metered", "amount": "0.10", "currency": "USD", "unit": "litre"} + })) + .is_ok() + ); +} + +// -- hierarchy ------------------------------------------------------------------------------------- + +/// COL-20: a Collection naming itself as a parent is a one-node cycle, and nothing walking the +/// hierarchy upwards from it would reach a root. +#[test] +fn refuses_a_collection_that_parents_itself() { + assert_rejected_for( + read_collection(json!({"parent_ids": ["plants"]})), + "self-parent", + ); + assert_rejected_for( + read_collection(json!({"parent_ids": ["garden", "plants"]})), + "self-parent", + ); +} + +/// COL-19: a Collection names each parent once. +#[test] +fn refuses_a_repeated_parent() { + assert!(read_collection(json!({"parent_ids": ["garden", "garden"]})).is_err()); +} + +// -- language and images ---------------------------------------------------------------------------- + +/// REP-20: a representation that says which language it is in says it with a language tag. +#[test] +fn refuses_a_representation_language_that_is_not_a_tag() { + assert_rejected_for( + read_offering(json!({"language": "not a tag"})), + "language-tag", + ); + assert_rejected_for( + read_collection(json!({"language": "not a tag"})), + "language-tag", + ); +} + +/// REP-21: a representation that lists its localizations lists the one it is written in. +#[test] +fn refuses_localizations_that_omit_the_representation_language() { + assert_rejected_for( + read_offering(json!({"language": "en", "localizations": ["fr"]})), + "contains-language", + ); +} + +/// A representation naming no language of its own has no membership to check. +#[test] +fn leaves_localizations_alone_when_no_language_was_named() { + assert!(read_offering(json!({"localizations": ["fr", "de"]})).is_ok()); +} + +/// A language with no localizations beside it is not a contradiction either. +#[test] +fn accepts_a_language_without_localizations() { + assert!(read_offering(json!({"language": "fr"})).is_ok()); +} + +/// REP-25: one image listed twice is one image, so a repeat is a defect rather than two pictures. +#[test] +fn refuses_a_repeated_image_source() { + let image = |src| json!({"src": src, "type": "image/png"}); + + assert_rejected_for( + read_offering(json!({"images": [image("/a.png"), image("/a.png")]})), + "unique-image-source", + ); + assert!(read_offering(json!({"images": [image("/a.png"), image("/b.png")]})).is_ok()); +} + +// -- what an Agent tolerates ------------------------------------------------------------------------- + +/// ROLE-03: an Agent describes a defect to its caller rather than discarding an Offering it can +/// still use, so the invariants a Service must satisfy do not refuse the document on that side. +#[test] +fn hands_an_agent_an_offering_a_service_must_not_publish() { + let data = encode(&amend( + offering(), + json!({"actions": [action("buy"), action("buy")]}), + )); + + assert!(parse_offering(&data).is_err()); + let tolerated = parse_agent_offering(&data).unwrap(); + assert_eq!(tolerated.actions.len(), 2, "the repeat is left to report"); +} + +#[test] +fn hands_an_agent_a_collection_a_service_must_not_publish() { + let data = encode(&amend(collection(), json!({"parent_ids": ["plants"]}))); + + assert!(parse_collection(&data).is_err()); + assert_eq!( + parse_agent_collection(&data).unwrap().parent_ids, + ["plants"] + ); +} + +/// Tolerance stops at defects that leave nothing to use: a representation an Agent cannot read at +/// all is still refused. +#[test] +fn refuses_an_agent_document_that_is_not_readable() { + assert!(parse_agent_offering(b"not json").is_err()); + assert!(parse_agent_collection(b"{}").is_err()); + assert!( + parse_agent_offering(&encode(&amend( + offering(), + json!({"language": "not a tag"}) + ))) + .is_err() + ); +} + +/// The Agent entry points normalize first, so a member ODP does not define never reaches the +/// schema. +#[test] +fn filters_what_a_later_odp_version_added_before_reading_it() { + let data = encode(&amend( + offering(), + json!({"price": {"type": "auction", "reserve": "10.00"}}), + )); + + assert!(parse_offering(&data).is_err()); + assert!(parse_agent_offering(&data).unwrap().price.is_none()); +} + +// -- Action relations -------------------------------------------------------------------------------- + +/// OFR-55: an Agent reads a relation ODP does not define rather than refusing the Action, because +/// the relation is a hint about the Action, not the Action itself. +#[test] +fn reads_a_relation_a_later_odp_version_may_define() { + let other = amend(action("swap"), json!({"rel": "barter"})); + let value = read_offering(json!({"actions": [other]})).unwrap(); + + assert_eq!( + value.actions[0].rel, + ActionRelation::Other("barter".to_owned()) + ); +} + +#[test] +fn round_trips_every_relation_odp_defines() { + for (wire, relation) in [ + ("download", ActionRelation::Download), + ("invoke", ActionRelation::Invoke), + ("purchase", ActionRelation::Purchase), + ("quote", ActionRelation::Quote), + ("reserve", ActionRelation::Reserve), + ("barter", ActionRelation::Other("barter".to_owned())), + ] { + let encoded = format!("\"{wire}\""); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + relation + ); + assert_eq!(serde_json::to_string(&relation).unwrap(), encoded); + } +} diff --git a/crates/odp-core/tests/support/mod.rs b/crates/odp-core/tests/support/mod.rs new file mode 100644 index 0000000..86aaf46 --- /dev/null +++ b/crates/odp-core/tests/support/mod.rs @@ -0,0 +1,132 @@ +//! Fixtures every odp-core conformance suite builds on. +//! +//! Each builder returns a document the spec accepts, so a test says only what it changes and a +//! rejection can be read as a consequence of that one change. + +#![allow(dead_code)] + +use odp_core::{ParseError, ValidationIssue}; +use serde_json::{Value, json}; + +/// Merges `changes` into `base`, where a null member removes it. This is what lets a test spell +/// out only its own departure from a conformant document. +#[must_use] +pub fn amend(mut base: Value, changes: Value) -> Value { + let target = base.as_object_mut().expect("fixture is an object"); + for (key, value) in changes.as_object().expect("changes are an object") { + if value.is_null() { + target.remove(key); + } else { + target.insert(key.clone(), value.clone()); + } + } + base +} + +#[must_use] +pub fn encode(value: &Value) -> Vec { + serde_json::to_vec(value).expect("fixture encodes") +} + +/// A conformant Service Document. +#[must_use] +pub fn service_document() -> Value { + json!({ + "odp_version": "1.0", + "name": "Plants", + "description": "A shop that sells plants.", + "language": "en", + "localizations": ["en"], + "operations": [ + {"authentication": "not-required", "name": "list-offerings"}, + {"authentication": "not-required", "name": "get-offering"} + ], + "http": {"endpoint_base": "/odp"} + }) +} + +/// A conformant Offering. +#[must_use] +pub fn offering() -> Value { + json!({"odp_version": "1.0", "id": "plant-1", "name": "Monstera"}) +} + +/// A conformant Collection. +#[must_use] +pub fn collection() -> Value { + json!({"odp_version": "1.0", "id": "plants", "name": "Plants"}) +} + +/// A conformant Action, which needs an identifier of its own to sit beside another. +#[must_use] +pub fn action(id: &str) -> Value { + json!({ + "id": id, + "rel": "purchase", + "authentication": "not-required", + "http": {"href": "https://plants.example/checkout", "method": "POST"} + }) +} + +/// A conformant Filter Definition of the named type. +#[must_use] +pub fn filter_definition(filter_type: &str) -> Value { + json!({ + "id": "weight", + "title": "Weight", + "description": "How heavy the plant is.", + "type": filter_type, + "operators": ["eq"] + }) +} + +/// A conformant Sort Definition. +#[must_use] +pub fn sort_definition() -> Value { + json!({ + "id": "cheapest", + "title": "Cheapest first", + "description": "Orders plants by price.", + "keys": [{"filter_id": "price", "direction": "ascending", "missing": "last"}] + }) +} + +/// A conformant page envelope carrying `items`. +#[must_use] +pub fn page(items: Value) -> Value { + json!({"odp_version": "1.0", "items": items}) +} + +/// A conformant Problem Details document. +#[must_use] +pub fn problem() -> Value { + json!({ + "type": "https://offeringprotocol.org/problems/invalid-request", + "title": "Invalid request", + "status": 400, + "code": "INVALID_REQUEST" + }) +} + +/// The issues a rejection reported, or a panic naming what was accepted instead. +#[must_use] +pub fn issues(outcome: Result) -> Vec { + match outcome { + Ok(_) => panic!("the document was accepted"), + Err(ParseError::Validation(error)) => error.issues, + Err(error) => panic!("{error}"), + } +} + +/// Asserts the rejection names this rule, so a test cannot pass on an unrelated defect. +pub fn assert_rejected_for(outcome: Result, keyword: &str) { + let issues = issues(outcome); + assert!( + issues.iter().any(|issue| issue.keyword == keyword), + "expected {keyword}, got {:?}", + issues + .iter() + .map(|issue| format!("{} at {}", issue.keyword, issue.path)) + .collect::>() + ); +} diff --git a/crates/odp-directory/Cargo.toml b/crates/odp-directory/Cargo.toml index 33e2feb..0ba17a4 100644 --- a/crates/odp-directory/Cargo.toml +++ b/crates/odp-directory/Cargo.toml @@ -23,7 +23,8 @@ thiserror.workspace = true url.workspace = true [dev-dependencies] -tokio.workspace = true +tiny_http.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] workspace = true diff --git a/crates/odp-directory/src/client.rs b/crates/odp-directory/src/client.rs index f2ea851..dcaa26c 100644 --- a/crates/odp-directory/src/client.rs +++ b/crates/odp-directory/src/client.rs @@ -1,18 +1,32 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + net::IpAddr, + sync::Arc, +}; -use odp_core::{derive_service_origin, parse_agent_service_document}; +use odp_core::{Protocol, derive_service_origin, is_public, parse_agent_service_document}; use serde_json::{Value, json}; use thiserror::Error; -use url::Url; +use url::{Host, Url}; use crate::{ DirectoryService, Environment, HttpRequest, HttpResponse, IterationOptions, - ResourceSearchRequest, SearchPage, SearchRequest, SearchResponse, SuggestionRequest, Transport, - TransportError, default_transport, + ResourceSearchRequest, SearchPage, SearchRequest, SearchResponse, ServiceIssue, + SuggestionRequest, Transport, TransportError, default_transport, }; const MAXIMUM_REDIRECTS: usize = 5; const MAXIMUM_RESPONSE_BYTES: usize = 524_288; +/// ERR-21: a failure is described far more tightly than a result is returned. +const MAXIMUM_ERROR_BYTES: usize = 16_384; +/// How much of a failure body is worth repeating to a caller. +const MAXIMUM_ERROR_MESSAGE: usize = 2_048; +const MAXIMUM_ITEMS_PER_PAGE: usize = 100; +const MAXIMUM_FACET_ENTRIES: usize = 100; +const MAXIMUM_SUGGESTIONS: usize = 25; +const MAXIMUM_TRAVERSAL: usize = 10_000; +const MAXIMUM_REFERENCE_CHARACTERS: usize = 2_048; +const MAXIMUM_PAGES: usize = 16; #[derive(Debug, Error)] pub enum DirectoryError { @@ -100,26 +114,68 @@ impl DirectoryClient { self.request_page("GET", target.as_str(), Vec::new()).await } + /// Every page of a search, up to `max_pages`. + /// + /// The last page returned still carries its `next`, so a caller that wants to go further can + /// resume from it: reaching the bound is not the same as reaching the end. + pub async fn search_pages( + &self, + request: &SearchRequest, + options: IterationOptions, + ) -> Result, DirectoryError> { + self.traverse(request, options, usize::MAX) + .await + .map(|(pages, _)| pages) + } + pub async fn collect_services( &self, request: &SearchRequest, options: IterationOptions, ) -> Result, DirectoryError> { - let maximum_items = bounded(options.max_items, 10_000, 10_000, "max_items")?; - let maximum_responses = bounded(options.max_pages, 16, 16, "max_pages")?; - let mut services = Vec::new(); + let maximum_items = bounded(options.max_items, MAXIMUM_TRAVERSAL, "max_items")?; + let (pages, _) = self.traverse(request, options, maximum_items).await?; + Ok(pages + .into_iter() + .flat_map(|page| page.items) + .take(maximum_items) + .collect()) + } + + /// Walks a search from its first page, stopping at whichever bound is reached first. + /// + /// A page is only fetched when something still wants it, so a caller asking for ten Services + /// does not pay for a hundred, and a cursor that repeats is refused rather than followed. + async fn traverse( + &self, + request: &SearchRequest, + options: IterationOptions, + maximum_items: usize, + ) -> Result<(Vec, usize), DirectoryError> { + let maximum_pages = bounded(options.max_pages, MAXIMUM_PAGES, "max_pages")?; + // Validated here as well, so `search_pages` refuses the same requests `search_services` does. + bounded(options.max_items, MAXIMUM_TRAVERSAL, "max_items")?; + let mut pages = Vec::new(); + let mut visited = BTreeSet::new(); + let mut items = 0_usize; let mut page = self.search_services(request).await?; - for index in 0..maximum_responses { - services.extend(page.items.into_iter().take(maximum_items - services.len())); - if page.next.is_empty() - || services.len() == maximum_items - || index + 1 == maximum_responses - { - break; + loop { + items += page.items.len(); + let next = page.next.clone(); + pages.push(page); + if next.is_empty() || pages.len() >= maximum_pages || items >= maximum_items { + return Ok((pages, items)); + } + let target = self.continuation_url(&next)?; + if !visited.insert(target.to_string()) { + return Err(DirectoryError::InvalidResponse( + "Directory pagination loop detected".to_owned(), + )); } - page = self.continue_search_services(&page.next).await?; + page = self + .request_page("GET", target.as_str(), Vec::new()) + .await?; } - Ok(services) } pub async fn suggest( @@ -180,23 +236,7 @@ impl DirectoryClient { } self.request("GET", target, Vec::new()).await? }; - #[derive(serde::Deserialize)] - struct Suggestions { - items: Vec, - } - let suggestions = serde_json::from_slice::(&response.body) - .map_err(|error| DirectoryError::InvalidResponse(error.to_string()))? - .items; - if suggestions.len() > 25 - || suggestions.iter().any(|value| { - value.trim() != value || value.is_empty() || value.chars().count() > 128 - }) - { - return Err(DirectoryError::InvalidResponse( - "Directory suggestions are invalid".to_owned(), - )); - } - Ok(suggestions) + parse_suggestions(&response.body) } async fn request_page( @@ -210,41 +250,42 @@ impl DirectoryClient { let response = self.request(method, target, body).await?; let mut value = serde_json::from_slice::(&response.body) .map_err(|error| DirectoryError::InvalidResponse(error.to_string()))?; - if let Some(items) = value - .as_object_mut() - .and_then(|object| object.get_mut("items")) + let object = value.as_object_mut().ok_or_else(|| { + DirectoryError::InvalidResponse("Directory search page must be an object".to_owned()) + })?; + require_continuation(object.get("next"))?; + require_facets(object.get("facets"))?; + let raw = object + .get_mut("items") .and_then(Value::as_array_mut) - { - for item in items { - normalize_service_protocols(item)?; - } - } - let page = serde_json::from_value::(value) - .map_err(|error| DirectoryError::InvalidResponse(error.to_string()))?; - if page.items.len() > 100 { + .ok_or_else(|| { + DirectoryError::InvalidResponse( + "Directory search page items are invalid".to_owned(), + ) + })?; + if raw.len() > MAXIMUM_ITEMS_PER_PAGE { return Err(DirectoryError::InvalidResponse( "Directory search page exceeds 100 Services".to_owned(), )); } - if page.facets.as_ref().is_some_and(|facets| { - facets - .trust - .iter() - .any(|facet| facet.value.name != odp_core::Protocol::Tap) - }) { - return Err(DirectoryError::InvalidResponse( - "Directory trust facets are invalid".to_owned(), - )); - } - for service in &page.items { - let canonical = derive_service_origin(&service.service_origin) - .map_err(|error| DirectoryError::InvalidResponse(error.to_string()))?; - if canonical != service.service_origin { - return Err(DirectoryError::InvalidResponse( - "Directory Service origin is not canonical".to_owned(), - )); + // ROLE-03: one record this client cannot read is a note about that record. Withholding the + // whole page would let a single bad row in an index nobody controls deny every other Service. + let mut items = Vec::with_capacity(raw.len()); + let mut issues = Vec::new(); + for (index, item) in raw.iter_mut().enumerate() { + match read_service(item) { + Ok(service) => items.push(service), + Err(error) => issues.push(ServiceIssue { + index, + message: error.to_string(), + }), } } + object.insert("items".to_owned(), Value::Array(Vec::new())); + let mut page = serde_json::from_value::(value) + .map_err(|error| DirectoryError::InvalidResponse(error.to_string()))?; + page.items = items; + page.issues = issues; Ok(page) } @@ -254,7 +295,8 @@ impl DirectoryClient { mut target: Url, mut body: Vec, ) -> Result { - for redirects in 0..=MAXIMUM_REDIRECTS { + let mut redirects = 0_usize; + loop { let mut headers = BTreeMap::from([("accept".to_owned(), "application/json".to_owned())]); if !body.is_empty() { @@ -294,10 +336,8 @@ impl DirectoryClient { body.clear(); } target = next; + redirects += 1; } - Err(DirectoryError::InvalidResponse( - "Directory response exceeded its redirect limit".to_owned(), - )) } fn continuation_url(&self, next: &str) -> Result { @@ -323,10 +363,207 @@ impl DirectoryClient { } } -fn normalize_service_protocols(item: &mut Value) -> Result<(), DirectoryError> { - let Some(object) = item.as_object_mut() else { +/// Reads one Directory record, or says why it cannot be used. +fn read_service(item: &mut Value) -> Result { + let object = item.as_object_mut().ok_or_else(|| { + DirectoryError::InvalidResponse("Directory Service must be an object".to_owned()) + })?; + require_service_origin(object.get("service_origin"))?; + require_indexed_at(object.get("indexed_at"))?; + normalize_service_protocols(object)?; + serde_json::from_value::(item.take()) + .map_err(|error| DirectoryError::InvalidResponse(error.to_string())) +} + +/// SVC-17 and SEC-08: an origin an index published is checked before a caller is handed it. +fn require_service_origin(value: Option<&Value>) -> Result<(), DirectoryError> { + let origin = value.and_then(Value::as_str).ok_or_else(|| { + DirectoryError::InvalidResponse("Directory Service origin is missing".to_owned()) + })?; + let canonical = derive_service_origin(origin) + .map_err(|error| DirectoryError::InvalidResponse(error.to_string()))?; + if canonical != origin { + return Err(DirectoryError::InvalidResponse( + "Directory Service origin is not canonical".to_owned(), + )); + } + let url = + Url::parse(origin).map_err(|error| DirectoryError::InvalidResponse(error.to_string()))?; + // An address literal is judged outright. A name is not resolved here: nothing is being + // reached, and an Agent that later connects resolves and judges it again for itself. + let reachable = match url.host() { + Some(Host::Ipv4(value)) => is_public(IpAddr::V4(value)), + Some(Host::Ipv6(value)) => is_public(IpAddr::V6(value)), + Some(Host::Domain(value)) => !is_local_name(value), + None => false, + }; + if !reachable { + return Err(DirectoryError::InvalidResponse( + "Directory Service origin names a non-public address".to_owned(), + )); + } + Ok(()) +} + +fn is_local_name(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + host == "localhost" || host.ends_with(".localhost") +} + +/// An indexing time is an RFC 3339 timestamp, not whatever a date parser happens to accept. +fn require_indexed_at(value: Option<&Value>) -> Result<(), DirectoryError> { + let indexed_at = value.and_then(Value::as_str).ok_or_else(|| { + DirectoryError::InvalidResponse("Directory indexing time is missing".to_owned()) + })?; + if !is_rfc3339(indexed_at) { + return Err(DirectoryError::InvalidResponse( + "Directory indexing time is not an RFC 3339 timestamp".to_owned(), + )); + } + Ok(()) +} + +fn is_rfc3339(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() < 20 || value.chars().count() > 64 { + return false; + } + let digits = |range: std::ops::Range| bytes[range].iter().all(u8::is_ascii_digit); + if !digits(0..4) || bytes[4] != b'-' || !digits(5..7) || bytes[7] != b'-' || !digits(8..10) { + return false; + } + if !matches!(bytes[10], b'T' | b't') { + return false; + } + if !digits(11..13) || bytes[13] != b':' || !digits(14..16) || bytes[16] != b':' { + return false; + } + if !digits(17..19) { + return false; + } + let mut rest = &value[19..]; + if let Some(fraction) = rest.strip_prefix('.') { + let taken = fraction.chars().take_while(char::is_ascii_digit).count(); + if taken == 0 { + return false; + } + rest = &fraction[taken..]; + } + if matches!(rest, "Z" | "z") { + return true; + } + let offset = rest.as_bytes(); + offset.len() == 6 + && matches!(offset[0], b'+' | b'-') + && offset[1..3].iter().all(u8::is_ascii_digit) + && offset[3] == b':' + && offset[4..6].iter().all(u8::is_ascii_digit) +} + +/// A continuation the Directory offers is a reference this client could actually use. +fn require_continuation(value: Option<&Value>) -> Result<(), DirectoryError> { + let Some(value) = value else { + return Ok(()); + }; + if value.is_null() { + return Ok(()); + } + let next = value.as_str().ok_or_else(|| { + DirectoryError::InvalidResponse("Directory continuation is invalid".to_owned()) + })?; + if next.trim() != next || next.chars().count() > MAXIMUM_REFERENCE_CHARACTERS { + return Err(DirectoryError::InvalidResponse( + "Directory continuation is invalid".to_owned(), + )); + } + Ok(()) +} + +/// A facet describes how many Services share a value, so the counts have to be countable. +fn require_facets(value: Option<&Value>) -> Result<(), DirectoryError> { + let Some(Value::Object(groups)) = value else { return Ok(()); }; + // A trust facet counts Services by trust protocol. `tap` is the only one this ODP version + // names, and a descriptor carries nothing but that name, so anything else describes a + // vocabulary this client cannot read and the page as a whole is not usable. + if let Some(entries) = groups.get("trust").and_then(Value::as_array) { + let readable = entries.iter().all(|entry| { + entry + .get("value") + .and_then(Value::as_object) + .is_some_and(|descriptor| { + descriptor.len() == 1 + && descriptor.get("name").and_then(Value::as_str) == Some("tap") + }) + }); + if !readable { + return Err(DirectoryError::InvalidResponse( + "Directory trust facets are invalid".to_owned(), + )); + } + } + for entries in groups.values() { + let Some(entries) = entries.as_array() else { + return Err(DirectoryError::InvalidResponse( + "Directory facets are invalid".to_owned(), + )); + }; + if entries.len() > MAXIMUM_FACET_ENTRIES { + return Err(DirectoryError::InvalidResponse( + "Directory facet exceeds 100 entries".to_owned(), + )); + } + for entry in entries { + let countable = entry + .get("count") + .is_some_and(|count| count.as_u64().is_some()); + if !countable { + return Err(DirectoryError::InvalidResponse( + "Directory facet count is invalid".to_owned(), + )); + } + } + } + Ok(()) +} + +/// The Directory answers suggestions as an envelope, and repeats nothing this client would not. +fn parse_suggestions(body: &[u8]) -> Result, DirectoryError> { + let value = serde_json::from_slice::(body) + .map_err(|error| DirectoryError::InvalidResponse(error.to_string()))?; + let items = value + .as_object() + .and_then(|object| object.get("items")) + .and_then(Value::as_array) + .ok_or_else(|| { + DirectoryError::InvalidResponse("Directory suggestions are invalid".to_owned()) + })?; + let mut seen = BTreeSet::new(); + let mut suggestions = Vec::new(); + for item in items { + let suggestion = item.as_str().filter(|value| { + !value.trim().is_empty() && value.trim() == *value && value.chars().count() <= 128 + }); + let Some(suggestion) = suggestion else { + return Err(DirectoryError::InvalidResponse( + "Directory suggestions are invalid".to_owned(), + )); + }; + if seen.insert(suggestion.to_owned()) { + suggestions.push(suggestion.to_owned()); + } + if suggestions.len() == MAXIMUM_SUGGESTIONS { + break; + } + } + Ok(suggestions) +} + +/// `read_service` has already established that `object` is a Service-shaped object. +fn normalize_service_protocols( + object: &mut serde_json::Map, +) -> Result<(), DirectoryError> { let Some(protocols) = object.get("protocols").cloned() else { return Ok(()); }; @@ -359,13 +596,8 @@ fn normalize_service_protocols(item: &mut Value) -> Result<(), DirectoryError> { Ok(()) } -fn bounded( - value: usize, - fallback: usize, - maximum: usize, - name: &str, -) -> Result { - let value = if value == 0 { fallback } else { value }; +fn bounded(value: usize, maximum: usize, name: &str) -> Result { + let value = if value == 0 { maximum } else { value }; if value > maximum { return Err(DirectoryError::InvalidRequest(format!( "{name} must be from 1 through {maximum}" @@ -383,7 +615,7 @@ fn validate_search( limit: usize, filters: Option<&crate::ServiceFilters>, ) -> Result<(), DirectoryError> { - if limit > 100 { + if limit > MAXIMUM_ITEMS_PER_PAGE { return Err(DirectoryError::InvalidRequest( "limit must be from 1 through 100".to_owned(), )); @@ -393,48 +625,90 @@ fn validate_search( "query must contain at most 512 characters without surrounding whitespace".to_owned(), )); } - if let Some(filters) = filters { - if filters.keywords.len() > 32 - || filters - .keywords - .iter() - .any(|value| value.is_empty() || value.chars().count() > 64) - { - return Err(DirectoryError::InvalidRequest( - "keywords must contain at most 32 values of at most 64 characters".to_owned(), - )); - } - if !filters.trust.is_empty() - && (filters.trust.len() != 1 || filters.trust[0].name != odp_core::Protocol::Tap) - { - return Err(DirectoryError::InvalidRequest( - "trust must contain exactly one tap descriptor".to_owned(), - )); + let Some(filters) = filters else { + return Ok(()); + }; + if filters.keywords.len() > 32 + || filters + .keywords + .iter() + .any(|value| value.trim().is_empty() || value.chars().count() > 64) + { + return Err(DirectoryError::InvalidRequest( + "keywords must contain at most 32 values of at most 64 characters".to_owned(), + )); + } + require_unique(&filters.keywords, "keywords")?; + // A Directory rejects the whole request over a repeated filter, so a caller hears about it + // here rather than as an opaque 400. + if filters.enrollment.len() > 1 { + return Err(DirectoryError::InvalidRequest( + "enrollment must contain at most 1 value".to_owned(), + )); + } + if filters.operations.len() > 21 { + return Err(DirectoryError::InvalidRequest( + "operations must contain at most 21 values".to_owned(), + )); + } + require_unique(&filters.operations, "operations")?; + if filters.payments.len() > 32 { + return Err(DirectoryError::InvalidRequest( + "payments must contain at most 32 values".to_owned(), + )); + } + require_unique(&filters.payments, "payments")?; + for payment in &filters.payments { + require_unique(&payment.options, "payment options")?; + } + // A trust filter, when present, is the single-item array `[{"name":"tap"}]`: `tap` is the only + // trust protocol this ODP version names, and the Directory refuses anything else. + if !filters.trust.is_empty() + && (filters.trust.len() != 1 || filters.trust[0].name != Protocol::Tap) + { + return Err(DirectoryError::InvalidRequest( + "trust must be the single-item array [{\"name\":\"tap\"}]".to_owned(), + )); + } + Ok(()) +} + +fn require_unique(values: &[T], name: &str) -> Result<(), DirectoryError> { + for (index, value) in values.iter().enumerate() { + if values[..index].contains(value) { + return Err(DirectoryError::InvalidRequest(format!( + "{name} must not repeat a value" + ))); } } Ok(()) } fn consume_response(response: HttpResponse) -> Result { - if response.body.len() > MAXIMUM_RESPONSE_BYTES { - return Err(DirectoryError::InvalidResponse( - "Directory response exceeds 524288 bytes".to_owned(), - )); - } - if !(200..300).contains(&response.status) { - let message = String::from_utf8_lossy(&response.body).into_owned(); + let declared = response + .headers + .get("content-length") + .and_then(|value| value.trim().parse::().ok()); + let failed = !(200..300).contains(&response.status); + // ERR-21: a failure is read under a far tighter limit than a result. + let limit = if failed { + MAXIMUM_ERROR_BYTES + } else { + MAXIMUM_RESPONSE_BYTES + }; + if failed { return Err(DirectoryError::Request { + message: failure_message(&response, limit, declared), headers: response.headers, - message, status: response.status, }); } - let content_type = response - .headers - .get("content-type") - .map(|value| value.split(';').next().unwrap_or_default().trim()) - .unwrap_or_default(); - if !content_type.eq_ignore_ascii_case("application/json") { + if declared.is_some_and(|value| value > limit) || response.body.len() > limit { + return Err(DirectoryError::InvalidResponse( + "Directory response exceeds 524288 bytes".to_owned(), + )); + } + if !media_type(&response.headers).eq_ignore_ascii_case("application/json") { return Err(DirectoryError::InvalidResponse( "Directory response must use application/json".to_owned(), )); @@ -442,14 +716,73 @@ fn consume_response(response: HttpResponse) -> Result) -> String { + let media_type = media_type(&response.headers); + let describable = media_type.eq_ignore_ascii_case("application/json") + || media_type.eq_ignore_ascii_case("application/problem+json"); + if !describable || declared.is_some_and(|value| value > limit) || response.body.len() > limit { + return String::new(); + } + let text = String::from_utf8_lossy(&response.body); + let detail = serde_json::from_str::(&text) + .ok() + .filter(Value::is_object) + .and_then(|value| { + ["detail", "title", "message"] + .iter() + .filter_map(|member| value.get(*member).and_then(Value::as_str)) + .find(|found| !found.trim().is_empty()) + .map(str::to_owned) + }) + .unwrap_or_else(|| text.into_owned()); + printable(&detail) +} + +/// Flattens control characters and keeps the excerpt short enough to read. +fn printable(value: &str) -> String { + let flattened = value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::(); + let collapsed = flattened.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= MAXIMUM_ERROR_MESSAGE { + return collapsed; + } + let mut truncated = collapsed + .chars() + .take(MAXIMUM_ERROR_MESSAGE) + .collect::(); + truncated.push('…'); + truncated +} + +fn media_type(headers: &BTreeMap) -> &str { + headers + .get("content-type") + .map(|value| value.split(';').next().unwrap_or_default().trim()) + .unwrap_or_default() +} + #[cfg(test)] mod tests { + use crate::{Facet, ServiceFilters}; use std::sync::Mutex; use async_trait::async_trait; use super::*; - use crate::{Facet, ServiceFilters}; struct MockTransport { requests: Mutex>, @@ -622,11 +955,14 @@ mod tests { Environment::Production, Arc::new(ResponseTransport(malformed.into_bytes())), ); - assert!( - client - .search_services(&SearchRequest::default()) - .await - .is_err() - ); + // A malformed known protocol makes that one record unusable, and says so, rather than + // withholding every other Service on the page. + let page = client + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert!(page.items.is_empty()); + assert_eq!(page.issues.len(), 1); + assert_eq!(page.issues[0].index, 0); } } diff --git a/crates/odp-directory/src/models.rs b/crates/odp-directory/src/models.rs index db06fc2..729818d 100644 --- a/crates/odp-directory/src/models.rs +++ b/crates/odp-directory/src/models.rs @@ -33,6 +33,8 @@ pub struct ServiceFilters { pub operations: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub payments: Vec, + /// A trust filter is either empty or the single-item array `[{"name":"tap"}]`: `tap` is the + /// only trust protocol this ODP version names. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub trust: Vec, } @@ -193,6 +195,8 @@ pub struct Facets { pub payment_options: Vec>, #[serde(default)] pub payments: Vec>, + /// A trust facet counts Services by trust protocol. `tap` is the only one this ODP version + /// names, so every descriptor carries that name and nothing else. #[serde(default)] pub trust: Vec>, } @@ -203,17 +207,36 @@ pub struct PaymentOptionFacetValue { pub option: PaymentOption, } +/// A record the Directory published that this client would not hand back. +/// +/// ROLE-03: a Directory result is discovery metadata, so one unusable record is a note about that +/// record rather than a reason to withhold the page. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServiceIssue { + /// The record's position in the page the Directory sent. + pub index: usize, + pub message: String, +} + #[derive(Clone, Debug, Deserialize, PartialEq)] pub struct SearchPage { #[serde(default)] pub facets: Option, + /// The records this client was able to read. Withheld records appear in `issues`. pub items: Vec, - #[serde(default)] + #[serde(default, skip)] + pub issues: Vec, + #[serde(default, deserialize_with = "absent_as_empty")] pub next: String, #[serde(flatten)] pub additional: BTreeMap, } +/// A Directory that offers no continuation may omit `next` or send it as null; both mean the same. +fn absent_as_empty<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + #[derive(Clone, Debug, Default, PartialEq, Serialize)] pub struct SuggestionRequest { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/odp-directory/src/results.rs b/crates/odp-directory/src/results.rs index 6af7007..1ced3c0 100644 --- a/crates/odp-directory/src/results.rs +++ b/crates/odp-directory/src/results.rs @@ -70,6 +70,7 @@ fn result(mut raw: Value) -> Result { let object = service .as_object_mut() .ok_or_else(|| invalid("service must be an object"))?; + let mut projection = object.clone(); for name in [ "branding", "http", @@ -78,9 +79,9 @@ fn result(mut raw: Value) -> Result { "payment_origins", "search_capabilities", ] { - object.remove(name); + projection.remove(name); } - let mut document = Value::Object(object.clone()); + let mut document = Value::Object(projection); document["odp_version"] = json!("1.0"); document["http"] = json!({"endpoint_base":"/"}); let parsed = parse_agent_service_document(&serde_json::to_vec(&document).map_err(invalid)?) diff --git a/crates/odp-directory/src/transport.rs b/crates/odp-directory/src/transport.rs index c78fee4..3c38be6 100644 --- a/crates/odp-directory/src/transport.rs +++ b/crates/odp-directory/src/transport.rs @@ -1,8 +1,21 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, net::SocketAddr, sync::Arc, time::Duration}; use async_trait::async_trait; use thiserror::Error; +/// How long a single exchange may take before it is abandoned. +/// +/// Nothing in ODP obliges a peer to answer, so without a deadline a half-open connection holds a +/// task for as long as the peer cares to keep it. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// The most any ODP response may weigh, whatever it is. +/// +/// Every caller applies its own tighter limit (ERR-21), but those are applied to a body already in +/// memory. This one is applied while reading, so a peer that answers with an endless body is cut +/// off rather than allowed to exhaust the process. +const MAXIMUM_TRANSPORT_BYTES: usize = 2 * 1024 * 1024; + #[derive(Clone, Debug, Eq, PartialEq)] pub struct HttpRequest { pub body: Vec, @@ -27,59 +40,184 @@ pub struct TransportError { #[async_trait] pub trait Transport: Send + Sync { async fn send(&self, request: HttpRequest) -> Result; + + async fn send_limited( + &self, + request: HttpRequest, + maximum_bytes: usize, + ) -> Result { + let response = self.send(request).await?; + check_size( + response.body.len(), + response_limit(response.status, maximum_bytes), + )?; + Ok(response) + } + + /// Connects only to the supplied addresses, without a proxy or a second DNS lookup. + /// Implementations must also verify the connected peer before consuming its response. + async fn send_to( + &self, + _request: HttpRequest, + _addresses: &[SocketAddr], + _maximum_bytes: usize, + ) -> Result { + Err(TransportError { + message: "Transport does not support pinned destinations".to_owned(), + }) + } } #[derive(Clone)] pub struct ReqwestTransport { client: reqwest::Client, + timeout: Duration, } impl ReqwestTransport { pub fn new() -> Result { + Self::with_timeout(DEFAULT_TIMEOUT) + } + + /// A transport that abandons an exchange taking longer than `timeout`. + pub fn with_timeout(timeout: Duration) -> Result { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) + .timeout(timeout) .build() .map_err(|error| TransportError { message: error.to_string(), })?; - Ok(Self { client }) + Ok(Self { client, timeout }) } } #[async_trait] impl Transport for ReqwestTransport { async fn send(&self, request: HttpRequest) -> Result { - let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|error| { - TransportError { - message: error.to_string(), - } - })?; - let mut builder = self.client.request(method, request.url).body(request.body); - for (name, value) in request.headers { - builder = builder.header(&name, &value); - } - let response = builder.send().await.map_err(|error| TransportError { + self.send_limited(request, MAXIMUM_TRANSPORT_BYTES).await + } + + async fn send_limited( + &self, + request: HttpRequest, + maximum_bytes: usize, + ) -> Result { + exchange(&self.client, request, maximum_bytes, None).await + } + + async fn send_to( + &self, + request: HttpRequest, + addresses: &[SocketAddr], + maximum_bytes: usize, + ) -> Result { + let url = reqwest::Url::parse(&request.url).map_err(|error| TransportError { message: error.to_string(), })?; - let status = response.status().as_u16(); - let headers = response - .headers() - .iter() - .filter_map(|(name, value)| { - value - .to_str() - .ok() - .map(|value| (name.as_str().to_ascii_lowercase(), value.to_owned())) - }) - .collect(); - let body = response.bytes().await.map_err(|error| TransportError { + let host = url.host_str().ok_or_else(|| TransportError { + message: "Missing destination host".to_owned(), + })?; + if addresses.is_empty() { + return Err(TransportError { + message: "Missing pinned destination addresses".to_owned(), + }); + } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .retry(reqwest::retry::never()) + .timeout(self.timeout) + .resolve_to_addrs(host, addresses) + .build() + .map_err(|error| TransportError { + message: error.to_string(), + })?; + exchange(&client, request, maximum_bytes, Some(addresses)).await + } +} + +async fn exchange( + client: &reqwest::Client, + request: HttpRequest, + maximum_bytes: usize, + addresses: Option<&[SocketAddr]>, +) -> Result { + let method = + reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|error| TransportError { message: error.to_string(), })?; - Ok(HttpResponse { - body: body.to_vec(), - headers, - status, + let mut builder = client.request(method, request.url).body(request.body); + for (name, value) in request.headers { + builder = builder.header(&name, &value); + } + let response = builder.send().await.map_err(|error| TransportError { + message: error.to_string(), + })?; + let status = response.status().as_u16(); + if addresses.is_some_and(|addresses| { + response + .remote_addr() + .is_none_or(|peer| !addresses.contains(&peer)) + }) { + return Err(TransportError { + message: "Connected peer is not a pinned destination".to_owned(), + }); + } + let maximum_bytes = response_limit(status, maximum_bytes.min(MAXIMUM_TRANSPORT_BYTES)); + let headers = response + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_ascii_lowercase(), value.to_owned())) + }) + .collect(); + // ERR-20: a declared length past the limit is refused before the body is read at all. + if response + .content_length() + .is_some_and(|value| value > maximum_bytes as u64) + { + return Err(TransportError { + message: "ODP response declares more than the transport will read".to_owned(), + }); + } + let mut body = Vec::new(); + let mut response = response; + while let Some(chunk) = response.chunk().await.map_err(|error| TransportError { + message: error.to_string(), + })? { + if body.len() + chunk.len() > maximum_bytes { + return Err(TransportError { + message: "ODP response exceeds what the transport will read".to_owned(), + }); + } + body.extend_from_slice(&chunk); + } + Ok(HttpResponse { + body, + headers, + status, + }) +} + +fn response_limit(status: u16, maximum_bytes: usize) -> usize { + if status >= 400 { + maximum_bytes.min(16_384) + } else { + maximum_bytes + } +} + +fn check_size(size: usize, maximum_bytes: usize) -> Result<(), TransportError> { + if size > maximum_bytes { + Err(TransportError { + message: "Response exceeds its byte limit".to_owned(), }) + } else { + Ok(()) } } diff --git a/crates/odp-directory/tests/mixed.rs b/crates/odp-directory/tests/mixed.rs index 982189c..8de7b30 100644 --- a/crates/odp-directory/tests/mixed.rs +++ b/crates/odp-directory/tests/mixed.rs @@ -185,7 +185,10 @@ async fn isolates_malformed_known_items_and_normalizes_future_operations() { panic!("collection") }; assert_eq!(item.service.operations.len(), 2); - assert!(!item.service.additional.contains_key("http")); + assert_eq!( + item.service.additional.get("http"), + Some(&json!({"endpoint_base":"https://untrusted.example"})) + ); } #[tokio::test] diff --git a/crates/odp-directory/tests/pagination_conformance.rs b/crates/odp-directory/tests/pagination_conformance.rs new file mode 100644 index 0000000..1d85660 --- /dev/null +++ b/crates/odp-directory/tests/pagination_conformance.rs @@ -0,0 +1,320 @@ +//! SVC-94: walking a Directory search yields candidate Services, within bounds a caller sets. + +mod support; + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use odp_directory::{IterationOptions, SearchRequest}; +use support::{Stub, client, json, named_service, page, service}; + +/// A Directory whose pages continue forever, each with a cursor of its own. +fn endless(per_page: usize) -> Arc { + let step = AtomicUsize::new(0); + Stub::new(move |_| { + let cursor = step.fetch_add(1, Ordering::SeqCst); + let items = (0..per_page) + .map(|index| { + named_service( + &format!("https://plant-{cursor}-{index}.example"), + &format!("Plant {cursor}-{index}"), + ) + }) + .collect::>(); + Ok(json( + 200, + page(&items, &format!("/v1/services/search?cursor=c{cursor}")), + )) + }) +} + +/// A Directory of exactly `pages` pages, the last of them final. +fn finite(pages: usize, per_page: usize) -> Arc { + let step = AtomicUsize::new(0); + Stub::new(move |_| { + let cursor = step.fetch_add(1, Ordering::SeqCst); + let items = (0..per_page) + .map(|index| service(&format!("https://plant-{cursor}-{index}.example"))) + .collect::>(); + let next = if cursor + 1 < pages { + format!("/v1/services/search?cursor=c{cursor}") + } else { + String::new() + }; + Ok(json(200, page(&items, &next))) + }) +} + +// -- following a sequence ------------------------------------------------------------------ + +#[tokio::test] +async fn follows_a_sequence_to_its_end() { + let stub = finite(3, 2); + let pages = client(&stub) + .search_pages(&SearchRequest::default(), IterationOptions::default()) + .await + .unwrap(); + + assert_eq!(pages.len(), 3); + assert_eq!(stub.count(), 3); + assert!(pages[2].next.is_empty()); +} + +/// A page is fetched only when something still wants it. +#[tokio::test] +async fn fetches_no_page_it_would_discard() { + let stub = endless(1); + let pages = client(&stub) + .search_pages( + &SearchRequest::default(), + IterationOptions { + max_items: 0, + max_pages: 3, + }, + ) + .await + .unwrap(); + + assert_eq!(pages.len(), 3); + assert_eq!(stub.count(), 3, "no fourth page was asked for"); +} + +/// Reaching the page bound is not reaching the end, so the last page keeps its continuation. +#[tokio::test] +async fn leaves_a_continuation_a_caller_can_resume_from() { + let stub = endless(1); + let pages = client(&stub) + .search_pages( + &SearchRequest::default(), + IterationOptions { + max_items: 0, + max_pages: 2, + }, + ) + .await + .unwrap(); + + let next = pages.last().unwrap().next.clone(); + assert!(!next.is_empty()); + + let resumed = client(&stub).continue_search_services(&next).await.unwrap(); + assert_eq!(resumed.items.len(), 1); +} + +/// Asking for ten Services does not pay for a thousand. +#[tokio::test] +async fn stops_once_it_has_the_services_it_was_asked_for() { + let stub = endless(10); + let services = client(&stub) + .collect_services( + &SearchRequest::default(), + IterationOptions { + max_items: 10, + max_pages: 0, + }, + ) + .await + .unwrap(); + + assert_eq!(services.len(), 10); + assert_eq!(stub.count(), 1, "one page held everything asked for"); +} + +#[tokio::test] +async fn reads_only_the_pages_the_item_budget_needs() { + let stub = endless(10); + let services = client(&stub) + .collect_services( + &SearchRequest::default(), + IterationOptions { + max_items: 25, + max_pages: 0, + }, + ) + .await + .unwrap(); + + assert_eq!(services.len(), 25); + assert_eq!(stub.count(), 3, "three pages of ten covered twenty-five"); +} + +#[tokio::test] +async fn gathers_every_service_a_finite_sequence_holds() { + let stub = finite(4, 10); + let services = client(&stub) + .collect_services(&SearchRequest::default(), IterationOptions::default()) + .await + .unwrap(); + + assert_eq!(services.len(), 40); + assert_eq!(stub.count(), 4); +} + +#[tokio::test] +async fn defaults_to_sixteen_pages() { + let stub = finite(40, 10); + let services = client(&stub) + .collect_services(&SearchRequest::default(), IterationOptions::default()) + .await + .unwrap(); + + assert_eq!(services.len(), 160); + assert_eq!(stub.count(), 16); +} + +/// The page bound stops a traversal a Directory would otherwise never end. +#[tokio::test] +async fn stops_an_endless_sequence_at_the_page_bound() { + let stub = endless(1); + let services = client(&stub) + .collect_services( + &SearchRequest::default(), + IterationOptions { + max_items: 0, + max_pages: 5, + }, + ) + .await + .unwrap(); + + assert_eq!(services.len(), 5); + assert_eq!(stub.count(), 5); +} + +// -- refusing what cannot be walked -------------------------------------------------------- + +/// A cursor that comes round again describes a walk that never ends. +#[tokio::test] +async fn refuses_a_continuation_that_repeats() { + let stub = Stub::serving(page( + &[service("https://plants.example")], + "/v1/services/search?cursor=same", + )); + let error = client(&stub) + .search_pages(&SearchRequest::default(), IterationOptions::default()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("loop"), "{error}"); + assert_eq!(stub.count(), 2, "the repeat was recognised, not followed"); +} + +/// PAG-07: a continuation stays on the Directory that offered it. +#[tokio::test] +async fn refuses_a_continuation_that_leaves_the_directory() { + let stub = Stub::serving(page(&[], "")); + for next in [ + "https://elsewhere.example/v1/services/search", + "//elsewhere.example/v1/services/search", + "http://api.inflowpay.ai/v1/services/search", + "https://user:secret@api.inflowpay.ai/v1/services/search", + ] { + let error = client(&stub) + .continue_search_services(next) + .await + .unwrap_err(); + assert!( + error.to_string().contains("canonical origin"), + "{next}: {error}" + ); + } + assert_eq!(stub.count(), 0, "nothing was sent"); +} + +#[tokio::test] +async fn refuses_a_continuation_that_is_not_a_reference() { + let stub = Stub::serving(page(&[], "")); + assert!( + client(&stub) + .continue_search_services("https://[not-an-address") + .await + .is_err() + ); +} + +/// A relative continuation resolves onto the Directory, which is where it came from. +#[tokio::test] +async fn resolves_a_relative_continuation_against_the_directory() { + let stub = Stub::serving(page(&[], "")); + client(&stub) + .continue_search_services("/v1/services/search?cursor=c2") + .await + .unwrap(); + assert_eq!( + stub.last().url, + "https://api.inflowpay.ai/v1/services/search?cursor=c2" + ); +} + +/// A traversal a Directory could not satisfy is refused before the first request. +#[tokio::test] +async fn refuses_bounds_past_what_a_traversal_allows() { + let stub = Stub::serving(page(&[], "")); + for (options, expected) in [ + ( + IterationOptions { + max_items: 10_001, + max_pages: 0, + }, + "max_items", + ), + ( + IterationOptions { + max_items: 0, + max_pages: 10_001, + }, + "max_pages", + ), + ] { + let error = client(&stub) + .collect_services(&SearchRequest::default(), options) + .await + .unwrap_err(); + assert!(error.to_string().contains(expected), "{error}"); + + let error = client(&stub) + .search_pages(&SearchRequest::default(), options) + .await + .unwrap_err(); + assert!(error.to_string().contains(expected), "{error}"); + } + assert_eq!(stub.count(), 0); +} + +/// A continuation is fetched as a GET carrying nothing. +#[tokio::test] +async fn continues_a_search_without_repeating_the_request() { + let stub = finite(2, 1); + client(&stub) + .collect_services( + &SearchRequest { + query: "plants".to_owned(), + ..SearchRequest::default() + }, + IterationOptions::default(), + ) + .await + .unwrap(); + + let requests = stub.requests(); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[1].method, "GET"); + assert!(requests[1].body.is_empty()); + assert!(requests[1].url.contains("cursor=c0"), "{}", requests[1].url); +} + +/// A record withheld from a page does not count towards what the caller asked for. +#[tokio::test] +async fn counts_only_the_services_it_hands_back() { + let stub = Stub::serving(format!( + r#"{{"items":[{},{{"broken":true}}],"next":""}}"#, + service("https://plants.example") + )); + let page = client(&stub) + .collect_services(&SearchRequest::default(), IterationOptions::default()) + .await + .unwrap(); + assert_eq!(page.len(), 1); +} diff --git a/crates/odp-directory/tests/reqwest_transport.rs b/crates/odp-directory/tests/reqwest_transport.rs new file mode 100644 index 0000000..fa4ef98 --- /dev/null +++ b/crates/odp-directory/tests/reqwest_transport.rs @@ -0,0 +1,288 @@ +//! The default transport, against a real socket: what it sends, and what it refuses to read. + +use std::{ + io::Cursor, + net::{Ipv4Addr, SocketAddr, TcpListener as StdListener}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + thread, + time::Duration, +}; + +use odp_directory::{HttpRequest, ReqwestTransport, Transport}; +use tiny_http::{Header, Response, Server}; + +/// What one request to the test server should be answered with. +enum Reply { + /// A body of this many bytes, sent with a Content-Length that matches. + Sized(usize), + /// A body of this many bytes, sent without any declared length. + Chunked(usize), + /// No answer at all until the client gives up. + Silent, +} + +/// Runs a one-request server on a loopback port and returns its base URL. +fn serve(reply: Reply) -> (String, Arc, Arc) { + let listener = StdListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = Arc::new(Server::from_listener(listener, None).unwrap()); + let seen = Arc::new(AtomicUsize::new(0)); + + let worker = server.clone(); + let counted = seen.clone(); + thread::spawn(move || { + while let Ok(request) = worker.recv() { + counted.fetch_add(1, Ordering::SeqCst); + match reply { + Reply::Silent => { + thread::sleep(Duration::from_secs(5)); + drop(request); + } + Reply::Sized(bytes) => { + let body = "x".repeat(bytes); + let _ = request.respond(Response::from_string(body).with_header(json_header())); + } + Reply::Chunked(bytes) => { + let body = "x".repeat(bytes); + // No length: tiny_http chunks it, so the cap has to hold while reading. + let _ = request.respond(Response::new( + 200.into(), + vec![json_header()], + Cursor::new(body.into_bytes()), + None, + None, + )); + } + } + } + }); + + ( + format!("http://127.0.0.1:{port}"), + seen, + Arc::new(ServerHandle(server)), + ) +} + +/// Stops the test server when the test ends, so no thread outlives it. +struct ServerHandle(Arc); + +impl Drop for ServerHandle { + fn drop(&mut self) { + self.0.unblock(); + } +} + +fn json_header() -> Header { + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap() +} + +fn request(url: &str, method: &str) -> HttpRequest { + HttpRequest { + body: Vec::new(), + headers: std::collections::BTreeMap::from([( + "accept".to_owned(), + "application/json".to_owned(), + )]), + method: method.to_owned(), + url: url.to_owned(), + } +} + +#[tokio::test] +async fn pins_a_hostname_without_resolving_it_again() { + let (base, seen, _server) = serve(Reply::Sized(16)); + let address: SocketAddr = base.strip_prefix("http://").unwrap().parse().unwrap(); + let target = format!("http://unresolvable.invalid:{}/", address.port()); + let transport = ReqwestTransport::new().unwrap(); + let response = transport + .send_to(request(&target, "GET"), &[address], 16) + .await + .unwrap(); + assert_eq!(response.body.len(), 16); + assert_eq!(seen.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn refuses_a_peer_outside_the_pinned_addresses() { + let (base, _, _server) = serve(Reply::Sized(16)); + let address: SocketAddr = base.strip_prefix("http://").unwrap().parse().unwrap(); + let other = SocketAddr::new("127.0.0.2".parse().unwrap(), address.port()); + let error = ReqwestTransport::new() + .unwrap() + .send_to(request(&base, "GET"), &[other], 16) + .await + .unwrap_err(); + assert!(error.message.contains("peer")); +} + +#[tokio::test] +async fn applies_the_callers_limit_to_declared_and_chunked_bodies() { + for reply in [Reply::Sized(65_537), Reply::Chunked(65_537)] { + let (base, _, _server) = serve(reply); + let error = ReqwestTransport::new() + .unwrap() + .send_limited(request(&base, "GET"), 65_536) + .await + .unwrap_err(); + assert!(error.message.contains("transport")); + } +} + +#[tokio::test] +async fn reads_a_response_and_lowercases_its_headers() { + let (base, seen, _server) = serve(Reply::Sized(16)); + let transport = ReqwestTransport::new().unwrap(); + + let response = transport.send(request(&base, "GET")).await.unwrap(); + + assert_eq!(response.status, 200); + assert_eq!(response.body.len(), 16); + assert_eq!( + response.headers.get("content-type").map(String::as_str), + Some("application/json"), + "header names arrive lowercased: {:?}", + response.headers + ); + assert_eq!(seen.load(Ordering::SeqCst), 1); +} + +/// ERR-20: a body past what the transport will hold is cut off rather than read to the end. +#[tokio::test] +async fn refuses_a_body_past_what_it_will_read() { + let (base, _seen, _server) = serve(Reply::Chunked(3 * 1024 * 1024)); + let transport = ReqwestTransport::new().unwrap(); + + let error = transport.send(request(&base, "GET")).await.unwrap_err(); + assert!(error.message.contains("exceeds"), "{}", error.message); +} + +/// A declared length past the cap is refused before the body is read at all. +/// +/// The response is written by hand: a server library would correct the length to match the body, +/// and the point here is a peer that declares one thing and would have sent another. +#[tokio::test] +async fn refuses_a_declared_length_past_what_it_will_read() { + use std::io::{Read, Write}; + + let listener = StdListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).unwrap(); + let port = listener.local_addr().unwrap().port(); + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 3145728\r\n\r\nxxxx", + ); + let _ = stream.flush(); + thread::sleep(Duration::from_secs(1)); + } + }); + + let transport = ReqwestTransport::with_timeout(Duration::from_secs(2)).unwrap(); + let error = transport + .send(request(&format!("http://127.0.0.1:{port}"), "GET")) + .await + .unwrap_err(); + assert!(error.message.contains("declares"), "{}", error.message); +} + +#[tokio::test] +async fn reads_a_body_just_within_the_cap() { + let (base, _seen, _server) = serve(Reply::Sized(2 * 1024 * 1024)); + let transport = ReqwestTransport::new().unwrap(); + + let response = transport.send(request(&base, "GET")).await.unwrap(); + assert_eq!(response.body.len(), 2 * 1024 * 1024); +} + +/// Nothing obliges a peer to answer, so an exchange that stalls is abandoned. +#[tokio::test] +async fn abandons_an_exchange_that_never_answers() { + let (base, _seen, _server) = serve(Reply::Silent); + let transport = ReqwestTransport::with_timeout(Duration::from_millis(200)).unwrap(); + + let error = transport.send(request(&base, "GET")).await.unwrap_err(); + assert!(!error.message.is_empty(), "the deadline was reported"); +} + +#[tokio::test] +async fn refuses_a_method_that_is_not_one() { + let transport = ReqwestTransport::new().unwrap(); + let error = transport + .send(request("http://127.0.0.1:1/", "BAD METHOD")) + .await + .unwrap_err(); + assert!(!error.message.is_empty()); +} + +#[tokio::test] +async fn reports_a_destination_it_cannot_reach() { + let transport = ReqwestTransport::with_timeout(Duration::from_millis(200)).unwrap(); + assert!( + transport + .send(request("http://127.0.0.1:1/", "GET")) + .await + .is_err() + ); +} + +/// The transport follows nothing of its own accord; a redirect comes back as it arrived. +#[tokio::test] +async fn hands_a_redirect_back_rather_than_following_it() { + let listener = StdListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = Arc::new(Server::from_listener(listener, None).unwrap()); + let handle = ServerHandle(server.clone()); + thread::spawn(move || { + while let Ok(request) = server.recv() { + let _ = request + .respond(Response::empty(308).with_header( + Header::from_bytes(&b"Location"[..], &b"/elsewhere"[..]).unwrap(), + )); + } + }); + + let transport = ReqwestTransport::new().unwrap(); + let response = transport + .send(request(&format!("http://127.0.0.1:{port}"), "GET")) + .await + .unwrap(); + + assert_eq!(response.status, 308); + assert_eq!( + response.headers.get("location").map(String::as_str), + Some("/elsewhere") + ); + drop(handle); +} + +/// A peer that stops mid-body leaves the response unusable, and that is reported, not ignored. +#[tokio::test] +async fn reports_a_body_that_stops_early() { + use std::io::{Read, Write}; + + let listener = StdListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).unwrap(); + let port = listener.local_addr().unwrap().port(); + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 4096\r\n\r\nxxxx", + ); + let _ = stream.flush(); + // The connection closes with most of the promised body still unsent. + } + }); + + let transport = ReqwestTransport::with_timeout(Duration::from_secs(2)).unwrap(); + let error = transport + .send(request(&format!("http://127.0.0.1:{port}"), "GET")) + .await + .unwrap_err(); + assert!(!error.message.is_empty(), "{}", error.message); +} diff --git a/crates/odp-directory/tests/search_conformance.rs b/crates/odp-directory/tests/search_conformance.rs new file mode 100644 index 0000000..afc2ac0 --- /dev/null +++ b/crates/odp-directory/tests/search_conformance.rs @@ -0,0 +1,909 @@ +//! ROLE-03, SVC-17 and SVC-27: what a Directory record is worth once this client has read it. + +mod support; + +use odp_core::{EnrollmentProtocol, Operation, PaymentOption, Protocol, TrustProtocol}; +use odp_directory::{ + OperationFilter, PaymentFilter, SearchRequest, ServiceFilters, SuggestionRequest, +}; +use support::{JSON, Stub, client, named_service, page, service, typed, with_headers}; + +/// A page carrying exactly one record built from the given members. +fn one(members: &str) -> String { + format!(r#"{{"items":[{{{members}}}]}}"#) +} + +const WHOLE: &str = r#""description":"Plants for agents.","indexed_at":"2026-08-25T00:00:00Z","language":"en","localizations":["en"],"name":"Plants","operations":[],"service_origin":"https://plants.example""#; + +// -- what the client sends ----------------------------------------------------------------- + +/// A Directory refuses a request it cannot satisfy, so the caller hears it here, not as a 400. +#[tokio::test] +async fn refuses_a_request_the_directory_would_refuse() { + let stub = Stub::serving(page(&[], "")); + let cases: Vec<(SearchRequest, &str)> = vec![ + ( + SearchRequest { + limit: 101, + ..SearchRequest::default() + }, + "limit", + ), + ( + SearchRequest { + query: " plants".to_owned(), + ..SearchRequest::default() + }, + "query", + ), + ( + SearchRequest { + query: "x".repeat(513), + ..SearchRequest::default() + }, + "query", + ), + ( + filtered(ServiceFilters { + keywords: (0..33).map(|index| index.to_string()).collect(), + ..ServiceFilters::default() + }), + "keywords", + ), + ( + filtered(ServiceFilters { + keywords: vec![" ".to_owned()], + ..ServiceFilters::default() + }), + "keywords", + ), + ( + filtered(ServiceFilters { + keywords: vec!["x".repeat(65)], + ..ServiceFilters::default() + }), + "keywords", + ), + ( + filtered(ServiceFilters { + keywords: vec!["plants".to_owned(), "plants".to_owned()], + ..ServiceFilters::default() + }), + "keywords must not repeat", + ), + ( + filtered(ServiceFilters { + enrollment: vec![ + EnrollmentProtocol { + name: Protocol::Aep, + }, + EnrollmentProtocol { + name: Protocol::Aep, + }, + ], + ..ServiceFilters::default() + }), + "enrollment", + ), + ( + filtered(ServiceFilters { + operations: (0..22).map(|_| operation(Operation::GetOffering)).collect(), + ..ServiceFilters::default() + }), + "operations must contain", + ), + ( + filtered(ServiceFilters { + operations: vec![ + operation(Operation::GetOffering), + operation(Operation::GetOffering), + ], + ..ServiceFilters::default() + }), + "operations must not repeat", + ), + ( + filtered(ServiceFilters { + payments: (0..33).map(|_| payment(Vec::new())).collect(), + ..ServiceFilters::default() + }), + "payments must contain", + ), + ( + filtered(ServiceFilters { + payments: vec![payment(Vec::new()), payment(Vec::new())], + ..ServiceFilters::default() + }), + "payments must not repeat", + ), + ( + filtered(ServiceFilters { + payments: vec![payment(vec![PaymentOption::Card, PaymentOption::Card])], + ..ServiceFilters::default() + }), + "payment options must not repeat", + ), + ]; + + for (request, expected) in cases { + let error = client(&stub) + .search_services(&request) + .await + .unwrap_err() + .to_string(); + assert!(error.contains(expected), "expected {expected}: {error}"); + } + assert_eq!(stub.count(), 0, "nothing was sent"); +} + +/// A request within every bound reaches the Directory as it was written. +#[tokio::test] +async fn sends_a_request_it_accepts() { + let stub = Stub::serving(page(&[], "")); + client(&stub) + .search_services(&SearchRequest { + filters: Some(ServiceFilters { + enrollment: vec![EnrollmentProtocol { + name: Protocol::Aep, + }], + keywords: vec!["plants".to_owned(), "seeds".to_owned()], + operations: vec![operation(Operation::GetOffering)], + payments: vec![payment(vec![PaymentOption::Card])], + trust: vec![TrustProtocol { + name: Protocol::Tap, + }], + }), + limit: 25, + query: "rubber plant".to_owned(), + }) + .await + .unwrap(); + + let body = String::from_utf8(stub.last().body).unwrap(); + assert!(body.contains(r#""query":"rubber plant""#), "{body}"); + assert!(body.contains(r#""limit":25"#), "{body}"); + assert!(body.contains(r#""keywords":["plants","seeds"]"#), "{body}"); +} + +/// An empty request carries nothing it does not mean. +#[tokio::test] +async fn omits_what_the_caller_did_not_ask_for() { + let stub = Stub::serving(page(&[], "")); + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert_eq!(String::from_utf8(stub.last().body).unwrap(), "{}"); +} + +fn filtered(filters: ServiceFilters) -> SearchRequest { + SearchRequest { + filters: Some(filters), + ..SearchRequest::default() + } +} + +fn operation(name: Operation) -> OperationFilter { + OperationFilter { + authentication: None, + name, + } +} + +fn payment(options: Vec) -> PaymentFilter { + PaymentFilter { + authentication: None, + name: Protocol::Mpp, + options, + } +} + +// -- what the client accepts back ---------------------------------------------------------- + +#[tokio::test] +async fn reads_a_page_of_records() { + let stub = Stub::serving(page( + &[ + named_service("https://plants.example", "Plants"), + named_service("https://seeds.example", "Seeds"), + ], + "/v1/services/search?cursor=c2", + )); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + + assert_eq!(result.items.len(), 2); + assert_eq!(result.items[0].name, "Plants"); + assert_eq!(result.next, "/v1/services/search?cursor=c2"); + assert!(result.issues.is_empty()); +} + +/// PAG: a page no larger than the Directory promises. +#[tokio::test] +async fn refuses_a_page_of_more_than_a_hundred_records() { + let items = (0..101) + .map(|index| service(&format!("https://plant-{index}.example"))) + .collect::>(); + let stub = Stub::serving(page(&items, "")); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!(error.to_string().contains("100 Services"), "{error}"); +} + +#[tokio::test] +async fn reads_a_page_of_exactly_a_hundred_records() { + let items = (0..100) + .map(|index| service(&format!("https://plant-{index}.example"))) + .collect::>(); + let stub = Stub::serving(page(&items, "")); + assert_eq!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .items + .len(), + 100 + ); +} + +/// A page that is not a page at all. +#[tokio::test] +async fn refuses_an_envelope_it_cannot_read() { + for body in [r#"["a"]"#, r#"{"items":{}}"#, r#"{}"#, r#""text""#] { + let stub = Stub::serving(body); + assert!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .is_err(), + "{body}" + ); + } +} + +/// ROLE-03: one record this client cannot read is a note about that record, not a lost page. +#[tokio::test] +async fn withholds_only_the_record_it_cannot_read() { + let body = format!( + r#"{{"items":[{},{{"name":"broken"}},{},"not an object"]}}"#, + named_service("https://a.example", "A"), + named_service("https://b.example", "B") + ); + let stub = Stub::serving(body); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + + assert_eq!(result.items.len(), 2); + assert_eq!(result.items[1].name, "B"); + assert_eq!(result.issues.len(), 2); + assert_eq!(result.issues[0].index, 1, "the position in the page sent"); + assert_eq!(result.issues[1].index, 3); + assert!(!result.issues[0].message.is_empty()); +} + +// -- Service origins ----------------------------------------------------------------------- + +/// SEC-08: an origin the public internet does not route is not a Service a caller can reach. +#[tokio::test] +async fn withholds_a_record_naming_a_non_public_origin() { + for origin in [ + "https://127.0.0.1", + "https://10.0.0.1", + "https://192.168.1.1", + "https://169.254.169.254", + "https://[::1]", + "https://[fd00::1]", + "https://[64:ff9b::a9fe:a9fe]", + "https://localhost", + "https://api.localhost", + ] { + let body = format!(r#"{{"items":[{}]}}"#, service(origin)); + let stub = Stub::serving(body); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert!(result.items.is_empty(), "{origin} was handed back"); + assert_eq!(result.issues.len(), 1, "{origin}"); + assert!( + result.issues[0].message.contains("non-public"), + "{origin}: {}", + result.issues[0].message + ); + } +} + +#[tokio::test] +async fn keeps_a_record_naming_a_public_address() { + let body = format!(r#"{{"items":[{}]}}"#, service("https://93.184.216.34")); + let stub = Stub::serving(body); + assert_eq!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .items + .len(), + 1 + ); +} + +/// SVC-17: an origin is the origin, not an origin with something appended to it. +#[tokio::test] +async fn withholds_a_record_whose_origin_is_not_canonical() { + for origin in [ + "https://plants.example/", + "https://plants.example/odp", + "https://user:secret@plants.example", + "http://plants.example", + "not a url", + ] { + let body = format!(r#"{{"items":[{}]}}"#, service(origin)); + let stub = Stub::serving(body); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert!(result.items.is_empty(), "{origin} was handed back"); + assert_eq!(result.issues.len(), 1, "{origin}"); + } +} + +#[tokio::test] +async fn withholds_a_record_with_no_origin_at_all() { + let stub = Stub::serving(one( + r#""description":"d","indexed_at":"2026-08-25T00:00:00Z","language":"en","localizations":["en"],"name":"n","operations":[]"#, + )); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert_eq!(result.issues.len(), 1); + assert!(result.issues[0].message.contains("origin is missing")); +} + +// -- indexing time ------------------------------------------------------------------------- + +/// An indexing time is an RFC 3339 timestamp, not whatever a date parser happens to accept. +#[tokio::test] +async fn withholds_a_record_whose_indexing_time_is_not_rfc_3339() { + for indexed_at in [ + "December 17, 1995 03:24:00", + "2026-08-25", + "2026-08-25 00:00:00Z", + "2026-08-25T00:00Z", + "2026-08-25T00:00:00", + "2026-08-25T00x00:00Z", + "2026-08-25T00:00x00Z", + "2026-08-25T00:00:0aZ", + "2026-08-25T0a:00:00Z", + "2026-08-25T00:00:00+0000", + "2026-08-25T00:00:00.Z", + "20260825T000000Z", + "", + ] { + let stub = Stub::serving(one(&format!( + r#""description":"d","indexed_at":"{indexed_at}","language":"en","localizations":["en"],"name":"n","operations":[],"service_origin":"https://plants.example""# + ))); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert!(result.items.is_empty(), "{indexed_at} was accepted"); + assert!( + result.issues[0].message.contains("RFC 3339"), + "{indexed_at}: {}", + result.issues[0].message + ); + } +} + +#[tokio::test] +async fn accepts_every_shape_rfc_3339_allows() { + for indexed_at in [ + "2026-08-25T00:00:00Z", + "2026-08-25t00:00:00z", + "2026-08-25T00:00:00.123456Z", + "2026-08-25T00:00:00+02:00", + "2026-08-25T00:00:00.5-07:00", + ] { + let stub = Stub::serving(one(&format!( + r#""description":"d","indexed_at":"{indexed_at}","language":"en","localizations":["en"],"name":"n","operations":[],"service_origin":"https://plants.example""# + ))); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert_eq!(result.items.len(), 1, "{indexed_at}: {:?}", result.issues); + } +} + +#[tokio::test] +async fn withholds_a_record_with_no_indexing_time() { + let stub = Stub::serving(one( + r#""description":"d","language":"en","localizations":["en"],"name":"n","operations":[],"service_origin":"https://plants.example""#, + )); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert!( + result.issues[0] + .message + .contains("indexing time is missing") + ); +} + +// -- what a Directory cannot vouch for ----------------------------------------------------- + +#[tokio::test] +async fn preserves_directory_metadata_without_dereferencing_it() { + let stub = Stub::serving(one(&format!( + r#"{WHOLE},"branding":{{"logo":"x"}},"http":{{"endpoint_base":"/odp"}},"mcp":{{"url":"https://evil.example/mcp"}},"odp_version":"1.0","payment_origins":["https://pay.example"],"search_capabilities":{{"filters":{{"inline":[]}}}},"future_member":true"# + ))); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + + let carried = result.items[0].additional.keys().collect::>(); + assert_eq!( + carried, + [ + "branding", + "future_member", + "http", + "mcp", + "odp_version", + "payment_origins", + "search_capabilities" + ], + "metadata is preserved without treating it as execution authority" + ); + assert_eq!(stub.count(), 1, "nothing in the record was dereferenced"); +} + +/// EXT: a protocol this version does not define is dropped, not a reason to lose the record. +#[tokio::test] +async fn drops_a_protocol_it_does_not_know() { + let stub = Stub::serving(one(&format!( + r#"{WHOLE},"protocols":{{"payments":[{{"authentication":"not-required","name":"future"}},{{"authentication":"not-required","name":"mpp"}}],"trust":[{{"name":"future"}},{{"name":"tap"}}]}}"# + ))); + let protocols = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .items + .swap_remove(0) + .protocols + .unwrap(); + + assert_eq!(protocols.payments.len(), 1); + assert_eq!(protocols.trust.len(), 1); +} + +/// A category left with nothing in it is removed rather than reported as empty. +#[tokio::test] +async fn removes_a_protocol_block_nothing_survived() { + let stub = Stub::serving(one(&format!( + r#"{WHOLE},"protocols":{{"trust":[{{"name":"future"}}]}}"# + ))); + let service = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .items + .swap_remove(0); + assert!(service.protocols.is_none_or(|value| value.trust.is_empty())); +} + +/// A record with no protocols at all is complete as it stands. +#[tokio::test] +async fn keeps_a_record_that_declares_no_protocols() { + let stub = Stub::serving(one(WHOLE)); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert!(result.items[0].protocols.is_none()); +} + +#[tokio::test] +async fn withholds_a_record_whose_protocols_are_not_a_block() { + let stub = Stub::serving(one(&format!(r#"{WHOLE},"protocols":5"#))); + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert_eq!(result.issues.len(), 1); +} + +// -- facets -------------------------------------------------------------------------------- + +#[tokio::test] +async fn reads_the_facets_a_directory_publishes() { + let stub = + Stub::serving(r#"{"facets":{"keywords":[{"count":12,"value":"plants"}]},"items":[]}"#); + let facets = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .facets + .unwrap(); + assert_eq!(facets.keywords[0].count, 12); + assert_eq!(facets.keywords[0].value, "plants"); +} + +/// A facet describes how many Services share a value, so the count has to be countable. +#[tokio::test] +async fn refuses_a_facet_it_cannot_count() { + for facets in [ + r#"{"keywords":[{"value":"plants"}]}"#, + r#"{"keywords":[{"count":-1,"value":"plants"}]}"#, + r#"{"keywords":[{"count":"many","value":"plants"}]}"#, + r#"{"keywords":{}}"#, + ] { + let stub = Stub::serving(format!(r#"{{"facets":{facets},"items":[]}}"#)); + assert!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .is_err(), + "{facets}" + ); + } +} + +#[tokio::test] +async fn refuses_a_facet_of_more_than_a_hundred_entries() { + let entries = (0..101) + .map(|index| format!(r#"{{"count":1,"value":"k{index}"}}"#)) + .collect::>() + .join(","); + let stub = Stub::serving(format!( + r#"{{"facets":{{"keywords":[{entries}]}},"items":[]}}"# + )); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!(error.to_string().contains("100 entries"), "{error}"); +} + +/// Facets are optional, and a page without them is not missing anything. +#[tokio::test] +async fn reads_a_page_that_publishes_no_facets() { + for body in [r#"{"items":[]}"#, r#"{"facets":null,"items":[]}"#] { + let stub = Stub::serving(body); + assert!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .facets + .is_none(), + "{body}" + ); + } +} + +// -- continuations ------------------------------------------------------------------------- + +/// A continuation this client could not use is not one it will carry. +#[tokio::test] +async fn refuses_a_continuation_it_could_not_use() { + for next in [ + r#"" /v1/services/search""#, + "5", + "{}", + &format!(r#""{}""#, "x".repeat(2_049)), + ] { + let stub = Stub::serving(format!(r#"{{"items":[],"next":{next}}}"#)); + assert!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .is_err(), + "{next}" + ); + } +} + +#[tokio::test] +async fn reads_a_page_that_offers_no_continuation() { + for body in [r#"{"items":[]}"#, r#"{"items":[],"next":null}"#] { + let stub = Stub::serving(body); + assert!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .next + .is_empty(), + "{body}" + ); + } +} + +// -- suggestions --------------------------------------------------------------------------- + +/// ROLE-06: a suggestion is a prefix completion, and arrives in its own envelope. +#[tokio::test] +async fn reads_the_suggestions_a_directory_offers() { + let stub = Stub::serving(r#"{"items":["plants","planters","plant food"]}"#); + let suggestions = client(&stub) + .suggest_services(&SuggestionRequest { + filters: None, + limit: 10, + prefix: " plan ".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(suggestions, ["plants", "planters", "plant food"]); + let url = stub.last().url; + assert!(url.contains("prefix=plan"), "the prefix is trimmed: {url}"); + assert!(url.contains("limit=10"), "{url}"); +} + +#[tokio::test] +async fn asks_for_no_limit_it_was_not_given() { + let stub = Stub::serving(r#"{"items":[]}"#); + client(&stub) + .suggest_services(&SuggestionRequest { + filters: None, + limit: 0, + prefix: "pl".to_owned(), + }) + .await + .unwrap(); + assert!(!stub.last().url.contains("limit="), "{}", stub.last().url); +} + +/// A suggestion repeated is still one suggestion, and twenty-five is all a caller gets. +#[tokio::test] +async fn keeps_each_suggestion_once_and_no_more_than_twenty_five() { + let items = (0..40) + .map(|index| format!(r#""s{}""#, index % 30)) + .collect::>() + .join(","); + let stub = Stub::serving(format!(r#"{{"items":[{items}]}}"#)); + let suggestions = client(&stub) + .suggest_services(&SuggestionRequest { + filters: None, + limit: 25, + prefix: "s".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(suggestions.len(), 25); + assert_eq!(suggestions[0], "s0"); + let mut unique = suggestions.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), suggestions.len()); +} + +#[tokio::test] +async fn refuses_a_prefix_the_directory_would_refuse() { + let stub = Stub::serving(r#"{"items":[]}"#); + for request in [ + SuggestionRequest { + filters: None, + limit: 0, + prefix: " ".to_owned(), + }, + SuggestionRequest { + filters: None, + limit: 0, + prefix: "x".repeat(129), + }, + SuggestionRequest { + filters: None, + limit: 26, + prefix: "pl".to_owned(), + }, + ] { + assert!(client(&stub).suggest_services(&request).await.is_err()); + } + assert_eq!(stub.count(), 0); +} + +#[tokio::test] +async fn refuses_suggestions_it_cannot_read() { + for body in [ + r#"["plants"]"#, + r#"{"suggestions":["plants"]}"#, + r#"{"items":[5]}"#, + r#"{"items":[" plants"]}"#, + r#"{"items":[" "]}"#, + r#"{"items":[""]}"#, + "not json", + ] { + let stub = Stub::serving(body); + let long = format!(r#"{{"items":["{}"]}}"#, "x".repeat(129)); + assert!( + client(&stub) + .suggest_services(&SuggestionRequest { + filters: None, + limit: 0, + prefix: "pl".to_owned(), + }) + .await + .is_err(), + "{body}" + ); + let stub = Stub::serving(long); + assert!( + client(&stub) + .suggest_services(&SuggestionRequest { + filters: None, + limit: 0, + prefix: "pl".to_owned(), + }) + .await + .is_err() + ); + } +} + +/// A failed suggestion request reads like any other failure. +#[tokio::test] +async fn reports_a_failed_suggestion_request() { + let stub = Stub::new(|_| Ok(with_headers(503, b"{}".to_vec(), JSON, &[]))); + assert!( + client(&stub) + .suggest_services(&SuggestionRequest { + filters: None, + limit: 0, + prefix: "pl".to_owned(), + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn refuses_suggestions_of_another_media_type() { + let stub = Stub::new(|_| Ok(typed(200, br#"{"items":[]}"#.to_vec(), "text/plain"))); + assert!( + client(&stub) + .suggest_services(&SuggestionRequest { + filters: None, + limit: 0, + prefix: "pl".to_owned(), + }) + .await + .is_err() + ); +} + +// -- trust ----------------------------------------------------------------------------------- + +/// A trust filter names `tap` or nothing: it is the only trust protocol this ODP version defines, +/// and the Directory refuses any other. +#[tokio::test] +async fn refuses_a_trust_filter_this_version_does_not_define() { + let stub = Stub::serving(page(&[], "")); + for trust in [ + vec![TrustProtocol { + name: Protocol::Mpp, + }], + vec![TrustProtocol { + name: Protocol::Aep, + }], + // Exactly one descriptor is allowed, so even a repeated `tap` is one too many. + vec![ + TrustProtocol { + name: Protocol::Tap, + }, + TrustProtocol { + name: Protocol::Tap, + }, + ], + ] { + let error = client(&stub) + .search_services(&filtered(ServiceFilters { + trust: trust.clone(), + ..ServiceFilters::default() + })) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("trust"), "{trust:?}: {error}"); + } + assert_eq!(stub.count(), 0, "nothing was sent"); +} + +/// The one shape the Directory does accept travels as written. +#[tokio::test] +async fn sends_the_only_trust_filter_this_version_defines() { + let stub = Stub::serving(page(&[], "")); + client(&stub) + .search_services(&filtered(ServiceFilters { + trust: vec![TrustProtocol { + name: Protocol::Tap, + }], + ..ServiceFilters::default() + })) + .await + .unwrap(); + + let body = String::from_utf8(stub.last().body).unwrap(); + assert!(body.contains(r#""trust":[{"name":"tap"}]"#), "{body}"); +} + +/// A request naming no trust filter carries none. +#[tokio::test] +async fn omits_a_trust_filter_the_caller_did_not_ask_for() { + let stub = Stub::serving(page(&[], "")); + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert!( + !String::from_utf8(stub.last().body) + .unwrap() + .contains("trust") + ); +} + +/// A trust facet counting anything but a bare `tap` descriptor is one this client cannot read. +#[tokio::test] +async fn refuses_a_trust_facet_this_version_does_not_define() { + for facets in [ + r#"{"trust":[{"count":3,"value":{"name":"mpp"}}]}"#, + r#"{"trust":[{"count":3,"value":{"name":"tap"}},{"count":1,"value":{"name":"aep"}}]}"#, + r#"{"trust":[{"count":3,"value":{}}]}"#, + // A descriptor carries `name` and nothing else. + r#"{"trust":[{"count":3,"value":{"name":"tap","extra":1}}]}"#, + ] { + let stub = Stub::serving(format!(r#"{{"facets":{facets},"items":[]}}"#)); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!( + error.to_string().contains("trust facets are invalid"), + "{facets}: {error}" + ); + } +} + +#[tokio::test] +async fn reads_the_trust_facet_this_version_defines() { + let stub = + Stub::serving(r#"{"facets":{"trust":[{"count":12,"value":{"name":"tap"}}]},"items":[]}"#); + let facets = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .facets + .unwrap(); + + assert_eq!(facets.trust.len(), 1); + assert_eq!(facets.trust[0].count, 12); + assert_eq!(facets.trust[0].value.name, Protocol::Tap); +} + +/// A page publishing no trust facet is not missing anything. +#[tokio::test] +async fn reads_a_page_that_publishes_no_trust_facet() { + let stub = + Stub::serving(r#"{"facets":{"keywords":[{"count":1,"value":"plants"}]},"items":[]}"#); + let facets = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .facets + .unwrap(); + assert!(facets.trust.is_empty()); +} diff --git a/crates/odp-directory/tests/support/mod.rs b/crates/odp-directory/tests/support/mod.rs new file mode 100644 index 0000000..5822860 --- /dev/null +++ b/crates/odp-directory/tests/support/mod.rs @@ -0,0 +1,147 @@ +//! The fixtures the Directory conformance tests are written against. +//! +//! Each test binary compiles this module separately, so not every binary uses every fixture. +#![allow(dead_code)] + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use odp_directory::{ + DirectoryClient, Environment, HttpRequest, HttpResponse, Transport, TransportError, +}; + +pub const JSON: &str = "application/json"; +pub const PROBLEM_JSON: &str = "application/problem+json"; +pub const ORIGIN: &str = "https://api.inflowpay.ai"; +pub const SEARCH_URL: &str = "https://api.inflowpay.ai/v1/services/search"; + +/// How a [`Stub`] answers one request. +type Reply = dyn Fn(&HttpRequest) -> Result + Send + Sync; + +/// One scripted exchange, and a record of everything the client asked for. +pub struct Stub { + reply: Box, + requests: Mutex>, +} + +impl Stub { + pub fn new( + reply: impl Fn(&HttpRequest) -> Result + Send + Sync + 'static, + ) -> Arc { + Arc::new(Self { + reply: Box::new(reply), + requests: Mutex::new(Vec::new()), + }) + } + + /// A Directory that answers every request with the same body. + pub fn serving(body: impl Into>) -> Arc { + let body = body.into(); + Self::new(move |_| Ok(json(200, body.clone()))) + } + + /// A Directory whose replies are taken from a script, one per request, repeating the last. + pub fn scripted(steps: Vec) -> Arc { + let cursor = Mutex::new(0_usize); + Self::new(move |_| { + let mut index = cursor.lock().unwrap(); + let step = steps[(*index).min(steps.len() - 1)].clone(); + *index += 1; + Ok(step) + }) + } + + pub fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + pub fn count(&self) -> usize { + self.requests.lock().unwrap().len() + } + + pub fn last(&self) -> HttpRequest { + self.requests().last().cloned().expect("a request") + } +} + +#[async_trait] +impl Transport for Stub { + async fn send(&self, request: HttpRequest) -> Result { + self.requests.lock().unwrap().push(request.clone()); + (self.reply)(&request) + } +} + +pub fn json(status: u16, body: impl Into>) -> HttpResponse { + HttpResponse { + body: body.into(), + headers: BTreeMap::from([("content-type".to_owned(), JSON.to_owned())]), + status, + } +} + +pub fn typed(status: u16, body: impl Into>, content_type: &str) -> HttpResponse { + HttpResponse { + body: body.into(), + headers: BTreeMap::from([("content-type".to_owned(), content_type.to_owned())]), + status, + } +} + +pub fn with_headers( + status: u16, + body: impl Into>, + content_type: &str, + extra: &[(&str, &str)], +) -> HttpResponse { + let mut value = typed(status, body, content_type); + for (name, setting) in extra { + value + .headers + .insert((*name).to_owned(), (*setting).to_owned()); + } + value +} + +/// A response carrying no body at all, as a redirect does. +pub fn bare(status: u16, extra: &[(&str, &str)]) -> HttpResponse { + HttpResponse { + body: Vec::new(), + headers: extra + .iter() + .map(|(name, setting)| ((*name).to_owned(), (*setting).to_owned())) + .collect(), + status, + } +} + +/// One well-formed Directory record at the given Service Origin. +pub fn service(origin: &str) -> String { + named_service(origin, "Plants") +} + +pub fn named_service(origin: &str, name: &str) -> String { + format!( + r#"{{"description":"Plants for agents.","indexed_at":"2026-08-25T00:00:00Z","language":"en","localizations":["en"],"name":"{name}","operations":[{{"authentication":"not-required","name":"get-offering"}}],"service_origin":"{origin}"}}"# + ) +} + +/// A search page carrying the given records and continuation. +pub fn page(items: &[String], next: &str) -> String { + let next = if next.is_empty() { + String::new() + } else { + format!(r#""next":"{next}","#) + }; + format!( + r#"{{"items":[{}],{next}"odp_version":"1.0"}}"#, + items.join(",") + ) +} + +pub fn client(stub: &Arc) -> DirectoryClient { + DirectoryClient::with_transport(Environment::Production, stub.clone()) +} diff --git a/crates/odp-directory/tests/transport_conformance.rs b/crates/odp-directory/tests/transport_conformance.rs new file mode 100644 index 0000000..28ae6fd --- /dev/null +++ b/crates/odp-directory/tests/transport_conformance.rs @@ -0,0 +1,435 @@ +//! What the client sends, what it will read back, and what it says when a Directory refuses. + +mod support; + +use odp_directory::{DirectoryError, SearchRequest, SuggestionRequest}; +use support::{ + JSON, ORIGIN, PROBLEM_JSON, SEARCH_URL, Stub, bare, client, json, page, service, typed, + with_headers, +}; + +// -- the request --------------------------------------------------------------------------- + +/// A search is a POST of JSON, and says so. +#[tokio::test] +async fn states_what_it_sends_and_what_it_accepts() { + let stub = Stub::serving(page(&[], "")); + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + + let request = stub.last(); + assert_eq!(request.method, "POST"); + assert_eq!(request.url, SEARCH_URL); + assert_eq!( + request.headers.get("accept").map(String::as_str), + Some(JSON) + ); + assert_eq!( + request.headers.get("content-type").map(String::as_str), + Some(JSON) + ); +} + +/// A request with no body describes no content type, because there is no content to describe. +#[tokio::test] +async fn describes_no_content_type_without_a_body() { + let stub = Stub::serving(r#"{"items":[]}"#); + client(&stub) + .suggest_services(&SuggestionRequest { + filters: None, + limit: 0, + prefix: "pl".to_owned(), + }) + .await + .unwrap(); + + let request = stub.last(); + assert_eq!(request.method, "GET"); + assert!(!request.headers.contains_key("content-type")); +} + +/// Only the selected environment is ever contacted. +#[tokio::test] +async fn reaches_only_the_directory_it_was_built_for() { + use odp_directory::{DirectoryClient, Environment}; + + let stub = Stub::serving(page(&[], "")); + DirectoryClient::with_transport(Environment::Sandbox, stub.clone()) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + + assert_eq!( + stub.last().url, + "https://sandbox.inflowpay.ai/v1/services/search" + ); +} + +/// A client built without a transport of its own brings the default one. +#[test] +fn builds_a_client_over_the_default_transport() { + use odp_directory::{DirectoryClient, Environment}; + + let client = DirectoryClient::new(Environment::Sandbox).unwrap(); + assert_eq!(client.environment(), Environment::Sandbox); +} + +#[test] +fn names_an_origin_for_every_environment() { + use odp_directory::{DirectoryClient, Environment}; + + assert_eq!(Environment::Production.origin(), ORIGIN); + assert_eq!( + Environment::Sandbox.origin(), + "https://sandbox.inflowpay.ai" + ); + assert_eq!(Environment::default(), Environment::Production); + + let client = DirectoryClient::with_transport(Environment::Sandbox, Stub::serving("{}")); + assert_eq!(client.environment(), Environment::Sandbox); +} + +// -- media types --------------------------------------------------------------------------- + +/// A result is JSON. Anything else describes something this client did not ask for. +#[tokio::test] +async fn refuses_a_result_of_another_media_type() { + for content_type in ["text/html", PROBLEM_JSON, ""] { + let stub = Stub::new(move |_| Ok(typed(200, page(&[], ""), content_type))); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!( + error.to_string().contains("application/json"), + "{content_type}: {error}" + ); + } +} + +#[tokio::test] +async fn reads_a_media_type_with_parameters() { + let stub = Stub::new(|_| Ok(typed(200, page(&[], ""), "application/JSON; charset=utf-8"))); + assert!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn refuses_a_body_that_is_not_json() { + let stub = Stub::serving("not json"); + assert!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .is_err() + ); +} + +// -- byte limits --------------------------------------------------------------------------- + +/// ERR-20: a declared length past the limit is refused without trusting the body. +#[tokio::test] +async fn refuses_a_declared_length_past_the_limit() { + let stub = Stub::new(|_| { + Ok(with_headers( + 200, + page(&[], ""), + JSON, + &[("content-length", "524289")], + )) + }); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!(error.to_string().contains("524288"), "{error}"); +} + +#[tokio::test] +async fn accepts_a_declared_length_within_the_limit() { + let body = page(&[service("https://plants.example")], ""); + let length = body.len().to_string(); + let stub = Stub::new(move |_| { + Ok(with_headers( + 200, + body.clone(), + JSON, + &[("content-length", &length)], + )) + }); + assert_eq!( + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap() + .items + .len(), + 1 + ); +} + +#[tokio::test] +async fn refuses_a_body_past_the_limit() { + let body = format!(r#"{{"items":[],"padding":"{}"}}"#, "x".repeat(524_300)); + let stub = Stub::serving(body); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!(error.to_string().contains("524288"), "{error}"); +} + +// -- failures ------------------------------------------------------------------------------ + +/// ERR-21: a failure is described from Problem Details, briefly, and with the headers kept. +#[tokio::test] +async fn describes_a_failure_from_its_problem_details() { + let stub = Stub::new(|_| { + Ok(with_headers( + 429, + br#"{"detail":"Slow down.","status":429,"title":"Too Many Requests"}"#.to_vec(), + PROBLEM_JSON, + &[("retry-after", "30")], + )) + }); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + + let DirectoryError::Request { + headers, + message, + status, + } = error + else { + panic!("expected a request failure"); + }; + assert_eq!(status, 429); + assert!(message.contains("Slow down."), "{message}"); + assert_eq!(headers.get("retry-after").map(String::as_str), Some("30")); +} + +/// With no `detail`, the title says what happened. +#[tokio::test] +async fn falls_back_through_the_members_that_carry_a_reason() { + for (body, expected) in [ + (r#"{"detail":"d","title":"t"}"#, "d"), + (r#"{"detail":"","title":"t"}"#, "t"), + (r#"{"message":"m"}"#, "m"), + ] { + let stub = Stub::new(move |_| Ok(typed(500, body.as_bytes().to_vec(), PROBLEM_JSON))); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err() + .to_string(); + assert!(error.ends_with(expected), "{body}: {error}"); + } +} + +/// A failure body this client cannot read is not repeated at all. +#[tokio::test] +async fn repeats_nothing_of_a_failure_it_cannot_read() { + let noisy = format!("\u{0007}{}", "A".repeat(64)); + for (body, content_type) in [ + (noisy.clone(), "text/html"), + ("x".repeat(20_000), PROBLEM_JSON), + ] { + let stub = Stub::new(move |_| Ok(typed(503, body.clone().into_bytes(), content_type))); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err() + .to_string(); + assert!(error.ends_with("HTTP 503: "), "{content_type}: {error}"); + } +} + +/// A body that is not Problem Details at all is quoted, but only as printable text. +#[tokio::test] +async fn flattens_and_shortens_whatever_it_does_quote() { + let stub = Stub::new(|_| { + Ok(typed( + 500, + format!("first\u{0007}line\n\n{}", "b".repeat(4_000)).into_bytes(), + JSON, + )) + }); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err() + .to_string(); + + assert!( + !error.contains('\u{0007}'), + "control characters are flattened" + ); + assert!(error.contains("first line"), "{}", &error[..40]); + assert!(error.ends_with('…'), "the excerpt is cut short"); + let detail = error.rsplit(": ").next().unwrap_or_default(); + assert!( + detail.chars().count() <= 2_049, + "{}", + detail.chars().count() + ); +} + +/// A failure body declaring more than the limit is not read either. +#[tokio::test] +async fn repeats_nothing_of_a_failure_that_declares_too_much() { + let stub = Stub::new(|_| { + Ok(with_headers( + 500, + br#"{"detail":"short"}"#.to_vec(), + PROBLEM_JSON, + &[("content-length", "99999")], + )) + }); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err() + .to_string(); + assert!(error.ends_with("HTTP 500: "), "{error}"); +} + +/// A transport that cannot reach the Directory says so as itself. +#[tokio::test] +async fn reports_a_transport_failure_as_such() { + use odp_directory::TransportError; + + let stub = Stub::new(|_| { + Err(TransportError { + message: "connection reset".to_owned(), + }) + }); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!(matches!(error, DirectoryError::Transport(_)), "{error}"); +} + +// -- redirects ----------------------------------------------------------------------------- + +/// ERR-24: a redirect is followed, and a POST that is told to look elsewhere becomes a GET. +#[tokio::test] +async fn continues_a_redirected_search_as_a_get() { + for status in [301, 302, 303] { + let stub = Stub::scripted(vec![ + bare(status, &[("location", "/v1/services/search/v2")]), + json(200, page(&[], "")), + ]); + client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + + let last = stub.last(); + assert_eq!(last.method, "GET", "{status}"); + assert!(last.body.is_empty(), "{status}"); + assert_eq!(last.url, "https://api.inflowpay.ai/v1/services/search/v2"); + } +} + +/// 307 and 308 preserve the method and the body they were given. +#[tokio::test] +async fn keeps_the_method_across_a_preserving_redirect() { + for status in [307, 308] { + let stub = Stub::scripted(vec![ + bare(status, &[("location", "/v1/services/search/v2")]), + json(200, page(&[], "")), + ]); + client(&stub) + .search_services(&SearchRequest { + query: "plants".to_owned(), + ..SearchRequest::default() + }) + .await + .unwrap(); + + let last = stub.last(); + assert_eq!(last.method, "POST", "{status}"); + assert!(!last.body.is_empty(), "{status}"); + } +} + +/// A redirect off the Directory's own origin is somebody else's answer. +#[tokio::test] +async fn refuses_a_redirect_that_leaves_the_directory() { + for location in [ + "https://elsewhere.example/v1/services/search", + "http://api.inflowpay.ai/v1/services/search", + ] { + let stub = Stub::new(move |_| Ok(bare(302, &[("location", location)]))); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!( + error.to_string().contains("changed origin"), + "{location}: {error}" + ); + } +} + +#[tokio::test] +async fn refuses_a_redirect_that_names_nowhere() { + let stub = Stub::new(|_| Ok(bare(308, &[]))); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + assert!(error.to_string().contains("Location"), "{error}"); +} + +/// ERR-25: five redirects are followed; the sixth is one too many. +#[tokio::test] +async fn refuses_a_sixth_redirect() { + let stub = Stub::new(|request| { + let step: usize = request + .url + .split("step=") + .nth(1) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + Ok(bare( + 308, + &[( + "location", + &format!("/v1/services/search?step={}", step + 1), + )], + )) + }); + let error = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("five redirects"), "{error}"); + assert_eq!(stub.count(), 6, "five were followed, the sixth was not"); +} + +#[tokio::test] +async fn follows_five_redirects_to_an_answer() { + let mut steps = (0..5) + .map(|step| bare(308, &[("location", &format!("/v1/services/search/{step}"))])) + .collect::>(); + steps.push(json(200, page(&[service("https://plants.example")], ""))); + let stub = Stub::scripted(steps); + + let result = client(&stub) + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert_eq!(result.items.len(), 1); + assert_eq!(stub.count(), 6); +} diff --git a/crates/odp-service/README.md b/crates/odp-service/README.md index 31f1875..f0779f6 100644 --- a/crates/odp-service/README.md +++ b/crates/odp-service/README.md @@ -69,6 +69,16 @@ resources during construction, defaults pages to 50 resources, and issues statel protected against tampering. They expire after one hour. The Service request boundary caps every requested page at 100 resources. +Individual Offering and Collection retrieval defaults to full representation; lists and searches +default to terse. Search body limits are passed to the catalog and checked against its response. +For search continuations on `/offerings/search` or `/collections/search`, implement +`continue_offering_search` or `continue_collection_search`. These receive the opaque cursor in +`CatalogRequest` without a fabricated search query. Their defaults return `CONTINUATION_EXPIRED`. + +`CatalogRequest.language` is the selected localization; `accept_language` retains the original +header. Use `ServiceError::retry_after` for failures with retry timing, or `ServiceError::request` +when no retry timing applies. + The storage boundary can wrap an existing asynchronous repository without coupling ODP to its database: diff --git a/crates/odp-service/src/service.rs b/crates/odp-service/src/service.rs index 1d47e9c..7a8dd4a 100644 --- a/crates/odp-service/src/service.rs +++ b/crates/odp-service/src/service.rs @@ -11,6 +11,7 @@ use odp_core::{ parse_offering_search_response, parse_page, parse_service_document, }; use serde_json::json; +use sha2::{Digest, Sha256}; use thiserror::Error; use url::form_urlencoded; @@ -18,6 +19,11 @@ pub const MEDIA_TYPE: &str = "application/odp+json"; pub const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; const MAXIMUM_REQUEST_BYTES: usize = 65_536; const MAXIMUM_RESOURCE_BYTES: usize = 524_288; +/// ERR-04: the bounds a Problem Details object's own schema puts on what it says. +const MAXIMUM_TITLE_CHARACTERS: usize = 128; +const MAXIMUM_DETAIL_CHARACTERS: usize = 2_048; +/// ERR-32: a Service that turns a request away has to say when to come back. +const DEFAULT_RETRY_AFTER: u64 = 1; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Request { @@ -37,8 +43,12 @@ pub struct Response { #[derive(Clone, Debug, PartialEq)] pub struct CatalogRequest { + /// The `Accept-Language` field as the Agent sent it. pub accept_language: Option, pub cursor: Option, + /// SVC-58: the localization the Service selected by RFC 4647 Lookup, which is what the + /// catalog should answer in. Empty only when the Service advertises no localizations. + pub language: String, pub limit: usize, pub path: String, pub representation: Representation, @@ -49,6 +59,7 @@ impl Default for CatalogRequest { Self { accept_language: None, cursor: None, + language: String::new(), limit: 0, path: String::new(), representation: Representation::Terse, @@ -68,14 +79,70 @@ pub enum ServiceError { Request { code: &'static str, message: String, + /// ERR-32 and ERR-33: how many seconds an Agent should wait. A `429` that names none + /// is given a default, because the field is required there. + retry_after: Option, status: u16, }, } +impl ServiceError { + /// A failure an Agent caused, described the way ODP describes one. + #[must_use] + pub fn request(status: u16, code: &'static str, message: impl Into) -> Self { + Self::Request { + code, + message: message.into(), + retry_after: None, + status, + } + } + + /// A failure an Agent should retry after `seconds`. + #[must_use] + pub fn retry_after( + status: u16, + code: &'static str, + message: impl Into, + seconds: u64, + ) -> Self { + Self::Request { + code, + message: message.into(), + retry_after: Some(seconds), + status, + } + } +} + #[async_trait] pub trait Catalog: Send + Sync { fn operations(&self) -> Vec; + /// Resumes a search using the opaque cursor, without reconstructing the original query. + async fn continue_offering_search( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Err(ServiceError::request( + 410, + "CONTINUATION_EXPIRED", + "Search continuation is unavailable", + )) + } + + /// Resumes a Collection search using the opaque cursor. + async fn continue_collection_search( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Err(ServiceError::request( + 410, + "CONTINUATION_EXPIRED", + "Search continuation is unavailable", + )) + } + async fn list_offerings( &self, request: CatalogRequest, @@ -327,6 +394,17 @@ impl Service { .map_err(|error| ServiceError::InvalidConfiguration(error.to_string()))?; document = parse_service_document(&encoded) .map_err(|error| ServiceError::InvalidConfiguration(error.to_string()))?; + // SVC-83 and SVC-84: a Service that cannot serve its own Service Document within the + // limit is misconfigured, and says so now rather than failing every request later. + if serde_json::to_vec(&document) + .map_err(|error| ServiceError::InvalidConfiguration(error.to_string()))? + .len() + > MAXIMUM_REQUEST_BYTES + { + return Err(ServiceError::InvalidConfiguration( + "Service Document exceeds 65536 bytes".to_owned(), + )); + } let endpoint_base = document.http.endpoint_base.trim_end_matches('/').to_owned(); Ok(Self { catalog, @@ -340,15 +418,43 @@ impl Service { } pub async fn handle(&self, request: Request) -> Response { - match self.handle_result(request).await { + // A HEAD is the GET it shadows, answered without the body (RFC 9110). Deciding it here + // means every resource that answers GET answers HEAD, with the same headers. + let head = request.method.eq_ignore_ascii_case("HEAD"); + let retrieval = head || request.method.eq_ignore_ascii_case("GET"); + let conditional = request.headers.get("if-none-match").cloned(); + let mut request = request; + if head { + request.method = "GET".to_owned(); + } + let mut response = match self.handle_result(request).await { Ok(response) => response, Err(ServiceError::Request { code, message, + retry_after, status, - }) => problem(status, code, &message), + }) => problem_with_retry(status, code, &message, retry_after), Err(error) => problem(500, "INTERNAL_ERROR", &error.to_string()), + }; + // PAG-31 and SVC-61: an entity tag distinguishes variants, and a validator that still + // matches means the Agent already holds this representation. + if response.status == 200 { + if let Some(etag) = response.headers.get("etag").cloned() { + if conditional.is_some_and(|value| matches_etag(&value, &etag)) { + response.body.clear(); + response.status = if retrieval { 304 } else { 412 }; + response.headers.remove("content-type"); + } + } + } + if head && response.status != 304 { + response + .headers + .insert("content-length".to_owned(), response.body.len().to_string()); + response.body.clear(); } + response } async fn handle_result(&self, request: Request) -> Result { @@ -359,15 +465,14 @@ impl Service { "Accept must allow application/odp+json", )); } + // SVC-58 and SVC-59: Lookup against what the Service actually publishes, and fall back to + // the default representation rather than refusing the request. + let language = self.select_language(request.headers.get("accept-language")); if request.path == "/.well-known/odp" { if request.method != "GET" { - return Ok(problem( - 405, - "METHOD_NOT_ALLOWED", - "The Service Document requires GET", - )); + return Ok(method_not_allowed("GET, HEAD")); } - return json_response(200, &self.document, MAXIMUM_REQUEST_BYTES); + return self.represent(200, &self.document, MAXIMUM_REQUEST_BYTES, &language); } let Some(path) = request.path.strip_prefix(&self.endpoint_base) else { return Ok(problem(404, "NOT_FOUND", "ODP resource not found")); @@ -382,44 +487,82 @@ impl Service { return Ok(problem(404, "NOT_FOUND", "ODP operation is not supported")); } } - let input = catalog_request(&request)?; + let mut input = catalog_request(&request, language.clone())?; + if !url::form_urlencoded::parse(request.query.as_bytes()) + .any(|(key, _)| key == "representation") + && matches!( + path_operation(request.method.as_str(), path), + Some(Operation::GetOffering | Operation::GetCollection) + ) + { + input.representation = Representation::Full; + } + let limit = input.limit; match (request.method.as_str(), path) { ("GET", "/offerings") => { let representation = input.representation; let page = self.catalog.list_offerings(input).await?; - validate_offering_page(&page, false, representation)?; - json_response(200, &page, MAXIMUM_RESOURCE_BYTES) + validate_offering_page(&page, false, representation, limit, &request)?; + self.represent(200, &page, MAXIMUM_RESOURCE_BYTES, &language) } ("POST", "/offerings/search") => { let query = decode_offering_search(&request)?; + input.limit = query.limit; + let limit = input.limit; let representation = input.representation; let page = self.catalog.search_offerings(query, input).await?; - validate_offering_page(&page, true, representation)?; - json_response(200, &page, MAXIMUM_RESOURCE_BYTES) + validate_offering_page(&page, true, representation, limit, &request)?; + self.represent(200, &page, MAXIMUM_RESOURCE_BYTES, &language) } ("GET", "/collections") => { let representation = input.representation; let page = self.catalog.list_collections(input).await?; - validate_collection_page(&page, representation)?; - json_response(200, &page, MAXIMUM_RESOURCE_BYTES) + validate_collection_page(&page, representation, limit, &request)?; + self.represent(200, &page, MAXIMUM_RESOURCE_BYTES, &language) } ("POST", "/collections/search") => { let query = decode_collection_search(&request)?; + input.limit = query.limit; + let limit = input.limit; let representation = input.representation; let page = self.catalog.search_collections(query, input).await?; - validate_collection_page(&page, representation)?; - json_response(200, &page, MAXIMUM_RESOURCE_BYTES) + validate_collection_page(&page, representation, limit, &request)?; + self.represent(200, &page, MAXIMUM_RESOURCE_BYTES, &language) } - ("GET", _) => self.get_path(path, input).await, - _ => Ok(problem( - 405, - "METHOD_NOT_ALLOWED", - "ODP operation uses a fixed HTTP method", - )), + ("GET", "/offerings/search") | ("GET", "/collections/search") => { + if input.cursor.as_ref().is_none_or(String::is_empty) { + return Err(request_error( + 400, + "INVALID_REQUEST", + "Search continuation requires a cursor", + )); + } + let representation = input.representation; + if path == "/offerings/search" { + let page = self.catalog.continue_offering_search(input).await?; + validate_offering_page(&page, true, representation, limit, &request)?; + self.represent(200, &page, MAXIMUM_RESOURCE_BYTES, &language) + } else { + let page = self.catalog.continue_collection_search(input).await?; + validate_collection_page(&page, representation, limit, &request)?; + self.represent(200, &page, MAXIMUM_RESOURCE_BYTES, &language) + } + } + (_, value) if RESERVED_PATHS.contains(&value) => { + Ok(method_not_allowed(allowed_methods(value))) + } + ("GET", _) => self.get_path(path, input, &request, &language).await, + (_, value) => Ok(method_not_allowed(allowed_methods(value))), } } - async fn get_path(&self, path: &str, input: CatalogRequest) -> Result { + async fn get_path( + &self, + path: &str, + input: CatalogRequest, + request: &Request, + language: &str, + ) -> Result { if let Some(id) = path.strip_prefix("/offerings/") { if !is_local_resource_identifier(id) { return Ok(problem( @@ -436,7 +579,7 @@ impl Service { parse_offering(&encoded) .map_err(|error| ServiceError::InvalidResponse(error.to_string()))?; validate_offering_representation(&offering, representation)?; - json_response(200, &offering, MAXIMUM_RESOURCE_BYTES) + self.represent(200, &offering, MAXIMUM_RESOURCE_BYTES, language) } Some(_) => Err(ServiceError::InvalidResponse( "Offering identifier does not match request path".to_owned(), @@ -445,11 +588,28 @@ impl Service { }; } if let Some(value) = path.strip_prefix("/collections/") { + let limit = input.limit; if let Some(id) = value.strip_suffix("/offerings") { + // IDN-08: a Collection identifier is checked the way an Offering identifier is, + // so a path that could never name a resource never reaches the catalog. + if !is_local_resource_identifier(id) { + return Ok(problem( + 400, + "INVALID_REQUEST", + "Collection identifier is invalid", + )); + } let representation = input.representation; let page = self.catalog.list_collection_offerings(id, input).await?; - validate_offering_page(&page, false, representation)?; - return json_response(200, &page, MAXIMUM_RESOURCE_BYTES); + validate_offering_page(&page, false, representation, limit, request)?; + return self.represent(200, &page, MAXIMUM_RESOURCE_BYTES, language); + } + if !is_local_resource_identifier(value) { + return Ok(problem( + 400, + "INVALID_REQUEST", + "Collection identifier is invalid", + )); } let representation = input.representation; return match self.catalog.get_collection(value, input).await? { @@ -459,7 +619,7 @@ impl Service { parse_collection(&encoded) .map_err(|error| ServiceError::InvalidResponse(error.to_string()))?; validate_collection_representation(&collection, representation)?; - json_response(200, &collection, MAXIMUM_RESOURCE_BYTES) + self.represent(200, &collection, MAXIMUM_RESOURCE_BYTES, language) } Some(_) => Err(ServiceError::InvalidResponse( "Collection identifier does not match request path".to_owned(), @@ -469,13 +629,91 @@ impl Service { } Ok(problem(404, "NOT_FOUND", "ODP resource not found")) } + + /// SVC-58: RFC 4647 Lookup against the localizations the Service Document advertises. + /// + /// Lookup walks a range down its own subtags and never sideways, so `de-CH-1901` finds + /// `de-CH` and then `de`, but never `de-DE`. + fn select_language(&self, accept_language: Option<&String>) -> String { + let default = self.document.language.clone(); + let Some(header) = accept_language else { + return default; + }; + for range in ranges_by_weight(header) { + if range == "*" { + return default; + } + let mut candidate = range.as_str(); + loop { + if let Some(found) = self + .document + .localizations + .iter() + .find(|value| value.eq_ignore_ascii_case(candidate)) + { + return found.clone(); + } + let Some(shorter) = candidate.rsplit_once('-') else { + break; + }; + candidate = shorter.0; + // RFC 4647: a single-character subtag introduces an extension rather than naming + // a language, so truncating to it would leave a range no tag can match. + if let Some((prefix, subtag)) = candidate.rsplit_once('-') { + if subtag.len() == 1 { + candidate = prefix; + } + } + } + } + // SVC-59: nothing matched, so the default representation is returned, not a 406. + default + } + + /// Serializes an ODP representation with the fields that describe the variant it is. + fn represent( + &self, + status: u16, + value: &T, + maximum_bytes: usize, + language: &str, + ) -> Result { + let mut response = json_response(status, value, maximum_bytes)?; + if !language.is_empty() { + // SVC-60: say which variant this is, and that the choice depends on the request. + response + .headers + .insert("content-language".to_owned(), language.to_owned()); + response + .headers + .insert("vary".to_owned(), "Accept-Language".to_owned()); + } + // SVC-61: the tag covers the language too, so two variants never share one. + response + .headers + .insert("etag".to_owned(), entity_tag(&response.body, language)); + Ok(response) + } } -fn catalog_request(request: &Request) -> Result { - let parameters = form_urlencoded::parse(request.query.as_bytes()) - .into_owned() - .collect::>(); - let representation = match parameters.get("representation").map(String::as_str) { +fn catalog_request(request: &Request, language: String) -> Result { + let mut parameters: BTreeMap> = BTreeMap::new(); + for (name, value) in form_urlencoded::parse(request.query.as_bytes()).into_owned() { + parameters.entry(name).or_default().push(value); + } + // SVC-73: a repeated `representation` describes two requests, and the Service answers + // neither. The same reasoning applies to the other single-valued parameters. + for name in ["representation", "limit", "cursor"] { + if parameters.get(name).is_some_and(|values| values.len() > 1) { + return Err(request_error( + 400, + "INVALID_REQUEST", + &format!("{name} must not be repeated"), + )); + } + } + let single = |name: &str| parameters.get(name).and_then(|values| values.first()); + let representation = match single("representation").map(String::as_str) { None | Some("terse") => Representation::Terse, Some("full") => Representation::Full, Some(_) => { @@ -486,24 +724,62 @@ fn catalog_request(request: &Request) -> Result { )); } }; - let limit = parameters - .get("limit") + let limit = single("limit") .map(|value| value.parse::()) .transpose() .map_err(|_| request_error(400, "INVALID_REQUEST", "limit is invalid"))? .unwrap_or(0); - if limit > 100 { - return Err(request_error(400, "INVALID_REQUEST", "limit exceeds 100")); + if limit > 100 || (limit == 0 && single("limit").is_some()) { + return Err(request_error( + 400, + "INVALID_REQUEST", + "limit must be from 1 through 100", + )); } Ok(CatalogRequest { accept_language: request.headers.get("accept-language").cloned(), - cursor: parameters.get("cursor").cloned(), + cursor: single("cursor").cloned(), + language, limit, path: request.path.clone(), representation, }) } +/// The language ranges of an `Accept-Language` field, most preferred first. +/// +/// A range weighted zero is not acceptable at all, so it is dropped rather than ordered last. +fn ranges_by_weight(header: &str) -> Vec { + let mut ranges = header + .split(',') + .filter_map(|entry| { + let mut parts = entry.split(';'); + let range = parts.next().unwrap_or_default().trim(); + if range.is_empty() { + return None; + } + let weight = quality(parts); + (weight > 0.0).then(|| (range.to_owned(), weight)) + }) + .collect::>(); + // A stable sort keeps equally weighted ranges in the order the Agent wrote them. + ranges.sort_by(|left, right| right.1.total_cmp(&left.1)); + ranges.into_iter().map(|(range, _)| range).collect() +} + +/// The `q` parameter of one media range or language range, defaulting to 1. +fn quality<'a>(parameters: impl Iterator) -> f32 { + for parameter in parameters { + let Some((name, value)) = parameter.split_once('=') else { + continue; + }; + if name.trim().eq_ignore_ascii_case("q") { + return value.trim().parse::().unwrap_or(0.0); + } + } + 1.0 +} + fn validate_search_request(request: &Request) -> Result<(), ServiceError> { if request.body.len() > MAXIMUM_REQUEST_BYTES { return Err(request_error( @@ -512,12 +788,14 @@ fn validate_search_request(request: &Request) -> Result<(), ServiceError> { "request body is too large", )); } - if request + // MED-06: a media type is compared by its essence, which is case-insensitive and carries no + // surrounding whitespace (RFC 9110). + let essence = request .headers .get("content-type") - .map(|value| value.split(';').next().unwrap_or_default()) - != Some(MEDIA_TYPE) - { + .map(|value| value.split(';').next().unwrap_or_default().trim()) + .unwrap_or_default(); + if !essence.eq_ignore_ascii_case(MEDIA_TYPE) { return Err(request_error( 415, "UNSUPPORTED_MEDIA_TYPE", @@ -540,18 +818,47 @@ fn decode_collection_search(request: &Request) -> Result ServiceError { - ServiceError::Request { - code, - message: message.to_owned(), - status, + ServiceError::request(status, code, message) +} + +/// PAG-11 and PAG-14: what a page owes the request that produced it. +fn validate_page_envelope( + next: &str, + items: usize, + limit: usize, + request: &Request, +) -> Result<(), ServiceError> { + // PAG-14 lets a Service answer with fewer items than asked for, never with more. + if limit != 0 && items > limit { + return Err(ServiceError::InvalidResponse( + "Catalog returned more items than the request allows".to_owned(), + )); } + if next.is_empty() { + return Ok(()); + } + // PAG-11: a continuation that points at the request it answers never advances. + let current = if request.query.is_empty() { + request.path.clone() + } else { + format!("{}?{}", request.path, request.query) + }; + if next == current { + return Err(ServiceError::InvalidResponse( + "Catalog returned the request URL as its own continuation".to_owned(), + )); + } + Ok(()) } fn validate_offering_page( page: &OfferingPage, search_response: bool, representation: Representation, + limit: usize, + request: &Request, ) -> Result<(), ServiceError> { + validate_page_envelope(&page.next, page.items.len(), limit, request)?; let encoded = serde_json::to_vec(page) .map_err(|error| ServiceError::InvalidResponse(error.to_string()))?; if search_response { @@ -578,7 +885,10 @@ fn validate_offering_page( fn validate_collection_page( page: &Page, representation: Representation, + limit: usize, + request: &Request, ) -> Result<(), ServiceError> { + validate_page_envelope(&page.next, page.items.len(), limit, request)?; let encoded = serde_json::to_vec(page) .map_err(|error| ServiceError::InvalidResponse(error.to_string()))?; parse_page::(&encoded) @@ -626,12 +936,19 @@ fn validate_collection_representation( Ok(()) } +/// The two paths that name an operation rather than a resource. +/// +/// `/offerings/search` is the search endpoint, so it is never read as an Offering called +/// `search`, however valid that identifier would otherwise be. +const RESERVED_PATHS: &[&str] = &["/offerings/search", "/collections/search"]; + fn path_operation(method: &str, path: &str) -> Option { match (method, path) { ("GET", "/offerings") => Some(Operation::ListOfferings), - ("POST", "/offerings/search") => Some(Operation::SearchOfferings), + ("POST" | "GET", "/offerings/search") => Some(Operation::SearchOfferings), ("GET", "/collections") => Some(Operation::ListCollections), - ("POST", "/collections/search") => Some(Operation::SearchCollections), + ("POST" | "GET", "/collections/search") => Some(Operation::SearchCollections), + (_, value) if RESERVED_PATHS.contains(&value) => None, ("GET", value) if value.starts_with("/offerings/") => Some(Operation::GetOffering), ("GET", value) if value.starts_with("/collections/") && value.ends_with("/offerings") => { Some(Operation::ListCollectionOfferings) @@ -646,7 +963,31 @@ fn json_response( value: &T, maximum_bytes: usize, ) -> Result { - let body = serde_json::to_vec(value) + let mut document = serde_json::to_value(value) + .map_err(|error| ServiceError::InvalidResponse(error.to_string()))?; + odp_core::validate_json_depth( + &document, + if maximum_bytes == MAXIMUM_REQUEST_BYTES { + 8 + } else { + 16 + }, + ) + .map_err(|error| ServiceError::InvalidResponse(error.to_string()))?; + if let Some(version) = document.get_mut("odp_version") { + *version = serde_json::Value::String(VERSION.to_owned()); + } + if let Some(items) = document + .get_mut("items") + .and_then(serde_json::Value::as_array_mut) + { + for item in items { + if let Some(version) = item.get_mut("odp_version") { + *version = serde_json::Value::String(VERSION.to_owned()); + } + } + } + let body = serde_json::to_vec(&document) .map_err(|error| ServiceError::InvalidResponse(error.to_string()))?; if body.len() > maximum_bytes { return Err(ServiceError::InvalidResponse( @@ -661,10 +1002,15 @@ fn json_response( } fn problem(status: u16, code: &str, detail: &str) -> Response { + problem_with_retry(status, code, detail, None) +} + +fn problem_with_retry(status: u16, code: &str, detail: &str, retry_after: Option) -> Response { let value = ProblemDetails { additional: BTreeMap::new(), code: code.to_owned(), - detail: detail.to_owned(), + // ERR-04: a Problem Details object that breaks its own limits describes nothing. + detail: bounded(detail, MAXIMUM_DETAIL_CHARACTERS), instance: String::new(), invalid_params: Vec::new(), problem_type: format!( @@ -672,23 +1018,120 @@ fn problem(status: u16, code: &str, detail: &str) -> Response { code.to_ascii_lowercase().replace('_', "-") ), status, - title: detail.to_owned(), + title: bounded(detail, MAXIMUM_TITLE_CHARACTERS), }; + let mut headers = BTreeMap::from([("content-type".to_owned(), PROBLEM_MEDIA_TYPE.to_owned())]); + // ERR-32 requires Retry-After on a 429 and ERR-33 asks for it on a 503, so a Service that + // named no interval still tells the Agent to come back rather than leaving it to guess. + if matches!(status, 429 | 503) { + headers.insert( + "retry-after".to_owned(), + retry_after.unwrap_or(DEFAULT_RETRY_AFTER).to_string(), + ); + } else if let Some(seconds) = retry_after { + headers.insert("retry-after".to_owned(), seconds.to_string()); + } Response { body: serde_json::to_vec(&value) .unwrap_or_else(|_| json!({"status":500}).to_string().into_bytes()), - headers: BTreeMap::from([("content-type".to_owned(), PROBLEM_MEDIA_TYPE.to_owned())]), + headers, status, } } +/// Keeps a message within a code-point bound, marking where it was cut. +fn bounded(value: &str, maximum: usize) -> String { + if value.chars().count() <= maximum { + return value.to_owned(); + } + let mut kept = value.chars().take(maximum - 1).collect::(); + kept.push('…'); + kept +} + +/// A 405 states what the resource does allow (RFC 9110). +fn method_not_allowed(allow: &str) -> Response { + let mut response = problem( + 405, + "METHOD_NOT_ALLOWED", + "ODP operation uses a fixed HTTP method", + ); + response + .headers + .insert("allow".to_owned(), allow.to_owned()); + response +} + +fn allowed_methods(path: &str) -> &'static str { + match path { + "/offerings/search" | "/collections/search" => "POST, GET, HEAD", + _ => "GET, HEAD", + } +} + +/// MED-03 and MED-04: whether the request's `Accept` permits an ODP representation. +/// +/// The most specific range that matches decides, and a range weighted zero rejects rather than +/// merely deprioritizes — so `application/odp+json;q=0` is a refusal, not a preference. fn accepts_odp(value: Option<&str>) -> bool { - value.is_none_or(|value| { - value.split(',').any(|entry| { - let media_type = entry.split(';').next().unwrap_or_default().trim(); - media_type == "*/*" || media_type.eq_ignore_ascii_case(MEDIA_TYPE) - }) - }) + let Some(value) = value else { + return true; + }; + if value.trim().is_empty() { + return true; + } + let mut best: Option<(u8, f32)> = None; + for entry in value.split(',') { + let mut parts = entry.split(';'); + let media_range = parts.next().unwrap_or_default().trim(); + let specificity = if media_range.eq_ignore_ascii_case(MEDIA_TYPE) { + 2 + } else if media_range.eq_ignore_ascii_case("application/*") { + 1 + } else if media_range == "*/*" { + 0 + } else { + continue; + }; + let weight = quality(parts); + if best.is_none_or(|(found, _)| specificity > found) { + best = Some((specificity, weight)); + } + } + best.is_some_and(|(_, weight)| weight > 0.0) +} + +/// SVC-61: a strong validator over the bytes served and the variant they represent. +fn entity_tag(body: &[u8], language: &str) -> String { + let mut digest = Sha256::new(); + digest.update(language.as_bytes()); + digest.update([0]); + digest.update(body); + let bytes = digest.finalize(); + let mut encoded = String::with_capacity(bytes.len() * 2 + 2); + encoded.push('"'); + for byte in bytes { + encoded.push(char::from_digit(u32::from(byte >> 4), 16).unwrap_or('0')); + encoded.push(char::from_digit(u32::from(byte & 0x0f), 16).unwrap_or('0')); + } + encoded.push('"'); + encoded +} + +/// Whether an `If-None-Match` field lists this entity tag, weakly compared (RFC 9110). +fn matches_etag(header: &str, etag: &str) -> bool { + let bare = |value: &str| { + value + .trim() + .trim_start_matches("W/") + .trim() + .trim_matches('"') + .to_owned() + }; + let wanted = bare(etag); + header + .split(',') + .any(|entry| entry.trim() == "*" || bare(entry) == wanted) } #[cfg(test)] @@ -820,6 +1263,23 @@ mod tests { .await; assert_eq!(response.status, 200); assert_eq!(parse_offering(&response.body).unwrap().id, "plant-1"); + + let listed = service + .handle(Request { + headers: BTreeMap::from([("accept".to_owned(), MEDIA_TYPE.to_owned())]), + method: "GET".to_owned(), + path: "/odp/offerings".to_owned(), + ..Request::default() + }) + .await; + assert_eq!(listed.status, 200); + assert_eq!( + parse_page::(&listed.body) + .unwrap() + .items + .len(), + 1 + ); } #[tokio::test] @@ -875,7 +1335,10 @@ mod tests { refinements: Vec::new(), }; - assert!(validate_offering_page(&page, false, Representation::Terse).is_err()); + assert!( + validate_offering_page(&page, false, Representation::Terse, 0, &Request::default()) + .is_err() + ); } #[test] diff --git a/crates/odp-service/src/static_catalog.rs b/crates/odp-service/src/static_catalog.rs index a59bcb6..1b15eac 100644 --- a/crates/odp-service/src/static_catalog.rs +++ b/crates/odp-service/src/static_catalog.rs @@ -74,6 +74,15 @@ impl StaticCatalog { ))); } } + let mut depths = BTreeMap::new(); + for id in collection_by_id.keys() { + collection_depth( + id, + &collection_by_id, + &mut depths, + &mut std::collections::BTreeSet::new(), + )?; + } Ok(Self { collections: options.collections, collection_by_id, @@ -84,6 +93,37 @@ impl StaticCatalog { } } +fn collection_depth( + id: &str, + collections: &BTreeMap, + depths: &mut BTreeMap, + visiting: &mut std::collections::BTreeSet, +) -> Result { + if let Some(depth) = depths.get(id) { + return Ok(*depth); + } + if visiting.len() > 32 || !visiting.insert(id.to_owned()) { + return Err(ServiceError::InvalidConfiguration( + "Collection hierarchy contains a cycle or exceeds 32 edges".to_owned(), + )); + } + let collection = collections.get(id).ok_or_else(|| { + ServiceError::InvalidConfiguration(format!("Unknown parent Collection {id}")) + })?; + let mut depth = 0; + for parent in &collection.parent_ids { + depth = depth.max(1 + collection_depth(parent, collections, depths, visiting)?); + } + visiting.remove(id); + if depth > 32 { + return Err(ServiceError::InvalidConfiguration( + "Collection hierarchy exceeds 32 edges".to_owned(), + )); + } + depths.insert(id.to_owned(), depth); + Ok(depth) +} + #[async_trait] impl Catalog for StaticCatalog { fn operations(&self) -> Vec { @@ -164,11 +204,11 @@ impl Catalog for StaticCatalog { request: CatalogRequest, ) -> Result, ServiceError> { if !self.collection_by_id.contains_key(collection_id) { - return Err(ServiceError::Request { - code: "NOT_FOUND", - message: "Collection not found".to_owned(), - status: 404, - }); + return Err(ServiceError::request( + 404, + "NOT_FOUND", + "Collection not found", + )); } let offerings = self .offerings @@ -232,7 +272,10 @@ fn offering_representation( embedded: bool, ) -> Offering { if representation == odp_core::Representation::Terse { + // OFR-55: a Terse Offering carries no Actions, but it may still point at them. value.actions.clear(); + } else { + // REP-12: a Full Representation omits nothing, so it has nothing to point at. value.detail_fields.clear(); } if embedded && representation == odp_core::Representation::Terse { @@ -246,7 +289,8 @@ fn collection_representation( representation: odp_core::Representation, embedded: bool, ) -> Collection { - if representation == odp_core::Representation::Terse { + if representation != odp_core::Representation::Terse { + // REP-12: a Full Representation omits nothing, so it has nothing to point at. value.detail_fields.clear(); } if embedded && representation == odp_core::Representation::Terse { @@ -312,8 +356,10 @@ fn decode_cursor( .duration_since(UNIX_EPOCH) .map_err(|_| invalid_cursor())? .as_secs(); - if value.expires < now - || value.limit != limit + if value.expires < now { + return Err(expired_cursor()); + } + if value.limit != limit || value.path != request.path || value.representation != representation_name(request) { @@ -323,11 +369,17 @@ fn decode_cursor( } fn invalid_cursor() -> ServiceError { - ServiceError::Request { - code: "CONTINUATION_UNAVAILABLE", - message: "Continuation is unavailable".to_owned(), - status: 410, - } + ServiceError::request( + 410, + "CONTINUATION_UNAVAILABLE", + "Continuation is unavailable", + ) +} + +/// PAG-20: a continuation that simply aged out is named as such, so an Agent can tell a stale +/// cursor from one that was never valid and restart the traversal deliberately. +fn expired_cursor() -> ServiceError { + ServiceError::request(410, "CONTINUATION_EXPIRED", "Continuation has expired") } fn representation_name(request: &CatalogRequest) -> &'static str { @@ -369,6 +421,99 @@ mod tests { .unwrap() } + /// PAG-19 and PAG-20: a continuation stays usable for an hour, and a stale one is named + /// as stale so an Agent can restart the traversal rather than guess. + #[tokio::test] + async fn distinguishes_a_stale_continuation_from_an_invalid_one() { + let catalog = StaticCatalog::new(StaticCatalogOptions { + collections: Vec::new(), + offerings: vec![offering("one"), offering("two")], + }) + .unwrap(); + let request = CatalogRequest { + limit: 1, + path: "/odp/offerings".to_owned(), + ..CatalogRequest::default() + }; + + // A cursor this catalog signed, but issued an hour and a second ago. + let stale = Cursor { + expires: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + - 1, + limit: 1, + offset: 1, + path: request.path.clone(), + representation: "terse".to_owned(), + }; + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&stale).unwrap()); + let mut mac = Hmac::::new_from_slice(&catalog.continuation_key).unwrap(); + mac.update(payload.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + + let error = catalog + .list_offerings(CatalogRequest { + cursor: Some(format!("{payload}.{signature}")), + ..request.clone() + }) + .await + .unwrap_err(); + let ServiceError::Request { code, status, .. } = error else { + panic!("a stale continuation is a request failure"); + }; + assert_eq!(status, 410); + assert_eq!(code, "CONTINUATION_EXPIRED"); + + // One that is simply unusable reads differently, so the two are never confused. + let error = catalog + .list_offerings(CatalogRequest { + cursor: Some("nonsense".to_owned()), + ..request + }) + .await + .unwrap_err(); + let ServiceError::Request { code, .. } = error else { + panic!("an unusable continuation is a request failure"); + }; + assert_eq!(code, "CONTINUATION_UNAVAILABLE"); + } + + /// REP-12: a Full Collection omits nothing, so it points at nothing. + #[tokio::test] + async fn keeps_collection_detail_fields_on_the_terse_representation() { + let collection = odp_core::parse_collection( + br#"{"detail_fields":["/description"],"id":"plants","name":"Plants","odp_version":"1.0"}"#, + ) + .unwrap(); + let catalog = StaticCatalog::new(StaticCatalogOptions { + collections: vec![collection], + offerings: Vec::new(), + }) + .unwrap(); + + let terse = catalog + .get_collection("plants", CatalogRequest::default()) + .await + .unwrap() + .unwrap(); + assert_eq!(terse.detail_fields, ["/description"]); + + let full = catalog + .get_collection( + "plants", + CatalogRequest { + representation: odp_core::Representation::Full, + ..CatalogRequest::default() + }, + ) + .await + .unwrap() + .unwrap(); + assert!(full.detail_fields.is_empty()); + } + #[tokio::test] async fn provides_bounded_stateless_pages() { let catalog = StaticCatalog::new(StaticCatalogOptions { diff --git a/crates/odp-service/tests/builder_conformance.rs b/crates/odp-service/tests/builder_conformance.rs new file mode 100644 index 0000000..facc80d --- /dev/null +++ b/crates/odp-service/tests/builder_conformance.rs @@ -0,0 +1,600 @@ +//! SVC-06/21/29/41/65, PAG-19/20/23/26: the Service Document a builder produces, and the +//! continuations a static catalog signs. + +mod support; + +use std::sync::Arc; + +use async_trait::async_trait; +use odp_core::{ + AuthenticationRequirement, Collection, EnrollmentProtocol, McpEndpoint, McpEndpointType, + Offering, OfferingPage, Operation, Page, Protocol, Representation, ServiceBranding, + ServiceBrandingImage, ServiceBrandingImageType, ServiceOpenApi, TrustProtocol, + parse_collection, parse_offering, parse_service_document, +}; +use odp_service::{ + Catalog, CatalogRequest, MEDIA_TYPE, Request, Service, ServiceBuilder, ServiceError, + StaticCatalog, StaticCatalogOptions, +}; +use support::{Stub, get, offering_page, query, service, with_header}; + +/// SVC-06: a Service Document carries every optional member the builder was given. +#[tokio::test] +async fn publishes_every_optional_member_it_was_given() { + let service = ServiceBuilder::new("Plants", "Plants for agents.", "en", "/odp") + .branding(ServiceBranding { + icon: ServiceBrandingImage { + media_type: Some(ServiceBrandingImageType::Svg), + src: "https://plants.example/icon.svg".to_owned(), + }, + logo: ServiceBrandingImage { + media_type: Some(ServiceBrandingImageType::Png), + src: "https://plants.example/logo.png".to_owned(), + }, + }) + .mcp(vec![McpEndpoint { + description: "Plants over MCP".to_owned(), + endpoint_type: McpEndpointType::StreamableHttp, + name: "Plants".to_owned(), + url: "https://plants.example/mcp".to_owned(), + }]) + .openapi(ServiceOpenApi { + url: "https://plants.example/openapi.json".to_owned(), + }) + .protocols( + vec![EnrollmentProtocol { + name: Protocol::Aep, + }], + Vec::new(), + ) + .trust(vec![TrustProtocol { + name: Protocol::Tap, + }]) + .build(Stub::serving(1)) + .unwrap(); + + let response = service.handle(get("/.well-known/odp")).await; + assert_eq!(response.status, 200); + + let document = parse_service_document(&response.body).unwrap(); + assert!(document.branding.is_some()); + assert_eq!(document.mcp.len(), 1); + assert_eq!( + document + .http + .openapi + .as_ref() + .map(|value| value.url.as_str()), + Some("https://plants.example/openapi.json") + ); + let protocols = document.protocols.as_ref().unwrap(); + assert_eq!(protocols.enrollment.len(), 1); + assert_eq!(protocols.trust.len(), 1); +} + +/// FLT: search capabilities travel with the document when the Service declares them. +#[tokio::test] +async fn publishes_the_search_capabilities_it_declares() { + let capabilities = serde_json::from_str( + r#"{"filters":{"inline":[{"description":"The colour","id":"colour","operators":["eq"],"title":"Colour","type":"string"}]}}"#, + ) + .unwrap(); + let service = ServiceBuilder::new("Plants", "Plants for agents.", "en", "/odp") + .search_capabilities(capabilities) + .build(Stub::serving(1)) + .unwrap(); + + let document = + parse_service_document(&service.handle(get("/.well-known/odp")).await.body).unwrap(); + assert!(document.search_capabilities.is_some()); +} + +/// SVC-77: the builder sets an operation's authentication once, however often it is named. +#[test] +fn keeps_one_descriptor_per_operation() { + let service = ServiceBuilder::new("Plants", "Plants for agents.", "en", "/odp") + .operation_authentication(Operation::GetOffering, AuthenticationRequirement::Optional) + .operation_authentication(Operation::GetOffering, AuthenticationRequirement::Required) + .protocols( + vec![EnrollmentProtocol { + name: Protocol::Aep, + }], + Vec::new(), + ) + .build(Stub::serving(1)) + .unwrap(); + + let document = service.document(); + let descriptors = document + .operations + .iter() + .filter(|descriptor| descriptor.name == Operation::GetOffering) + .collect::>(); + assert_eq!(descriptors.len(), 1); + assert_eq!( + descriptors[0].authentication, + AuthenticationRequirement::Required + ); +} + +/// A Service Document the parser refuses is a configuration error, not a served document. +#[test] +fn refuses_a_document_its_own_parser_would_not_accept() { + let Err(error) = ServiceBuilder::new("Plants", "Plants for agents.", "en", "not-a-path") + .build(Stub::serving(1)) + else { + panic!("an endpoint base that is not a path is not configurable"); + }; + assert!(matches!(error, ServiceError::InvalidConfiguration(_))); +} + +// -- the operations a catalog declines ------------------------------------------------------ + +/// A catalog advertising an operation it never implemented fails that request, not the Service. +#[tokio::test] +async fn reports_each_operation_the_catalog_left_unimplemented() { + struct Baseline; + + #[async_trait] + impl Catalog for Baseline { + fn operations(&self) -> Vec { + support::ALL_OPERATIONS.to_vec() + } + + async fn list_offerings( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(offering_page(Vec::new(), "")) + } + + async fn get_offering( + &self, + _id: &str, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(None) + } + } + + let service = Service::new(support::document(&["en"]), Arc::new(Baseline)).unwrap(); + let search = Request { + body: br#"{"odp_version":"1.0","query":"plants"}"#.to_vec(), + headers: std::collections::BTreeMap::from([ + ("accept".to_owned(), MEDIA_TYPE.to_owned()), + ("content-type".to_owned(), MEDIA_TYPE.to_owned()), + ]), + method: "POST".to_owned(), + path: "/odp/offerings/search".to_owned(), + ..Request::default() + }; + + for request in [ + search.clone(), + Request { + path: "/odp/collections/search".to_owned(), + ..search + }, + get("/odp/collections"), + get("/odp/collections/plants"), + get("/odp/collections/plants/offerings"), + ] { + let path = request.path.clone(); + let response = service.handle(request).await; + assert_eq!(response.status, 500, "{path}"); + } +} + +/// A catalog answering with a Collection other than the one asked for is not published. +#[tokio::test] +async fn refuses_a_collection_that_does_not_match_the_path() { + struct Wrong; + + #[async_trait] + impl Catalog for Wrong { + fn operations(&self) -> Vec { + support::ALL_OPERATIONS.to_vec() + } + + async fn list_offerings( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(offering_page(Vec::new(), "")) + } + + async fn get_offering( + &self, + _id: &str, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(None) + } + + async fn get_collection( + &self, + _id: &str, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(Some(support::collection("somebody-else"))) + } + + async fn list_collections( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(support::collection_page(Vec::new(), "")) + } + } + + let service = Service::new(support::document(&["en"]), Arc::new(Wrong)).unwrap(); + let response = service.handle(get("/odp/collections/plants")).await; + assert_eq!(response.status, 500); +} + +// -- language edge cases -------------------------------------------------------------------- + +/// Lookup skips a single-character subtag, which is an extension prefix rather than a tag. +#[tokio::test] +async fn skips_an_extension_prefix_while_truncating() { + let service = support::localized(Stub::serving(1), &["en", "zh-Hant"]); + let response = service + .handle(with_header( + get("/.well-known/odp"), + "accept-language", + "zh-Hant-a-myext-x-private", + )) + .await; + assert_eq!( + response.headers.get("content-language").map(String::as_str), + Some("zh-Hant") + ); +} + +/// An empty range in the field is ignored rather than matched. +#[tokio::test] +async fn ignores_an_empty_language_range() { + let service = support::localized(Stub::serving(1), &["en", "fr"]); + for accept in [", fr", " ,fr", ",,"] { + let response = service + .handle(with_header( + get("/.well-known/odp"), + "accept-language", + accept, + )) + .await; + assert_eq!(response.status, 200, "{accept}"); + } +} + +// -- the static catalog's configuration and cursors ------------------------------------------ + +/// IDN-15: one identifier names one resource of a type, so a repeat is a configuration error. +#[test] +fn refuses_a_static_catalog_that_repeats_an_identifier() { + let offering = + parse_offering(br#"{"id":"plant-1","name":"Plant","odp_version":"1.0"}"#).unwrap(); + let Err(error) = StaticCatalog::new(StaticCatalogOptions { + collections: Vec::new(), + offerings: vec![offering.clone(), offering], + }) else { + panic!("one identifier names one Offering"); + }; + assert!(matches!(error, ServiceError::InvalidConfiguration(_))); + + let collection = + parse_collection(br#"{"id":"plants","name":"Plants","odp_version":"1.0"}"#).unwrap(); + assert!( + StaticCatalog::new(StaticCatalogOptions { + collections: vec![collection.clone(), collection], + offerings: Vec::new(), + }) + .is_err() + ); +} + +/// COL-31: every Collection an Offering names has to exist. +#[test] +fn refuses_an_offering_naming_a_collection_that_is_not_there() { + let Err(error) = StaticCatalog::new(StaticCatalogOptions { + collections: Vec::new(), + offerings: vec![ + parse_offering( + br#"{"collection_ids":["absent"],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#, + ) + .unwrap(), + ], + }) else { + panic!("a Collection an Offering names has to exist"); + }; + assert!(matches!(error, ServiceError::InvalidConfiguration(_))); +} + +/// COL-29: the inverse query answers only for a Collection that exists. +#[tokio::test] +async fn reports_an_inverse_query_for_a_collection_that_is_not_there() { + let catalog = StaticCatalog::new(StaticCatalogOptions { + collections: vec![ + parse_collection(br#"{"id":"plants","name":"Plants","odp_version":"1.0"}"#).unwrap(), + ], + offerings: Vec::new(), + }) + .unwrap(); + + let error = catalog + .list_collection_offerings("absent", CatalogRequest::default()) + .await + .unwrap_err(); + assert!( + matches!(error, ServiceError::Request { status: 404, .. }), + "{error}" + ); +} + +/// PAG-26: a cursor is untrusted input, and is bound to the traversal that issued it. +#[tokio::test] +async fn refuses_a_cursor_from_another_traversal() { + let catalog = StaticCatalog::new(StaticCatalogOptions { + collections: Vec::new(), + offerings: (0..3) + .map(|index| { + parse_offering( + format!(r#"{{"id":"p{index}","name":"Plant","odp_version":"1.0"}}"#).as_bytes(), + ) + .unwrap() + }) + .collect(), + }) + .unwrap(); + + let first = catalog + .list_offerings(CatalogRequest { + limit: 1, + path: "/odp/offerings".to_owned(), + ..CatalogRequest::default() + }) + .await + .unwrap(); + let cursor = url::form_urlencoded::parse(first.next.split_once('?').unwrap().1.as_bytes()) + .find_map(|(name, value)| (name == "cursor").then(|| value.into_owned())) + .unwrap(); + + // A cursor carries the limit, path and representation it was issued for, and only answers + // for that traversal. + for request in [ + CatalogRequest { + cursor: Some(cursor.clone()), + limit: 2, + path: "/odp/offerings".to_owned(), + ..CatalogRequest::default() + }, + CatalogRequest { + cursor: Some(cursor.clone()), + limit: 1, + path: "/odp/collections".to_owned(), + ..CatalogRequest::default() + }, + CatalogRequest { + cursor: Some(cursor.clone()), + limit: 1, + path: "/odp/offerings".to_owned(), + representation: Representation::Full, + ..CatalogRequest::default() + }, + // PAG-23: the signature is what makes the cursor usable at all. + CatalogRequest { + cursor: Some("not-a-cursor".to_owned()), + limit: 1, + path: "/odp/offerings".to_owned(), + ..CatalogRequest::default() + }, + CatalogRequest { + cursor: Some("payload.c2lnbmF0dXJl".to_owned()), + limit: 1, + path: "/odp/offerings".to_owned(), + ..CatalogRequest::default() + }, + ] { + assert!(catalog.list_offerings(request).await.is_err()); + } +} + +/// A cursor another catalog signed is not this catalog's cursor. +#[tokio::test] +async fn refuses_a_cursor_another_catalog_signed() { + let options = || StaticCatalogOptions { + collections: Vec::new(), + offerings: (0..3) + .map(|index| { + parse_offering( + format!(r#"{{"id":"p{index}","name":"Plant","odp_version":"1.0"}}"#).as_bytes(), + ) + .unwrap() + }) + .collect(), + }; + let first = StaticCatalog::new(options()).unwrap(); + let second = StaticCatalog::new(options()).unwrap(); + + let page = first + .list_offerings(CatalogRequest { + limit: 1, + path: "/odp/offerings".to_owned(), + ..CatalogRequest::default() + }) + .await + .unwrap(); + let cursor = url::form_urlencoded::parse(page.next.split_once('?').unwrap().1.as_bytes()) + .find_map(|(name, value)| (name == "cursor").then(|| value.into_owned())) + .unwrap(); + + assert!( + second + .list_offerings(CatalogRequest { + cursor: Some(cursor), + limit: 1, + path: "/odp/offerings".to_owned(), + ..CatalogRequest::default() + }) + .await + .is_err() + ); +} + +/// PAG-12: the continuation preserves everything needed to continue the same operation. +#[tokio::test] +async fn carries_the_traversal_forward_in_its_continuation() { + let catalog = StaticCatalog::new(StaticCatalogOptions { + collections: Vec::new(), + offerings: (0..3) + .map(|index| { + parse_offering( + format!(r#"{{"id":"p{index}","name":"Plant","odp_version":"1.0"}}"#).as_bytes(), + ) + .unwrap() + }) + .collect(), + }) + .unwrap(); + + let page = catalog + .list_offerings(CatalogRequest { + limit: 2, + path: "/odp/offerings".to_owned(), + representation: Representation::Full, + ..CatalogRequest::default() + }) + .await + .unwrap(); + + assert!(page.next.starts_with("/odp/offerings?")); + assert!(page.next.contains("limit=2")); + assert!(page.next.contains("representation=full")); +} + +/// With no limit asked for, the catalog chooses its own page size. +#[tokio::test] +async fn chooses_its_own_page_size() { + let catalog = StaticCatalog::new(StaticCatalogOptions { + collections: Vec::new(), + offerings: (0..60) + .map(|index| { + parse_offering( + format!(r#"{{"id":"p{index}","name":"Plant","odp_version":"1.0"}}"#).as_bytes(), + ) + .unwrap() + }) + .collect(), + }) + .unwrap(); + + let page = catalog + .list_offerings(CatalogRequest { + path: "/odp/offerings".to_owned(), + ..CatalogRequest::default() + }) + .await + .unwrap(); + assert_eq!(page.items.len(), 50); + assert!(!page.next.is_empty()); +} + +/// A static catalog with no Collections advertises no Collection operations. +#[test] +fn advertises_collection_operations_only_when_it_has_collections() { + let bare = StaticCatalog::new(StaticCatalogOptions::default()).unwrap(); + assert_eq!(bare.operations().len(), 2); + + let with_collections = StaticCatalog::new(StaticCatalogOptions { + collections: vec![ + parse_collection(br#"{"id":"plants","name":"Plants","odp_version":"1.0"}"#).unwrap(), + ], + offerings: Vec::new(), + }) + .unwrap(); + assert_eq!(with_collections.operations().len(), 5); +} + +/// A Collection list is paged the same way an Offering list is. +#[tokio::test] +async fn pages_collections_too() { + let catalog = StaticCatalog::new(StaticCatalogOptions { + collections: (0..3) + .map(|index| { + parse_collection( + format!(r#"{{"id":"c{index}","name":"Collection","odp_version":"1.0"}}"#) + .as_bytes(), + ) + .unwrap() + }) + .collect(), + offerings: Vec::new(), + }) + .unwrap(); + + let page = catalog + .list_collections(CatalogRequest { + limit: 2, + path: "/odp/collections".to_owned(), + ..CatalogRequest::default() + }) + .await + .unwrap(); + assert_eq!(page.items.len(), 2); + assert!(!page.next.is_empty()); + + assert!( + catalog + .get_collection("c0", CatalogRequest::default()) + .await + .unwrap() + .is_some() + ); + assert!( + catalog + .get_collection("absent", CatalogRequest::default()) + .await + .unwrap() + .is_none() + ); +} + +/// The inverse query returns the Offerings that name the Collection, and no others. +#[tokio::test] +async fn answers_the_inverse_query() { + let catalog = StaticCatalog::new(StaticCatalogOptions { + collections: vec![ + parse_collection(br#"{"id":"plants","name":"Plants","odp_version":"1.0"}"#).unwrap(), + ], + offerings: vec![ + parse_offering( + br#"{"collection_ids":["plants"],"id":"p0","name":"Plant","odp_version":"1.0"}"#, + ) + .unwrap(), + parse_offering(br#"{"id":"p1","name":"Plant","odp_version":"1.0"}"#).unwrap(), + ], + }) + .unwrap(); + + let page = catalog + .list_collection_offerings( + "plants", + CatalogRequest { + path: "/odp/collections/plants/offerings".to_owned(), + ..CatalogRequest::default() + }, + ) + .await + .unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].id, "p0"); +} + +/// A Service over a static catalog still answers a search it never advertised. +#[tokio::test] +async fn reports_a_search_a_static_catalog_does_not_support() { + let response = service(Stub::serving(1)) + .handle(query("/odp/offerings", "representation=terse")) + .await; + assert_eq!(response.status, 200); +} diff --git a/crates/odp-service/tests/catalog_conformance.rs b/crates/odp-service/tests/catalog_conformance.rs new file mode 100644 index 0000000..03aa89e --- /dev/null +++ b/crates/odp-service/tests/catalog_conformance.rs @@ -0,0 +1,633 @@ +//! PAG-11/14, REP-12, OFR-55 and IDN-08: what a Service will publish on a catalog's behalf. + +mod support; + +use std::{collections::BTreeMap, sync::Arc}; + +use async_trait::async_trait; +use odp_core::{ + Action, ActionRelation, AdditionalMembers, AuthenticationRequirement, Collection, + HttpActionTarget, Offering, OfferingPage, Operation, Page, Representation, VERSION, + parse_collection, parse_offering, +}; +use odp_service::{ + Catalog, CatalogRequest, Request, Service, ServiceBuilder, ServiceError, StaticCatalog, + StaticCatalogOptions, +}; +use support::{ + Stub, collection, collection_page, document, get, offering, offering_page, query, service, +}; + +/// A catalog that answers with exactly what a test hands it. +struct Fixed { + collections: Page, + offerings: OfferingPage, + single: Option, +} + +impl Fixed { + fn offerings(page: OfferingPage) -> Arc { + Arc::new(Self { + collections: collection_page(Vec::new(), ""), + offerings: page, + single: None, + }) + } + + fn offering(value: Offering) -> Arc { + Arc::new(Self { + collections: collection_page(Vec::new(), ""), + offerings: offering_page(Vec::new(), ""), + single: Some(value), + }) + } + + fn collections(page: Page) -> Arc { + Arc::new(Self { + collections: page, + offerings: offering_page(Vec::new(), ""), + single: None, + }) + } +} + +#[async_trait] +impl Catalog for Fixed { + fn operations(&self) -> Vec { + support::ALL_OPERATIONS.to_vec() + } + + async fn list_offerings( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(self.offerings.clone()) + } + + async fn get_offering( + &self, + _id: &str, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(self.single.clone()) + } + + async fn list_collections( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(self.collections.clone()) + } + + async fn get_collection( + &self, + _id: &str, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(self.collections.items.first().cloned()) + } + + async fn list_collection_offerings( + &self, + _collection_id: &str, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(self.offerings.clone()) + } +} + +fn serving(catalog: Arc) -> Service { + Service::new(document(&["en"]), catalog).unwrap() +} + +// -- page contracts ------------------------------------------------------------------------- + +/// PAG-14: a Service may answer with fewer items than asked for, never with more. +#[tokio::test] +async fn refuses_a_page_larger_than_the_request_allows() { + let page = offering_page( + (0..5).map(|index| offering(&format!("p{index}"))).collect(), + "", + ); + let response = serving(Fixed::offerings(page)) + .handle(query("/odp/offerings", "limit=2")) + .await; + + assert_eq!(response.status, 500, "the Service does not publish it"); +} + +#[tokio::test] +async fn serves_a_page_within_the_request() { + let page = offering_page( + (0..2).map(|index| offering(&format!("p{index}"))).collect(), + "", + ); + let response = serving(Fixed::offerings(page)) + .handle(query("/odp/offerings", "limit=2")) + .await; + + assert_eq!(response.status, 200); +} + +/// With no limit asked for, the Service chooses its own page size (PAG-14). +#[tokio::test] +async fn serves_whatever_the_catalog_chose_when_no_limit_was_asked_for() { + let page = offering_page( + (0..30) + .map(|index| offering(&format!("p{index}"))) + .collect(), + "", + ); + let response = serving(Fixed::offerings(page)) + .handle(get("/odp/offerings")) + .await; + assert_eq!(response.status, 200); +} + +/// PAG-11: a continuation that points at the request it answers never advances. +#[tokio::test] +async fn refuses_a_continuation_that_does_not_advance() { + let page = offering_page(vec![offering("p0")], "/odp/offerings?limit=2"); + let response = serving(Fixed::offerings(page)) + .handle(query("/odp/offerings", "limit=2")) + .await; + + assert_eq!(response.status, 500); +} + +#[tokio::test] +async fn serves_a_continuation_that_advances() { + let page = offering_page(vec![offering("p0")], "/odp/offerings?cursor=c2&limit=2"); + let response = serving(Fixed::offerings(page)) + .handle(query("/odp/offerings", "limit=2")) + .await; + + assert_eq!(response.status, 200); + assert_eq!( + odp_core::parse_page::(&response.body) + .unwrap() + .next, + "/odp/offerings?cursor=c2&limit=2" + ); +} + +/// The same reasoning applies to a Collection page. +#[tokio::test] +async fn holds_a_collection_page_to_the_same_contract() { + let page = collection_page( + (0..5) + .map(|index| collection(&format!("c{index}"))) + .collect(), + "", + ); + let response = serving(Fixed::collections(page)) + .handle(query("/odp/collections", "limit=1")) + .await; + assert_eq!(response.status, 500); + + let looping = collection_page(vec![collection("c0")], "/odp/collections"); + let response = serving(Fixed::collections(looping)) + .handle(get("/odp/collections")) + .await; + assert_eq!(response.status, 500); +} + +/// PAG-01: a page envelope that is not one is not published either. +#[tokio::test] +async fn refuses_a_page_envelope_that_is_not_one() { + let mut page = offering_page(vec![offering("p0")], ""); + page.odp_version = String::new(); + let response = serving(Fixed::offerings(page)) + .handle(get("/odp/offerings")) + .await; + assert_eq!(response.status, 500); +} + +// -- representation contracts --------------------------------------------------------------- + +/// OFR-55: a Terse Offering carries no Actions. +#[tokio::test] +async fn refuses_actions_in_a_terse_offering() { + let mut value = offering("plant-1"); + value.actions.push(Action { + authentication: AuthenticationRequirement::NotRequired, + description: String::new(), + http: Some(HttpActionTarget { + href: "/purchase".to_owned(), + method: "POST".to_owned(), + request: None, + response_content_types: Vec::new(), + }), + id: "purchase".to_owned(), + openapi: None, + rel: ActionRelation::Purchase, + }); + + let response = serving(Fixed::offering(value.clone())) + .handle(query("/odp/offerings/plant-1", "representation=terse")) + .await; + assert_eq!(response.status, 500); + + let full = serving(Fixed::offering(value)) + .handle(query("/odp/offerings/plant-1", "representation=full")) + .await; + assert_eq!(full.status, 200, "a Full Offering may carry them"); +} + +#[tokio::test] +async fn get_resources_default_to_full_while_lists_default_to_terse() { + let catalog = Stub::serving(1); + let service = service(catalog.clone()); + for path in ["/odp/offerings/plant-1", "/odp/collections/plants"] { + assert_eq!(service.handle(get(path)).await.status, 200); + assert_eq!(catalog.last().representation, Representation::Full); + } + assert_eq!(service.handle(get("/odp/offerings")).await.status, 200); + assert_eq!(catalog.last().representation, Representation::Terse); +} + +#[tokio::test] +async fn serves_supported_version_without_changing_catalog_values() { + let mut value = offering("plant-1"); + value.odp_version = "1.7".to_owned(); + let catalog = Fixed::offering(value); + let response = serving(catalog.clone()) + .handle(get("/odp/offerings/plant-1")) + .await; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(body["odp_version"], "1.0"); + assert_eq!(catalog.single.as_ref().unwrap().odp_version, "1.7"); +} + +#[test] +fn static_catalog_checks_the_complete_parent_graph() { + let make = |id: &str, parents: Vec| { + let mut value = collection(id); + value.parent_ids = parents; + value + }; + for collections in [ + vec![make("a", vec!["missing".to_owned()])], + vec![ + make("a", vec!["b".to_owned()]), + make("b", vec!["a".to_owned()]), + ], + (0..34) + .map(|index| { + make( + &format!("c{index}"), + if index == 0 { + vec![] + } else { + vec![format!("c{}", index - 1)] + }, + ) + }) + .collect(), + ] { + assert!( + StaticCatalog::new(StaticCatalogOptions { + collections, + ..Default::default() + }) + .is_err() + ); + } + let collections = (0..33) + .map(|index| { + make( + &format!("c{index}"), + if index == 0 { + vec![] + } else { + vec![format!("c{}", index - 1)] + }, + ) + }) + .collect(); + assert!( + StaticCatalog::new(StaticCatalogOptions { + collections, + ..Default::default() + }) + .is_ok() + ); +} + +/// REP-12: a Full Representation omits nothing, so it has nothing to point at. +#[tokio::test] +async fn refuses_detail_fields_in_a_full_representation() { + let mut value = offering("plant-1"); + value.detail_fields.push("/description".to_owned()); + + let response = serving(Fixed::offering(value.clone())) + .handle(query("/odp/offerings/plant-1", "representation=full")) + .await; + assert_eq!(response.status, 500); + + let terse = serving(Fixed::offering(value)) + .handle(query("/odp/offerings/plant-1", "representation=terse")) + .await; + assert_eq!(terse.status, 200, "a Terse Offering may point at them"); +} + +/// A catalog that answers with a different resource than the one asked for is not published. +#[tokio::test] +async fn refuses_a_resource_that_does_not_match_the_path() { + let response = serving(Fixed::offering(offering("somebody-else"))) + .handle(get("/odp/offerings/plant-1")) + .await; + assert_eq!(response.status, 500); +} + +// -- identifiers ---------------------------------------------------------------------------- + +/// IDN-08: a Collection identifier is checked the way an Offering identifier is. +#[tokio::test] +async fn refuses_an_identifier_that_could_never_name_a_resource() { + for path in [ + "/odp/offerings/not a valid id", + "/odp/collections/not a valid id", + "/odp/collections/not a valid id/offerings", + "/odp/collections//offerings", + ] { + let response = service(Stub::serving(1)).handle(get(path)).await; + assert_eq!(response.status, 400, "{path}"); + assert_eq!( + odp_core::parse_problem_details(&response.body) + .unwrap() + .code, + "INVALID_REQUEST", + "{path}" + ); + } +} + +/// And a valid identifier that names nothing is a plain 404. +#[tokio::test] +async fn reports_a_resource_that_is_simply_absent() { + for path in ["/odp/offerings/absent", "/odp/collections/absent"] { + let response = service(Stub::serving(1)).handle(get(path)).await; + assert_eq!(response.status, 404, "{path}"); + } +} + +/// `/offerings/search` names an operation, so it is never read as an Offering called `search`. +#[tokio::test] +async fn keeps_the_search_paths_for_search() { + for path in ["/odp/offerings/search", "/odp/collections/search"] { + let response = service(Stub::serving(1)).handle(get(path)).await; + assert_eq!( + response.status, 400, + "a search GET needs its continuation cursor: {path}" + ); + } +} + +// -- the Service's own configuration -------------------------------------------------------- + +/// SVC-82: a Service that cannot list and get Offerings is not a Service. +#[test] +fn refuses_a_catalog_that_cannot_meet_the_baseline() { + struct Thin; + + #[async_trait] + impl Catalog for Thin { + fn operations(&self) -> Vec { + vec![Operation::ListOfferings] + } + + async fn list_offerings( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(offering_page(Vec::new(), "")) + } + + async fn get_offering( + &self, + _id: &str, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(None) + } + } + + let Err(error) = Service::new(document(&["en"]), Arc::new(Thin)) else { + panic!("a Service without the baseline operations is not conformant"); + }; + assert!(matches!(error, ServiceError::InvalidConfiguration(_))); +} + +/// ROLE-04: the advertised operations are the catalog's, not whatever the document claimed. +#[tokio::test] +async fn advertises_exactly_what_the_catalog_supports() { + let stub = Stub::with_operations( + support::Answer::Items(1, String::new()), + vec![Operation::GetOffering, Operation::ListOfferings], + ); + let service = Service::new(document(&["en"]), stub).unwrap(); + let document = service.document(); + + assert_eq!(document.operations.len(), 2); + assert!(document.operations.iter().all(|descriptor| matches!( + descriptor.name, + Operation::GetOffering | Operation::ListOfferings + ))); +} + +/// A builder produces a Service Document its own parser accepts. +#[tokio::test] +async fn builds_a_service_from_its_parts() { + let service = ServiceBuilder::new("Plants", "Plants for agents.", "en", "/odp") + .keywords(["plants"]) + .documentation_url("https://plants.example/docs") + .status_url("https://plants.example/status") + .support_url("https://plants.example/support") + .website_url("https://plants.example") + .localizations(["en", "fr"]) + .payment_origins(["https://pay.plants.example"]) + .operation_authentication( + Operation::GetOffering, + AuthenticationRequirement::NotRequired, + ) + .build(Stub::serving(1)) + .unwrap(); + + let response = service.handle(get("/.well-known/odp")).await; + assert_eq!(response.status, 200); + + let document = odp_core::parse_service_document(&response.body).unwrap(); + assert_eq!(document.name, "Plants"); + assert_eq!(document.localizations, ["en", "fr"]); + assert_eq!(document.odp_version, VERSION); +} + +/// A Service whose catalog fails outright reports it without leaking how. +#[tokio::test] +async fn reports_an_unsupported_operation_the_catalog_advertised() { + struct Lying; + + #[async_trait] + impl Catalog for Lying { + fn operations(&self) -> Vec { + support::ALL_OPERATIONS.to_vec() + } + + async fn list_offerings( + &self, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(offering_page(Vec::new(), "")) + } + + async fn get_offering( + &self, + _id: &str, + _request: CatalogRequest, + ) -> Result, ServiceError> { + Ok(None) + } + } + + // The default trait methods report the operations this catalog never implemented. + let service = Service::new(document(&["en"]), Arc::new(Lying)).unwrap(); + for path in ["/odp/collections", "/odp/collections/plants"] { + let response = service.handle(get(path)).await; + assert_eq!(response.status, 500, "{path}"); + } +} + +// -- the static catalog --------------------------------------------------------------------- + +/// REP-11 and REP-12: detail fields belong to a Terse representation and only to one. +#[tokio::test] +async fn keeps_detail_fields_on_the_representation_that_can_carry_them() { + let offering = parse_offering( + br#"{"actions":[{"authentication":"not-required","http":{"href":"/buy","method":"POST"},"id":"buy","rel":"purchase"}],"detail_fields":["/actions"],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#, + ) + .unwrap(); + let catalog = Arc::new( + StaticCatalog::new(StaticCatalogOptions { + collections: Vec::new(), + offerings: vec![offering], + }) + .unwrap(), + ); + + let terse = catalog + .get_offering("plant-1", CatalogRequest::default()) + .await + .unwrap() + .unwrap(); + assert_eq!(terse.detail_fields, ["/actions"]); + assert!(terse.actions.is_empty(), "OFR-55"); + + let full = catalog + .get_offering( + "plant-1", + CatalogRequest { + representation: Representation::Full, + ..CatalogRequest::default() + }, + ) + .await + .unwrap() + .unwrap(); + assert!(full.detail_fields.is_empty(), "REP-12"); + assert_eq!(full.actions.len(), 1); +} + +/// And the Service serves both without complaint. +#[tokio::test] +async fn serves_both_representations_of_a_static_offering() { + let offering = parse_offering( + br#"{"actions":[{"authentication":"not-required","http":{"href":"/buy","method":"POST"},"id":"buy","rel":"purchase"}],"detail_fields":["/actions"],"id":"plant-1","name":"Plant","odp_version":"1.0"}"#, + ) + .unwrap(); + let service = Service::new( + document(&["en"]), + Arc::new( + StaticCatalog::new(StaticCatalogOptions { + collections: vec![ + parse_collection(br#"{"id":"plants","name":"Plants","odp_version":"1.0"}"#) + .unwrap(), + ], + offerings: vec![offering], + }) + .unwrap(), + ), + ) + .unwrap(); + + for request in [ + get("/odp/offerings/plant-1"), + query("/odp/offerings/plant-1", "representation=full"), + get("/odp/offerings"), + query("/odp/offerings", "representation=full"), + get("/odp/collections"), + get("/odp/collections/plants"), + get("/odp/collections/plants/offerings"), + ] { + let path = format!("{} {}", request.path, request.query); + let response = service.handle(request).await; + assert_eq!(response.status, 200, "{path}"); + } +} + +/// A Collection a static catalog does not hold is absent, not an error. +#[tokio::test] +async fn reports_an_absent_static_collection() { + let service = Service::new( + document(&["en"]), + Arc::new( + StaticCatalog::new(StaticCatalogOptions { + collections: vec![ + parse_collection(br#"{"id":"plants","name":"Plants","odp_version":"1.0"}"#) + .unwrap(), + ], + offerings: vec![offering("plant-1")], + }) + .unwrap(), + ), + ) + .unwrap(); + + assert_eq!( + service.handle(get("/odp/collections/absent")).await.status, + 404 + ); + assert_eq!( + service + .handle(get("/odp/collections/absent/offerings")) + .await + .status, + 404 + ); +} + +/// A request carrying no headers at all is still a request. +#[tokio::test] +async fn serves_a_bare_request() { + let response = service(Stub::serving(1)) + .handle(Request { + headers: BTreeMap::new(), + method: "GET".to_owned(), + path: "/odp/offerings".to_owned(), + ..Request::default() + }) + .await; + assert_eq!(response.status, 200); +} + +/// A Service Document larger than its own limit is not published. +#[test] +fn refuses_a_service_document_past_its_limit() { + let mut document = document(&["en"]); + document.additional = AdditionalMembers::from([( + "padding".to_owned(), + serde_json::Value::String("x".repeat(70_000)), + )]); + assert!(Service::new(document, Stub::serving(1)).is_err()); +} diff --git a/crates/odp-service/tests/http_conformance.rs b/crates/odp-service/tests/http_conformance.rs new file mode 100644 index 0000000..7752a3e --- /dev/null +++ b/crates/odp-service/tests/http_conformance.rs @@ -0,0 +1,533 @@ +//! MED-03/04/06/07, SVC-73, ERR-01..07 and ERR-31..33: the HTTP contract a Service owes an Agent. + +mod support; + +use odp_core::parse_problem_details; +use odp_service::{MEDIA_TYPE, PROBLEM_MEDIA_TYPE, Request}; +use support::{Answer, Stub, get, header, post, query, service, with_header}; + +/// The smallest body each search operation accepts. +const SEARCH: &[u8] = br#"{"odp_version":"1.0","query":"plants"}"#; +const COLLECTION_SEARCH: &[u8] = br#"{"odp_version":"1.0","query":"plants"}"#; + +#[tokio::test] +async fn search_limits_are_forwarded_and_enforced() { + for path in ["/odp/offerings/search", "/odp/collections/search"] { + let catalog = Stub::serving(2); + let response = service(catalog.clone()) + .handle(post( + path, + br#"{"odp_version":"1.0","query":"plants","limit":1}"#, + )) + .await; + assert_eq!(catalog.last().limit, 1); + assert_eq!(response.status, 500); + } +} + +#[tokio::test] +async fn search_continuations_receive_the_opaque_cursor() { + for path in ["/odp/offerings/search", "/odp/collections/search"] { + let catalog = Stub::serving(1); + let response = service(catalog.clone()) + .handle(query(path, "cursor=opaque-token")) + .await; + assert_eq!(response.status, 200); + assert_eq!(catalog.last().cursor.as_deref(), Some("opaque-token")); + } +} + +#[tokio::test] +async fn post_preconditions_do_not_return_not_modified() { + let service = service(Stub::serving(1)); + let response = service.handle(post("/odp/offerings/search", SEARCH)).await; + let etag = header(&response, "etag").unwrap(); + let response = service + .handle(with_header( + post("/odp/offerings/search", SEARCH), + "if-none-match", + etag, + )) + .await; + assert_eq!(response.status, 412); +} + +// -- media types ---------------------------------------------------------------------------- + +/// MED-07: a successful ODP document is served as one. +#[tokio::test] +async fn serves_an_odp_document_as_odp() { + let response = service(Stub::serving(1)) + .handle(get("/.well-known/odp")) + .await; + + assert_eq!(response.status, 200); + assert_eq!(header(&response, "content-type"), Some(MEDIA_TYPE)); +} + +/// MED-03: an absent or wildcard `Accept` permits an ODP representation. +#[tokio::test] +async fn answers_an_accept_that_permits_odp() { + for accept in [ + None, + Some(""), + Some("*/*"), + Some("application/*"), + Some(MEDIA_TYPE), + Some("text/html, application/odp+json;q=0.9"), + Some("APPLICATION/ODP+JSON"), + Some("application/odp+json; charset=utf-8"), + ] { + let mut request = get("/.well-known/odp"); + match accept { + Some(value) => { + request + .headers + .insert("accept".to_owned(), value.to_owned()); + } + None => { + request.headers.remove("accept"); + } + } + let response = service(Stub::serving(1)).handle(request).await; + assert_eq!(response.status, 200, "{accept:?}"); + } +} + +/// MED-04: an `Accept` that excludes the media type is refused, however it says so. +#[tokio::test] +async fn refuses_an_accept_that_excludes_odp() { + for accept in [ + "text/html", + "application/json", + "application/odp+json;q=0", + "*/*;q=0", + "application/*;q=0", + "text/html, application/odp+json;q=0", + ] { + let response = service(Stub::serving(1)) + .handle(with_header(get("/.well-known/odp"), "accept", accept)) + .await; + assert_eq!(response.status, 406, "{accept}"); + assert_eq!(header(&response, "content-type"), Some(PROBLEM_MEDIA_TYPE)); + } +} + +/// A more specific range decides, so a wildcard refusal does not override an explicit welcome. +#[tokio::test] +async fn lets_the_most_specific_range_decide() { + let response = service(Stub::serving(1)) + .handle(with_header( + get("/.well-known/odp"), + "accept", + "*/*;q=0, application/odp+json", + )) + .await; + assert_eq!(response.status, 200); +} + +/// MED-06: a search body is ODP JSON, compared by media-type essence. +#[tokio::test] +async fn reads_a_body_whose_media_type_essence_matches() { + for content_type in [ + MEDIA_TYPE, + "APPLICATION/ODP+JSON", + " application/odp+json ", + "application/odp+json ; charset=utf-8", + ] { + let response = service(Stub::serving(1)) + .handle(with_header( + post("/odp/offerings/search", SEARCH), + "content-type", + content_type, + )) + .await; + assert_eq!(response.status, 200, "{content_type}"); + } +} + +#[tokio::test] +async fn refuses_a_body_of_another_media_type() { + for content_type in ["application/json", "text/plain", ""] { + let response = service(Stub::serving(1)) + .handle(with_header( + post("/odp/offerings/search", SEARCH), + "content-type", + content_type, + )) + .await; + assert_eq!(response.status, 415, "{content_type}"); + } + + let mut request = post("/odp/offerings/search", SEARCH); + request.headers.remove("content-type"); + let response = service(Stub::serving(1)).handle(request).await; + assert_eq!(response.status, 415, "a missing Content-Type is not ODP"); +} + +/// ERR-31: an oversized request is refused rather than read. +#[tokio::test] +async fn refuses_a_request_body_past_the_limit() { + let response = service(Stub::serving(1)) + .handle(post("/odp/offerings/search", &vec![b' '; 65_537])) + .await; + + assert_eq!(response.status, 413); + assert_eq!( + parse_problem_details(&response.body).unwrap().code, + "REQUEST_TOO_LARGE" + ); +} + +// -- methods -------------------------------------------------------------------------------- + +/// A resource that answers GET answers HEAD, with the same headers and no body. +#[tokio::test] +async fn answers_head_wherever_it_answers_get() { + for path in [ + "/.well-known/odp", + "/odp/offerings", + "/odp/offerings/plant-1", + "/odp/collections", + "/odp/collections/plants", + "/odp/collections/plants/offerings", + ] { + let body = service(Stub::serving(2)).handle(get(path)).await; + let mut head = get(path); + head.method = "HEAD".to_owned(); + let headless = service(Stub::serving(2)).handle(head).await; + + assert_eq!(headless.status, body.status, "{path}"); + assert!(headless.body.is_empty(), "{path}"); + assert_eq!( + header(&headless, "content-type"), + header(&body, "content-type"), + "{path}" + ); + assert_eq!(header(&headless, "etag"), header(&body, "etag"), "{path}"); + assert_eq!( + header(&headless, "content-length").map(str::to_owned), + Some(body.body.len().to_string()), + "{path}" + ); + } +} + +/// A 405 states what the resource does allow. +#[tokio::test] +async fn states_what_a_refused_method_could_have_used() { + for (method, path, allow) in [ + ("DELETE", "/odp/offerings", "GET, HEAD"), + ("PUT", "/odp/offerings/plant-1", "GET, HEAD"), + ("PUT", "/odp/offerings/search", "POST, GET, HEAD"), + ("POST", "/.well-known/odp", "GET, HEAD"), + ] { + let mut request = get(path); + request.method = method.to_owned(); + let response = service(Stub::serving(1)).handle(request).await; + + assert_eq!(response.status, 405, "{method} {path}"); + assert_eq!(header(&response, "allow"), Some(allow), "{method} {path}"); + } +} + +// -- query parameters ----------------------------------------------------------------------- + +/// SVC-73: a repeated `representation` describes two requests, and neither is answered. +#[tokio::test] +async fn refuses_a_repeated_single_valued_parameter() { + for value in [ + "representation=terse&representation=full", + "representation=full&representation=full", + "limit=1&limit=2", + "cursor=a&cursor=b", + ] { + let response = service(Stub::serving(1)) + .handle(query("/odp/offerings", value)) + .await; + assert_eq!(response.status, 400, "{value}"); + assert_eq!( + parse_problem_details(&response.body).unwrap().code, + "INVALID_REQUEST" + ); + } +} + +/// SVC-73: and an unsupported value is refused the same way. +#[tokio::test] +async fn refuses_an_unsupported_representation() { + let response = service(Stub::serving(1)) + .handle(query("/odp/offerings", "representation=summary")) + .await; + assert_eq!(response.status, 400); +} + +#[tokio::test] +async fn refuses_a_limit_it_cannot_serve() { + for value in ["limit=0", "limit=101", "limit=-1", "limit=many", "limit="] { + let response = service(Stub::serving(1)) + .handle(query("/odp/offerings", value)) + .await; + assert_eq!(response.status, 400, "{value}"); + } +} + +#[tokio::test] +async fn passes_the_request_through_to_the_catalog() { + let stub = Stub::serving(1); + service(stub.clone()) + .handle(query( + "/odp/offerings", + "cursor=abc&limit=7&representation=full", + )) + .await; + + let request = stub.last(); + assert_eq!(request.cursor.as_deref(), Some("abc")); + assert_eq!(request.limit, 7); + assert_eq!(request.representation, odp_core::Representation::Full); + assert_eq!(request.path, "/odp/offerings"); +} + +// -- problem details ------------------------------------------------------------------------ + +/// ERR-02, ERR-03 and ERR-07: a problem describes itself consistently. +#[tokio::test] +async fn describes_every_failure_as_problem_details() { + for (request, status, code) in [ + (get("/odp/nowhere"), 404, "NOT_FOUND"), + (get("/odp/offerings/not a valid id"), 400, "INVALID_REQUEST"), + (get("/odp/offerings/absent"), 404, "NOT_FOUND"), + ] { + let response = service(Stub::serving(1)).handle(request).await; + let problem = parse_problem_details(&response.body).unwrap(); + + assert_eq!(response.status, status); + assert_eq!(problem.status, status); + assert_eq!(problem.code, code); + assert_eq!( + problem.problem_type, + format!( + "https://offeringprotocol.org/problems/{}", + code.to_ascii_lowercase().replace('_', "-") + ) + ); + assert_eq!(header(&response, "content-type"), Some(PROBLEM_MEDIA_TYPE)); + } +} + +/// ERR-04: a Problem Details object that breaks its own limits describes nothing, so a long +/// failure is cut to fit rather than serialized whole. +#[tokio::test] +async fn keeps_a_long_failure_within_the_problem_limits() { + let response = service(Stub::new(Answer::LongFailure(5_000))) + .handle(get("/odp/offerings")) + .await; + + assert_eq!(response.status, 500); + let problem = parse_problem_details(&response.body).expect("a valid Problem Details object"); + assert!(problem.title.chars().count() <= 128); + assert!(problem.detail.chars().count() <= 2_048); + assert!(problem.title.ends_with('…')); +} + +/// ERR-32: a 429 says when to come back, whether or not the catalog named an interval. +#[tokio::test] +async fn names_a_retry_interval_where_one_is_required() { + for (status, seconds, expected) in [ + (429_u16, None, "1"), + (429, Some(30), "30"), + (503, None, "1"), + (503, Some(120), "120"), + ] { + let response = service(Stub::new(Answer::Fail(status, "UNAVAILABLE", seconds))) + .handle(get("/odp/offerings")) + .await; + + assert_eq!(response.status, status); + assert_eq!(header(&response, "retry-after"), Some(expected), "{status}"); + } +} + +/// A failure that is nobody's fault to wait on carries no interval. +#[tokio::test] +async fn names_no_retry_interval_where_none_applies() { + let response = service(Stub::new(Answer::Fail(404, "NOT_FOUND", None))) + .handle(get("/odp/offerings")) + .await; + + assert_eq!(response.status, 404); + assert_eq!(header(&response, "retry-after"), None); +} + +/// A catalog may still name one on another status when it knows something useful. +#[tokio::test] +async fn carries_an_interval_a_catalog_names_on_any_status() { + let response = service(Stub::new(Answer::Fail(409, "CONFLICT", Some(5)))) + .handle(get("/odp/offerings")) + .await; + assert_eq!(header(&response, "retry-after"), Some("5")); +} + +// -- routing -------------------------------------------------------------------------------- + +/// ROLE-04: a Service answers only for operations it advertises. +#[tokio::test] +async fn refuses_an_operation_it_does_not_advertise() { + use odp_core::Operation; + + let stub = Stub::with_operations( + Answer::Items(1, String::new()), + vec![Operation::GetOffering, Operation::ListOfferings], + ); + let service = odp_service::Service::new(support::document(&["en"]), stub).unwrap(); + + for request in [ + get("/odp/collections"), + get("/odp/collections/plants"), + get("/odp/collections/plants/offerings"), + post("/odp/offerings/search", SEARCH), + post("/odp/collections/search", COLLECTION_SEARCH), + ] { + let path = request.path.clone(); + let response = service.handle(request).await; + assert_eq!(response.status, 404, "{path}"); + } +} + +#[tokio::test] +async fn answers_nothing_outside_its_endpoint_base() { + for path in ["/offerings", "/other/offerings", "/", "/odp"] { + let response = service(Stub::serving(1)).handle(get(path)).await; + assert_eq!(response.status, 404, "{path}"); + } +} + +/// The Service Document is served from the well-known path whatever the endpoint base is. +#[tokio::test] +async fn serves_the_service_document_from_the_well_known_path() { + let response = service(Stub::serving(1)) + .handle(get("/.well-known/odp")) + .await; + let document = odp_core::parse_service_document(&response.body).unwrap(); + + assert_eq!(document.name, "Plants"); + assert_eq!(document.http.endpoint_base, "/odp"); +} + +#[tokio::test] +async fn reports_a_body_it_cannot_read_as_the_agent_s_mistake() { + let response = service(Stub::serving(1)) + .handle(post("/odp/offerings/search", b"not json")) + .await; + + assert_eq!(response.status, 400); + assert_eq!( + parse_problem_details(&response.body).unwrap().code, + "INVALID_REQUEST" + ); +} + +#[tokio::test] +async fn serves_a_collection_search() { + let response = service(Stub::serving(2)) + .handle(post("/odp/collections/search", COLLECTION_SEARCH)) + .await; + assert_eq!(response.status, 200); + assert_eq!( + odp_core::parse_page::(&response.body) + .unwrap() + .items + .len(), + 2 + ); +} + +#[tokio::test] +async fn serves_a_request_with_no_accept_at_all() { + let response = service(Stub::serving(1)) + .handle(Request { + method: "GET".to_owned(), + path: "/.well-known/odp".to_owned(), + ..Request::default() + }) + .await; + assert_eq!(response.status, 200); +} + +/// A parameter with no value at all is not a quality value. +#[tokio::test] +async fn reads_a_media_range_whose_parameters_carry_no_value() { + let response = service(Stub::serving(1)) + .handle(with_header( + get("/.well-known/odp"), + "accept", + "application/odp+json;charset;q=0.8", + )) + .await; + assert_eq!(response.status, 200); +} + +/// A language range that is nothing but an extension prefix matches nothing, and says so +/// by falling back rather than looping. +#[tokio::test] +async fn falls_back_from_a_range_that_is_only_a_prefix() { + for accept in ["a-b", "x-private", "i-default"] { + let response = service(Stub::serving(1)) + .handle(with_header( + get("/.well-known/odp"), + "accept-language", + accept, + )) + .await; + assert_eq!(response.status, 200, "{accept}"); + assert_eq!( + header(&response, "content-language"), + Some("en"), + "{accept}" + ); + } +} + +/// ERR-19: a Service produces documents within the applicable limits, or serves none. +#[tokio::test] +async fn refuses_to_publish_a_response_past_its_limit() { + use odp_core::{AdditionalMembers, Offering}; + + let mut offering = support::offering("plant-1"); + offering.additional = AdditionalMembers::from([( + "padding".to_owned(), + serde_json::Value::String("x".repeat(600_000)), + )]); + let page = support::offering_page(vec![offering.clone()], ""); + + struct Huge(odp_core::OfferingPage); + + #[async_trait::async_trait] + impl odp_service::Catalog for Huge { + fn operations(&self) -> Vec { + support::ALL_OPERATIONS.to_vec() + } + + async fn list_offerings( + &self, + _request: odp_service::CatalogRequest, + ) -> Result, odp_service::ServiceError> { + Ok(self.0.clone()) + } + + async fn get_offering( + &self, + _id: &str, + _request: odp_service::CatalogRequest, + ) -> Result, odp_service::ServiceError> { + Ok(None) + } + } + + let service = + odp_service::Service::new(support::document(&["en"]), std::sync::Arc::new(Huge(page))) + .unwrap(); + let response = service.handle(get("/odp/offerings")).await; + assert_eq!(response.status, 500); +} diff --git a/crates/odp-service/tests/support/mod.rs b/crates/odp-service/tests/support/mod.rs new file mode 100644 index 0000000..bba531b --- /dev/null +++ b/crates/odp-service/tests/support/mod.rs @@ -0,0 +1,322 @@ +//! The fixtures the Service conformance tests are written against. +//! +//! Each test binary compiles this module separately, so not every binary uses every fixture. +#![allow(dead_code)] + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use odp_core::{ + AdditionalMembers, Collection, CollectionSearchRequest, Offering, OfferingPage, + OfferingSearchRequest, Operation, Page, Representation, ServiceDocument, VERSION, + parse_collection, parse_offering, parse_service_document, +}; +use odp_service::{Catalog, CatalogRequest, MEDIA_TYPE, Request, Response, Service, ServiceError}; + +pub const ENDPOINT_BASE: &str = "/odp"; + +/// Every operation this crate can route, so a test can choose what to advertise. +pub const ALL_OPERATIONS: &[Operation] = &[ + Operation::GetCollection, + Operation::GetOffering, + Operation::ListCollectionOfferings, + Operation::ListCollections, + Operation::ListOfferings, + Operation::SearchCollections, + Operation::SearchOfferings, +]; + +pub fn offering(id: &str) -> Offering { + parse_offering(format!(r#"{{"id":"{id}","name":"Plant {id}","odp_version":"1.0"}}"#).as_bytes()) + .unwrap() +} + +pub fn collection(id: &str) -> Collection { + parse_collection( + format!(r#"{{"id":"{id}","name":"Collection {id}","odp_version":"1.0"}}"#).as_bytes(), + ) + .unwrap() +} + +pub fn offering_page(items: Vec, next: &str) -> OfferingPage { + OfferingPage { + additional: AdditionalMembers::new(), + auth_expands: false, + items, + next: next.to_owned(), + odp_version: VERSION.to_owned(), + refinements: Vec::new(), + } +} + +pub fn collection_page(items: Vec, next: &str) -> Page { + Page { + additional: AdditionalMembers::new(), + auth_expands: false, + items, + next: next.to_owned(), + odp_version: VERSION.to_owned(), + } +} + +/// What a [`Stub`] should do when the catalog is asked for something. +pub enum Answer { + /// Serve this many Offerings or Collections, with the given continuation. + Items(usize, String), + /// Fail the way a Service under load would. + Fail(u16, &'static str, Option), + /// Fail with a message of this many characters. + LongFailure(usize), +} + +/// A catalog whose answers a test chooses, and which records what it was asked. +pub struct Stub { + answer: Answer, + operations: Vec, + requests: Mutex>, +} + +impl Stub { + pub fn new(answer: Answer) -> Arc { + Self::with_operations(answer, ALL_OPERATIONS.to_vec()) + } + + pub fn with_operations(answer: Answer, operations: Vec) -> Arc { + Arc::new(Self { + answer, + operations, + requests: Mutex::new(Vec::new()), + }) + } + + /// A catalog that simply serves `items` Offerings and Collections. + pub fn serving(items: usize) -> Arc { + Self::new(Answer::Items(items, String::new())) + } + + pub fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + pub fn last(&self) -> CatalogRequest { + self.requests().last().cloned().expect("a catalog request") + } + + fn record(&self, request: CatalogRequest) { + self.requests.lock().unwrap().push(request); + } + + fn offerings(&self, request: CatalogRequest) -> Result, ServiceError> { + self.record(request); + match &self.answer { + Answer::Items(count, next) => Ok(offering_page( + (0..*count) + .map(|index| offering(&format!("p{index}"))) + .collect(), + next, + )), + answer => Err(failure(answer)), + } + } + + fn collections(&self, request: CatalogRequest) -> Result, ServiceError> { + self.record(request); + match &self.answer { + Answer::Items(count, next) => Ok(collection_page( + (0..*count) + .map(|index| collection(&format!("c{index}"))) + .collect(), + next, + )), + answer => Err(failure(answer)), + } + } +} + +fn failure(answer: &Answer) -> ServiceError { + match answer { + Answer::Fail(status, code, Some(seconds)) => { + ServiceError::retry_after(*status, code, "Come back later", *seconds) + } + Answer::Fail(status, code, None) => ServiceError::request(*status, code, "Not right now"), + Answer::LongFailure(length) => ServiceError::Catalog("x".repeat(*length)), + Answer::Items(..) => unreachable!("an items answer is not a failure"), + } +} + +#[async_trait] +impl Catalog for Stub { + async fn continue_offering_search( + &self, + request: CatalogRequest, + ) -> Result, ServiceError> { + self.offerings(request) + } + + async fn continue_collection_search( + &self, + request: CatalogRequest, + ) -> Result, ServiceError> { + self.collections(request) + } + fn operations(&self) -> Vec { + self.operations.clone() + } + + async fn list_offerings( + &self, + request: CatalogRequest, + ) -> Result, ServiceError> { + self.offerings(request) + } + + async fn get_offering( + &self, + id: &str, + request: CatalogRequest, + ) -> Result, ServiceError> { + let representation = request.representation; + self.record(request); + if let Answer::Items(..) = self.answer { + let mut value = offering(id); + if representation == Representation::Full { + value.description = "A healthy plant".to_owned(); + } + return Ok((id != "absent").then_some(value)); + } + Err(failure(&self.answer)) + } + + async fn search_offerings( + &self, + _query: OfferingSearchRequest, + request: CatalogRequest, + ) -> Result, ServiceError> { + self.offerings(request) + } + + async fn list_collections( + &self, + request: CatalogRequest, + ) -> Result, ServiceError> { + self.collections(request) + } + + async fn get_collection( + &self, + id: &str, + request: CatalogRequest, + ) -> Result, ServiceError> { + self.record(request); + if let Answer::Items(..) = self.answer { + return Ok((id != "absent").then(|| collection(id))); + } + Err(failure(&self.answer)) + } + + async fn search_collections( + &self, + _query: CollectionSearchRequest, + request: CatalogRequest, + ) -> Result, ServiceError> { + self.collections(request) + } + + async fn list_collection_offerings( + &self, + _collection_id: &str, + request: CatalogRequest, + ) -> Result, ServiceError> { + self.offerings(request) + } +} + +/// A Service Document advertising every operation, in the given localizations. +pub fn document(localizations: &[&str]) -> ServiceDocument { + let operations = ALL_OPERATIONS + .iter() + .map(|operation| { + format!( + r#"{{"authentication":"not-required","name":"{}"}}"#, + operation_name(*operation) + ) + }) + .collect::>() + .join(","); + let tags = localizations + .iter() + .map(|value| format!(r#""{value}""#)) + .collect::>() + .join(","); + parse_service_document( + format!( + r#"{{"description":"Plants for agents.","http":{{"endpoint_base":"{ENDPOINT_BASE}"}},"language":"{}","localizations":[{tags}],"name":"Plants","odp_version":"1.0","operations":[{operations}]}}"#, + localizations[0] + ) + .as_bytes(), + ) + .unwrap() +} + +pub const fn operation_name(operation: Operation) -> &'static str { + match operation { + Operation::GetCollection => "get-collection", + Operation::GetOffering => "get-offering", + Operation::ListCollectionOfferings => "list-collection-offerings", + Operation::ListCollections => "list-collections", + Operation::ListOfferings => "list-offerings", + Operation::SearchCollections => "search-collections", + Operation::SearchOfferings => "search-offerings", + } +} + +/// A Service over the given catalog, advertising English only. +pub fn service(catalog: Arc) -> Service { + Service::new(document(&["en"]), catalog).unwrap() +} + +pub fn localized(catalog: Arc, localizations: &[&str]) -> Service { + Service::new(document(localizations), catalog).unwrap() +} + +pub fn get(path: &str) -> Request { + Request { + headers: BTreeMap::from([("accept".to_owned(), MEDIA_TYPE.to_owned())]), + method: "GET".to_owned(), + path: path.to_owned(), + ..Request::default() + } +} + +pub fn query(path: &str, query: &str) -> Request { + Request { + query: query.to_owned(), + ..get(path) + } +} + +pub fn post(path: &str, body: &[u8]) -> Request { + Request { + body: body.to_vec(), + headers: BTreeMap::from([ + ("accept".to_owned(), MEDIA_TYPE.to_owned()), + ("content-type".to_owned(), MEDIA_TYPE.to_owned()), + ]), + method: "POST".to_owned(), + path: path.to_owned(), + ..Request::default() + } +} + +pub fn with_header(request: Request, name: &str, value: &str) -> Request { + let mut request = request; + request.headers.insert(name.to_owned(), value.to_owned()); + request +} + +pub fn header<'a>(response: &'a Response, name: &str) -> Option<&'a str> { + response.headers.get(name).map(String::as_str) +} diff --git a/crates/odp-service/tests/variant_conformance.rs b/crates/odp-service/tests/variant_conformance.rs new file mode 100644 index 0000000..3d81a93 --- /dev/null +++ b/crates/odp-service/tests/variant_conformance.rs @@ -0,0 +1,295 @@ +//! SVC-58/59/60/61 and PAG-31: which variant a Service serves, and how an Agent revalidates it. + +mod support; + +use support::{Stub, get, header, localized, service, with_header}; + +/// The localizations a multilingual Service in these tests publishes. +const TAGS: &[&str] = &["en", "en-GB", "fr", "de-CH", "zh-Hant"]; + +/// SVC-58: RFC 4647 Lookup, which walks a range down its own subtags and never sideways. +#[tokio::test] +async fn selects_a_language_by_lookup() { + for (accept, expected) in [ + ("fr", "fr"), + ("FR", "fr"), + ("en-GB", "en-GB"), + // Lookup truncates: en-GB-oed has no match, en-GB does. + ("en-GB-oed", "en-GB"), + // de-CH-1901 falls back to de-CH, never sideways to another de-* tag. + ("de-CH-1901", "de-CH"), + ("zh-Hant-TW", "zh-Hant"), + // A range with no match at all falls back to the default representation. + ("de-DE", "en"), + ("ja", "en"), + ("*", "en"), + ] { + let response = localized(Stub::serving(1), TAGS) + .handle(with_header( + get("/.well-known/odp"), + "accept-language", + accept, + )) + .await; + + assert_eq!(response.status, 200, "{accept}"); + assert_eq!( + header(&response, "content-language"), + Some(expected), + "{accept}" + ); + } +} + +/// Weight orders the ranges, and a range weighted zero is not wanted at all. +#[tokio::test] +async fn honours_the_order_the_agent_asked_in() { + for (accept, expected) in [ + ("fr;q=0.5, de-CH;q=0.9", "de-CH"), + ("de-CH;q=0.1, fr", "fr"), + ("fr, de-CH", "fr"), + ("fr;q=0, de-CH", "de-CH"), + // Every range refused leaves only the default. + ("fr;q=0, de-CH;q=0", "en"), + ] { + let response = localized(Stub::serving(1), TAGS) + .handle(with_header( + get("/.well-known/odp"), + "accept-language", + accept, + )) + .await; + assert_eq!( + header(&response, "content-language"), + Some(expected), + "{accept}" + ); + } +} + +/// SVC-59: nothing matching is not a reason to refuse the request. +#[tokio::test] +async fn never_refuses_a_request_over_language() { + let response = localized(Stub::serving(1), TAGS) + .handle(with_header( + get("/odp/offerings"), + "accept-language", + "ja, ko", + )) + .await; + + assert_eq!(response.status, 200); + assert_eq!(header(&response, "content-language"), Some("en")); +} + +/// SVC-60: a localized response says which variant it is and what the choice depended on. +#[tokio::test] +async fn describes_the_variant_it_served() { + for path in [ + "/.well-known/odp", + "/odp/offerings", + "/odp/offerings/plant-1", + "/odp/collections", + "/odp/collections/plants", + "/odp/collections/plants/offerings", + ] { + let response = localized(Stub::serving(1), TAGS) + .handle(with_header(get(path), "accept-language", "fr")) + .await; + + assert_eq!(header(&response, "content-language"), Some("fr"), "{path}"); + assert_eq!(header(&response, "vary"), Some("Accept-Language"), "{path}"); + } +} + +/// The catalog is told which variant to answer in, not left to parse the field itself. +#[tokio::test] +async fn tells_the_catalog_which_variant_to_answer_in() { + let stub = Stub::serving(1); + localized(stub.clone(), TAGS) + .handle(with_header( + get("/odp/offerings"), + "accept-language", + "de-CH-1901, fr", + )) + .await; + + let request = stub.last(); + assert_eq!(request.language, "de-CH"); + assert_eq!(request.accept_language.as_deref(), Some("de-CH-1901, fr")); +} + +/// With no field at all the Service serves its own language. +#[tokio::test] +async fn serves_its_own_language_when_none_was_asked_for() { + let response = localized(Stub::serving(1), TAGS) + .handle(get("/.well-known/odp")) + .await; + assert_eq!(header(&response, "content-language"), Some("en")); +} + +// -- entity tags ---------------------------------------------------------------------------- + +/// SVC-61: every representation carries a validator. +#[tokio::test] +async fn tags_every_representation_it_serves() { + for path in [ + "/.well-known/odp", + "/odp/offerings", + "/odp/offerings/plant-1", + "/odp/collections", + "/odp/collections/plants", + "/odp/collections/plants/offerings", + ] { + let response = service(Stub::serving(1)).handle(get(path)).await; + let etag = header(&response, "etag").unwrap_or_default(); + + assert!( + etag.starts_with('"') && etag.ends_with('"'), + "{path}: {etag}" + ); + assert!(etag.len() > 2, "{path}"); + } +} + +/// SVC-61: a tag distinguishes variants, so two languages never share one. +#[tokio::test] +async fn gives_each_variant_a_tag_of_its_own() { + let service = localized(Stub::serving(1), TAGS); + let english = service + .handle(with_header( + get("/.well-known/odp"), + "accept-language", + "en", + )) + .await; + let french = service + .handle(with_header( + get("/.well-known/odp"), + "accept-language", + "fr", + )) + .await; + + assert_ne!(header(&english, "etag"), header(&french, "etag")); +} + +/// The same representation asked for twice carries the same tag. +#[tokio::test] +async fn gives_one_representation_one_tag() { + let service = service(Stub::serving(3)); + let first = service.handle(get("/odp/offerings")).await; + let second = service.handle(get("/odp/offerings")).await; + + assert_eq!(header(&first, "etag"), header(&second, "etag")); +} + +/// Two different representations of one resource are two variants. +#[tokio::test] +async fn distinguishes_terse_from_full() { + let service = service(Stub::serving(1)); + let terse = service + .handle(support::query( + "/odp/offerings/plant-1", + "representation=terse", + )) + .await; + let full = service + .handle(support::query( + "/odp/offerings/plant-1", + "representation=full", + )) + .await; + + assert_ne!(header(&terse, "etag"), header(&full, "etag")); +} + +/// PAG-31: a validator that still matches means the Agent already holds this representation. +#[tokio::test] +async fn answers_a_conditional_request_that_still_matches() { + let service = service(Stub::serving(2)); + let first = service.handle(get("/odp/offerings")).await; + let etag = header(&first, "etag").unwrap().to_owned(); + + let second = service + .handle(with_header(get("/odp/offerings"), "if-none-match", &etag)) + .await; + + assert_eq!(second.status, 304); + assert!(second.body.is_empty()); + assert_eq!(header(&second, "etag"), Some(etag.as_str())); + assert_eq!(header(&second, "content-type"), None); +} + +/// A field listing several tags matches when any of them is this one, weakly compared. +#[tokio::test] +async fn reads_every_form_a_conditional_field_takes() { + let service = service(Stub::serving(2)); + let etag = header(&service.handle(get("/odp/offerings")).await, "etag") + .unwrap() + .to_owned(); + let weak = format!("W/{etag}"); + let listed = format!("\"other\", {etag}"); + + for value in [etag.as_str(), weak.as_str(), listed.as_str(), "*"] { + let response = service + .handle(with_header(get("/odp/offerings"), "if-none-match", value)) + .await; + assert_eq!(response.status, 304, "{value}"); + } +} + +/// A validator for something else is no reason to withhold the representation. +#[tokio::test] +async fn serves_a_conditional_request_that_no_longer_matches() { + let response = service(Stub::serving(2)) + .handle(with_header( + get("/odp/offerings"), + "if-none-match", + "\"something-else\"", + )) + .await; + + assert_eq!(response.status, 200); + assert!(!response.body.is_empty()); +} + +/// A conditional HEAD is answered like a conditional GET. +#[tokio::test] +async fn answers_a_conditional_head() { + let service = service(Stub::serving(2)); + let etag = header(&service.handle(get("/odp/offerings")).await, "etag") + .unwrap() + .to_owned(); + + let mut request = with_header(get("/odp/offerings"), "if-none-match", &etag); + request.method = "HEAD".to_owned(); + let response = service.handle(request).await; + + assert_eq!(response.status, 304); + assert!(response.body.is_empty()); +} + +/// A conditional request that fails for another reason is not turned into a 304. +#[tokio::test] +async fn does_not_confuse_a_failure_with_an_unchanged_representation() { + let response = service(Stub::serving(1)) + .handle(with_header( + get("/odp/offerings/absent"), + "if-none-match", + "*", + )) + .await; + assert_eq!(response.status, 404); +} + +/// A Service advertising one language still describes the variant it serves. +#[tokio::test] +async fn describes_the_variant_of_a_monolingual_service() { + let response = service(Stub::serving(1)) + .handle(get("/.well-known/odp")) + .await; + + assert_eq!(header(&response, "content-language"), Some("en")); + assert_eq!(header(&response, "vary"), Some("Accept-Language")); +} diff --git a/tools/odp-conformance/src/bin/odp_conformance_adapter.rs b/tools/odp-conformance/src/bin/odp_conformance_adapter.rs index 50f98f5..062c74a 100644 --- a/tools/odp-conformance/src/bin/odp_conformance_adapter.rs +++ b/tools/odp-conformance/src/bin/odp_conformance_adapter.rs @@ -97,6 +97,18 @@ async fn evaluate_case( ) -> Result, String> { let valid = field::(case, "valid").unwrap_or(false); match subject { + "protocol-version" => { + let received: String = required(case, "received")?; + let compatible: bool = required(case, "compatible")?; + let document = serde_json::json!({"odp_version": received,"name":"Conformance","description":"Conformance","language":"en","localizations":["en"],"http":{"endpoint_base":"/odp"},"operations":[{"name":"list-offerings","authentication":"not-required"},{"name":"get-offering","authentication":"not-required"}]}); + Ok(Some( + parse_service_document( + &serde_json::to_vec(&document).map_err(|error| error.to_string())?, + ) + .is_ok() + == compatible, + )) + } "local-identifier" => { let value: String = required(case, "value")?; Ok(Some(is_local_resource_identifier(&value) == valid)) @@ -145,7 +157,7 @@ async fn evaluate_case( parse_result(case, "sort", parse_sort_definition) } "filter-sort-contract" => Ok(None), - "pagination-contract" => evaluate_pagination(case), + "pagination-contract" => evaluate_pagination(case).await, "errors-limits-contract" => evaluate_errors_and_limits(case).await, "role-baseline" => evaluate_baseline(case, role), _ => Ok(None), @@ -451,13 +463,25 @@ async fn attribute_schema_details( Ok((details, supporting_transport.calls.load(Ordering::Relaxed))) } -fn evaluate_pagination(case: &BTreeMap) -> Result, String> { +async fn evaluate_pagination(case: &BTreeMap) -> Result, String> { let valid = field::(case, "valid").unwrap_or(false); match operation(case).as_str() { "validate-page" => parse_result(case, "page", parse_page::), "validate-limit" => { let limit: usize = required(case, "limit")?; - Ok(Some((1..=100).contains(&limit) == valid)) + let service = + odp_service::ServiceBuilder::new("Conformance", "Conformance", "en", "/odp") + .build(Arc::new(ConformanceCatalog)) + .map_err(|error| error.to_string())?; + let response = service + .handle(Request { + method: "GET".to_owned(), + path: "/odp/offerings".to_owned(), + query: format!("limit={limit}"), + ..Request::default() + }) + .await; + Ok(Some((response.status == 200) == valid)) } "validate-next" => { let next: String = required(case, "next")?; diff --git a/tools/odp-conformance/src/bin/odp_node_interop.rs b/tools/odp-conformance/src/bin/odp_node_interop.rs index de8d677..257b55c 100644 --- a/tools/odp-conformance/src/bin/odp_node_interop.rs +++ b/tools/odp-conformance/src/bin/odp_node_interop.rs @@ -6,7 +6,7 @@ async fn main() -> Result<(), Box> { let service_url = std::env::args() .nth(1) .ok_or("usage: odp-node-interop SERVICE_URL")?; - let client = ServiceClient::new(&service_url)?; + let client = ServiceClient::for_local_development(&service_url)?; let inspection = client.inspect().await?; if inspection.document.name != "Small Example Store" { return Err(format!("unexpected Service {:?}", inspection.document.name).into());