//! Integration tests for DefectDojo plugin using wiremock //! //! These tests require a running PostgreSQL instance. //! Set DATABASE_URL environment variable to run them. //! They are ignored by default in CI unless DATABASE_URL is set. use charybdis::charybdis::entities::entity::{Metadata, Spec}; use charybdis::charybdis::entities::Entity; use charybdis::database::{ensure_schema, EntityRepository}; use charybdis::plugins::ResourceHandler; use charybdis_defectdojo::{DefectDojoClient, DefectDojoConfig, EngagementConfig, OwnerResolutionConfig}; use serde_json::json; use sqlx::PgPool; use std::collections::HashMap; use std::sync::Arc; use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; async fn setup_db() -> PgPool { let url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set for integration tests"); let pool = PgPool::connect(&url).await.expect("Failed to connect to test database"); ensure_schema(&pool).await.expect("Failed to create schema"); // Clean up from previous test runs sqlx::query("DELETE FROM entities") .execute(&pool) .await .expect("Failed to clean entities table"); pool } fn make_component_entity(id: &str, name: &str, owner: &str) -> Entity { Entity { id: id.to_string(), kind: "Component".to_string(), annotations: HashMap::new(), created_at: None, updated_at: None, metadata: Some(Metadata::ComponentMetadata( charybdis::charybdis::core::ComponentMetadata { name: name.to_string(), namespace: "default".to_string(), description: format!("{} service", name), labels: HashMap::new(), tags: vec![], links: vec![], }, )), spec: Some(Spec::ComponentSpec( charybdis::charybdis::core::ComponentSpec { r#type: "service".to_string(), lifecycle: "production".to_string(), owner: owner.to_string(), system: String::new(), subcomponent_of: String::new(), depends_on: vec![], provides_apis: vec![], consumes_apis: vec![], }, )), } } fn make_config(base_url: &str) -> DefectDojoConfig { DefectDojoConfig { base_url: base_url.to_string(), api_token: "test-token".to_string(), default_product_type_id: Some(1), auto_create_users: false, auto_create_product_types: false, default_engagement: EngagementConfig::default(), owner_resolution: OwnerResolutionConfig::default(), field_mappings: HashMap::new(), } } // ────────────────────────────────────────────────────────────── // Product creation // ────────────────────────────────────────────────────────────── #[tokio::test] #[ignore] // Requires DATABASE_URL async fn test_product_creation_on_component_create() { let pool = setup_db().await; let repository = Arc::new(EntityRepository::new(pool.clone())); let mock_server = MockServer::start().await; // Mock: POST /api/v2/products/ → returns product with id=42 Mock::given(method("POST")) .and(path("/api/v2/products/")) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 42, "name": "payment-api", "description": "payment-api service", "prod_type": 1 }))) .expect(1) .mount(&mock_server) .await; // Mock: POST /api/v2/engagements/ → returns engagement with id=100 Mock::given(method("POST")) .and(path("/api/v2/engagements/")) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 100, "name": "CI/CD Scans", "product": 42 }))) .expect(1) .mount(&mock_server) .await; let mut config = make_config(&mock_server.uri()); config.default_engagement.auto_create = true; // Disable owner resolution for this test (no owner group exists) config.owner_resolution.assign_all_members = false; let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap(); let engagement_handler = Arc::new( charybdis_defectdojo::handlers::EngagementHandler::new( client.clone(), repository.clone(), HashMap::new(), ), ); let handler = charybdis_defectdojo::handlers::ProductHandler::new( client, repository.clone(), HashMap::new(), config, engagement_handler, ); // Create the entity in the database first (returns entity with real UUID) let entity = make_component_entity("", "payment-api", "team-payments"); let entity = repository.create(&entity).await.expect("Failed to create entity"); // Trigger handler let result = handler.handle_create(&entity).await; assert!(result.is_ok(), "handle_create failed: {:?}", result.err()); // Verify annotations were saved let updated = repository.get_by_id(&entity.id).await.unwrap().unwrap(); assert_eq!( updated.annotations.get("defectdojo.com/product-id"), Some(&"42".to_string()) ); assert_eq!( updated.annotations.get("defectdojo.com/engagement-id"), Some(&"100".to_string()) ); } // ────────────────────────────────────────────────────────────── // Product update // ────────────────────────────────────────────────────────────── #[tokio::test] #[ignore] // Requires DATABASE_URL async fn test_product_update_with_existing_product_id() { let pool = setup_db().await; let repository = Arc::new(EntityRepository::new(pool.clone())); let mock_server = MockServer::start().await; // Mock: PUT /api/v2/products/42/ → success Mock::given(method("PUT")) .and(path("/api/v2/products/42/")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 42, "name": "payment-api-updated" }))) .expect(1) .mount(&mock_server) .await; let config = make_config(&mock_server.uri()); let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap(); let engagement_handler = Arc::new( charybdis_defectdojo::handlers::EngagementHandler::new( client.clone(), repository.clone(), HashMap::new(), ), ); let handler = charybdis_defectdojo::handlers::ProductHandler::new( client, repository.clone(), HashMap::new(), config, engagement_handler, ); // Create entity with existing product-id annotation let mut entity = make_component_entity("", "payment-api", "team-payments"); entity.annotations.insert( "defectdojo.com/product-id".to_string(), "42".to_string(), ); let entity = repository.create(&entity).await.expect("Failed to create entity"); // Trigger update handler let result = handler.handle_update(&entity).await; assert!(result.is_ok(), "handle_update failed: {:?}", result.err()); } // ────────────────────────────────────────────────────────────── // Product deletion // ────────────────────────────────────────────────────────────── #[tokio::test] #[ignore] // Requires DATABASE_URL async fn test_product_deletion() { let pool = setup_db().await; let repository = Arc::new(EntityRepository::new(pool.clone())); let mock_server = MockServer::start().await; // Mock: DELETE /api/v2/products/42/ → 204 No Content Mock::given(method("DELETE")) .and(path("/api/v2/products/42/")) .respond_with(ResponseTemplate::new(204)) .expect(1) .mount(&mock_server) .await; let config = make_config(&mock_server.uri()); let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap(); let engagement_handler = Arc::new( charybdis_defectdojo::handlers::EngagementHandler::new( client.clone(), repository.clone(), HashMap::new(), ), ); let handler = charybdis_defectdojo::handlers::ProductHandler::new( client, repository.clone(), HashMap::new(), config, engagement_handler, ); // Entity with product-id annotation let mut entity = make_component_entity("", "payment-api", "team-payments"); entity.annotations.insert( "defectdojo.com/product-id".to_string(), "42".to_string(), ); let entity = repository.create(&entity).await.expect("Failed to create entity"); // Trigger delete handler let result = handler.handle_delete(&entity).await; assert!(result.is_ok(), "handle_delete failed: {:?}", result.err()); } // ────────────────────────────────────────────────────────────── // Owner resolution with product member assignment // ────────────────────────────────────────────────────────────── #[tokio::test] #[ignore] // Requires DATABASE_URL async fn test_owner_resolution_assigns_product_members() { let pool = setup_db().await; let repository = Arc::new(EntityRepository::new(pool.clone())); let mock_server = MockServer::start().await; // Mock: POST /api/v2/products/ → product id=10 Mock::given(method("POST")) .and(path("/api/v2/products/")) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 10, "name": "orders-api", "prod_type": 1 }))) .expect(1) .mount(&mock_server) .await; // Mock: POST /api/v2/engagements/ → engagement id=20 Mock::given(method("POST")) .and(path("/api/v2/engagements/")) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 20, "name": "CI/CD Scans", "product": 10 }))) .expect(1) .mount(&mock_server) .await; // Mock: GET /api/v2/users/?email=alice@example.com → found user id=5 Mock::given(method("GET")) .and(path("/api/v2/users/")) .and(query_param("email", "alice@example.com")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "count": 1, "results": [{"id": 5, "username": "alice@example.com"}] }))) .expect(1) .mount(&mock_server) .await; // Mock: GET /api/v2/users/?email=bob@example.com → found user id=7 Mock::given(method("GET")) .and(path("/api/v2/users/")) .and(query_param("email", "bob@example.com")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "count": 1, "results": [{"id": 7, "username": "bob@example.com"}] }))) .expect(1) .mount(&mock_server) .await; // Mock: GET /api/v2/roles/?name=Owner → role id=4 Mock::given(method("GET")) .and(path("/api/v2/roles/")) .and(query_param("name", "Owner")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "count": 1, "results": [{"id": 4, "name": "Owner"}] }))) .expect(1) .mount(&mock_server) .await; // Mock: GET /api/v2/product_members/?product=10&user=5 → not yet a member Mock::given(method("GET")) .and(path("/api/v2/product_members/")) .and(query_param("product", "10")) .and(query_param("user", "5")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "count": 0, "results": [] }))) .expect(1) .mount(&mock_server) .await; // Mock: GET /api/v2/product_members/?product=10&user=7 → not yet a member Mock::given(method("GET")) .and(path("/api/v2/product_members/")) .and(query_param("product", "10")) .and(query_param("user", "7")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "count": 0, "results": [] }))) .expect(1) .mount(&mock_server) .await; // Mock: POST /api/v2/product_members/ → member created (called twice) Mock::given(method("POST")) .and(path("/api/v2/product_members/")) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 99, "product": 10, "user": 5, "role": 4 }))) .expect(2) .mount(&mock_server) .await; // Set up config with owner resolution enabled let mut config = make_config(&mock_server.uri()); config.default_engagement.auto_create = true; config.owner_resolution.user_email_annotation = "keycloak.com/email".to_string(); config.owner_resolution.defectdojo_lookup_field = "email".to_string(); config.owner_resolution.assign_all_members = true; // Create Group "backend-team" with members alice and bob let group_entity = Entity { id: "group-backend".to_string(), kind: "Group".to_string(), annotations: HashMap::new(), created_at: None, updated_at: None, metadata: Some(Metadata::GroupMetadata( charybdis::charybdis::core::GroupMetadata { name: "backend-team".to_string(), namespace: "default".to_string(), description: "Backend team".to_string(), labels: HashMap::new(), tags: vec![], links: vec![], }, )), spec: Some(Spec::GroupSpec(charybdis::charybdis::core::GroupSpec { r#type: "team".to_string(), profile: None, parent: String::new(), children: vec![], members: vec!["alice".to_string(), "bob".to_string()], })), }; repository.create(&group_entity).await.unwrap(); // Create User "alice" with keycloak email annotation let mut alice = Entity { id: "user-alice".to_string(), kind: "User".to_string(), annotations: HashMap::new(), created_at: None, updated_at: None, metadata: Some(Metadata::UserMetadata( charybdis::charybdis::core::UserMetadata { name: "alice".to_string(), namespace: "default".to_string(), description: "Alice".to_string(), labels: HashMap::new(), tags: vec![], links: vec![], }, )), spec: Some(Spec::UserSpec(charybdis::charybdis::core::UserSpec { profile: None, member_of: vec!["backend-team".to_string()], })), }; alice.annotations.insert("keycloak.com/email".to_string(), "alice@example.com".to_string()); repository.create(&alice).await.unwrap(); // Create User "bob" with keycloak email annotation let mut bob = Entity { id: "user-bob".to_string(), kind: "User".to_string(), annotations: HashMap::new(), created_at: None, updated_at: None, metadata: Some(Metadata::UserMetadata( charybdis::charybdis::core::UserMetadata { name: "bob".to_string(), namespace: "default".to_string(), description: "Bob".to_string(), labels: HashMap::new(), tags: vec![], links: vec![], }, )), spec: Some(Spec::UserSpec(charybdis::charybdis::core::UserSpec { profile: None, member_of: vec!["backend-team".to_string()], })), }; bob.annotations.insert("keycloak.com/email".to_string(), "bob@example.com".to_string()); repository.create(&bob).await.unwrap(); // Create handlers let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap(); let engagement_handler = Arc::new( charybdis_defectdojo::handlers::EngagementHandler::new( client.clone(), repository.clone(), HashMap::new(), ), ); let handler = charybdis_defectdojo::handlers::ProductHandler::new( client, repository.clone(), HashMap::new(), config, engagement_handler, ); // Create component owned by "backend-team" let component = make_component_entity("", "orders-api", "backend-team"); let component = repository.create(&component).await.unwrap(); // Trigger handler let result = handler.handle_create(&component).await; assert!(result.is_ok(), "handle_create failed: {:?}", result.err()); // Verify annotations let updated = repository.get_by_id(&component.id).await.unwrap().unwrap(); assert_eq!( updated.annotations.get("defectdojo.com/product-id"), Some(&"10".to_string()) ); assert_eq!( updated.annotations.get("defectdojo.com/engagement-id"), Some(&"20".to_string()) ); // Owner member IDs should be set assert!( updated.annotations.contains_key("defectdojo.com/owner-member-ids"), "Expected owner-member-ids annotation" ); } // ────────────────────────────────────────────────────────────── // Engagement auto-creation disabled // ────────────────────────────────────────────────────────────── #[tokio::test] #[ignore] // Requires DATABASE_URL async fn test_product_creation_without_engagement() { let pool = setup_db().await; let repository = Arc::new(EntityRepository::new(pool.clone())); let mock_server = MockServer::start().await; // Mock: POST /api/v2/products/ → product id=55 Mock::given(method("POST")) .and(path("/api/v2/products/")) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 55, "name": "simple-service", "prod_type": 1 }))) .expect(1) .mount(&mock_server) .await; // No engagement mock — should NOT be called Mock::given(method("POST")) .and(path("/api/v2/engagements/")) .respond_with(ResponseTemplate::new(201).set_body_json(json!({"id": 999}))) .expect(0) // MUST NOT be called .mount(&mock_server) .await; let mut config = make_config(&mock_server.uri()); config.default_engagement.auto_create = false; let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap(); let engagement_handler = Arc::new( charybdis_defectdojo::handlers::EngagementHandler::new( client.clone(), repository.clone(), HashMap::new(), ), ); let handler = charybdis_defectdojo::handlers::ProductHandler::new( client, repository.clone(), HashMap::new(), config, engagement_handler, ); let entity = make_component_entity("", "simple-service", ""); let entity = repository.create(&entity).await.unwrap(); let result = handler.handle_create(&entity).await; assert!(result.is_ok(), "handle_create failed: {:?}", result.err()); // Verify only product-id annotation (no engagement-id) let updated = repository.get_by_id(&entity.id).await.unwrap().unwrap(); assert_eq!( updated.annotations.get("defectdojo.com/product-id"), Some(&"55".to_string()) ); assert!(!updated.annotations.contains_key("defectdojo.com/engagement-id")); } // ────────────────────────────────────────────────────────────── // DefectDojo API error handling // ────────────────────────────────────────────────────────────── #[tokio::test] #[ignore] // Requires DATABASE_URL async fn test_product_creation_handles_api_error() { let pool = setup_db().await; let repository = Arc::new(EntityRepository::new(pool.clone())); let mock_server = MockServer::start().await; // Mock: POST /api/v2/products/ → 400 Bad Request Mock::given(method("POST")) .and(path("/api/v2/products/")) .respond_with( ResponseTemplate::new(400) .set_body_json(json!({"name": ["This field may not be blank."]})), ) .mount(&mock_server) .await; let config = make_config(&mock_server.uri()); let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap(); let engagement_handler = Arc::new( charybdis_defectdojo::handlers::EngagementHandler::new( client.clone(), repository.clone(), HashMap::new(), ), ); let handler = charybdis_defectdojo::handlers::ProductHandler::new( client, repository.clone(), HashMap::new(), config, engagement_handler, ); let entity = make_component_entity("", "bad-entity", "team-x"); let entity = repository.create(&entity).await.unwrap(); let result = handler.handle_create(&entity).await; assert!(result.is_err(), "Expected error on 400 response"); }