diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c7b54461..e0843587 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -23,7 +23,7 @@ jobs: SQLX_OFFLINE: true steps: - name: Checkout sources - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: ref: ${{ github.ref }} @@ -47,7 +47,7 @@ jobs: components: rustfmt, clippy - name: Cache cargo registry - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: ~/.cargo/registry key: docker-registry-${{ hashFiles('**/Cargo.lock') }} @@ -56,7 +56,7 @@ jobs: docker- - name: Cache cargo index - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: ~/.cargo/git key: docker-index-${{ hashFiles('**/Cargo.lock') }} @@ -69,7 +69,7 @@ jobs: head -c16 /dev/urandom > src/secret.key - name: Cache cargo build - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: target key: docker-build-${{ hashFiles('**/Cargo.lock') }} @@ -124,7 +124,7 @@ jobs: - name: Archive production artifacts if: ${{ hashFiles('web/package.json') != '' }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: name: dist-without-markdown path: | @@ -141,13 +141,14 @@ jobs: cp target/release/server app/stacker/server if [ -d web/dist ]; then cp -a web/dist/. app/stacker; fi cp Dockerfile app/Dockerfile + cp access_control.conf.dist app/access_control.conf.dist cd app touch .env tar -czvf ../app.tar.gz . cd .. - name: Upload app archive for Docker job - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: name: artifact-linux-docker path: app.tar.gz @@ -157,16 +158,10 @@ jobs: runs-on: ubuntu-latest needs: cicd-docker steps: - - name: Download app archive - uses: actions/download-artifact@v7 + - name: Checkout sources + uses: actions/checkout@v4 with: - name: artifact-linux-docker - - - name: Extract app archive - run: tar -zxvf app.tar.gz - - - name: Display structure of downloaded files - run: ls -R + ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} - name: Set up QEMU @@ -192,7 +187,8 @@ jobs: name: Build and push uses: docker/build-push-action@v6 with: - push: true + context: . + push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.docker_tags.outputs.tags }} stackerdb-docker: @@ -201,7 +197,7 @@ jobs: needs: cicd-docker steps: - name: Checkout sources - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: ref: ${{ github.ref }} - @@ -231,3 +227,4 @@ jobs: file: ./stackerdb/Dockerfile push: true tags: ${{ steps.stackerdb_tags.outputs.tags }} + diff --git a/migrations/20260325100000_casbin_template_reviews_rule.down.sql b/migrations/20260325100000_casbin_template_reviews_rule.down.sql new file mode 100644 index 00000000..012d81f2 --- /dev/null +++ b/migrations/20260325100000_casbin_template_reviews_rule.down.sql @@ -0,0 +1 @@ +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/templates/:id/reviews' AND v2 = 'GET'; diff --git a/migrations/20260325100000_casbin_template_reviews_rule.up.sql b/migrations/20260325100000_casbin_template_reviews_rule.up.sql new file mode 100644 index 00000000..faf805ae --- /dev/null +++ b/migrations/20260325100000_casbin_template_reviews_rule.up.sql @@ -0,0 +1,2 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/:id/reviews', 'GET', '', '', '') ON CONFLICT DO NOTHING; diff --git a/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql b/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql new file mode 100644 index 00000000..0d0574e0 --- /dev/null +++ b/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_admin' + AND v1 = '/admin/project/:id/compose' + AND v2 = 'GET'; diff --git a/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql b/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql new file mode 100644 index 00000000..a19690ad --- /dev/null +++ b/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql @@ -0,0 +1,8 @@ +-- Allow group_admin (and roles inheriting it, like root) to access the admin +-- project compose endpoint. The existing rule only grants access to the +-- admin_service JWT role, but OAuth-based access (User Service client +-- credentials) authenticates as the client owner whose role is "root". +-- root inherits group_admin, so adding the policy here covers both paths. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/project/:id/compose', 'GET', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index b3d86add..29aa29ff 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -278,6 +278,27 @@ enum MarketplaceCommands { #[arg(long)] json: bool, }, + /// Submit current stack to the marketplace for review + Submit { + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Stack version (default: from stacker.yml or "1.0.0") + #[arg(long)] + version: Option, + /// Short description for marketplace listing + #[arg(long)] + description: Option, + /// Category code (e.g. ai-agents, data-pipelines, saas-starter) + #[arg(long)] + category: Option, + /// Pricing: free, one_time, subscription (default: free) + #[arg(long, value_name = "TYPE")] + plan_type: Option, + /// Price amount (required if plan_type is not free) + #[arg(long)] + price: Option, + }, } #[derive(Debug, Subcommand)] @@ -1186,6 +1207,16 @@ fn get_command( name, json, ), ), + MarketplaceCommands::Submit { + file, + version, + description, + category, + plan_type, + price, + } => Box::new(stacker::console::commands::cli::submit::SubmitCommand::new( + file, version, description, category, plan_type, price, + )), }, // Completion is handled in main() before this function is called. StackerCommands::Completion { .. } => unreachable!(), diff --git a/src/cli/error.rs b/src/cli/error.rs index 8c90dd4c..c942e6a0 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -47,6 +47,9 @@ pub enum CliError { EnvFileNotFound { path: std::path::PathBuf }, SecretKeyNotFound { key: String }, + // Marketplace errors + MarketplaceFailed(String), + // Agent errors AgentNotFound { deployment_hash: String }, AgentOffline { deployment_hash: String }, @@ -148,6 +151,9 @@ impl fmt::Display for CliError { Self::SecretKeyNotFound { key } => { write!(f, "Secret key not found: {key}") } + Self::MarketplaceFailed(msg) => { + write!(f, "Marketplace error: {msg}") + } Self::AgentNotFound { deployment_hash } => { write!( f, diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index bad88d55..0f462e03 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -1361,25 +1361,19 @@ impl StackerClient { .bearer_auth(&self.token) .send() .await - .map_err(|e| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server unreachable: {}", e), - })?; + .map_err(|e| CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)))?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("GET /api/templates/mine failed ({}): {}", status, body), - }); + return Err(CliError::MarketplaceFailed(format!( + "GET /api/templates/mine failed ({}): {}", + status, body + ))); } let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Invalid response from Stacker server: {}", e), - } + CliError::MarketplaceFailed(format!("Invalid response from Stacker server: {}", e)) })?; Ok(api.list.unwrap_or_default()) @@ -1390,46 +1384,29 @@ impl StackerClient { &self, template_id: &str, ) -> Result, CliError> { - let url = format!("{}/api/admin/templates/{}", self.base_url, template_id); + let url = format!("{}/api/templates/{}/reviews", self.base_url, template_id); let resp = self .http .get(&url) .bearer_auth(&self.token) .send() .await - .map_err(|e| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server unreachable: {}", e), - })?; + .map_err(|e| CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)))?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "GET /api/admin/templates/{} failed ({}): {}", - template_id, status, body - ), - }); + return Err(CliError::MarketplaceFailed(format!( + "GET /api/templates/{}/reviews failed ({}): {}", + template_id, status, body + ))); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Invalid response from Stacker server: {}", e), - } + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::MarketplaceFailed(format!("Invalid response from Stacker server: {}", e)) })?; - let item = api.item.unwrap_or(serde_json::json!({})); - let reviews: Vec = serde_json::from_value( - item.get("reviews") - .cloned() - .unwrap_or(serde_json::json!([])), - ) - .unwrap_or_default(); - - Ok(reviews) + Ok(api.list.unwrap_or_default()) } /// Create or update a marketplace template (POST /api/templates). @@ -1445,31 +1422,23 @@ impl StackerClient { .json(&body) .send() .await - .map_err(|e| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("create template: {}", e), - })?; + .map_err(|e| CliError::MarketplaceFailed(format!("create template: {}", e)))?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Create template failed ({}): {}", status, body), - }); + return Err(CliError::MarketplaceFailed(format!( + "Create template failed ({}): {}", + status, body + ))); } let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("create template response: {}", e), - } + CliError::MarketplaceFailed(format!("create template response: {}", e)) })?; - api.item.ok_or_else(|| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: "No template in response".to_string(), - }) + api.item + .ok_or_else(|| CliError::MarketplaceFailed("No template in response".to_string())) } /// Submit a template for marketplace review. @@ -1481,18 +1450,15 @@ impl StackerClient { .bearer_auth(&self.token) .send() .await - .map_err(|e| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server unreachable: {}", e), - })?; + .map_err(|e| CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)))?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Submit failed ({}): {}", status, body), - }); + return Err(CliError::MarketplaceFailed(format!( + "Submit failed ({}): {}", + status, body + ))); } Ok(()) diff --git a/src/connectors/dockerhub_service.rs b/src/connectors/dockerhub_service.rs index e9aaefda..9a3c6be4 100644 --- a/src/connectors/dockerhub_service.rs +++ b/src/connectors/dockerhub_service.rs @@ -82,9 +82,10 @@ impl RedisCache { ConnectorError::Internal(format!("Invalid Redis URL for Docker Hub cache: {}", err)) })?; - let connection = ConnectionManager::new(client).await.map_err(|err| { - ConnectorError::ServiceUnavailable(format!("Redis unavailable: {}", err)) - })?; + let connection = tokio::time::timeout(Duration::from_secs(3), ConnectionManager::new(client)) + .await + .map_err(|_| ConnectorError::ServiceUnavailable("Redis connection timed out".to_string()))? + .map_err(|err| ConnectorError::ServiceUnavailable(format!("Redis unavailable: {}", err)))?; Ok(Self { connection: Arc::new(Mutex::new(connection)), diff --git a/src/forms/project/docker_image.rs b/src/forms/project/docker_image.rs index 9ed254d0..c2cb7c36 100644 --- a/src/forms/project/docker_image.rs +++ b/src/forms/project/docker_image.rs @@ -59,3 +59,93 @@ impl DockerImage { DockerHub::try_from(self)?.is_active().await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_full_image() { + let img = DockerImage { + dockerhub_user: Some("trydirect".to_string()), + dockerhub_name: Some("postgres:v8".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "trydirect/postgres:v8"); + } + + #[test] + fn test_display_image_only() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: None, + dockerhub_image: Some("nginx:latest".to_string()), + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "nginx:latest"); + } + + #[test] + fn test_display_name_without_tag_adds_latest() { + let img = DockerImage { + dockerhub_user: Some("myuser".to_string()), + dockerhub_name: Some("myapp".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "myuser/myapp:latest"); + } + + #[test] + fn test_display_name_with_tag_no_latest() { + let img = DockerImage { + dockerhub_user: Some("myuser".to_string()), + dockerhub_name: Some("myapp:v2".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "myuser/myapp:v2"); + } + + #[test] + fn test_display_no_user_with_name() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: Some("redis".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "redis:latest"); + } + + #[test] + fn test_display_all_empty() { + let img = DockerImage::default(); + assert_eq!(format!("{}", img), ":latest"); + } + + #[test] + fn test_display_image_takes_precedence_when_name_empty() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: None, + dockerhub_image: Some("custom/image:tag".to_string()), + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "custom/image:tag"); + } + + #[test] + fn test_docker_image_serialization() { + let img = DockerImage { + dockerhub_user: Some("user".to_string()), + dockerhub_name: Some("app".to_string()), + dockerhub_image: Some("user/app:1.0".to_string()), + dockerhub_password: None, + }; + let json = serde_json::to_string(&img).unwrap(); + let deserialized: DockerImage = serde_json::from_str(&json).unwrap(); + assert_eq!(img, deserialized); + } +} diff --git a/src/forms/project/network.rs b/src/forms/project/network.rs index d412f148..45160dd3 100644 --- a/src/forms/project/network.rs +++ b/src/forms/project/network.rs @@ -54,3 +54,60 @@ impl Into for Network { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_network_default_is_external() { + let net = Network::default(); + assert_eq!(net.id, "default_network"); + assert_eq!(net.name, "default_network"); + assert_eq!(net.external, Some(true)); + } + + #[test] + fn test_default_network_to_settings() { + let net = Network::default(); + let settings: dctypes::NetworkSettings = net.into(); + assert_eq!(settings.name, Some("default_network".to_string())); + // default_network is always external + assert!(matches!( + settings.external, + Some(dctypes::ComposeNetwork::Bool(true)) + )); + } + + #[test] + fn test_custom_network_not_external() { + let net = Network { + id: "custom_net".to_string(), + name: "my-network".to_string(), + external: Some(false), + driver: Some("bridge".to_string()), + attachable: Some(true), + enable_ipv6: Some(false), + internal: Some(false), + driver_opts: None, + ipam: None, + labels: None, + }; + let settings: dctypes::NetworkSettings = net.into(); + assert_eq!(settings.name, Some("my-network".to_string())); + assert!(matches!( + settings.external, + Some(dctypes::ComposeNetwork::Bool(false)) + )); + assert_eq!(settings.driver, Some("bridge".to_string())); + assert!(settings.attachable); + } + + #[test] + fn test_network_serialization() { + let net = Network::default(); + let json = serde_json::to_string(&net).unwrap(); + let deserialized: Network = serde_json::from_str(&json).unwrap(); + assert_eq!(net, deserialized); + } +} diff --git a/src/forms/project/port.rs b/src/forms/project/port.rs index 101eb8d0..27cbe30d 100644 --- a/src/forms/project/port.rs +++ b/src/forms/project/port.rs @@ -84,3 +84,106 @@ impl TryInto for &Port { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_non_empty_none() { + assert!(validate_non_empty(&None).is_ok()); + } + + #[test] + fn test_validate_non_empty_empty_string() { + assert!(validate_non_empty(&Some("".to_string())).is_ok()); + } + + #[test] + fn test_validate_non_empty_valid_port() { + assert!(validate_non_empty(&Some("8080".to_string())).is_ok()); + assert!(validate_non_empty(&Some("80".to_string())).is_ok()); + assert!(validate_non_empty(&Some("443".to_string())).is_ok()); + } + + #[test] + fn test_validate_non_empty_invalid_port() { + assert!(validate_non_empty(&Some("abc".to_string())).is_err()); + assert!(validate_non_empty(&Some("1".to_string())).is_err()); // too short (min 2 digits) + assert!(validate_non_empty(&Some("1234567".to_string())).is_err()); // too long (max 6 digits) + } + + #[test] + fn test_port_try_into_valid() { + let port = Port { + host_port: Some("8080".to_string()), + container_port: "80".to_string(), + protocol: Some("tcp".to_string()), + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 80); + } + + #[test] + fn test_port_try_into_no_host_port() { + let port = Port { + host_port: None, + container_port: "3000".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 3000); + assert!(dc_port.published.is_none()); + } + + #[test] + fn test_port_try_into_empty_host_port() { + let port = Port { + host_port: Some("".to_string()), + container_port: "5432".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert!(dc_port.published.is_none()); + } + + #[test] + fn test_port_try_into_invalid_container_port() { + let port = Port { + host_port: None, + container_port: "not_a_number".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Could not parse container port")); + } + + #[test] + fn test_port_default() { + let port = Port::default(); + assert!(port.host_port.is_none()); + assert_eq!(port.container_port, ""); + assert!(port.protocol.is_none()); + } + + #[test] + fn test_port_serialization() { + let port = Port { + host_port: Some("8080".to_string()), + container_port: "80".to_string(), + protocol: Some("tcp".to_string()), + }; + let json = serde_json::to_string(&port).unwrap(); + let deserialized: Port = serde_json::from_str(&json).unwrap(); + assert_eq!(port, deserialized); + } +} diff --git a/src/forms/rating/add.rs b/src/forms/rating/add.rs index a2c90d2c..2011c1c0 100644 --- a/src/forms/rating/add.rs +++ b/src/forms/rating/add.rs @@ -25,3 +25,81 @@ impl Into for AddRating { rating } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_valid::Validate; + + #[test] + fn test_add_rating_into_model() { + let rating = AddRating { + obj_id: 42, + category: models::RateCategory::Application, + comment: Some("Great app!".to_string()), + rate: 8, + }; + let model: models::Rating = rating.into(); + assert_eq!(model.obj_id, 42); + assert_eq!(model.hidden, Some(false)); + assert_eq!(model.rate, Some(8)); + assert_eq!(model.comment, Some("Great app!".to_string())); + } + + #[test] + fn test_add_rating_no_comment() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Cloud, + comment: None, + rate: 5, + }; + let model: models::Rating = rating.into(); + assert!(model.comment.is_none()); + assert_eq!(model.rate, Some(5)); + } + + #[test] + fn test_add_rating_validation_valid() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Price, + comment: Some("OK".to_string()), + rate: 5, + }; + assert!(rating.validate().is_ok()); + } + + #[test] + fn test_add_rating_validation_rate_too_high() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Design, + comment: None, + rate: 11, // max is 10 + }; + assert!(rating.validate().is_err()); + } + + #[test] + fn test_add_rating_validation_rate_negative() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Design, + comment: None, + rate: -1, // min is 0 + }; + assert!(rating.validate().is_err()); + } + + #[test] + fn test_add_rating_validation_comment_too_long() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::TechSupport, + comment: Some("a".repeat(1001)), // max is 1000 + rate: 5, + }; + assert!(rating.validate().is_err()); + } +} diff --git a/src/forms/server.rs b/src/forms/server.rs index b7e6e2a4..b4367972 100644 --- a/src/forms/server.rs +++ b/src/forms/server.rs @@ -87,3 +87,96 @@ impl Into for models::Server { form } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_ssh_port() { + assert_eq!(default_ssh_port(), Some(22)); + } + + #[test] + fn test_server_form_to_model() { + let form = ServerForm { + server_id: None, + cloud_id: Some(5), + region: Some("us-east-1".to_string()), + zone: Some("us-east-1a".to_string()), + server: Some("s-2vcpu".to_string()), + os: Some("ubuntu".to_string()), + disk_type: Some("ssd".to_string()), + srv_ip: Some("10.0.0.1".to_string()), + ssh_port: Some(2222), + ssh_user: Some("admin".to_string()), + name: Some("my-server".to_string()), + connection_mode: Some("ssh".to_string()), + vault_key_path: Some("/vault/path".to_string()), + public_key: None, + ssh_private_key: None, + }; + let server: models::Server = (&form).into(); + assert_eq!(server.cloud_id, Some(5)); + assert_eq!(server.region, Some("us-east-1".to_string())); + assert_eq!(server.ssh_port, Some(2222)); + assert_eq!(server.ssh_user, Some("admin".to_string())); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.name, Some("my-server".to_string())); + } + + #[test] + fn test_server_form_to_model_defaults() { + let form = ServerForm::default(); + let server: models::Server = (&form).into(); + assert_eq!(server.ssh_port, Some(22)); // default_ssh_port fallback + assert_eq!(server.connection_mode, "ssh"); + } + + #[test] + fn test_model_to_server_form() { + let server = models::Server { + id: 42, + cloud_id: Some(10), + region: Some("eu-west-1".to_string()), + ssh_port: Some(22), + ssh_user: Some("root".to_string()), + connection_mode: "ssh".to_string(), + name: Some("prod".to_string()), + vault_key_path: Some("/v/k".to_string()), + ..Default::default() + }; + let form: ServerForm = server.into(); + assert_eq!(form.server_id, Some(42)); + assert_eq!(form.cloud_id, Some(10)); + assert_eq!(form.region, Some("eu-west-1".to_string())); + assert_eq!(form.ssh_port, Some(22)); + assert_eq!(form.connection_mode, Some("ssh".to_string())); + assert_eq!(form.name, Some("prod".to_string())); + } + + #[test] + fn test_server_form_roundtrip() { + let server = models::Server { + id: 1, + cloud_id: Some(3), + region: Some("us-west".to_string()), + zone: Some("a".to_string()), + server: Some("large".to_string()), + os: Some("debian".to_string()), + disk_type: Some("nvme".to_string()), + srv_ip: Some("1.2.3.4".to_string()), + ssh_port: Some(2222), + ssh_user: Some("deploy".to_string()), + connection_mode: "ssh".to_string(), + vault_key_path: Some("path".to_string()), + name: Some("test".to_string()), + ..Default::default() + }; + let form: ServerForm = server.into(); + let back: models::Server = (&form).into(); + assert_eq!(back.cloud_id, Some(3)); + assert_eq!(back.region, Some("us-west".to_string())); + assert_eq!(back.ssh_port, Some(2222)); + } +} diff --git a/src/helpers/cloud/security.rs b/src/helpers/cloud/security.rs index 0f0b4122..6a9846dd 100644 --- a/src/helpers/cloud/security.rs +++ b/src/helpers/cloud/security.rs @@ -120,3 +120,125 @@ impl Secret { String::from_utf8(plaintext).map_err(|e| format!("UTF-8 conversion failed: {:?}", e)) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + const TEST_KEY: &str = "01234567890123456789012345678901"; + + #[test] + fn test_secret_new() { + let secret = Secret::new(); + assert_eq!(secret.user_id, ""); + assert_eq!(secret.provider, ""); + assert_eq!(secret.field, ""); + } + + #[test] + fn test_b64_encode() { + let data = vec![72, 101, 108, 108, 111]; // "Hello" + let encoded = Secret::b64_encode(&data); + assert_eq!(encoded, "SGVsbG8="); + } + + #[test] + fn test_b64_decode_valid() { + let result = Secret::b64_decode(&"SGVsbG8=".to_string()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec![72, 101, 108, 108, 111]); + } + + #[test] + fn test_b64_decode_invalid() { + let result = Secret::b64_decode(&"not!valid!base64!!!".to_string()); + assert!(result.is_err()); + } + + #[test] + fn test_b64_roundtrip() { + let original = vec![1, 2, 3, 4, 5, 255, 0, 128]; + let encoded = Secret::b64_encode(&original); + let decoded = Secret::b64_decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_b64_encode_empty() { + let data: Vec = vec![]; + let encoded = Secret::b64_encode(&data); + assert_eq!(encoded, ""); + } + + #[test] + fn test_b64_decode_empty() { + let result = Secret::b64_decode(&"".to_string()); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_encrypt_requires_security_key() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::remove_var("SECURITY_KEY"); + let secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + let result = secret.encrypt("my-token".to_string()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("SECURITY_KEY")); + } + + #[test] + fn test_encrypt_invalid_key_length() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", "short-key"); + let secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + let result = secret.encrypt("my-token".to_string()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("32 bytes")); + std::env::remove_var("SECURITY_KEY"); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", TEST_KEY); + + let mut secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + + let original = "my-super-secret-token-123"; + let encrypted = secret.encrypt(original.to_string()).unwrap(); + assert!(!encrypted.is_empty()); + assert!(encrypted.len() > 12); // nonce (12) + ciphertext + + let decrypted = secret.decrypt(encrypted).unwrap(); + assert_eq!(decrypted, original); + + std::env::remove_var("SECURITY_KEY"); + } + + #[test] + fn test_decrypt_too_short_data() { + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", TEST_KEY); + let mut secret = Secret::new(); + let result = secret.decrypt(vec![1, 2, 3]); // less than 12 bytes + assert!(result.is_err()); + assert!(result.unwrap_err().contains("too short")); + std::env::remove_var("SECURITY_KEY"); + } +} diff --git a/src/helpers/compressor.rs b/src/helpers/compressor.rs index d2065783..81c9a931 100644 --- a/src/helpers/compressor.rs +++ b/src/helpers/compressor.rs @@ -9,3 +9,35 @@ pub fn compress(input: &str) -> Vec { drop(compressor); compressed } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compress_non_empty() { + let result = compress("Hello, World!"); + assert!(!result.is_empty()); + } + + #[test] + fn test_compress_empty_string() { + let result = compress(""); + // Even empty input produces some compressed output (brotli header) + assert!(!result.is_empty()); + } + + #[test] + fn test_compress_reduces_size_for_repetitive_data() { + let input = "a".repeat(10000); + let result = compress(&input); + assert!(result.len() < input.len()); + } + + #[test] + fn test_compress_different_inputs_different_outputs() { + let result1 = compress("Hello"); + let result2 = compress("World"); + assert_ne!(result1, result2); + } +} diff --git a/src/models/agent.rs b/src/models/agent.rs index 8b8e6847..af72a690 100644 --- a/src/models/agent.rs +++ b/src/models/agent.rs @@ -95,3 +95,114 @@ impl AuditLog { self } } + +#[cfg(test)] +mod tests { + use super::*; + + // Agent tests + #[test] + fn test_agent_new() { + let agent = Agent::new("deploy-hash-123".to_string()); + assert_eq!(agent.deployment_hash, "deploy-hash-123"); + assert_eq!(agent.status, "offline"); + assert!(agent.last_heartbeat.is_none()); + assert_eq!(agent.capabilities, Some(serde_json::json!([]))); + assert_eq!(agent.system_info, Some(serde_json::json!({}))); + assert!(agent.version.is_none()); + } + + #[test] + fn test_agent_is_online_when_offline() { + let agent = Agent::new("h".to_string()); + assert!(!agent.is_online()); + } + + #[test] + fn test_agent_mark_online() { + let mut agent = Agent::new("h".to_string()); + assert!(!agent.is_online()); + agent.mark_online(); + assert!(agent.is_online()); + assert!(agent.last_heartbeat.is_some()); + } + + #[test] + fn test_agent_mark_offline() { + let mut agent = Agent::new("h".to_string()); + agent.mark_online(); + assert!(agent.is_online()); + agent.mark_offline(); + assert!(!agent.is_online()); + } + + #[test] + fn test_agent_online_offline_cycle() { + let mut agent = Agent::new("h".to_string()); + for _ in 0..3 { + agent.mark_online(); + assert!(agent.is_online()); + agent.mark_offline(); + assert!(!agent.is_online()); + } + } + + // AuditLog tests + #[test] + fn test_audit_log_new() { + let agent_id = Uuid::new_v4(); + let log = AuditLog::new( + Some(agent_id), + Some("hash-1".to_string()), + "deploy".to_string(), + Some("success".to_string()), + ); + assert_eq!(log.agent_id, Some(agent_id)); + assert_eq!(log.deployment_hash, Some("hash-1".to_string())); + assert_eq!(log.action, "deploy"); + assert_eq!(log.status, Some("success".to_string())); + assert_eq!(log.details, serde_json::json!({})); + assert!(log.ip_address.is_none()); + assert!(log.user_agent.is_none()); + } + + #[test] + fn test_audit_log_new_minimal() { + let log = AuditLog::new(None, None, "heartbeat".to_string(), None); + assert!(log.agent_id.is_none()); + assert!(log.deployment_hash.is_none()); + assert!(log.status.is_none()); + } + + #[test] + fn test_audit_log_with_details() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_details(serde_json::json!({"error": "timeout"})); + assert_eq!(log.details, serde_json::json!({"error": "timeout"})); + } + + #[test] + fn test_audit_log_with_ip() { + let log = + AuditLog::new(None, None, "test".to_string(), None).with_ip("192.168.1.1".to_string()); + assert_eq!(log.ip_address, Some("192.168.1.1".to_string())); + } + + #[test] + fn test_audit_log_with_user_agent() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_user_agent("Mozilla/5.0".to_string()); + assert_eq!(log.user_agent, Some("Mozilla/5.0".to_string())); + } + + #[test] + fn test_audit_log_builder_chaining() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_details(serde_json::json!({"key": "value"})) + .with_ip("10.0.0.1".to_string()) + .with_user_agent("curl/7.68".to_string()); + assert_eq!(log.details, serde_json::json!({"key": "value"})); + assert_eq!(log.ip_address, Some("10.0.0.1".to_string())); + assert_eq!(log.user_agent, Some("curl/7.68".to_string())); + } +} diff --git a/src/models/client.rs b/src/models/client.rs index d8815970..d91369bd 100644 --- a/src/models/client.rs +++ b/src/models/client.rs @@ -21,3 +21,51 @@ impl std::fmt::Debug for Client { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_debug_masks_secret() { + let client = Client { + id: 1, + user_id: "user1".to_string(), + secret: Some("mysecretvalue".to_string()), + }; + let debug = format!("{:?}", client); + assert!(debug.contains("myse****")); + assert!(!debug.contains("mysecretvalue")); + assert!(debug.contains("user1")); + } + + #[test] + fn test_client_debug_no_secret() { + let client = Client { + id: 2, + user_id: "user2".to_string(), + secret: None, + }; + let debug = format!("{:?}", client); + assert!(debug.contains("user2")); + } + + #[test] + fn test_client_debug_short_secret() { + let client = Client { + id: 3, + user_id: "u".to_string(), + secret: Some("ab".to_string()), + }; + let debug = format!("{:?}", client); + assert!(debug.contains("ab****")); + } + + #[test] + fn test_client_default() { + let client = Client::default(); + assert_eq!(client.id, 0); + assert_eq!(client.user_id, ""); + assert!(client.secret.is_none()); + } +} diff --git a/src/models/cloud.rs b/src/models/cloud.rs index 2108bc61..8916c689 100644 --- a/src/models/cloud.rs +++ b/src/models/cloud.rs @@ -77,3 +77,99 @@ impl Default for Cloud { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mask_string_some() { + assert_eq!(mask_string(Some(&"abcdefgh".to_string())), "abcd****"); + } + + #[test] + fn test_mask_string_short() { + assert_eq!(mask_string(Some(&"ab".to_string())), "ab****"); + } + + #[test] + fn test_mask_string_none() { + assert_eq!(mask_string(None), ""); + } + + #[test] + fn test_mask_string_empty() { + assert_eq!(mask_string(Some(&"".to_string())), "****"); + } + + #[test] + fn test_cloud_display_masks_credentials() { + let cloud = Cloud::new( + "user1".to_string(), + "my-cloud".to_string(), + "aws".to_string(), + Some("token12345".to_string()), + Some("key12345".to_string()), + Some("secret12345".to_string()), + Some(true), + ); + let display = format!("{}", cloud); + assert!(display.contains("aws")); + assert!(display.contains("toke****")); + assert!(display.contains("key1****")); + assert!(display.contains("secr****")); + assert!(!display.contains("token12345")); + assert!(!display.contains("key12345")); + assert!(!display.contains("secret12345")); + } + + #[test] + fn test_cloud_display_none_credentials() { + let cloud = Cloud::default(); + let display = format!("{}", cloud); + assert!(display.contains("cloud_key : ")); + } + + #[test] + fn test_cloud_new() { + let cloud = Cloud::new( + "user1".to_string(), + "test".to_string(), + "hetzner".to_string(), + None, + Some("key".to_string()), + None, + Some(false), + ); + assert_eq!(cloud.id, 0); + assert_eq!(cloud.user_id, "user1"); + assert_eq!(cloud.provider, "hetzner"); + assert!(cloud.cloud_token.is_none()); + assert_eq!(cloud.cloud_key, Some("key".to_string())); + assert!(cloud.cloud_secret.is_none()); + } + + #[test] + fn test_cloud_default() { + let cloud = Cloud::default(); + assert_eq!(cloud.id, 0); + assert_eq!(cloud.provider, ""); + assert_eq!(cloud.save_token, Some(false)); + } + + #[test] + fn test_cloud_serialization() { + let cloud = Cloud::new( + "u1".to_string(), + "c".to_string(), + "do".to_string(), + Some("tok".to_string()), + None, + None, + None, + ); + let json = serde_json::to_string(&cloud).unwrap(); + let deserialized: Cloud = serde_json::from_str(&json).unwrap(); + assert_eq!(cloud, deserialized); + } +} diff --git a/src/models/command.rs b/src/models/command.rs index 6611a2ce..61016674 100644 --- a/src/models/command.rs +++ b/src/models/command.rs @@ -203,3 +203,257 @@ pub struct CommandQueueEntry { pub priority: i32, pub created_at: DateTime, } + +#[cfg(test)] +mod tests { + use super::*; + + // CommandStatus tests + #[test] + fn test_command_status_display() { + assert_eq!(CommandStatus::Queued.to_string(), "queued"); + assert_eq!(CommandStatus::Sent.to_string(), "sent"); + assert_eq!(CommandStatus::Executing.to_string(), "executing"); + assert_eq!(CommandStatus::Completed.to_string(), "completed"); + assert_eq!(CommandStatus::Failed.to_string(), "failed"); + assert_eq!(CommandStatus::Cancelled.to_string(), "cancelled"); + } + + #[test] + fn test_command_status_serde() { + let json = serde_json::to_string(&CommandStatus::Queued).unwrap(); + assert_eq!(json, "\"queued\""); + let deserialized: CommandStatus = serde_json::from_str("\"completed\"").unwrap(); + assert_eq!(deserialized, CommandStatus::Completed); + } + + // CommandPriority tests + #[test] + fn test_priority_to_int() { + assert_eq!(CommandPriority::Low.to_int(), 0); + assert_eq!(CommandPriority::Normal.to_int(), 1); + assert_eq!(CommandPriority::High.to_int(), 2); + assert_eq!(CommandPriority::Critical.to_int(), 3); + } + + #[test] + fn test_priority_display() { + assert_eq!(CommandPriority::Low.to_string(), "low"); + assert_eq!(CommandPriority::Normal.to_string(), "normal"); + assert_eq!(CommandPriority::High.to_string(), "high"); + assert_eq!(CommandPriority::Critical.to_string(), "critical"); + } + + #[test] + fn test_priority_serde() { + let json = serde_json::to_string(&CommandPriority::High).unwrap(); + assert_eq!(json, "\"high\""); + let deserialized: CommandPriority = serde_json::from_str("\"low\"").unwrap(); + assert_eq!(deserialized, CommandPriority::Low); + } + + #[test] + fn test_priority_ordering() { + assert!(CommandPriority::Low.to_int() < CommandPriority::Normal.to_int()); + assert!(CommandPriority::Normal.to_int() < CommandPriority::High.to_int()); + assert!(CommandPriority::High.to_int() < CommandPriority::Critical.to_int()); + } + + // Command builder tests + #[test] + fn test_command_new_defaults() { + let cmd = Command::new( + "cmd-1".to_string(), + "hash-abc".to_string(), + "deploy".to_string(), + "admin".to_string(), + ); + assert_eq!(cmd.command_id, "cmd-1"); + assert_eq!(cmd.deployment_hash, "hash-abc"); + assert_eq!(cmd.r#type, "deploy"); + assert_eq!(cmd.created_by, "admin"); + assert_eq!(cmd.status, "queued"); + assert_eq!(cmd.priority, "normal"); + assert_eq!(cmd.timeout_seconds, Some(300)); + assert!(cmd.parameters.is_none()); + assert!(cmd.result.is_none()); + assert!(cmd.error.is_none()); + assert!(cmd.metadata.is_none()); + } + + #[test] + fn test_command_with_priority() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_priority(CommandPriority::Critical); + assert_eq!(cmd.priority, "critical"); + } + + #[test] + fn test_command_with_parameters() { + let params = serde_json::json!({"key": "value"}); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_parameters(params.clone()); + assert_eq!(cmd.parameters, Some(params)); + } + + #[test] + fn test_command_with_timeout() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_timeout(600); + assert_eq!(cmd.timeout_seconds, Some(600)); + } + + #[test] + fn test_command_with_metadata() { + let meta = serde_json::json!({"retry_count": 3}); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_metadata(meta.clone()); + assert_eq!(cmd.metadata, Some(meta)); + } + + #[test] + fn test_command_builder_chaining() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_priority(CommandPriority::High) + .with_timeout(120) + .with_parameters(serde_json::json!({"action": "restart"})) + .with_metadata(serde_json::json!({"source": "api"})); + + assert_eq!(cmd.priority, "high"); + assert_eq!(cmd.timeout_seconds, Some(120)); + assert!(cmd.parameters.is_some()); + assert!(cmd.metadata.is_some()); + } + + // Command status transitions + #[test] + fn test_command_mark_sent() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_sent(); + assert_eq!(cmd.status, "sent"); + } + + #[test] + fn test_command_mark_executing() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_executing(); + assert_eq!(cmd.status, "executing"); + } + + #[test] + fn test_command_mark_completed() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_completed(); + assert_eq!(cmd.status, "completed"); + } + + #[test] + fn test_command_mark_failed() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_failed(); + assert_eq!(cmd.status, "failed"); + } + + #[test] + fn test_command_mark_cancelled() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_cancelled(); + assert_eq!(cmd.status, "cancelled"); + } + + #[test] + fn test_command_status_transition_chain() { + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ); + assert_eq!(cmd.status, "queued"); + let cmd = cmd.mark_sent(); + assert_eq!(cmd.status, "sent"); + let cmd = cmd.mark_executing(); + assert_eq!(cmd.status, "executing"); + let cmd = cmd.mark_completed(); + assert_eq!(cmd.status, "completed"); + } + + // CommandResult and CommandError serde + #[test] + fn test_command_result_deserialization() { + let json = r#"{ + "command_id": "cmd-1", + "deployment_hash": "hash-1", + "status": "completed", + "result": {"output": "success"}, + "error": null, + "metadata": null + }"#; + let result: CommandResult = serde_json::from_str(json).unwrap(); + assert_eq!(result.command_id, "cmd-1"); + assert_eq!(result.status, CommandStatus::Completed); + assert!(result.error.is_none()); + } + + #[test] + fn test_command_error_deserialization() { + let json = r#"{ + "code": "TIMEOUT", + "message": "Command timed out", + "details": {"elapsed_seconds": 300} + }"#; + let error: CommandError = serde_json::from_str(json).unwrap(); + assert_eq!(error.code, "TIMEOUT"); + assert_eq!(error.message, "Command timed out"); + } +} diff --git a/src/models/deployment.rs b/src/models/deployment.rs index a9753830..0bc8e6ef 100644 --- a/src/models/deployment.rs +++ b/src/models/deployment.rs @@ -56,3 +56,66 @@ impl Default for Deployment { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deployment_new() { + let deployment = Deployment::new( + 1, + Some("user1".to_string()), + "hash-abc".to_string(), + "running".to_string(), + serde_json::json!({"apps": ["nginx"]}), + ); + assert_eq!(deployment.id, 0); + assert_eq!(deployment.project_id, 1); + assert_eq!(deployment.user_id, Some("user1".to_string())); + assert_eq!(deployment.deployment_hash, "hash-abc"); + assert_eq!(deployment.status, "running"); + assert_eq!(deployment.deleted, Some(false)); + assert!(deployment.last_seen_at.is_none()); + } + + #[test] + fn test_deployment_new_no_user() { + let deployment = Deployment::new( + 2, + None, + "hash-xyz".to_string(), + "pending".to_string(), + Value::Null, + ); + assert!(deployment.user_id.is_none()); + } + + #[test] + fn test_deployment_default() { + let deployment = Deployment::default(); + assert_eq!(deployment.id, 0); + assert_eq!(deployment.project_id, 0); + assert_eq!(deployment.deployment_hash, ""); + assert!(deployment.user_id.is_none()); + assert_eq!(deployment.deleted, Some(false)); + assert_eq!(deployment.status, "pending"); + assert_eq!(deployment.metadata, Value::Null); + } + + #[test] + fn test_deployment_serialization() { + let deployment = Deployment::new( + 1, + Some("user1".to_string()), + "test-hash".to_string(), + "active".to_string(), + serde_json::json!({}), + ); + let json = serde_json::to_string(&deployment).unwrap(); + let deserialized: Deployment = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.project_id, 1); + assert_eq!(deserialized.deployment_hash, "test-hash"); + assert_eq!(deserialized.status, "active"); + } +} diff --git a/src/models/project.rs b/src/models/project.rs index ee25abd2..19b66459 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -222,3 +222,206 @@ impl Default for Project { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Test validate_project_name + #[test] + fn test_validate_empty_name() { + assert_eq!(validate_project_name(""), Err(ProjectNameError::Empty)); + } + + #[test] + fn test_validate_too_long_name() { + let long_name = "a".repeat(256); + assert_eq!( + validate_project_name(&long_name), + Err(ProjectNameError::TooLong(256)) + ); + } + + #[test] + fn test_validate_reserved_names() { + for name in &["root", "tmp", "etc", "var", "dev", ".", ".."] { + assert!(matches!( + validate_project_name(name), + Err(ProjectNameError::ReservedName(_)) + )); + } + } + + #[test] + fn test_validate_reserved_names_case_insensitive() { + assert!(matches!( + validate_project_name("ROOT"), + Err(ProjectNameError::ReservedName(_)) + )); + assert!(matches!( + validate_project_name("Tmp"), + Err(ProjectNameError::ReservedName(_)) + )); + } + + #[test] + fn test_validate_invalid_characters() { + assert!(matches!( + validate_project_name("my project"), + Err(ProjectNameError::InvalidCharacters(_)) + )); + assert!(matches!( + validate_project_name("name/path"), + Err(ProjectNameError::InvalidCharacters(_)) + )); + assert!(matches!( + validate_project_name("-starts-with-dash"), + Err(ProjectNameError::InvalidCharacters(_)) + )); + } + + #[test] + fn test_validate_valid_names() { + assert!(validate_project_name("myproject").is_ok()); + assert!(validate_project_name("my-project").is_ok()); + assert!(validate_project_name("my_project").is_ok()); + assert!(validate_project_name("my.project").is_ok()); + assert!(validate_project_name("Project123").is_ok()); + assert!(validate_project_name("_private").is_ok()); + } + + #[test] + fn test_validate_max_length_name() { + let name = "a".repeat(255); + assert!(validate_project_name(&name).is_ok()); + } + + // Test sanitize_project_name + #[test] + fn test_sanitize_empty() { + assert_eq!(sanitize_project_name(""), "project"); + } + + #[test] + fn test_sanitize_lowercases() { + assert_eq!(sanitize_project_name("MyProject"), "myproject"); + } + + #[test] + fn test_sanitize_replaces_invalid_chars() { + assert_eq!(sanitize_project_name("my project"), "my_project"); + assert_eq!(sanitize_project_name("my/project"), "my_project"); + } + + #[test] + fn test_sanitize_reserved_name() { + assert_eq!(sanitize_project_name("root"), "project_root"); + assert_eq!(sanitize_project_name("tmp"), "project_tmp"); + } + + #[test] + fn test_sanitize_first_char_special() { + assert_eq!(sanitize_project_name("-myproject"), "_myproject"); + assert_eq!(sanitize_project_name(".myproject"), "_myproject"); + } + + #[test] + fn test_sanitize_truncates_long_name() { + let long_name = "a".repeat(300); + let result = sanitize_project_name(&long_name); + assert_eq!(result.len(), 255); + } + + // Test ProjectNameError Display + #[test] + fn test_error_display() { + assert_eq!( + ProjectNameError::Empty.to_string(), + "Project name cannot be empty" + ); + assert_eq!( + ProjectNameError::TooLong(300).to_string(), + "Project name too long (300 chars, max 255)" + ); + assert!(ProjectNameError::InvalidCharacters("bad name".to_string()) + .to_string() + .contains("bad name")); + assert!(ProjectNameError::ReservedName("root".to_string()) + .to_string() + .contains("root")); + } + + // Test Project methods + #[test] + fn test_project_new() { + let project = Project::new( + "user1".to_string(), + "test-project".to_string(), + serde_json::json!({}), + serde_json::json!({}), + ); + assert_eq!(project.id, 0); + assert_eq!(project.user_id, "user1"); + assert_eq!(project.name, "test-project"); + assert!(project.source_template_id.is_none()); + } + + #[test] + fn test_project_validate_name() { + let project = Project::new( + "u".to_string(), + "valid-name".to_string(), + Value::Null, + Value::Null, + ); + assert!(project.validate_name().is_ok()); + + let bad_project = Project::new("u".to_string(), "".to_string(), Value::Null, Value::Null); + assert!(bad_project.validate_name().is_err()); + } + + #[test] + fn test_project_safe_dir_name() { + let project = Project::new( + "u".to_string(), + "My Project".to_string(), + Value::Null, + Value::Null, + ); + assert_eq!(project.safe_dir_name(), "my_project"); + } + + #[test] + fn test_project_deploy_dir() { + let project = Project::new( + "u".to_string(), + "myapp".to_string(), + Value::Null, + Value::Null, + ); + assert_eq!(project.deploy_dir(Some("/deploy")), "/deploy/myapp"); + assert_eq!(project.deploy_dir(Some("/deploy/")), "/deploy/myapp"); + } + + #[test] + fn test_project_deploy_dir_with_hash() { + let project = Project::new( + "u".to_string(), + "myapp".to_string(), + Value::Null, + Value::Null, + ); + assert_eq!( + project.deploy_dir_with_hash(Some("/deploy"), "abc123"), + "/deploy/abc123" + ); + } + + #[test] + fn test_project_default() { + let project = Project::default(); + assert_eq!(project.id, 0); + assert_eq!(project.user_id, ""); + assert_eq!(project.name, ""); + } +} diff --git a/src/models/project_app.rs b/src/models/project_app.rs index 6882056c..9cd609c4 100644 --- a/src/models/project_app.rs +++ b/src/models/project_app.rs @@ -209,3 +209,160 @@ impl Default for ProjectApp { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_defaults() { + let app = ProjectApp::new( + 1, + "nginx".to_string(), + "Nginx".to_string(), + "nginx:latest".to_string(), + ); + assert_eq!(app.project_id, 1); + assert_eq!(app.code, "nginx"); + assert_eq!(app.name, "Nginx"); + assert_eq!(app.image, "nginx:latest"); + assert_eq!(app.enabled, Some(true)); + assert_eq!(app.ssl_enabled, Some(false)); + assert_eq!(app.restart_policy, Some("unless-stopped".to_string())); + assert_eq!(app.config_version, Some(1)); + assert!(app.vault_synced_at.is_none()); + assert!(app.vault_sync_version.is_none()); + } + + #[test] + fn test_is_enabled_true() { + let app = ProjectApp { + enabled: Some(true), + ..Default::default() + }; + assert!(app.is_enabled()); + } + + #[test] + fn test_is_enabled_false() { + let app = ProjectApp { + enabled: Some(false), + ..Default::default() + }; + assert!(!app.is_enabled()); + } + + #[test] + fn test_is_enabled_none_defaults_true() { + let app = ProjectApp { + enabled: None, + ..Default::default() + }; + assert!(app.is_enabled()); + } + + #[test] + fn test_env_map_with_data() { + let app = ProjectApp { + environment: Some(serde_json::json!({"DB_HOST": "localhost", "DB_PORT": "5432"})), + ..Default::default() + }; + let map = app.env_map(); + assert_eq!(map.len(), 2); + assert_eq!(map.get("DB_HOST").unwrap(), "localhost"); + } + + #[test] + fn test_env_map_empty() { + let app = ProjectApp { + environment: None, + ..Default::default() + }; + let map = app.env_map(); + assert!(map.is_empty()); + } + + #[test] + fn test_env_map_non_object() { + let app = ProjectApp { + environment: Some(serde_json::json!("not an object")), + ..Default::default() + }; + let map = app.env_map(); + assert!(map.is_empty()); + } + + #[test] + fn test_needs_vault_sync_never_synced() { + let app = ProjectApp { + config_version: Some(1), + vault_sync_version: None, + ..Default::default() + }; + assert!(app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_outdated() { + let app = ProjectApp { + config_version: Some(3), + vault_sync_version: Some(2), + ..Default::default() + }; + assert!(app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_up_to_date() { + let app = ProjectApp { + config_version: Some(2), + vault_sync_version: Some(2), + ..Default::default() + }; + assert!(!app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_no_version() { + let app = ProjectApp { + config_version: None, + vault_sync_version: None, + ..Default::default() + }; + assert!(!app.needs_vault_sync()); + } + + #[test] + fn test_increment_version() { + let mut app = ProjectApp { + config_version: Some(1), + ..Default::default() + }; + app.increment_version(); + assert_eq!(app.config_version, Some(2)); + } + + #[test] + fn test_increment_version_from_none() { + let mut app = ProjectApp { + config_version: None, + ..Default::default() + }; + app.increment_version(); + assert_eq!(app.config_version, Some(1)); + } + + #[test] + fn test_mark_synced() { + let mut app = ProjectApp { + config_version: Some(3), + vault_synced_at: None, + vault_sync_version: None, + ..Default::default() + }; + app.mark_synced(); + assert!(app.vault_synced_at.is_some()); + assert_eq!(app.vault_sync_version, Some(3)); + assert!(!app.needs_vault_sync()); + } +} diff --git a/src/models/ratecategory.rs b/src/models/ratecategory.rs index 397cd1de..c4c8df54 100644 --- a/src/models/ratecategory.rs +++ b/src/models/ratecategory.rs @@ -25,3 +25,40 @@ impl Default for RateCategory { RateCategory::Application } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rate_category_into_string() { + let s: String = RateCategory::Application.into(); + assert_eq!(s, "Application"); + } + + #[test] + fn test_rate_category_all_variants() { + let variants = vec![ + (RateCategory::Application, "Application"), + (RateCategory::Cloud, "Cloud"), + (RateCategory::Project, "Project"), + (RateCategory::DeploymentSpeed, "DeploymentSpeed"), + (RateCategory::Documentation, "Documentation"), + (RateCategory::Design, "Design"), + (RateCategory::TechSupport, "TechSupport"), + (RateCategory::Price, "Price"), + (RateCategory::MemoryUsage, "MemoryUsage"), + ]; + for (cat, expected) in variants { + let s: String = cat.into(); + assert_eq!(s, expected); + } + } + + #[test] + fn test_rate_category_default() { + let cat = RateCategory::default(); + let s: String = cat.into(); + assert_eq!(s, "Application"); + } +} diff --git a/src/models/server.rs b/src/models/server.rs index 57fb2523..9f8e27bd 100644 --- a/src/models/server.rs +++ b/src/models/server.rs @@ -132,3 +132,120 @@ impl From for ServerWithProvider { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_valid::Validate; + + #[test] + fn test_server_default() { + let server = Server::default(); + assert_eq!(server.id, 0); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.key_status, "none"); + assert!(server.region.is_none()); + assert!(server.ssh_port.is_none()); + } + + #[test] + fn test_default_connection_mode() { + assert_eq!(default_connection_mode(), "ssh"); + } + + #[test] + fn test_default_key_status() { + assert_eq!(default_key_status(), "none"); + } + + #[test] + fn test_server_validation_valid() { + let server = Server { + region: Some("us-east-1".to_string()), + zone: Some("us-east-1a".to_string()), + server: Some("s-2vcpu-4gb".to_string()), + os: Some("ubuntu-22".to_string()), + disk_type: Some("ssd".to_string()), + srv_ip: Some("192.168.1.100".to_string()), + ssh_port: Some(22), + ssh_user: Some("root".to_string()), + ..Default::default() + }; + assert!(server.validate().is_ok()); + } + + #[test] + fn test_server_validation_short_region() { + let server = Server { + region: Some("a".to_string()), // too short, min 2 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_too_low() { + let server = Server { + ssh_port: Some(10), // minimum 20 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_too_high() { + let server = Server { + ssh_port: Some(70000), // maximum 65535 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_valid_range() { + let server = Server { + ssh_port: Some(22), + ..Default::default() + }; + assert!(server.validate().is_ok()); + + let server_max = Server { + ssh_port: Some(65535), + ..Default::default() + }; + assert!(server_max.validate().is_ok()); + } + + #[test] + fn test_server_to_server_with_provider() { + let server = Server { + id: 42, + user_id: "user1".to_string(), + project_id: 5, + cloud_id: Some(10), + region: Some("eu-west-1".to_string()), + connection_mode: "ssh".to_string(), + key_status: "active".to_string(), + name: Some("my-server".to_string()), + ..Default::default() + }; + let provider: ServerWithProvider = server.into(); + assert_eq!(provider.id, 42); + assert_eq!(provider.user_id, "user1"); + assert_eq!(provider.project_id, 5); + assert_eq!(provider.cloud_id, Some(10)); + assert!(provider.cloud.is_none()); // Populated by query later + assert_eq!(provider.region, Some("eu-west-1".to_string())); + assert_eq!(provider.connection_mode, "ssh"); + assert_eq!(provider.key_status, "active"); + assert_eq!(provider.name, Some("my-server".to_string())); + } + + #[test] + fn test_server_serialization_defaults() { + let json = r#"{"id":0,"user_id":"","project_id":0,"cloud_id":null,"region":null,"zone":null,"server":null,"os":null,"disk_type":null,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","srv_ip":null,"ssh_port":null,"ssh_user":null,"vault_key_path":null,"connection_mode":"ssh","key_status":"none","name":null}"#; + let server: Server = serde_json::from_str(json).unwrap(); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.key_status, "none"); + } +} diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index 3fcfad23..a65ab55e 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -285,9 +285,10 @@ pub async fn resubmit_handler( #[tracing::instrument(name = "List my templates")] #[get("/mine")] pub async fn mine_handler( - user: web::ReqData>, + user: Option>>, pg_pool: web::Data, ) -> Result { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; db::marketplace::list_mine(pg_pool.get_ref(), &user.id) .await .map_err(|err| { @@ -295,3 +296,29 @@ pub async fn mine_handler( }) .map(|templates| JsonResponse::build().set_list(templates).ok("OK")) } + +#[tracing::instrument(name = "List reviews for my template")] +#[get("/{id}/reviews")] +pub async fn my_reviews_handler( + user: Option>>, + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| JsonResponse::::build().not_found("Template not found"))?; + + if template.creator_user_id != user.id { + return Err(JsonResponse::::build().forbidden("Access denied")); + } + + db::marketplace::list_reviews_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map(|reviews| JsonResponse::build().set_list(reviews).ok("OK")) +} diff --git a/src/routes/marketplace/mod.rs b/src/routes/marketplace/mod.rs index 1ed063d9..a7936dee 100644 --- a/src/routes/marketplace/mod.rs +++ b/src/routes/marketplace/mod.rs @@ -10,6 +10,6 @@ pub use admin::{ }; pub use creator::{ CreateTemplateRequest, ResubmitRequest, UpdateTemplateRequest, create_handler, mine_handler, - resubmit_handler, submit_handler, update_handler, + my_reviews_handler, resubmit_handler, submit_handler, update_handler, }; pub use public::TemplateListQuery; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9afe0852..16ba90b9 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -28,6 +28,6 @@ pub use deployment::{ pub use marketplace::{ AdminDecisionRequest, CreateTemplateRequest, ResubmitRequest, TemplateListQuery, UpdateTemplateRequest, UnapproveRequest, approve_handler, create_handler, list_plans_handler, - list_submitted_handler, mine_handler, reject_handler, resubmit_handler, + list_submitted_handler, mine_handler, my_reviews_handler, reject_handler, resubmit_handler, security_scan_handler, submit_handler, unapprove_handler, update_handler, }; diff --git a/src/startup.rs b/src/startup.rs index d9f59399..de9bdc05 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -193,12 +193,13 @@ pub async fn run( .service( web::scope("/templates") .service(crate::routes::marketplace::public::list_handler) - .service(crate::routes::marketplace::public::detail_handler) + .service(crate::routes::marketplace::creator::mine_handler) + .service(crate::routes::marketplace::creator::my_reviews_handler) .service(crate::routes::marketplace::creator::create_handler) .service(crate::routes::marketplace::creator::update_handler) .service(crate::routes::marketplace::creator::submit_handler) .service(crate::routes::marketplace::creator::resubmit_handler) - .service(crate::routes::marketplace::creator::mine_handler), + .service(crate::routes::marketplace::public::detail_handler), ) .service( web::scope("/v1/agent") diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3006212c..28774511 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -202,9 +202,11 @@ async fn mock_auth() -> actix_web::Result { Ok(web::Json(user_form)) } -async fn mock_auth_server(listener: TcpListener) -> actix_web::dev::Server { +async fn mock_auth_server(listener: TcpListener) { HttpServer::new(|| App::new().service(web::scope("/me").service(mock_auth))) .listen(listener) .unwrap() .run() + .await + .unwrap(); } diff --git a/tests/marketplace_mine.rs b/tests/marketplace_mine.rs index 2e415ca5..6c33b434 100644 --- a/tests/marketplace_mine.rs +++ b/tests/marketplace_mine.rs @@ -116,9 +116,10 @@ async fn mine_returns_forbidden_without_authorization_header() { .await .expect("Failed to send request"); + let status = response.status(); assert_eq!( StatusCode::FORBIDDEN, - response.status(), + status, "Missing auth should yield 403 Forbidden — Stacker never returns 404 for this route" ); }