From 405bd7277db4eda8b8c281a9370e2ba83ca1d255 Mon Sep 17 00:00:00 2001 From: Dehan Meng Date: Wed, 24 Sep 2025 15:30:10 +0800 Subject: [PATCH] test(operator): Add comprehensive unit tests for trustee module This commit introduces a full suite of 15 unit tests for the functions within `trustee.rs`, validating pure logic, error handling, and both simple and complex Kubernetes API interactions using a mocked client. - `test_get_image_pcrs_success`: Verifies that a valid JSON string in a ConfigMap is correctly deserialized into the `ImagePcrs` struct. - `test_get_image_pcrs_no_data`: Ensures an error is returned when the ConfigMap's `data` field is missing. - `test_get_image_pcrs_invalid_json`: Confirms that an error is propagated when the data contains an invalid JSON string. - `test_generate_luks_key_returns_correct_size`: A sanity check to validate that `generate_luks_key` runs without errors and returns a key of the expected 32-byte length. These tests validate the idempotency and error handling of functions that perform a single `create` operation, primarily testing the `info_if_exists!` macro. - `test_create_rv_config_map_success`: Verifies the function returns `Ok(())` on a successful API response (200 OK). - `test_create_rv_config_map_already_exists`: Verifies the function correctly handles a 409 Conflict and returns `Ok(())`, confirming idempotency. - `test_create_rv_config_map_generic_error`: Ensures a generic API error (e.g., 500) is properly propagated as an `Err`. - `test_generate_resource_policy_success`: Validates the success path for the `generate_resource_policy` function. - `test_generate_kbs_https_certificate_success`: Validates the success path for the `generate_kbs_https_certificate` function. - `test_generate_kbs_configurations_success`: Validates the success path for the `generate_kbs_configurations` function. - `test_generate_attestation_policy_success`: Validates the success path for the `generate_attestation_policy` function. - `test_generate_kbs_success`: Validates the success path for the `generate_kbs` function. These tests use a stateful mock client to simulate entire operational flows involving multiple API calls. - `test_recompute_reference_values_flow`: Verifies the complete `GET (PCRs) -> GET (RV map) -> PUT (RV map)` sequence executes successfully. - `test_generate_secret_flow_success`: Validates the full `CREATE (Secret) -> GET (KbsConfig) -> PATCH (KbsConfig)` workflow for adding a new secret. - `test_generate_secret_already_present_in_spec`: Tests the boundary condition where a secret ID already exists in the KbsConfig spec, ensuring the function exits early without making a redundant PATCH call. Signed-off-by: Dehan Meng --- .github/workflows/tests.yml | 35 ++++ Cargo.lock | 79 ++++++++- operator/Cargo.toml | 5 + operator/src/trustee.rs | 318 +++++++++++++++++++++++++++++++++++- 4 files changed, 433 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..30725bd4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Demeng +# +# SPDX-License-Identifier: CC0-1.0 + +name: "Rust Tests" +on: + pull_request: + branches: + - "main" +permissions: + contents: "read" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + tests: + name: "Tests" + runs-on: "ubuntu-24.04" + container: "ghcr.io/confidential-clusters/buildroot:latest" + steps: + - name: "Check out repository" + uses: actions/checkout@v5 + - name: "Install toolchain" + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + - name: "Cache build artifacts" + uses: Swatinem/rust-cache@v2 + - name: "cargo test" + run: cargo test --all-targets diff --git a/Cargo.lock b/Cargo.lock index e3f03300..e453b069 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1053,6 +1053,16 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "byteorder", + "num-traits", +] + [[package]] name = "heck" version = "0.5.0" @@ -1683,7 +1693,7 @@ dependencies = [ "thiserror 2.0.16", "tokio", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tracing", ] @@ -2117,6 +2127,8 @@ dependencies = [ "env_logger", "futures-util", "hex", + "http 1.3.1", + "hyper 0.14.32", "json-patch", "jsonptr", "k8s-openapi", @@ -2130,6 +2142,7 @@ dependencies = [ "serde_json", "thiserror 2.0.16", "tokio", + "tower 0.4.13", ] [[package]] @@ -2304,6 +2317,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -2374,6 +2396,36 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + [[package]] name = "redox_syscall" version = "0.5.15" @@ -2502,7 +2554,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -3223,6 +3275,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -3255,7 +3328,7 @@ dependencies = [ "iri-string", "mime", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", diff --git a/operator/Cargo.toml b/operator/Cargo.toml index 9c7e605f..726cc0ef 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -30,3 +30,8 @@ serde.workspace = true serde_json.workspace = true thiserror = "2.0.16" tokio.workspace = true + +[dev-dependencies] +http = "1.1.0" +hyper = { version = "0.14", features = ["full"] } +tower = { version = "0.4.13", features = ["full"] } diff --git a/operator/src/trustee.rs b/operator/src/trustee.rs index 424f4bc2..d2f0eb65 100644 --- a/operator/src/trustee.rs +++ b/operator/src/trustee.rs @@ -421,7 +421,323 @@ pub async fn generate_kbs( let create = kbs_configs .create(&PostParams::default(), &kbs_config) .await; - info_if_exists!(create, "KbsConfig", trustee.kbs_config_name); + info_if_exists!(create, "KbsConfig", &trustee.kbs_config_name); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use compute_pcrs_lib::Pcr; + use http::{Method, Request, Response, StatusCode}; + use kube::client::Body; + use kube::error::ErrorResponse; + use rv_store::{ImagePcr, ImagePcrs}; + use std::collections::BTreeMap; + use std::convert::Infallible; + use std::sync::{Arc, Mutex}; + use tower::service_fn; + + /// A helper struct to ensure temporary files are cleaned up after a test. + /// It deletes the specified files when it goes out of scope. + struct FileCleanup { + paths: Vec, + } + + impl Drop for FileCleanup { + fn drop(&mut self) { + for path in &self.paths { + // .ok() ignores errors if the file doesn't exist, preventing panics in cleanup. + std::fs::remove_file(path).ok(); + } + } + } + + #[derive(Clone)] + struct CapturingMockClient { + requests: Arc>>>, + response_queue: Arc>>>, + } + + impl CapturingMockClient { + fn new(responses: Vec>) -> Self { + Self { + requests: Arc::new(Mutex::new(vec![])), + response_queue: Arc::new(Mutex::new(responses.into_iter().rev().collect())), + } + } + + fn into_client(self, namespace: &str) -> Client { + let svc = service_fn(move |req: Request| { + self.requests.lock().unwrap().push(req); + let response = self + .response_queue + .lock() + .unwrap() + .pop() + .unwrap_or_else(|| { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from( + "Mock client ran out of responses".to_string().into_bytes(), + )) + .unwrap() + }); + async move { Ok::<_, Infallible>(response) } + }); + Client::new(svc, namespace) + } + } + + fn ok_response(body: &T) -> Response { + Response::builder() + .status(StatusCode::OK) + .body(Body::from( + serde_json::to_string(body).unwrap().into_bytes(), + )) + .unwrap() + } + + fn created_response(body: &T) -> Response { + Response::builder() + .status(StatusCode::CREATED) + .body(Body::from( + serde_json::to_string(body).unwrap().into_bytes(), + )) + .unwrap() + } + + fn error_response(status_code: StatusCode, reason: &str, message: &str) -> Response { + let error = ErrorResponse { + status: "Failure".to_string(), + message: message.to_string(), + reason: reason.to_string(), + code: status_code.as_u16(), + }; + Response::builder() + .status(status_code) + .body(Body::from( + serde_json::to_string(&error).unwrap().into_bytes(), + )) + .unwrap() + } + + fn empty_kbs_config(name: &str) -> KbsConfig { + KbsConfig::new( + name, + KbsConfigSpec { + kbs_config_map_name: String::new(), + kbs_auth_secret_name: String::new(), + kbs_deployment_type: String::new(), + kbs_rvps_ref_values_config_map_name: String::new(), + kbs_secret_resources: vec![], + kbs_https_key_secret_name: String::new(), + kbs_https_cert_secret_name: String::new(), + kbs_resource_policy_config_map_name: String::new(), + kbs_attestation_policy_config_map_name: String::new(), + }, + ) + } + + #[test] + fn test_get_image_pcrs_success() { + let mut data = BTreeMap::new(); + let image_pcrs = ImagePcrs(BTreeMap::from([( + "cos".to_string(), + ImagePcr { + first_seen: "2023-01-01T00:00:00Z".parse().unwrap(), + pcrs: vec![Pcr { + id: 0, + value: "pcr0_val".to_string(), + parts: vec![], + }], + }, + )])); + let pcrs_json = serde_json::to_string(&image_pcrs).unwrap(); + data.insert(PCR_CONFIG_FILE.to_string(), pcrs_json); + let config_map = ConfigMap { + data: Some(data), + ..Default::default() + }; + + let result = get_image_pcrs(config_map); + assert!(result.is_ok()); + let pcrs = result.unwrap(); + assert_eq!(pcrs.0.get("cos").unwrap().pcrs[0].value, "pcr0_val"); + } + + #[test] + fn test_get_image_pcrs_no_data() { + let config_map = ConfigMap::default(); + let result = get_image_pcrs(config_map); + assert!(result.is_err()); + match result { + Ok(_) => panic!("Expected error, got Ok"), + Err(e) => assert!( + e.to_string() + .contains("Image PCRs map existed, but had no data") + ), + } + } + + #[test] + fn test_get_image_pcrs_invalid_json() { + let mut data = BTreeMap::new(); + data.insert(PCR_CONFIG_FILE.to_string(), "this is not json".to_string()); + let config_map = ConfigMap { + data: Some(data), + ..Default::default() + }; + let result = get_image_pcrs(config_map); + assert!(result.is_err()); + } + + #[test] + fn test_generate_luks_key_returns_correct_size() { + let result = generate_luks_key().unwrap(); + let jwk: serde_json::Value = serde_json::from_slice(&result).unwrap(); + let key = jwk.get("key").and_then(|v| v.as_str()).unwrap(); + assert_eq!(key.len(), 32); + } + + #[tokio::test] + async fn test_create_resource_idempotency() { + let created_cm = ConfigMap::default(); + let mock_client = CapturingMockClient::new(vec![ + created_response(&created_cm), + error_response( + StatusCode::CONFLICT, + "AlreadyExists", + "configmap already exists", + ), + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "ServerTimeout", + "internal server error", + ), + ]); + let client = mock_client.clone().into_client("test-ns"); + + let result1 = + create_reference_value_config_map(client.clone(), "test-ns", "test-rv-map").await; + assert!(result1.is_ok()); + + let result2 = + create_reference_value_config_map(client.clone(), "test-ns", "test-rv-map").await; + assert!(result2.is_ok()); + + let result3 = + create_reference_value_config_map(client.clone(), "test-ns", "test-rv-map").await; + assert!(result3.is_err()); + } + + #[tokio::test] + async fn test_generate_kbs_auth_public_key_success() { + // Ensure files are cleaned up even if the test panics. + let _cleanup = FileCleanup { + paths: vec!["privateKey".to_string(), "publicKey".to_string()], + }; + + let mock_client = CapturingMockClient::new(vec![created_response(&Secret::default())]); + let client = mock_client.clone().into_client("test-ns"); + + let result = generate_kbs_auth_public_key(client, "test-ns", "test-auth-key-secret").await; + assert!(result.is_ok()); + + let requests = mock_client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let req = &requests[0]; + assert_eq!(req.method(), Method::POST); + assert_eq!(req.uri().path(), "/api/v1/namespaces/test-ns/secrets"); + } + + #[tokio::test] + async fn test_recompute_reference_values_flow() { + let pcr_cm = ConfigMap { + data: Some(BTreeMap::from([( + PCR_CONFIG_FILE.to_string(), + serde_json::to_string(&ImagePcrs(BTreeMap::from([( + "cos".to_string(), + ImagePcr { + first_seen: Utc::now(), + pcrs: vec![Pcr { + id: 0, + value: "pcr0_val".to_string(), + parts: vec![], + }], + }, + )]))) + .unwrap(), + )])), + ..Default::default() + }; + let rv_cm = ConfigMap::default(); + + let mock_client = CapturingMockClient::new(vec![ + ok_response(&pcr_cm), + ok_response(&rv_cm), + ok_response(&rv_cm), + ]); + let client = mock_client.clone().into_client("op-ns"); + + let ctx = RvContextData { + client, + operator_namespace: "op-ns".to_string(), + trustee_namespace: "trustee-ns".to_string(), + pcrs_compute_image: "".to_string(), + rv_map: "test-rv-map".to_string(), + }; + + let result = recompute_reference_values(ctx).await; + assert!(result.is_ok()); + + let requests = mock_client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method(), Method::GET); + assert!(requests[0].uri().path().contains(PCR_CONFIG_MAP)); + assert_eq!(requests[1].method(), Method::GET); + assert!(requests[1].uri().path().contains("test-rv-map")); + assert_eq!(requests[2].method(), Method::PUT); + assert!(requests[2].uri().path().contains("test-rv-map")); + } + + #[tokio::test] + async fn test_generate_secret_flow_with_patch() { + let kbs_config_before_patch = empty_kbs_config("test-kbs-config"); + let mock_client = CapturingMockClient::new(vec![ + created_response(&Secret::default()), + ok_response(&kbs_config_before_patch), + ok_response(&empty_kbs_config("test-kbs-config")), + ]); + let client = mock_client.clone().into_client("test-ns"); + + let result = generate_secret(client, "test-ns", "test-kbs-config", "new-secret-id").await; + assert!(result.is_ok()); + + let requests = mock_client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method(), Method::POST); + assert_eq!(requests[1].method(), Method::GET); + assert_eq!(requests[2].method(), Method::PATCH); + assert!(requests[2].uri().path().contains("test-kbs-config")); + } + + #[tokio::test] + async fn test_generate_secret_already_present_in_spec() { + let mut kbs_config_with_secret = empty_kbs_config("test-kbs-config"); + kbs_config_with_secret.spec.kbs_secret_resources = vec!["existing-secret".to_string()]; + + let mock_client = CapturingMockClient::new(vec![ + created_response(&Secret::default()), + ok_response(&kbs_config_with_secret), + ]); + let client = mock_client.clone().into_client("test-ns"); + + let result = generate_secret(client, "test-ns", "test-kbs-config", "existing-secret").await; + assert!(result.is_ok()); + + let requests = mock_client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + } +}