diff --git a/crates/cli/src/meta/types/dotrain/order_builder_state_v1.rs b/crates/cli/src/meta/types/dotrain/order_builder_state_v1.rs index ce968aa5..37acdbd2 100644 --- a/crates/cli/src/meta/types/dotrain/order_builder_state_v1.rs +++ b/crates/cli/src/meta/types/dotrain/order_builder_state_v1.rs @@ -402,4 +402,37 @@ mod tests { _ => panic!("Expected SerdeCborError"), } } + + #[test] + fn test_extract_from_meta_nested_rain_document() { + // A decoded item whose magic is RainMetaDocumentV1 carries a complete + // prefixed document as payload; extract_from_meta must recurse into + // it and surface the instance found inside. + let original_instance = create_test_instance(); + let inner_item: RainMetaDocumentV1Item = original_instance.clone().try_into().unwrap(); + let inner_doc_bytes = RainMetaDocumentV1Item::cbor_encode_seq( + &vec![inner_item], + KnownMagic::RainMetaDocumentV1, + ) + .unwrap(); + + let outer_item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(inner_doc_bytes), + magic: KnownMagic::RainMetaDocumentV1, + content_type: ContentType::OctetStream, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + schema: None, + }; + let outer_bytes = RainMetaDocumentV1Item::cbor_encode_seq( + &vec![outer_item], + KnownMagic::RainMetaDocumentV1, + ) + .unwrap(); + + let extracted = OrderBuilderStateV1::extract_from_meta(&outer_bytes) + .unwrap() + .unwrap(); + assert_eq!(extracted, original_instance); + } } diff --git a/crates/cli/src/meta/types/dotrain/source_v1.rs b/crates/cli/src/meta/types/dotrain/source_v1.rs index 24283232..7e0cce6d 100644 --- a/crates/cli/src/meta/types/dotrain/source_v1.rs +++ b/crates/cli/src/meta/types/dotrain/source_v1.rs @@ -447,4 +447,95 @@ mod tests { let _ = DotrainSourceV1::fetch_by_subject(subject, mock_url).await; mock.assert(); } + + #[test] + fn test_hash_known_keccak256_vectors() { + // Vectors derived from the Keccak-256 reference values (independent + // of this implementation): keccak256("") and keccak256("hello world"). + // Pins hash() to keccak256 over the exact utf8 bytes of the source. + assert_eq!( + DotrainSourceV1(String::new()).hash(), + "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + .parse::() + .unwrap() + ); + assert_eq!( + DotrainSourceV1("hello world".to_string()).hash(), + "0x47173285a8d7341e5e972fc677286384f802f8ef42a5ec5f03bbfa254cb01fad" + .parse::() + .unwrap() + ); + } + + #[tokio::test] + async fn test_fetch_by_subject_takes_first_decoded_item() { + use httpmock::prelude::*; + let server = MockServer::start(); + let mock_url = Url::parse(&server.url("/")).unwrap(); + let subject = [0x42; 32]; + + // One meta blob that cbor-decodes to TWO dotrain items: the first + // one must win. + let first: RainMetaDocumentV1Item = DotrainSourceV1("first".to_string()).into(); + let second: RainMetaDocumentV1Item = DotrainSourceV1("second".to_string()).into(); + let cbor_bytes = RainMetaDocumentV1Item::cbor_encode_seq( + &vec![first, second], + KnownMagic::RainMetaDocumentV1, + ) + .unwrap(); + let cbor_hex = hex::encode(&cbor_bytes); + + let mock = server.mock(|when, then| { + when.method(POST).path("/").body_contains("subject"); + then.status(200) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "data": { + "metaV1S": [ + { + "meta": format!("0x{}", cbor_hex), + "metaHash": "0x1234567890abcdef", + "sender": "0x1234567890123456789012345678901234567890", + "id": "0x123", + "metaBoard": { + "address": "0x1234567890123456789012345678901234567890" + }, + "subject": hex::encode(subject) + } + ] + } + })); + }); + + let result = DotrainSourceV1::fetch_by_subject(subject, mock_url) + .await + .unwrap() + .unwrap(); + assert_eq!(result.0, "first"); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_by_subject_propagates_non_empty_client_errors() { + use httpmock::prelude::*; + let server = MockServer::start(); + let mock_url = Url::parse(&server.url("/")).unwrap(); + + // An HTTP-level failure is not "no meta found": it must surface as + // Err(MetaboardSubgraphClientError), never Ok(None). + let mock = server.mock(|when, then| { + when.method(POST).path("/"); + then.status(500); + }); + + let result = DotrainSourceV1::fetch_by_subject([0x42; 32], mock_url).await; + match result { + Err(Error::MetaboardSubgraphClientError(_)) => {} + other => panic!( + "Expected Err(MetaboardSubgraphClientError), got {:?}", + other + ), + } + mock.assert(); + } } diff --git a/crates/cli/src/meta/types/interpreter_caller/v1.rs b/crates/cli/src/meta/types/interpreter_caller/v1.rs index aa228bb6..8899cca0 100644 --- a/crates/cli/src/meta/types/interpreter_caller/v1.rs +++ b/crates/cli/src/meta/types/interpreter_caller/v1.rs @@ -159,3 +159,117 @@ pub struct ContextCell { #[validate] pub alias: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn base_json() -> serde_json::Value { + json!({ + "name": "Test Caller", + "abiName": "TestCaller", + "methods": [{ + "name": "Add Order", + "abiName": "addOrder", + "inputs": [{ + "name": "Config", + "abiName": "config", + "path": "[7].inputs[0]" + }], + "expressions": [{ + "name": "Calculate", + "abiName": "calculateIO", + "path": "[7].expressions[0]", + "contextColumns": [{ + "name": "Base", + "cells": [{ "name": "Cell" }] + }] + }] + }] + }) + } + + fn try_parse(value: &serde_json::Value) -> Result { + InterpreterCallerMeta::try_from(serde_json::to_vec(value).unwrap()) + } + + #[test] + fn test_base_fixture_is_valid() { + let meta = try_parse(&base_json()).unwrap(); + assert_eq!(meta.name.value, "Test Caller"); + assert_eq!(meta.abi_name.value, "TestCaller"); + assert_eq!(meta.methods.len(), 1); + assert_eq!(meta.methods[0].inputs.len(), 1); + assert_eq!(meta.methods[0].expressions[0].context_columns.len(), 1); + } + + #[test] + fn test_deny_unknown_fields_top_level() { + let mut v = base_json(); + v["unknownField"] = json!(1); + assert!(matches!(try_parse(&v), Err(Error::SerdeJsonError(_)))); + } + + #[test] + fn test_deny_unknown_fields_method() { + let mut v = base_json(); + v["methods"][0]["unknownField"] = json!(1); + assert!(matches!(try_parse(&v), Err(Error::SerdeJsonError(_)))); + } + + #[test] + fn test_deny_unknown_fields_method_input() { + let mut v = base_json(); + v["methods"][0]["inputs"][0]["unknownField"] = json!(1); + assert!(matches!(try_parse(&v), Err(Error::SerdeJsonError(_)))); + } + + #[test] + fn test_deny_unknown_fields_expression() { + let mut v = base_json(); + v["methods"][0]["expressions"][0]["unknownField"] = json!(1); + assert!(matches!(try_parse(&v), Err(Error::SerdeJsonError(_)))); + } + + #[test] + fn test_deny_unknown_fields_context_column() { + let mut v = base_json(); + v["methods"][0]["expressions"][0]["contextColumns"][0]["unknownField"] = json!(1); + assert!(matches!(try_parse(&v), Err(Error::SerdeJsonError(_)))); + } + + #[test] + fn test_deny_unknown_fields_context_cell() { + let mut v = base_json(); + v["methods"][0]["expressions"][0]["contextColumns"][0]["cells"][0]["unknownField"] = + json!(1); + assert!(matches!(try_parse(&v), Err(Error::SerdeJsonError(_)))); + } + + #[test] + fn test_methods_min_length_one() { + let mut v = base_json(); + v["methods"] = json!([]); + assert!(matches!(try_parse(&v), Err(Error::ValidationErrors(_)))); + } + + #[test] + fn test_method_inputs_min_length_one() { + let mut v = base_json(); + v["methods"][0]["inputs"] = json!([]); + assert!(matches!(try_parse(&v), Err(Error::ValidationErrors(_)))); + } + + #[test] + fn test_context_columns_max_255() { + let column = json!({ "name": "Base" }); + let mut v = base_json(); + v["methods"][0]["expressions"][0]["contextColumns"] = + serde_json::Value::Array(vec![column.clone(); 255]); + assert!(try_parse(&v).is_ok()); + v["methods"][0]["expressions"][0]["contextColumns"] = + serde_json::Value::Array(vec![column; 256]); + assert!(matches!(try_parse(&v), Err(Error::ValidationErrors(_)))); + } +} diff --git a/crates/cli/src/meta/types/op/v1.rs b/crates/cli/src/meta/types/op/v1.rs index 63017951..c262efee 100644 --- a/crates/cli/src/meta/types/op/v1.rs +++ b/crates/cli/src/meta/types/op/v1.rs @@ -258,3 +258,150 @@ pub struct OperandArg { #[validate] pub valid_range: Option>, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::meta::{ContentEncoding, ContentLanguage, ContentType, KnownMagic}; + + #[test] + fn test_bit_integer_bounds() { + // Operand is u16, so operand bits are zero-indexed 0..=15. + assert_eq!(MIN_BIT_INTEGER, 0); + assert_eq!(MAX_BIT_INTEGER, 15); + assert!(BitInteger { value: 0 }.validate().is_ok()); + assert!(BitInteger { value: 15 }.validate().is_ok()); + assert!(BitInteger { value: 16 }.validate().is_err()); + assert!(BitInteger { value: 255 }.validate().is_err()); + } + + #[test] + fn test_bit_integer_range_order() { + let range = |a, b| BitIntegerRange(BitInteger { value: a }, BitInteger { value: b }); + assert!(range(1, 2).validate().is_ok()); + assert!(range(3, 3).validate().is_ok()); + assert!(range(2, 1).validate().is_err()); + } + + // NOTE: no test asserts that BitIntegerRange rejects in-order ranges + // with out-of-bounds ends (e.g. (0, 16)): the hand-rolled + // ValidationErrors::merge_all call currently drops the per-end + // BitInteger results, so (0, 16) validates Ok today. Pinning either + // outcome is wrong while that is unresolved — see + // rainlanguage/rain.metadata#173. + + #[test] + fn test_operand_arg_range_exact_is_valid() { + assert!(OperandArgRange::Exact(Operand { value: 0 }) + .validate() + .is_ok()); + assert!(OperandArgRange::Exact(Operand { value: u16::MAX }) + .validate() + .is_ok()); + } + + #[test] + fn test_operand_arg_range_min_max() { + let range = |a, b| OperandArgRange::Range(Operand { value: a }, Operand { value: b }); + // Equal bounds are a valid (degenerate) range. + assert!(range(5, 5).validate().is_ok()); + assert!(range(2, 9).validate().is_ok()); + assert!(range(6, 5).validate().is_err()); + } + + #[test] + fn test_output_validation_accepts_well_formed() { + let good_range = || BitIntegerRange(BitInteger { value: 0 }, BitInteger { value: 3 }); + let computation = |s: &str| RainString { + value: s.to_string(), + }; + assert!(Output::Exact(Operand { value: 2 }).validate().is_ok()); + assert!(Output::Computed(good_range(), computation("bits * 2")) + .validate() + .is_ok()); + // NOTE: no rejection cases are asserted here. Output::validate + // currently drops its sub-validation results (merge_all misuse: + // the parent is a literal Ok and child errors are not Struct-kind + // under the "output" key), so a Computed output with an + // out-of-bounds range or non-ASCII computation validates Ok today. + // Pinning either outcome is wrong while that is unresolved — see + // rainlanguage/rain.metadata#173. + } + + #[test] + fn test_opmeta_minimal_json_defaults() { + // Only `name` is required; everything else defaults. + let meta = OpMeta::try_from(br#"{"name":"add"}"#.to_vec()).unwrap(); + assert_eq!(meta.name.value, "add"); + assert_eq!(meta.desc.value, ""); + assert!(meta.operand.is_empty()); + assert!(meta.inputs.is_empty()); + assert!(meta.outputs.is_empty()); + assert!(meta.aliases.is_empty()); + } + + #[test] + fn test_opmeta_try_from_validates() { + // Parses as JSON but must fail RainSymbol validation on `name`. + assert!(OpMeta::try_from(br#"{"name":"NOT-A-SYMBOL"}"#.to_vec()).is_err()); + } + + #[test] + fn test_opmeta_aliases_validated() { + assert!(OpMeta::try_from(br#"{"name":"add","aliases":["ok-alias"]}"#.to_vec()).is_ok()); + assert!(OpMeta::try_from(br#"{"name":"add","aliases":["BAD"]}"#.to_vec()).is_err()); + } + + #[test] + fn test_opmeta_input_bits_validated() { + assert!(OpMeta::try_from(br#"{"name":"add","inputs":[{"bits":[0,15]}]}"#.to_vec()).is_ok()); + // An out-of-order range fails BitIntegerRange's own order check, + // which must propagate through Input.bits' nested #[validate]. + // (The per-end bounds check, e.g. bits [0,16], is currently + // dropped by merge_all misuse — see rainlanguage/rain.metadata#173 — + // so only the order violation is pinned here.) + assert!( + OpMeta::try_from(br#"{"name":"add","inputs":[{"bits":[16,0]}]}"#.to_vec()).is_err() + ); + } + + #[test] + fn test_opmeta_input_computation_validated() { + assert!(OpMeta::try_from( + br#"{"name":"add","inputs":[{"computation":"bits + 1"}]}"#.to_vec() + ) + .is_ok()); + assert!(OpMeta::try_from( + "{\"name\":\"add\",\"inputs\":[{\"computation\":\"\u{2665}\"}]}" + .as_bytes() + .to_vec() + ) + .is_err()); + } + + #[test] + fn test_input_parameter_spread_defaults_false() { + let meta = OpMeta::try_from( + br#"{"name":"add","inputs":[{"parameters":[{"name":"lhs"}]}]}"#.to_vec(), + ) + .unwrap(); + assert!(!meta.inputs[0].parameters[0].spread); + } + + #[test] + fn test_opmeta_try_from_item_unpacks_content_encoding() { + // TryFrom must unpack() (honouring + // content_encoding), not read the raw payload. + let json = br#"{"name":"add"}"#.to_vec(); + let item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(ContentEncoding::Deflate.encode(&json)), + magic: KnownMagic::OpMetaV1, + content_type: ContentType::Json, + content_encoding: ContentEncoding::Deflate, + content_language: ContentLanguage::None, + schema: None, + }; + let meta = OpMeta::try_from(item).unwrap(); + assert_eq!(meta.name.value, "add"); + } +} diff --git a/crates/cli/src/meta/types/raindex_signed_context_oracle/mod.rs b/crates/cli/src/meta/types/raindex_signed_context_oracle/mod.rs index a44b4993..bc909745 100644 --- a/crates/cli/src/meta/types/raindex_signed_context_oracle/mod.rs +++ b/crates/cli/src/meta/types/raindex_signed_context_oracle/mod.rs @@ -150,7 +150,11 @@ mod tests { content_language: ContentLanguage::None, schema: None, }; - assert!(RaindexSignedContextOracleV1::find_in_items(&[item]).is_err()); + // The failure must be the utf8 decode error itself, propagated. + match RaindexSignedContextOracleV1::find_in_items(&[item]) { + Err(Error::FromUtf8Error(_)) => {} + other => panic!("Expected Err(FromUtf8Error), got {:?}", other), + } } #[test] @@ -168,7 +172,10 @@ mod tests { #[test] fn test_parse_invalid_url() { - assert!(RaindexSignedContextOracleV1::parse("not a url").is_err()); + match RaindexSignedContextOracleV1::parse("not a url") { + Err(Error::InvalidUrl(_)) => {} + other => panic!("Expected Err(InvalidUrl), got {:?}", other), + } } #[test] @@ -186,7 +193,10 @@ mod tests { content_language: ContentLanguage::None, schema: None, }; - assert!(RaindexSignedContextOracleV1::try_from(item).is_err()); + match RaindexSignedContextOracleV1::try_from(item) { + Err(Error::UnsupportedMeta) => {} + other => panic!("Expected Err(UnsupportedMeta), got {:?}", other), + } } #[test] @@ -197,4 +207,52 @@ mod tests { assert_eq!(parsed.host_str(), Some("example.com")); assert_eq!(parsed.path(), "/feed"); } + + #[test] + fn test_to_meta_item_fields() { + let url = "https://oracle.example.com/prices/eth-usd"; + let oracle = RaindexSignedContextOracleV1::parse(url).unwrap(); + let item = oracle.to_meta_item(); + // Payload is the raw utf8 bytes of the URL; every envelope field is + // the None variant. + assert_eq!(item.payload.as_ref(), url.as_bytes()); + assert_eq!(item.magic, KnownMagic::RaindexSignedContextOracleV1); + assert_eq!(item.content_type, ContentType::None); + assert_eq!(item.content_encoding, ContentEncoding::None); + assert_eq!(item.content_language, ContentLanguage::None); + assert!(item.schema.is_none()); + } + + #[test] + fn test_try_from_unpacks_content_encoding() { + // TryFrom must unpack() the payload (honouring content_encoding), + // not read the raw bytes. + let url = "https://oracle.example.com/prices/eth-usd"; + let item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(ContentEncoding::Deflate.encode(url.as_bytes())), + magic: KnownMagic::RaindexSignedContextOracleV1, + content_type: ContentType::None, + content_encoding: ContentEncoding::Deflate, + content_language: ContentLanguage::None, + schema: None, + }; + let oracle = RaindexSignedContextOracleV1::try_from(item).unwrap(); + assert_eq!(oracle.url(), url); + } + + #[test] + fn test_try_from_rejects_non_url_payload() { + let item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(b"not a url".to_vec()), + magic: KnownMagic::RaindexSignedContextOracleV1, + content_type: ContentType::None, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + schema: None, + }; + match RaindexSignedContextOracleV1::try_from(item) { + Err(Error::InvalidUrl(_)) => {} + other => panic!("Expected Err(InvalidUrl), got {:?}", other), + } + } }