diff --git a/crates/cli/src/meta/query/mod.rs b/crates/cli/src/meta/query/mod.rs index 7f6ab792..a6482b90 100644 --- a/crates/cli/src/meta/query/mod.rs +++ b/crates/cli/src/meta/query/mod.rs @@ -170,3 +170,220 @@ pub(super) async fn process_deployer_query( Err(Error::NoRecordFound) } } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use super::super::{ + types::authoring::v1::AuthoringMetaItem, ContentEncoding, ContentLanguage, ContentType, + }; + use httpmock::Method::POST; + use httpmock::MockServer; + + fn request_body(hash: &str) -> QueryBody { + MetaQuery::build_query(meta_query::Variables { + hash: Some(hash.to_string()), + }) + } + + const HASH: &str = "0x1111111111111111111111111111111111111111111111111111111111111111"; + + /// A found meta resolves to exactly the hex-decoded rawBytes of the + /// response, fetched with a POST carrying the query body. + #[tokio::test] + async fn test_process_meta_query_success_exact_bytes() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(POST) + .path("/") + .json_body_partial(format!(r#"{{"variables":{{"hash":"{}"}}}}"#, HASH)); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"meta":{"__typename":"RainMetaV1","rawBytes":"0xff0a89c674ee7874deadbeef"}}}"#); + }); + let result = process_meta_query( + Arc::new(Client::new()), + &request_body(HASH), + &server.url("/"), + ) + .await + .unwrap(); + assert_eq!( + result.bytes, + vec![0xff, 0x0a, 0x89, 0xc6, 0x74, 0xee, 0x78, 0x74, 0xde, 0xad, 0xbe, 0xef] + ); + } + + /// A response with no data member at all is "no record found". + #[tokio::test] + async fn test_process_meta_query_missing_data_is_no_record_found() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":null}"#); + }); + let result = process_meta_query( + Arc::new(Client::new()), + &request_body(HASH), + &server.url("/"), + ) + .await; + assert!(matches!(result, Err(Error::NoRecordFound)), "{result:?}"); + } + + /// A response with data but a null meta is "no record found". + #[tokio::test] + async fn test_process_meta_query_missing_meta_is_no_record_found() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"meta":null}}"#); + }); + let result = process_meta_query( + Arc::new(Client::new()), + &request_body(HASH), + &server.url("/"), + ) + .await; + assert!(matches!(result, Err(Error::NoRecordFound)), "{result:?}"); + } + + /// rawBytes that do not hex-decode resolve to "no record found", + /// never to successfully-returned bytes. + #[tokio::test] + async fn test_process_meta_query_bad_hex_is_no_record_found() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":{"meta":{"__typename":"RainMetaV1","rawBytes":"zz-not-hex"}}}"#); + }); + let result = process_meta_query( + Arc::new(Client::new()), + &request_body(HASH), + &server.url("/"), + ) + .await; + assert!(matches!(result, Err(Error::NoRecordFound)), "{result:?}"); + } + + /// A transport failure surfaces as a reqwest error, not as a + /// no-record-found result. + #[tokio::test] + async fn test_process_meta_query_send_error_is_reqwest_error() { + // Bind and immediately release a local port so the request targets a + // closed port. + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let url = format!("http://127.0.0.1:{port}/"); + let result = process_meta_query(Arc::new(Client::new()), &request_body(HASH), &url).await; + assert!(matches!(result, Err(Error::ReqwestError(_))), "{result:?}"); + } + + /// A non-JSON response body surfaces as a reqwest decode error, not as + /// a no-record-found result. + #[tokio::test] + async fn test_process_meta_query_non_json_is_reqwest_error() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .header("content-type", "text/plain") + .body("not json"); + }); + let result = process_meta_query( + Arc::new(Client::new()), + &request_body(HASH), + &server.url("/"), + ) + .await; + assert!(matches!(result, Err(Error::ReqwestError(_))), "{result:?}"); + } + + fn authoring_meta() -> AuthoringMeta { + AuthoringMeta(vec![AuthoringMetaItem { + word: "some-word".to_string(), + operand_parser_offset: 0, + description: "a description".to_string(), + }]) + } + + fn authoring_item() -> RainMetaDocumentV1Item { + RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(authoring_meta().abi_encode_validate().unwrap()), + magic: KnownMagic::AuthoringMetaV1, + content_type: ContentType::None, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + schema: None, + } + } + + fn deployer_response_with(meta_bytes: Vec) -> DeployerResponse { + DeployerResponse { + tx_hash: vec![], + bytecode_meta_hash: vec![], + meta_hash: vec![], + meta_bytes, + bytecode: vec![], + parser: vec![], + store: vec![], + interpreter: vec![], + } + } + + /// An authoring-magic item whose payload fails to unpack is skipped; + /// a later valid authoring meta item is still found. + #[test] + fn test_get_authoring_meta_skips_unpack_failure() { + // Deflate-encoded item whose payload is not valid deflate data. + let bad_unpack = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(vec![0xffu8, 0xff, 0xff, 0xff]), + magic: KnownMagic::AuthoringMetaV1, + content_type: ContentType::None, + content_encoding: ContentEncoding::Deflate, + content_language: ContentLanguage::None, + schema: None, + }; + assert!(bad_unpack.unpack().is_err()); + let meta_bytes = RainMetaDocumentV1Item::cbor_encode_seq( + &vec![bad_unpack, authoring_item()], + KnownMagic::RainMetaDocumentV1, + ) + .unwrap(); + assert_eq!( + deployer_response_with(meta_bytes).get_authoring_meta(), + Some(authoring_meta()) + ); + } + + /// The scan covers every item in the document: an authoring meta in the + /// second position is found behind a non-authoring first item. + #[test] + fn test_get_authoring_meta_scans_beyond_first_item() { + let other_magic = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(b"_: int-add(1 2);".to_vec()), + magic: KnownMagic::RainlangV1, + content_type: ContentType::None, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + schema: None, + }; + let meta_bytes = RainMetaDocumentV1Item::cbor_encode_seq( + &vec![other_magic, authoring_item()], + KnownMagic::RainMetaDocumentV1, + ) + .unwrap(); + assert_eq!( + deployer_response_with(meta_bytes).get_authoring_meta(), + Some(authoring_meta()) + ); + } +} diff --git a/crates/cli/src/meta/types/common/v1.rs b/crates/cli/src/meta/types/common/v1.rs index 1dc0270e..26e59398 100644 --- a/crates/cli/src/meta/types/common/v1.rs +++ b/crates/cli/src/meta/types/common/v1.rs @@ -105,7 +105,7 @@ mod test { // invalids for i in [ - "", "♥", "-", " ", "A", "A0", "a ", "0", "_", "0a", "0A", "\n", "\t", "\r", + "", "♥", "-", " ", "A", "A0", "a ", "0", "_", "0a", "0A", "\n", "\t", "\r", "aA", ] { assert!( RainSymbol { @@ -137,7 +137,7 @@ mod test { } // invalids - for i in ["", " ", " a", "a ", "♥", "\n", "\t", "\r"] { + for i in ["", " ", " a", "a ", "♥", "\n", "\t", "\r", "\u{7f}"] { assert!( RainTitle { value: i.to_string() @@ -169,7 +169,7 @@ mod test { } // invalids - for i in ["♥", "∴"] { + for i in ["♥", "∴", "\u{7f}"] { assert!( RainString { value: i.to_string() @@ -249,6 +249,8 @@ mod test { "0x78fd1edb0bdb928db6015990fecafbb964b44692e2d435693062dd4efc6254dd ", " 0x78fd1edb0bdb928db6015990fecafbb964b44692e2d435693062dd4efc6254dd", "0x78fd1edb0bdb928db6015990fecafbb9 64b44692e2d435693062dd4efc6254dd", + "0xgggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + "0X78fd1edb0bdb928db6015990fecafbb964b44692e2d435693062dd4efc6254dd", ] { assert!( !HASH_PATTERN.is_match(i), diff --git a/crates/cli/tests/magic_cli.rs b/crates/cli/tests/magic_cli.rs new file mode 100644 index 00000000..b996ec80 --- /dev/null +++ b/crates/cli/tests/magic_cli.rs @@ -0,0 +1,43 @@ +// End-to-end check of `rain-metadata magic ls` output. +#![cfg(not(target_family = "wasm"))] + +use std::process::Command; + +/// `magic ls` prints every known magic number, one per line, as the +/// 0x-prefixed hex value followed by the kebab-case name, in declaration +/// order, and nothing else. +#[test] +fn test_magic_ls_exact_output() { + let output = Command::new(env!("CARGO_BIN_EXE_rain-metadata")) + .args(["magic", "ls"]) + .output() + .expect("failed to run rain-metadata"); + assert!(output.status.success(), "exit: {:?}", output.status); + assert!( + output.stderr.is_empty(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let expected = "\ +0xff0a89c674ee7874 rain-meta-document-v1 +0xffe5282f43e495b4 op-meta-v1 +0xffdac2f2f37be894 dotrain-v1 +0xff1c198cec3b48a7 rainlang-v1 +0xffe5ffb4a3ff2cde solidity-abi-v2 +0xffe9e3a02ca8e235 authoring-meta-v1 +0xff52fe42f1a05093 authoring-meta-v2 +0xffc21bbf86cc199b interpreter-caller-meta-v1 +0xffdb988a8cd04d32 expression-deployer-v2-bytecode-v1 +0xff13109e41336ff2 rainlang-source-v1 +0xffb2637608c09e38 address-list +0xffa15ef0fc437099 dotrain-source-v1 +0xffda7b2fb167c286 order-builder-state-v1 +0xff7a1507ba4419ca raindex-signed-context-oracle-v1 +0xffa8e8a9b9cf4a31 oa-schema +0xff9fae3cc645f463 oa-hash-list +0xffc47a6299e8a911 oa-structure +0xff8cd2927c8c86cb oa-token-image +0xffbc38eb14ad2209 oa-token-credential-links +"; + assert_eq!(String::from_utf8(output.stdout).unwrap(), expected); +} diff --git a/test/script/CopyArtifacts.t.sol b/test/script/CopyArtifacts.t.sol index d0e2536b..cbf0497a 100644 --- a/test/script/CopyArtifacts.t.sol +++ b/test/script/CopyArtifacts.t.sol @@ -4,6 +4,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; import {LibCopyArtifacts} from "script/lib/LibCopyArtifacts.sol"; +import {CopyArtifacts} from "script/CopyArtifacts.sol"; contract CopyArtifactsTest is Test { function _assertCommittedMatches(string memory contractName) internal { @@ -18,10 +19,47 @@ contract CopyArtifactsTest is Test { ); } + /// All committed-artifact behaviour lives in this single test function: + /// forge runs test functions in parallel, and every step below mutates or + /// reads the same on-disk committed files, so splitting the steps into + /// separate tests races them against each other. function testArtifactsCommitted() external { string[] memory names = LibCopyArtifacts.contracts(); for (uint256 i = 0; i < names.length; i++) { _assertCommittedMatches(names[i]); } + + // Corrupting every committed artifact and running the script restores + // each committed file byte-exactly to its pre-corruption on-disk + // state. + bytes32[] memory preCorruption = new bytes32[](names.length); + for (uint256 i = 0; i < names.length; i++) { + preCorruption[i] = keccak256(bytes(vm.readFile(LibCopyArtifacts.committedPath(names[i])))); + vm.writeFile(LibCopyArtifacts.committedPath(names[i]), "corrupt"); + } + new CopyArtifacts().run(); + for (uint256 i = 0; i < names.length; i++) { + assertEq( + keccak256(bytes(vm.readFile(LibCopyArtifacts.committedPath(names[i])))), + preCorruption[i], + string.concat(names[i], ": committed artifact not restored byte-exactly") + ); + } + + // Deleting a committed artifact and running the script recreates it + // byte-exactly; a missing destination is written without an attempted + // removal. + string memory dst = LibCopyArtifacts.committedPath(names[0]); + bytes32 preDeletion = keccak256(bytes(vm.readFile(dst))); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeFile(dst); + assertFalse(vm.exists(dst)); + new CopyArtifacts().run(); + assertTrue(vm.exists(dst), string.concat(names[0], ": committed artifact not recreated")); + assertEq( + keccak256(bytes(vm.readFile(dst))), + preDeletion, + string.concat(names[0], ": recreated artifact does not match pre-deletion state") + ); } }