mhome-script-api 0.1.2

Shared JavaScript resource identity and typed Automation script contracts
Documentation
use mhome_script_api::*;
use serde_json::{json, Value};

fn corpus() -> Value {
    serde_json::from_str(include_str!("../fixtures/javascript-v1.json")).unwrap()
}

#[test]
fn shared_tagged_value_corpus() {
    let fixture = corpus();
    for value in fixture["validValues"].as_array().unwrap() {
        let decoded: ScriptValue = serde_json::from_value(value.clone()).unwrap();
        decoded.validate(ValueLimits::default()).unwrap();
        assert_eq!(serde_json::to_value(decoded).unwrap(), *value);
    }
    for value in fixture["invalidValues"].as_array().unwrap() {
        let result = serde_json::from_value::<ScriptValue>(value.clone())
            .map_err(|e| e.to_string())
            .and_then(|value| {
                value
                    .validate(ValueLimits::default())
                    .map_err(|e| e.to_string())
            });
        assert!(result.is_err(), "accepted invalid value: {value}");
    }
}

#[test]
fn shared_artifact_corpus() {
    for entry in corpus()["validArtifacts"].as_array().unwrap() {
        let reference: ScriptRef = serde_json::from_value(entry["reference"].clone()).unwrap();
        let artifact: ScriptArtifact = serde_json::from_value(entry["artifact"].clone()).unwrap();
        artifact.validate_for(&reference).unwrap();
        assert_eq!(serde_json::to_value(artifact).unwrap(), entry["artifact"]);
    }
}

#[test]
fn shared_contract_corpus() {
    let fixture = corpus();
    for value in fixture["validContracts"].as_array().unwrap() {
        let contract: ScriptContract = serde_json::from_value(value.clone()).unwrap();
        contract.validate().unwrap();
        assert_eq!(serde_json::to_value(contract).unwrap(), *value);
    }
    for value in fixture["invalidContracts"].as_array().unwrap() {
        let result = serde_json::from_value::<ScriptContract>(value.clone())
            .map_err(|e| e.to_string())
            .and_then(|contract| contract.validate().map_err(|e| e.to_string()));
        assert!(result.is_err(), "accepted invalid contract: {value}");
    }
}

#[test]
fn shared_reference_corpus() {
    let fixture = corpus();
    for value in fixture["validReferences"].as_array().unwrap() {
        let reference: ScriptRef = serde_json::from_value(value.clone()).unwrap();
        reference.validate().unwrap();
        assert_eq!(serde_json::to_value(reference).unwrap(), *value);
    }
    for value in fixture["invalidReferences"].as_array().unwrap() {
        let result = serde_json::from_value::<ScriptRef>(value.clone())
            .map_err(|e| e.to_string())
            .and_then(|reference| reference.validate().map_err(|e| e.to_string()));
        assert!(result.is_err(), "accepted invalid reference: {value}");
    }
}

#[test]
fn new_instruction_has_no_legacy_alias() {
    let fixture = corpus();
    assert_eq!(fixture["instruction"]["opcode"], SCRIPT_CALL_OPCODE);
    assert_eq!(
        fixture["instruction"]["operands"].as_array().unwrap().len(),
        SCRIPT_CALL_OPERANDS
    );
    for opcode in fixture["instruction"]["rejectedLegacyOpcodes"]
        .as_array()
        .unwrap()
    {
        assert_ne!(opcode.as_u64().unwrap(), u64::from(SCRIPT_CALL_OPCODE));
    }
}

#[test]
fn list_nesting_is_bounded_without_flattening() {
    let mut contract: ScriptContract =
        serde_json::from_value(corpus()["validContracts"][1].clone()).unwrap();
    let mut ty = FieldType::Scalar(ScalarType::Number);
    for _ in 0..MAX_CONTRACT_DEPTH {
        ty = FieldType::list(ty);
    }
    contract.input.push(Field {
        name: "items".into(),
        ty,
    });
    contract.validate().unwrap();
    contract.input[0].ty = FieldType::list(contract.input[0].ty.clone());
    assert!(contract.validate().is_err());
}

fn reference(index: usize) -> ScriptRef {
    serde_json::from_value(corpus()["validReferences"][index].clone()).unwrap()
}

fn artifact(reference: &ScriptRef) -> ScriptArtifact {
    ScriptArtifact {
        format: ArtifactFormat::JavascriptEsm,
        sha256: reference.sha256().into(),
        size: 512,
        sdk_version: "0.1.0".into(),
        contract: match reference {
            ScriptRef::Automation { .. } => {
                Some(serde_json::from_value(corpus()["validContracts"][0].clone()).unwrap())
            }
            ScriptRef::Integration { .. } => None,
        },
    }
}

#[test]
fn automation_cannot_execute_unbound_or_incompatible_metadata() {
    let reference = reference(1);
    let original = artifact(&reference);
    original.validate_for(&reference).unwrap();
    let mut changed = original.clone();
    changed.contract = None;
    assert!(changed.validate_for(&reference).is_err());
    let mut changed = original.clone();
    changed.contract.as_mut().unwrap().sdk_version = "0.2.0".into();
    assert!(changed.validate_for(&reference).is_err());
    let mut changed = original;
    changed.sha256 = "c".repeat(64);
    assert!(changed.validate_for(&reference).is_err());
}

#[test]
fn integration_authority_remains_in_role_schema() {
    let reference = reference(0);
    let mut artifact = artifact(&reference);
    artifact.validate_for(&reference).unwrap();
    artifact.contract =
        Some(serde_json::from_value(corpus()["validContracts"][0].clone()).unwrap());
    assert!(artifact.validate_for(&reference).is_err());
}

#[test]
fn artifact_size_is_bounded_and_wasm_format_is_rejected() {
    let reference = reference(0);
    let mut artifact = artifact(&reference);
    for size in [1, MAX_ARTIFACT_BYTES] {
        artifact.size = size;
        artifact.validate_for(&reference).unwrap();
    }
    for size in [0, MAX_ARTIFACT_BYTES + 1, usize::MAX] {
        artifact.size = size;
        assert!(artifact.validate_for(&reference).is_err());
    }
    let mut value = serde_json::to_value(artifact).unwrap();
    value["format"] = json!("wasm");
    assert!(serde_json::from_value::<ScriptArtifact>(value).is_err());
}

#[test]
fn internal_outcome_does_not_accept_both_success_and_failure() {
    let success: ScriptOutcome<Value> =
        serde_json::from_value(json!({"status":"success","data":{"ok":false}})).unwrap();
    assert!(matches!(success, ScriptOutcome::Success { .. }));
    for value in [
        json!({"ok":true,"data":{}}),
        json!({"status":"success","data":{},"error":{"code":"X","message":"x"}}),
        json!({"status":"failure","error":{"code":"X","message":"x"},"data":{}}),
    ] {
        assert!(serde_json::from_value::<ScriptOutcome<Value>>(value).is_err());
    }
}

#[test]
fn runtime_contract_requires_fields_lists_and_exact_number_compatibility() {
    let contract = ScriptContract {
        version: 1,
        sdk_version: "0.1.0".into(),
        input: vec![
            Field {
                name: "values".into(),
                ty: FieldType::list(FieldType::list(FieldType::Scalar(ScalarType::Number))),
            },
            Field {
                name: "dynamic".into(),
                ty: FieldType::Scalar(ScalarType::Value),
            },
        ],
        output: vec![],
    };
    let valid = ScriptValue::Object(
        [
            (
                "values".into(),
                ScriptValue::List(vec![ScriptValue::List(vec![ScriptValue::Number(1.5)])]),
            ),
            ("dynamic".into(), ScriptValue::U64(u64::MAX)),
        ]
        .into_iter()
        .collect(),
    );
    contract.validate_input(&valid).unwrap();
    let ScriptValue::Object(mut fields) = valid else {
        unreachable!()
    };
    fields.remove("dynamic");
    assert!(contract
        .validate_input(&ScriptValue::Object(fields.clone()))
        .is_err());
    fields.insert("dynamic".into(), ScriptValue::Null);
    for invalid in [
        ScriptValue::List(vec![ScriptValue::Number(1.0)]),
        ScriptValue::List(vec![ScriptValue::List(vec![ScriptValue::I64(i64::MAX)])]),
        ScriptValue::List(vec![ScriptValue::List(vec![ScriptValue::String(
            "1".into(),
        )])]),
    ] {
        fields.insert("values".into(), invalid);
        assert!(contract
            .validate_input(&ScriptValue::Object(fields.clone()))
            .is_err());
    }
    assert!(contract
        .validate_output(&ScriptValue::Object(Default::default()))
        .is_ok());
    assert!(contract
        .validate_output(&ScriptValue::List(vec![]))
        .is_err());
}

#[test]
fn runtime_domain_contracts_validate_units_and_integral_timestamps() {
    let mut contract = ScriptContract {
        version: 1,
        sdk_version: "0.1.0".into(),
        input: vec![],
        output: vec![Field {
            name: "result".into(),
            ty: FieldType::Scalar(ScalarType::Temperature),
        }],
    };
    let wrap = |value| ScriptValue::Object([("result".into(), value)].into_iter().collect());
    let quantity = |tag: &str, unit: &str, value| {
        ScriptValue::Object(
            [
                ("_type".into(), ScriptValue::String(tag.into())),
                ("unit".into(), ScriptValue::String(unit.into())),
                ("value".into(), ScriptValue::Number(value)),
            ]
            .into_iter()
            .collect(),
        )
    };
    assert!(contract
        .validate_output(&wrap(quantity("Temperature", "F", 72.123456789)))
        .is_ok());
    assert!(contract
        .validate_output(&wrap(quantity("Temperature", "°F", 72.0)))
        .is_err());
    contract.output[0].ty = FieldType::Scalar(ScalarType::Duration);
    assert!(contract
        .validate_output(&wrap(quantity("Duration", "ms", 0.125)))
        .is_ok());
    assert!(contract
        .validate_output(&wrap(quantity("Duration", "s", 1.0)))
        .is_err());
    contract.output[0].ty = FieldType::Scalar(ScalarType::Timestamp);
    for (value, expected) in [
        (ScriptValue::I64(i64::MIN), true),
        (ScriptValue::Number(0.5), false),
        (ScriptValue::U64(u64::MAX), false),
        (ScriptValue::Number(1.0), true),
    ] {
        let timestamp = ScriptValue::Object(
            [
                ("_type".into(), ScriptValue::String("Timestamp".into())),
                ("value".into(), value),
            ]
            .into_iter()
            .collect(),
        );
        assert_eq!(contract.validate_output(&wrap(timestamp)).is_ok(), expected);
    }
}

#[test]
fn number_outputs_reject_bigint_recursively_but_inputs_accept_safe_core_integers() {
    let wrap = |value| ScriptValue::Object([("result".into(), value)].into_iter().collect());
    for (ty, value) in [
        (FieldType::Scalar(ScalarType::Number), ScriptValue::I64(1)),
        (
            FieldType::list(FieldType::list(FieldType::Scalar(ScalarType::Number))),
            ScriptValue::List(vec![ScriptValue::List(vec![ScriptValue::I64(1)])]),
        ),
        (
            FieldType::Scalar(ScalarType::Temperature),
            ScriptValue::Object(
                [
                    ("_type".into(), ScriptValue::String("Temperature".into())),
                    ("unit".into(), ScriptValue::String("C".into())),
                    ("value".into(), ScriptValue::I64(20)),
                ]
                .into_iter()
                .collect(),
            ),
        ),
        (
            FieldType::Scalar(ScalarType::Duration),
            ScriptValue::Object(
                [
                    ("_type".into(), ScriptValue::String("Duration".into())),
                    ("unit".into(), ScriptValue::String("ms".into())),
                    ("value".into(), ScriptValue::I64(1)),
                ]
                .into_iter()
                .collect(),
            ),
        ),
    ] {
        let field = Field {
            name: "result".into(),
            ty,
        };
        let contract = ScriptContract {
            version: 1,
            sdk_version: "0.1.0".into(),
            input: vec![field.clone()],
            output: vec![field],
        };
        let value = wrap(value);
        contract.validate_input(&value).unwrap();
        assert!(contract.validate_output(&value).is_err());
    }
}