Files
Guillaume GRABÉ 1302ce412c
CI / Format (push) Successful in 48s
CI / Test (push) Failing after 7m9s
CI / Check (push) Successful in 12m6s
CI / Clippy (push) Successful in 12m10s
fix: format and lint
2026-06-08 23:51:29 +02:00

617 lines
22 KiB
Rust

//! 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");
}