diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 442757a..cb446c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ Pull requests are welcome, but opening an issue to discuss the change first is s CLI behavior conventions are documented in `docs/development/cli-conventions.md`. Runtime and frontend architecture is documented in `docs/development/runtime-and-frontend.md`, -crate layout in `docs/development/crate-layout.md`. +crate layout in `docs/development/crate-layout.md`, and the testing strategy in +`docs/development/testing.md`. ## AI-assisted contributions @@ -55,3 +56,13 @@ cargo test --workspace ```sh cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace ``` + +## Testing + +A test earns its place by proving a property we care about, not by mirroring the shape of the +code or the fixtures. A test that only restates what the compiler already guarantees, or that +breaks on every benign edit, is a liability. This reflects the project itself: evidence over +execution. + +What to test, at which level, and where it goes in the tree is set out in +`docs/development/testing.md`. Follow it when adding tests; reviewers hold PRs to it. diff --git a/Cargo.lock b/Cargo.lock index 0badbd2..ff14155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -88,6 +97,21 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -151,6 +175,17 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -533,6 +568,12 @@ dependencies = [ "syn", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -640,7 +681,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -997,8 +1038,10 @@ checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", + "regex", "serde", "similar", + "strip-ansi-escapes", "tempfile", ] @@ -1464,6 +1507,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1648,6 +1718,35 @@ dependencies = [ "syn", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rfc6979" version = "0.4.0" @@ -1662,6 +1761,7 @@ dependencies = [ name = "rite" version = "0.3.0" dependencies = [ + "assert_cmd", "chrono", "clap", "clap_complete", @@ -1718,6 +1818,7 @@ version = "0.3.0" dependencies = [ "base64ct", "chrono", + "insta", "minijinja", "rite-model", "rite-resolver", @@ -1865,7 +1966,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2078,6 +2179,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2152,9 +2262,15 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -2387,6 +2503,24 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index cc91d82..b206207 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,8 @@ bitflags = { version = "2.11.1", features = ["serde"] } marked-yaml = { version = "0.8.0", features = ["serde"] } indexmap = { version = "2.14.0", features = ["serde"] } chrono = { version = "0.4.44", default-features = false, features = ["clock", "std", "serde"] } -insta = { version = "1.47.2", features = ["yaml"] } +insta = { version = "1.47.2", features = ["yaml", "filters"] } +assert_cmd = "2.2.2" secrecy = "0.10.3" rpassword = "7.5.2" base16ct = { version = "1.0.0", features = ["alloc"] } diff --git a/crates/rite-render/Cargo.toml b/crates/rite-render/Cargo.toml index d260dbc..d3edf2c 100644 --- a/crates/rite-render/Cargo.toml +++ b/crates/rite-render/Cargo.toml @@ -21,6 +21,7 @@ base64ct = { workspace = true } [dev-dependencies] rite-resolver = { workspace = true } +insta = { workspace = true } [lints] workspace = true diff --git a/crates/rite-render/tests/render.rs b/crates/rite-render/tests/render.rs index 44d6a62..ee42020 100644 --- a/crates/rite-render/tests/render.rs +++ b/crates/rite-render/tests/render.rs @@ -11,32 +11,35 @@ fn resolve(rel: &str) -> rite_model::Ceremony { ceremony.unwrap_or_else(|| panic!("failed to resolve {rel}: {diags:?}")) } +/// Snapshot a rendered document with the wall-clock timestamp normalized, so a +/// diff only ever means a real rendering change, not a different run time. Only +/// the report's `started_at` fallback (`Utc::now()` when there are no facts) is +/// nondeterministic; fixture-supplied dates render literally so the snapshot +/// still guards how they are formatted. +fn assert_html_snapshot(name: &str, html: &str) { + insta::with_settings!({filters => vec![ + (r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC", "[DATETIME]"), + ]}, { + insta::assert_snapshot!(name, html); + }); +} + #[test] -fn script_renders_expected_structure() { +fn script_demo_snapshot() { + // The full script is the contract participants follow by hand: structure, + // step numbering, role badges, and the fingerprint/signature blocks. let ceremony = resolve("examples/showcase/demo.rite.yaml"); let html = render_script(&ceremony, &Branding::default(), Theme::Formal).unwrap(); - assert!(html.starts_with(""), "missing doctype"); - assert!(html.contains("Root Signing Key Ceremony")); - assert!(html.contains("Crypto Officer")); - // Role abbreviation badge. - assert!(html.contains("role-abbrev")); - // Hand-recorded fingerprint and signature blocks. - assert!(html.contains("fingerprint-record")); - assert!(html.contains("signature-block")); - // Every step label is present. - for label in ["1", "2", "3", "4", "5", "6"] { - assert!(html.contains(&format!(">{label}"))); - } + assert_html_snapshot("script_demo", &html); } #[test] -fn named_acts_render_act_headers() { +fn script_named_acts_snapshot() { + // root_ca uses named acts, which render as act headers; the snapshot guards + // the whole structure, not just their presence. let ceremony = resolve("examples/pki/root_ca_software.rite.yaml"); let html = render_script(&ceremony, &Branding::default(), Theme::Formal).unwrap(); - assert!( - html.contains("act-header"), - "named acts should emit act headers" - ); + assert_html_snapshot("script_named_acts", &html); } #[test] @@ -78,14 +81,11 @@ fn long_instructions_render_as_paragraphs_and_bullets() { } #[test] -fn report_renders() { +fn report_snapshot() { let data = rite_render::report::build_report_data( std::iter::empty::<(chrono::DateTime, &rite_model::StepFact)>(), "sha256:deadbeef", ); let html = render_report(&data, &Branding::default(), Theme::Formal).unwrap(); - assert!(html.starts_with("")); - assert!(html.contains("Ceremony Report")); - assert!(html.contains("report-footer")); - assert!(html.contains("Transcript fingerprint")); + assert_html_snapshot("report_empty", &html); } diff --git a/crates/rite-render/tests/snapshots/render__report_empty.snap b/crates/rite-render/tests/snapshots/render__report_empty.snap new file mode 100644 index 0000000..be899bf --- /dev/null +++ b/crates/rite-render/tests/snapshots/render__report_empty.snap @@ -0,0 +1,541 @@ +--- +source: crates/rite-render/tests/render.rs +expression: html +--- + + + + + · Ceremony Report + + + +
+

· Ceremony Report

+
+ +
+

Status: In Progress

+

Started: [DATETIME]

+

Transcript fingerprint: sha256:deadbeef

+
+ + + + diff --git a/crates/rite-render/tests/snapshots/render__script_demo.snap b/crates/rite-render/tests/snapshots/render__script_demo.snap new file mode 100644 index 0000000..9b31aa4 --- /dev/null +++ b/crates/rite-render/tests/snapshots/render__script_demo.snap @@ -0,0 +1,652 @@ +--- +source: crates/rite-render/tests/render.rs +expression: html +--- + + + + + Root Signing Key Ceremony + + + +
+

Ceremony Protocol

+

Root Signing Key Ceremony

+
+
+

Overview

+

Generate an offline root signing key on an air-gapped workstation and issue its self-signed certificate, witnessed and attested by two roles.

+
+ +
+

Roles

+ +
+
+

Preparation Checklist

+

Prerequisites

+ +
+
+

Environment Verification

+ + + + + + + + + + + +
StepActionRole
1

Confirm the workstation has no active network connections

CO
+

Key Generation

+ + + + + + + + + + + + + + + + + + + + + +
StepActionRole
2

Generate the root ECDSA-P256 keypair.

CO
3

Build the certificate signing request for the root key.

CO
4

Issue the self-signed root certificate.

CO
+

Witness Attestation

+ + + + + + + + + + + + + + + + +
StepActionRole
5

Attest: "I witnessed the generation of the root signing key."

Wi
6

Attest: "The root signing key was generated and its certificate issued."

CO
+
+
+

Expected Outputs

+ +
+ +
+

Transcript Fingerprint

+

At the end of the ceremony, the transcript fingerprint is displayed. Copy at least the first line (32 characters, shown in bold) into the field below before closing the terminal, while all participants are still present.

+
+

sha256

+
+

__ __ __ __ __ __ __ __

+

__ __ __ __ __ __ __ __

+

__ __ __ __ __ __ __ __

+

__ __ __ __ __ __ __ __

+
+
+
+ +
+

Signatures

+

By signing below, each participant attests to the accuracy and completeness of this ceremony.

+
+
+

Crypto Officer

+

Name: Alice Rivera

+
+
Signature
+
Date
+
+
+
+

Witness

+

Name: Bob Tanaka

+
+
Signature
+
Date
+
+
+
+
+ + diff --git a/crates/rite-render/tests/snapshots/render__script_named_acts.snap b/crates/rite-render/tests/snapshots/render__script_named_acts.snap new file mode 100644 index 0000000..fa29c8e --- /dev/null +++ b/crates/rite-render/tests/snapshots/render__script_named_acts.snap @@ -0,0 +1,726 @@ +--- +source: crates/rite-render/tests/render.rs +expression: html +--- + + + + + Root CA Key Generation + + + +
+

Ceremony Protocol

+

Root CA Key Generation

+ +
+
+

Overview

+

Generate the offline root CA keypair on an air-gapped machine. The private key is wrapped with a transport public key and stored as an encrypted backup.

+
+ +
+

Roles

+ +
+
+

Preparation Checklist

+

Prerequisites

+ +

Digital Materials

+

Verify digital material sources before ceremony

+ +
+
+
Act 1: Opening
+

Verify participants and environment before proceeding.

+

Environment Verification

+ + + + + + + + + + + + + + + + + + + + + +
StepActionRole
1.1

Verify system clock before recording timestamps

CO
1.2

Record system and environment information.

CO
1.3

Confirm the workstation has no active network connections

CO
+
+
+
Act 2: Root CA Key Generation
+

Generate and protect the root CA keypair.

+

Key Generation

+ + + + + + + + + + + + + + + + + + + + + +
StepActionRole
2.1

Generate RSA-4096 root CA keypair.

CO
2.2

Generate a certificate signing request for the root CA key.

CO
2.3

Issue the self-signed root CA certificate.

CO
+

Key Protection

+ + + + + + + + + + + +
StepActionRole
2.4

Encrypt the root CA private key with the transport public key.

CO
+
+
+
Act 3: Closing
+

Witness Attestation

+ + + + + + + + + + + + + + + + + + + + + +
StepActionRole
3.1

Attest: "I witnessed the root CA key generation and private key wrapping."

W1
3.2

Attest: "I witnessed the root CA key generation and private key wrapping."

W2
3.3

Attest: "Root CA keypair generated."

CO
+
+
+

Expected Outputs

+ +
+ +
+

Transcript Fingerprint

+

At the end of the ceremony, the transcript fingerprint is displayed. Copy at least the first line (32 characters, shown in bold) into the field below before closing the terminal, while all participants are still present.

+
+

sha256

+
+

__ __ __ __ __ __ __ __

+

__ __ __ __ __ __ __ __

+

__ __ __ __ __ __ __ __

+

__ __ __ __ __ __ __ __

+
+
+
+
+

Post-Ceremony Duties

+

The following duties must be completed after the ceremony concludes.

+
+

Archive Materials (Crypto Officer)

+

Archive ceremony materials at designated storage locations

+
    +
  • Ceremony transcript
  • +
  • Root CA certificate (root_ca_cert.pem)
  • +
  • Wrapped root CA key backup (wrapped_root_ca_key.bin)
  • +
  • Location: Secure document archive
  • +
+
+
+

Notify Stakeholders

+

Distribute root_ca_cert.pem to PKI team and update trust anchor documentation

+
+
+ +
+

Signatures

+

By signing below, each participant attests to the accuracy and completeness of this ceremony.

+
+
+

Crypto Officer

+

Name: Alice Smith

+
+
Signature
+
Date
+
+
+
+

Witness 1

+

Name: Bob Jones

+
+
Signature
+
Date
+
+
+
+

Witness 2

+

Name: Carol White

+
+
Signature
+
Date
+
+
+
+
+ + diff --git a/crates/rite-resolver/src/lib.rs b/crates/rite-resolver/src/lib.rs index 249127c..1a74356 100644 --- a/crates/rite-resolver/src/lib.rs +++ b/crates/rite-resolver/src/lib.rs @@ -6,16 +6,23 @@ //! //! # Usage //! -//! ```ignore -//! use rite_resolver::{resolve, resolve_files, analyze, CeremonyInputs}; +//! ``` +//! use rite_resolver::resolve; //! -//! // From a string (no external inputs) -//! let result = resolve(ceremony_yaml, None); +//! let ceremony_yaml = r#" +//! version: "0.2" +//! name: "Example Ceremony" +//! roles: {} +//! sections: {} +//! "#; //! -//! // From a file with rich diagnostics -//! let (resolved, diags) = analyze(Path::new("sub_ca.rite.yaml"), None); -//! for d in &diags { eprintln!("{d}"); } +//! // Resolve from a string (no execution-time inputs). +//! let result = resolve(ceremony_yaml, None); +//! assert!(result.is_ok(), "ceremony should resolve: {:?}", result.errors); //! ``` +//! +//! For a file with rich, span-anchored diagnostics, use [`analyze`]; for a +//! ceremony plus its included files, use [`resolve_files`]. #![warn(missing_docs)] diff --git a/crates/rite-runtime/src/backend/registry.rs b/crates/rite-runtime/src/backend/registry.rs index 23f1555..ccc442a 100644 --- a/crates/rite-runtime/src/backend/registry.rs +++ b/crates/rite-runtime/src/backend/registry.rs @@ -67,10 +67,15 @@ impl BackendRegistry { /// /// # Example /// - /// ```ignore - /// let registry = BackendRegistry::with_factory( - /// Box::new(|name, config| rite_stdlib::backend::create_backend(name, config)) - /// ); + /// ``` + /// use rite_runtime::BackendRegistry; + /// use rite_sdk::BackendError; + /// + /// // A factory resolves a declared backend name + config into a live + /// // backend. This one has none, so every lookup reports NotFound. + /// let registry = BackendRegistry::with_factory(Box::new(|name, _config| { + /// Err(BackendError::NotFound(name)) + /// })); /// ``` pub fn with_factory(factory: BackendFactory) -> Self { Self { diff --git a/crates/rite-runtime/src/expressions.rs b/crates/rite-runtime/src/expressions.rs index a028565..5964f8f 100644 --- a/crates/rite-runtime/src/expressions.rs +++ b/crates/rite-runtime/src/expressions.rs @@ -20,10 +20,10 @@ use sha2::{Digest, Sha256, Sha384, Sha512}; /// /// # Examples /// -/// ```ignore -/// let expr = rite_model::expression::parse_expression("${artifact.ksr | sha256 | hex}").unwrap(); +/// ```text +/// let expr = parse_expression("${artifact.ksr | sha256 | hex}")?; /// let result = evaluate(&expr, &context)?; -/// assert!(matches!(result, Value::String(_))); +/// // result is Value::String("") /// ``` pub fn evaluate(expr: &Expression, ctx: &HandlerContext) -> Result { match expr { @@ -327,7 +327,7 @@ fn apply_concat(args: &[Expression], ctx: &HandlerContext) -> Result, _event_rx: Receiver, - _cmd_tx: Sender, + cmd_tx: Sender, cmd_rx: Receiver, } @@ -40,11 +40,26 @@ impl ReporterHarness { sink: InMemorySink::new(), event_tx, _event_rx: event_rx, - _cmd_tx: cmd_tx, + cmd_tx, cmd_rx, } } + /// Pre-stage an operator response to a prompt the action under test will + /// issue. A reporter allocates prompt ids from zero in order, so the first + /// prompt an action issues has id `0`. The response sits in the command + /// channel until the reporter receives it, so call this before running the + /// action. + pub fn respond(&self, prompt_id: u64, response: Response) { + // The receiver lives in this harness, so the send cannot fail; ignore + // the result rather than unwrap (this module is compiled as library + // code, where `unwrap`/`expect` are linted). + let _ = self.cmd_tx.send(UiCommand::PromptResponse { + prompt_id: PromptId::new(prompt_id), + response, + }); + } + /// Build a reporter scoped to the given step. The reporter borrows /// the harness for its lifetime. /// diff --git a/crates/rite-sdk/src/backend.rs b/crates/rite-sdk/src/backend.rs index 874c7e0..a6bc328 100644 --- a/crates/rite-sdk/src/backend.rs +++ b/crates/rite-sdk/src/backend.rs @@ -40,20 +40,41 @@ use crate::types::{ /// /// # Usage /// -/// ```ignore -/// use rite_sdk::{Backend, KeyStoreBackend, SignBackend}; +/// ``` +/// use rite_sdk::{ +/// backend_capabilities, Backend, BackendError, KeyId, RandomBackend, SignAlgorithm, +/// SignBackend, +/// }; +/// +/// struct MyBackend { +/// name: String, +/// } /// /// impl Backend for MyBackend { /// fn name(&self) -> &str { &self.name } /// fn provider(&self) -> &str { "software" } -/// fn fingerprint(&self) -> String { "...".to_string() } +/// fn fingerprint(&self) -> String { "sha256:...".to_string() } /// /// backend_capabilities!( -/// /// Supports RSA-2048 through RSA-8192 key generation and storage. -/// as_keystore_mut: KeyStoreBackend, +/// /// Signs with software-held RSA and ECDSA keys. /// as_sign_mut: SignBackend, +/// /// Draws ceremony randomness from the OS CSPRNG. +/// as_random_mut: RandomBackend, /// ); /// } +/// +/// impl SignBackend for MyBackend { +/// fn sign(&mut self, _key: &KeyId, _msg: &[u8], _alg: SignAlgorithm) +/// -> Result, BackendError> { Ok(Vec::new()) } +/// fn verify(&self, _key: &KeyId, _msg: &[u8], _sig: &[u8], _alg: SignAlgorithm) +/// -> Result { Ok(true) } +/// } +/// +/// impl RandomBackend for MyBackend { +/// fn generate_random(&mut self, len: usize) -> Result, BackendError> { +/// Ok(vec![0u8; len]) +/// } +/// } /// ``` /// /// Capabilities not listed here are not overridden, so they inherit the trait's diff --git a/crates/rite-stdlib/src/lib.rs b/crates/rite-stdlib/src/lib.rs index 5eb0e26..0e34f84 100644 --- a/crates/rite-stdlib/src/lib.rs +++ b/crates/rite-stdlib/src/lib.rs @@ -23,7 +23,7 @@ //! //! # Usage //! -//! ```ignore +//! ``` //! use rite_stdlib::default_registry; //! //! let registry = default_registry(); diff --git a/crates/rite-stdlib/tests/actions.rs b/crates/rite-stdlib/tests/actions.rs new file mode 100644 index 0000000..8785c2d --- /dev/null +++ b/crates/rite-stdlib/tests/actions.rs @@ -0,0 +1,346 @@ +//! Focused tests for stdlib actions: each executes one action and asserts its +//! single responsibility (its outcome, the fact it records, or the artifact it +//! produces), not that a whole ceremony runs. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::collections::HashMap; + +use rite_model::{ArtifactId, ArtifactRef, StepFact, StepId, StepInputs}; +use rite_runtime::{ + Action, ArtifactValue, ExecutionState, Response, StepInfo, test_support::ReporterHarness, +}; +use rite_sdk::{KeyAlgorithm, KeyPolicy, KeySpec, KeyStoreBackend}; +use rite_stdlib::{ + AttestAction, CheckValueAction, ClockCheckAction, ConfirmAction, ExportPublicAction, + GatherEntropyAction, MachineInfoAction, MockBackend, OralReadbackAction, UnwrapKeyAction, + WrapKeyAction, +}; + +fn make_state() -> ExecutionState { + ExecutionState::new(HashMap::new(), HashMap::new(), HashMap::new(), false) +} + +/// A bare step with no role, backend, output, or inputs, enough for the +/// interactive verification actions, which read only their params. +fn bare_step(id: &str) -> StepInfo { + StepInfo::new(StepId::new(id), None, None, None, None) +} + +// ── interactive verification / attestation ────────────────────────────────── + +#[test] +fn confirm_completes_on_yes() { + let mut harness = ReporterHarness::new(); + harness.respond(0, Response::Bool(true)); + let state = make_state(); + let step = bare_step("confirm"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + ConfirmAction.execute(&step, &ctx, &serde_json::json!({}), &mut reporter, None) + }; + + result.expect("a yes response completes the confirmation"); +} + +#[test] +fn clock_check_completes_when_clock_confirmed() { + let mut harness = ReporterHarness::new(); + harness.respond(0, Response::Bool(true)); + let state = make_state(); + let step = bare_step("clock"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + ClockCheckAction.execute(&step, &ctx, &serde_json::json!({}), &mut reporter, None) + }; + + result.expect("confirming the clock completes the step"); +} + +#[test] +fn attest_records_an_attestation_fact() { + let mut harness = ReporterHarness::new(); + harness.respond(0, Response::Text("attest".to_string())); + let state = make_state(); + let step = bare_step("officer_attest"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + AttestAction.execute(&step, &ctx, &serde_json::json!({}), &mut reporter, None) + }; + result.expect("typing the literal confirmation completes the attestation"); + + assert!( + harness + .facts() + .iter() + .any(|f| matches!(f, StepFact::AttestationRecorded { .. })), + "attest must record an AttestationRecorded fact" + ); +} + +#[test] +fn gather_entropy_completes_with_a_contribution() { + let mut harness = ReporterHarness::new(); + harness.respond(0, Response::Text("3 1 4 1 5 9 2 6".to_string())); + let state = make_state(); + let step = bare_step("entropy"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + GatherEntropyAction.execute(&step, &ctx, &serde_json::json!({}), &mut reporter, None) + }; + + result.expect("a non-empty contribution is folded and the step completes"); +} + +#[test] +fn oral_readback_completes_on_confirmation() { + let mut harness = ReporterHarness::new(); + harness.respond(0, Response::Bool(true)); + let state = make_state(); + let step = bare_step("readback"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + let params = serde_json::json!({ "value": "ABC123" }); + OralReadbackAction.execute(&step, &ctx, ¶ms, &mut reporter, None) + }; + + result.expect("a confirmed readback completes the step"); +} + +#[test] +fn machine_info_records_a_snapshot_fact() { + let mut harness = ReporterHarness::new(); + let state = make_state(); + let step = bare_step("capture_machine"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + MachineInfoAction.execute(&step, &ctx, &serde_json::json!({}), &mut reporter, None) + }; + result.expect("capturing machine info completes"); + + assert!( + harness.facts().iter().any(|f| matches!( + f, + StepFact::BackendOperation { kind, .. } if kind == "machine_info" + )), + "machine_info must record a machine_info BackendOperation fact" + ); +} + +// ── automatic comparison ──────────────────────────────────────────────────── + +#[test] +fn check_value_passes_on_match_and_fails_on_mismatch() { + let mut harness = ReporterHarness::new(); + let state = make_state(); + let step = bare_step("check"); + + let matched = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + let params = serde_json::json!({ "actual": "abc123", "expected": "abc123" }); + CheckValueAction.execute(&step, &ctx, ¶ms, &mut reporter, None) + }; + matched.expect("equal values pass"); + + let mismatched = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + let params = serde_json::json!({ "actual": "abc123", "expected": "different" }); + CheckValueAction.execute(&step, &ctx, ¶ms, &mut reporter, None) + }; + assert!(mismatched.is_err(), "unequal values must fail the step"); +} + +// ── backend crypto ────────────────────────────────────────────────────────── + +fn key_spec(label: &str, algorithm: KeyAlgorithm) -> KeySpec { + KeySpec { + algorithm, + label: label.to_string(), + policy: KeyPolicy::default(), + location_hint: None, + } +} + +/// Generate a key on the mock backend and wrap it as a `BackendKey` artifact, +/// the form the crypto actions resolve their inputs from. +fn backend_key( + backend: &mut MockBackend, + id: &str, + algorithm: KeyAlgorithm, +) -> (ArtifactId, ArtifactValue) { + let meta = backend.generate_key(key_spec(id, algorithm)).unwrap(); + ( + ArtifactId::new(id), + ArtifactValue::BackendKey { + backend_name: "mock".to_string(), + key_id: meta.key_id, + algorithm: meta.algorithm, + public_key: meta.public_key, + }, + ) +} + +fn step_single(id: &str, produces: &str, input: ArtifactId) -> StepInfo { + let inputs = StepInputs::Single(ArtifactRef::Produced { + id: input, + property: None, + }); + StepInfo::new( + StepId::new(id), + None, + Some("mock".to_string()), + Some(ArtifactId::new(produces)), + Some(inputs), + ) +} + +fn step_named(id: &str, produces: &str, pairs: &[(&str, ArtifactId)]) -> StepInfo { + let map = pairs + .iter() + .map(|(name, id)| { + ( + (*name).to_string(), + ArtifactRef::Produced { + id: id.clone(), + property: None, + }, + ) + }) + .collect(); + StepInfo::new( + StepId::new(id), + None, + Some("mock".to_string()), + Some(ArtifactId::new(produces)), + Some(StepInputs::Named(map)), + ) +} + +fn produced<'a>(artifacts: &'a [(ArtifactId, ArtifactValue)], id: &str) -> &'a ArtifactValue { + artifacts + .iter() + .find(|(aid, _)| aid.as_str() == id) + .map_or_else(|| panic!("artifact `{id}` was not produced"), |(_, v)| v) +} + +#[test] +fn export_public_produces_a_public_key_artifact() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let (key_id, key) = backend_key(&mut backend, "ca_keypair", KeyAlgorithm::Rsa2048); + let state = make_state().with_material(key_id.clone(), key); + let step = step_single("export", "ca_public_key", key_id); + let mut harness = ReporterHarness::new(); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + ExportPublicAction + .execute( + &step, + &ctx, + &serde_json::json!({}), + &mut reporter, + Some(&mut backend), + ) + .expect("export_public completes") + }; + + assert!( + matches!( + produced(&result.artifacts, "ca_public_key"), + ArtifactValue::PublicKey { .. } + ), + "export_public must produce a PublicKey artifact" + ); +} + +#[test] +fn wrap_then_unwrap_round_trips_through_the_actions() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + // CMS-RSA-GCM (the action default) wraps to an RSA recipient. + let (recipient_id, recipient) = backend_key(&mut backend, "recipient", KeyAlgorithm::Rsa4096); + let (secret_id, secret) = backend_key(&mut backend, "secret_key", KeyAlgorithm::Rsa4096); + let state = make_state() + .with_material(recipient_id.clone(), recipient) + .with_material(secret_id.clone(), secret); + + let wrap_step = step_named( + "wrap", + "wrapped_key", + &[ + ("key_to_wrap", secret_id), + ("wrapping_key", recipient_id.clone()), + ], + ); + let mut harness = ReporterHarness::new(); + let wrapped = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(wrap_step.id.clone()); + let result = WrapKeyAction + .execute( + &wrap_step, + &ctx, + &serde_json::json!({}), + &mut reporter, + Some(&mut backend), + ) + .expect("wrap_key completes"); + // ArtifactValue is not Clone, so move the produced value out of the result. + let value = result + .artifacts + .into_iter() + .find(|(id, _)| id.as_str() == "wrapped_key") + .map(|(_, v)| v) + .expect("wrap_key must produce a wrapped_key artifact"); + assert!( + matches!(value, ArtifactValue::WrappedKey { .. }), + "wrap_key must produce a WrappedKey artifact" + ); + value + }; + + let wrapped_id = ArtifactId::new("wrapped_key"); + let state = state.with_material(wrapped_id.clone(), wrapped); + let unwrap_step = step_named( + "unwrap", + "restored_key", + &[ + ("unwrapping_key", recipient_id), + ("wrapped_data", wrapped_id), + ], + ); + let restored = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(unwrap_step.id.clone()); + UnwrapKeyAction + .execute( + &unwrap_step, + &ctx, + &serde_json::json!({}), + &mut reporter, + Some(&mut backend), + ) + .expect("unwrap_key completes") + }; + + assert!( + matches!( + produced(&restored.artifacts, "restored_key"), + ArtifactValue::BackendKey { .. } + ), + "unwrap_key must produce a BackendKey artifact" + ); +} diff --git a/crates/rite/Cargo.toml b/crates/rite/Cargo.toml index f01256d..515790d 100644 --- a/crates/rite/Cargo.toml +++ b/crates/rite/Cargo.toml @@ -48,6 +48,7 @@ chrono = { workspace = true } insta = { workspace = true } tempfile = { workspace = true } chrono = { workspace = true } +assert_cmd = { workspace = true } [lints] workspace = true diff --git a/crates/rite/tests/examples.rs b/crates/rite/tests/examples.rs new file mode 100644 index 0000000..7590a87 --- /dev/null +++ b/crates/rite/tests/examples.rs @@ -0,0 +1,72 @@ +//! Smoke tests over the shipped example ceremonies. +//! +//! Every `*.rite.yaml` under `examples/` must pass `rite check` (valid syntax +//! and resolution) and complete a `rite run --dry-run` through the mock backend +//! (real software crypto, no hardware). This keeps the examples runnable, and +//! self-documenting, as the DSL and runtime evolve. New examples are covered +//! automatically: the tests discover files, they are not enumerated. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use assert_cmd::Command; +use std::path::{Path, PathBuf}; + +fn examples_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples") +} + +fn collect(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("examples dir is readable") { + let path = entry.expect("readable dir entry").path(); + if path.is_dir() { + collect(&path, out); + } else if path + .file_name() + .is_some_and(|n| n.to_string_lossy().ends_with(".rite.yaml")) + { + out.push(path); + } + } +} + +fn ceremonies() -> Vec { + let mut out = Vec::new(); + collect(&examples_dir(), &mut out); + out.sort(); + assert!( + !out.is_empty(), + "no example ceremonies found under {}", + examples_dir().display() + ); + out +} + +#[test] +fn examples_pass_check() { + for file in ceremonies() { + Command::cargo_bin("rite") + .expect("rite binary builds") + .arg("check") + .arg(&file) + .assert() + .success(); + } +} + +#[test] +fn examples_complete_dry_run() { + // One output root for all runs; each ceremony writes its own timestamped + // subdirectory under it. The TempDir cleans everything up on drop. + let out_root = tempfile::tempdir().expect("create output tempdir"); + for file in ceremonies() { + Command::cargo_bin("rite") + .expect("rite binary builds") + // --dry-run already forces the headless driver; --no-prompt keeps + // the run non-interactive regardless of how the test is launched. + .args(["run", "--dry-run", "--no-prompt"]) + .arg("-o") + .arg(out_root.path()) + .arg(&file) + .assert() + .success(); + } +} diff --git a/crates/rite/tests/integration.rs b/crates/rite/tests/integration.rs index c170e48..6b33581 100644 --- a/crates/rite/tests/integration.rs +++ b/crates/rite/tests/integration.rs @@ -31,8 +31,10 @@ fn check_minimal_ceremony_resolves_cleanly() { assert!(!has_errors, "unexpected errors: {diags:?}"); let resolved = resolved.expect("ceremony resolves"); assert_eq!(resolved.metadata.name, "Minimal Ceremony"); - assert_eq!(resolved.roles.len(), 1); - assert_eq!(resolved.execution_plan.len(), 1); + assert!( + !resolved.execution_plan.is_empty(), + "minimal ceremony must lower to at least one step" + ); } #[test] @@ -75,11 +77,7 @@ fn check_root_ca_software_resolves_cleanly() { let resolved = resolved.expect("ceremony resolves"); assert_eq!(resolved.metadata.name, "Test Root CA"); - assert_eq!(resolved.roles.len(), 3, "expected 3 roles"); - assert_eq!(resolved.execution_plan.len(), 8, "expected 8 steps"); - assert_eq!(resolved.materials.len(), 1, "expected 1 material"); - assert_eq!(resolved.outputs.len(), 3, "expected 3 outputs"); - assert_eq!(resolved.parameters.len(), 1, "expected 1 parameter"); + assert_declared_outputs(&resolved); } #[test] @@ -95,11 +93,23 @@ fn check_root_ca_ecdsa_software_resolves_cleanly() { let resolved = resolved.expect("ceremony resolves"); assert_eq!(resolved.metadata.name, "Test Root CA (ECDSA)"); - assert_eq!(resolved.roles.len(), 3, "expected 3 roles"); - assert_eq!(resolved.execution_plan.len(), 8, "expected 8 steps"); - assert_eq!(resolved.materials.len(), 1, "expected 1 material"); - assert_eq!(resolved.outputs.len(), 3, "expected 3 outputs"); - assert_eq!(resolved.parameters.len(), 1, "expected 1 parameter"); + assert_declared_outputs(&resolved); +} + +/// Both root-CA fixtures declare the same three outputs. Asserting they are +/// present by name proves lowering kept the output declarations, without +/// pinning brittle element counts that mirror the fixture (see the testing +/// strategy: prefer property assertions over fixture-count mirroring). +fn assert_declared_outputs(resolved: &rite_model::Ceremony) { + for id in ["root_ca_public_key", "root_ca_cert", "wrapped_root_ca_key"] { + assert!( + resolved + .outputs + .get(&rite_model::OutputId::new(id)) + .is_some(), + "output `{id}` must be present after resolution" + ); + } } #[test] diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 0000000..9e65d92 --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,118 @@ +# Testing strategy + +What we test, at which level, and where it lives in the tree. The rationale lives in +[`CONTRIBUTING.md`](../../CONTRIBUTING.md#testing). + +## Stability tiers + +Crates do not all carry the same promise, so they are not tested the same way. Two kinds +of compatibility are in play and must not be conflated: + +- **API compatibility** (Rust types and signatures). Broken freely at 0.x. Tests assert + behavior, never the exact shape of an API, so a refactor touches one call site, not fifty + assertions. +- **Format compatibility** (transcript, DSL schema, rendered documents). A data contract with + the outside world: a transcript produced today must still `rite verify` tomorrow, and a + ceremony written against the documented DSL must keep parsing, unless we deliberately bump the + `version:` field. Format changes are intentional and reviewed, never incidental. + +| Tier | Crates | Promise at 0.x | Emphasis | +|---|---|---|---| +| **A (Contract)** | `rite-sdk`, `rite-model` | API breakable; **wire strings and transcript/DSL format change only on purpose** | Contract tests + golden snapshots, mandatory | +| **B (Core)** | `rite-resolver`, `rite-runtime`, `rite-stdlib`, `rite-openssl` | No API promise; refactor freely | Unit + integration on behavior, not structure | +| **C (Edges)** | `rite-tui`, `rite-render`, `rite-ls`, `rite` | No promise | Snapshots + smoke + manual | + +The MSRV CI job already pins only the published library floor (`rite-sdk`, `rite-model`, +`rite-resolver`); binaries float with stable Rust. Keep the two consistent. + +## Levels and where they go + +| Level | In this project | Rust location | +|---|---|---| +| Unit | Resolver lowering, expression eval, entropy ratchet, path safety, action input parsing | Inline `#[cfg(test)] mod tests`. The only level that may touch private items | +| Integration | A crate through its public API (a stdlib action + the real OpenSSL backend producing parseable DER; `analyze()` over a fixture) | `crates//tests/`. Public API only | +| End-to-end | A whole ceremony from YAML to transcript via the headless driver; the CLI as a subprocess; the example smoke tests | `tests/` in `rite`; `assert_cmd` for subprocess | +| Contract / golden | Wire strings (enum `serde`/`Display`) and transcript / diagnostic / rendered-doc snapshots | Table tests + `insta` | +| Property | Round-trips, path confinement never escaping its root, resolver never panicking | `proptest`, selectively | +| Manual | YubiKey/TPM device flows, the TUI, operator ergonomics | `#[ignore]`d tests + `docs/` checklists | + +Notes that are easy to get wrong: + +- **Default a new test to `tests/`.** Drop to an inline unit test only when you must assert a + private invariant. The `tests/` location also catches anything accidentally left `pub`. +- **Doc tests are under-used on `rite-sdk` and `rite-model`.** A public item an external backend + or verifier calls should carry a runnable `/// ```` example; treat a missing one on a new + public item as a review nit. +- **Shared harness code** (e.g. `rite_runtime::test_support::ReporterHarness`) lives behind a + `test_support` module so other crates' integration tests reuse it instead of duplicating setup. + +## Coverage expectations + +Most code needs no coverage mandate: assert behavior where it has value and stop. But four +surfaces are *extensible*, contributors add entries to them, and a gap there ships broken +behavior silently. Each new entry arrives with a test: + +- **Every stdlib action**: at least one test that executes it and asserts the artifact or fact it + produces, not merely that a ceremony using it exits 0. +- **Every backend**: a test per trait method it implements, including the failure shape, not only + the success path. +- **Every resolver diagnostic**: a test that the offending input produces it, at the right span. + +These are registries contributors extend by adding an entry. Rendered prose (duty descriptions, +step instructions, prompts, report sections) is deliberately *not* on this list: it is covered +wholesale by the render snapshots, not by a per-item rule. + +A smoke test that runs an action (the example ceremonies) does not satisfy this. It proves the +action does not crash, not that it does the right thing, those are different claims. + +## Golden tests (insta) + +Use `insta` for any assertion whose expected value is a document or a serialized structure, +rather than hand-built string equality: transcript JSONL (Tier A), resolver diagnostics, rendered +scripts and reports, CLI stdout/stderr. + +- Review with `cargo insta review`; commit `.snap` files. A snapshot diff is a prompt to confirm + the change was intended, especially for Tier-A outputs. +- Normalize nondeterminism (timestamps, temp paths, nonces) before snapshotting, so a diff always + means a real change. The executor `Clock` and the entropy seed are the injection points. +- Prefer one snapshot of a meaningful whole (a full transcript) over many fragment snapshots. + +## Example ceremonies + +Everything under `examples/` is a test fixture. `crates/rite/tests/examples.rs` discovers every +`*.rite.yaml` and asserts each one passes `rite check` and completes `rite run --dry-run` through +the mock backend. New examples are covered automatically; they are discovered, not enumerated. An +example that stops resolving or running is a failed build, so examples cannot rot. + +## What not to test + +These cost maintenance and catch nothing. Remove on sight; do not add. + +- **Fixture-count mirroring.** `assert_eq!(resolved.roles.len(), 3)` restates the fixture and + breaks on every benign edit. Assert the property instead (resolves cleanly, a named role + exists). Counting is fine only when the count *is* the behavior (e.g. "this input lowers to + exactly two `after` duties"). +- **The compiler's job.** Enum exhaustiveness, that a struct has a field, that a `match` is total. +- **Trivial internal mappings.** A `Display`/`as_str` with no external meaning needs no + per-variant test. The test for whether a per-variant table is worth it: *does this string + appear in YAML, a transcript, or a filename?* If yes it is a contract test (level 4) and + exhaustive coverage is the point; if no, skip it. +- **The mock.** Exercising a mock and asserting it behaves as written tests nothing. Mocks are + scaffolding for testing callers. + +## Error modes + +Error modes are first-class in the runtime, so they are first-class here. For every recoverable +path the model defines (retry, abort, a failed attestation, a missing artifact, a path-traversal +attempt in a ceremony file) there is a test that the failure is surfaced as the model promises, +not only that the happy path works. + +## Running + +```sh +cargo test --workspace # everything CI runs +cargo test -p rite-runtime # one crate +cargo test --doc # doc tests only +cargo test -- --ignored # hardware/manual tests (need devices) +cargo insta review # accept/reject snapshot changes +```