diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..78c265b2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release CLI Binaries + +permissions: + contents: write + +on: + release: + types: [published] + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-upload: + name: Build & upload (${{ matrix.target }}) + env: + SQLX_OFFLINE: true + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset_os: linux + asset_arch: x86_64 + - os: macos-latest + target: x86_64-apple-darwin + asset_os: darwin + asset_arch: x86_64 + - os: macos-latest + target: aarch64-apple-darwin + asset_os: darwin + asset_arch: aarch64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + override: true + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-index- + + - name: Cache target directory + uses: actions/cache@v4 + with: + path: target + key: release-${{ runner.os }}-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + release-${{ runner.os }}-target-${{ matrix.target }}- + + - name: Build stacker-cli (release) + run: cargo build --release --target ${{ matrix.target }} --bin stacker-cli --verbose + + - name: Package binary + run: | + VERSION="${GITHUB_REF_NAME#v}" + ASSET_NAME="stacker-v${VERSION}-${{ matrix.asset_arch }}-${{ matrix.asset_os }}.tar.gz" + mkdir -p staging + cp target/${{ matrix.target }}/release/stacker-cli staging/stacker + tar -czf "${ASSET_NAME}" -C staging . + echo "ASSET_NAME=${ASSET_NAME}" >> "$GITHUB_ENV" + + - name: Upload release asset + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.ASSET_NAME }} diff --git a/.gitignore b/.gitignore index 82bf7858..a777a745 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,6 @@ configuration.yaml.backup configuration.yaml.orig .vscode/ .env +docker/local/ docs/*.sql config-to-validate.yaml diff --git a/Cargo.toml b/Cargo.toml index c268c04e..0a4f0314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stacker" -version = "0.2.3" +version = "0.2.4" edition = "2021" default-run= "server" diff --git a/README.md b/README.md index 009bf2e8..a510321e 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ Stacker is a platform for turning any project into a deployable Docker stack. Ad ``` ┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐ -│ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │ -│ │ REST │ │ queue │ (on target server) │ -│ stacker.yml │ API │ Stack Builder UI │ pull │ │ -│ init/deploy │ │ 48+ MCP tools │◄────────│ health / logs / │ -│ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ +│ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │ +│ │ REST │ │ queue │ (on target server) │ +│ stacker.yml │ API │ Stack Builder UI│ pull │ │ +│ init/deploy │ │ 48+ MCP tools │◄────────│ health / logs / │ +│ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ └──────────────┘ └──────────────────┘ │ deploy_app / proxy │ - │ └─────────────────────┘ + │ └─────────────────────┘ ▼ Terraform + Ansible ──► Cloud (Hetzner, DO, AWS, Linode) diff --git a/docker/dev/.env b/docker/dev/.env index 892f3064..53d4f824 100644 --- a/docker/dev/.env +++ b/docker/dev/.env @@ -7,9 +7,11 @@ POSTGRES_DB=stacker POSTGRES_PORT=5432 # Vault Configuration -VAULT_ADDRESS=http://127.0.0.1:8200 +VAULT_ADDRESS=http://vault2.try.direct:8200 VAULT_TOKEN=your_vault_token_here VAULT_AGENT_PATH_PREFIX=agent +VAULT_API_PREFIX=v1 +VAULT_SSH_KEY_PATH_PREFIX=users ### 10.3 Environment Variables Required # User Service integration diff --git a/src/helpers/vault.rs b/src/helpers/vault.rs index d468c4a7..3a0a6c21 100644 --- a/src/helpers/vault.rs +++ b/src/helpers/vault.rs @@ -167,13 +167,13 @@ impl VaultClient { // ============ SSH Key Management Methods ============ - /// Build the Vault path for SSH keys: {base}/v1/secret/users/{user_id}/ssh_keys/{server_id} + /// Build the Vault API URL for SSH keys (KV v1). + /// Path: `{address}/{api_prefix}/secret/{prefix}/{user_id}/ssh_keys/{server_id}` fn ssh_key_path(&self, user_id: &str, server_id: i32) -> String { let base = self.address.trim_end_matches('/'); let api_prefix = self.api_prefix.trim_matches('/'); let prefix = self.ssh_key_path_prefix.trim_matches('/'); - // Path without 'data' segment (KV v1 or custom mount) if api_prefix.is_empty() { format!( "{}/secret/{}/{}/ssh_keys/{}", @@ -219,13 +219,11 @@ impl VaultClient { let path = self.ssh_key_path(user_id, server_id); let payload = json!({ - "data": { - "public_key": public_key, - "private_key": private_key, - "user_id": user_id, - "server_id": server_id, - "created_at": chrono::Utc::now().to_rfc3339() - } + "public_key": public_key, + "private_key": private_key, + "user_id": user_id, + "server_id": server_id, + "created_at": chrono::Utc::now().to_rfc3339() }); self.client @@ -244,7 +242,7 @@ impl VaultClient { format!("Vault error: {}", e) })?; - // Return the vault path for storage in database + // Return the logical vault path for storage in database let vault_key_path = format!( "secret/{}/{}/ssh_keys/{}", self.ssh_key_path_prefix.trim_matches('/'), @@ -293,7 +291,7 @@ impl VaultClient { format!("Vault parse error: {}", e) })?; - vault_response["data"]["data"]["private_key"] + vault_response["data"]["private_key"] .as_str() .map(|s| s.to_string()) .ok_or_else(|| { @@ -339,7 +337,7 @@ impl VaultClient { format!("Vault parse error: {}", e) })?; - vault_response["data"]["data"]["public_key"] + vault_response["data"]["public_key"] .as_str() .map(|s| s.to_string()) .ok_or_else(|| { diff --git a/src/routes/project/compose.rs b/src/routes/project/compose.rs index 3cc7d8ae..a36f8ff1 100644 --- a/src/routes/project/compose.rs +++ b/src/routes/project/compose.rs @@ -27,7 +27,7 @@ pub async fn add( DcBuilder::new(project) .build() - .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map_err(|err| JsonResponse::::build().bad_request(err)) .map(|fc| JsonResponse::build().set_id(id).set_item(fc).ok("Success")) } @@ -50,6 +50,6 @@ pub async fn admin( DcBuilder::new(project) .build() - .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map_err(|err| JsonResponse::::build().bad_request(err)) .map(|fc| JsonResponse::build().set_id(id).set_item(fc).ok("Success")) } diff --git a/src/routes/server/ssh_key.rs b/src/routes/server/ssh_key.rs index 30dbdf53..56a03cb0 100644 --- a/src/routes/server/ssh_key.rs +++ b/src/routes/server/ssh_key.rs @@ -211,14 +211,24 @@ pub async fn get_public_key( .not_found("No active SSH key found for this server")); } + if server.vault_key_path.is_none() { + return Err(JsonResponse::::build() + .bad_request("SSH key is not stored in Vault (Vault was unavailable when the key was generated). Please delete this key and generate a new one.")); + } + let public_key = vault_client .get_ref() .fetch_ssh_public_key(&user.id, server_id) .await .map_err(|e| { tracing::error!("Failed to fetch public key from Vault: {}", e); - JsonResponse::::build() - .internal_server_error("Failed to retrieve public key") + if e.to_lowercase().contains("not found") { + JsonResponse::::build() + .not_found("SSH key not found in Vault. The key may have been lost or Vault was restored without its data. Please delete this key and generate a new one.") + } else { + JsonResponse::::build() + .bad_request("Failed to retrieve SSH key from Vault. Please try again or regenerate the key.") + } })?; let response = PublicKeyResponse { @@ -311,6 +321,19 @@ pub async fn validate_key( .ok("Validation failed")); } + if server.vault_key_path.is_none() { + let response = ValidateResponse { + valid: false, + server_id, + srv_ip: server.srv_ip.clone(), + message: "SSH key is not stored in Vault (Vault was unavailable when the key was generated). Please delete this key and generate a new one.".to_string(), + ..Default::default() + }; + return Ok(JsonResponse::build() + .set_item(Some(response)) + .ok("Validation failed")); + } + // Verify we have the server IP let srv_ip = match &server.srv_ip { Some(ip) if !ip.is_empty() => ip.clone(), diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 555fec29..3006212c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -4,6 +4,7 @@ use stacker::configuration::{get_configuration, DatabaseSettings, Settings}; use stacker::forms; use stacker::helpers::AgentPgPool; use std::net::TcpListener; +use wiremock::MockServer; pub async fn spawn_app_with_configuration(mut configuration: Settings) -> Option { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); @@ -85,6 +86,106 @@ pub struct TestApp { pub db_pool: PgPool, } +pub struct TestAppWithVault { + pub address: String, + pub db_pool: PgPool, + pub vault_server: MockServer, +} + +/// Spawn the full app with a mock Vault server. +/// The returned `vault_server` is a wiremock MockServer — mount expectations on it +/// before calling API endpoints that touch Vault. +pub async fn spawn_app_with_vault() -> Option { + let mut configuration = get_configuration().expect("Failed to get configuration"); + + // Mock auth server + let auth_listener = std::net::TcpListener::bind("127.0.0.1:0") + .expect("Failed to bind port for testing auth server"); + configuration.auth_url = format!( + "http://127.0.0.1:{}/me", + auth_listener.local_addr().unwrap().port() + ); + let _ = tokio::spawn(mock_auth_server(auth_listener)); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Mock Vault server + let vault_server = MockServer::start().await; + configuration.vault.address = vault_server.uri(); + configuration.vault.token = "test-vault-token".to_string(); + configuration.vault.api_prefix = "v1".to_string(); + configuration.vault.ssh_key_path_prefix = Some("users".to_string()); + + configuration.database.database_name = uuid::Uuid::new_v4().to_string(); + + let connection_pool = match configure_database(&configuration.database).await { + Ok(pool) => pool, + Err(err) => { + eprintln!("Skipping tests: failed to connect to postgres: {}", err); + return None; + } + }; + + let app_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind app port"); + let port = app_listener.local_addr().unwrap().port(); + let address = format!("http://127.0.0.1:{}", port); + + let agent_pool = AgentPgPool::new(connection_pool.clone()); + let server = stacker::startup::run(app_listener, connection_pool.clone(), agent_pool, configuration) + .await + .expect("Failed to bind address."); + let _ = tokio::spawn(server); + + Some(TestAppWithVault { + address, + db_pool: connection_pool, + vault_server, + }) +} + +/// Insert a minimal project into the DB and return its id. +/// Required because server.project_id has a FK constraint to project(id). +pub async fn create_test_project(pool: &PgPool, user_id: &str) -> i32 { + sqlx::query( + r#"INSERT INTO project (stack_id, user_id, name, body, created_at, updated_at) + VALUES (gen_random_uuid(), $1, 'Test Project', '{}', NOW(), NOW()) + RETURNING id"#, + ) + .bind(user_id) + .fetch_one(pool) + .await + .map(|row| { + use sqlx::Row; + row.get::("id") + }) + .expect("Failed to insert test project") +} + +/// Insert a test server with specific SSH key state and return its id. +pub async fn create_test_server( + pool: &PgPool, + user_id: &str, + project_id: i32, + key_status: &str, + vault_key_path: Option<&str>, +) -> i32 { + sqlx::query( + r#"INSERT INTO server (user_id, project_id, connection_mode, key_status, vault_key_path, created_at, updated_at) + VALUES ($1, $2, 'ssh', $3, $4, NOW(), NOW()) + RETURNING id"#, + ) + .bind(user_id) + .bind(project_id) + .bind(key_status) + .bind(vault_key_path) + .fetch_one(pool) + .await + .map(|row| { + use sqlx::Row; + row.get::("id") + }) + .expect("Failed to insert test server") +} + #[get("")] async fn mock_auth() -> actix_web::Result { println!("Mock auth endpoint called - returning test user"); diff --git a/tests/server_ssh.rs b/tests/server_ssh.rs index f012a9a8..f0986cb7 100644 --- a/tests/server_ssh.rs +++ b/tests/server_ssh.rs @@ -1,179 +1,454 @@ mod common; -use serde_json::json; +use serde_json::{json, Value}; +use wiremock::matchers::{method, path_regex}; +use wiremock::{Mock, ResponseTemplate}; -// Test SSH key generation for server -// Run: cargo t --test server_ssh -- --nocapture --show-output +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── -/// Test that the server list endpoint returns success +/// Vault path pattern for SSH keys: /v1/secret/users/{user_id}/ssh_keys/{server_id} +fn vault_ssh_path_regex(user_id: &str, server_id: i32) -> String { + format!( + r"/v1/secret/users/{}/ssh_keys/{}", + user_id, server_id + ) +} + +/// Successful Vault GET response body for a KV v1 SSH key read. +fn vault_key_response(public_key: &str, private_key: &str) -> serde_json::Value { + json!({ + "data": { + "public_key": public_key, + "private_key": private_key + } + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: GET /server/{id}/ssh-key/public +// ───────────────────────────────────────────────────────────────────────────── + +/// Server has key_status=active but vault_key_path=NULL (Vault failed during generate). +/// Must return 400, not 500. #[tokio::test] -async fn get_server_list() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_get_public_key_vault_path_null_returns_400() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + None, // vault_key_path is NULL + ) + .await; - let response = client - .get(&format!("{}/server", &app.address)) + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should return 200 OK (empty list is fine) - assert!(response.status().is_success()); + assert_eq!(resp.status().as_u16(), 400, "Should be 400, not 500"); + let body: Value = resp.json().await.unwrap(); + let msg = body["message"].as_str().unwrap_or(""); + assert!( + msg.to_lowercase().contains("vault") || msg.to_lowercase().contains("regenerate") || msg.to_lowercase().contains("delete"), + "Error message should mention Vault or remediation: {}", msg + ); + // Vault server must NOT have been called (no vault_key_path to use) + assert_eq!(app.vault_server.received_requests().await.unwrap().len(), 0); } -/// Test that getting a non-existent server returns 404 +/// Server has key_status=active and vault_key_path set, but Vault returns 404. +/// Must return 404 (key lost from Vault), not 500. #[tokio::test] -async fn get_server_not_found() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_get_public_key_vault_returns_404_propagates_as_404() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + Some(&format!("secret/users/test_user_id/ssh_keys/{}", 999)), + ) + .await; + + // Mount Vault mock: GET → 404 + Mock::given(method("GET")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({"errors": []}))) + .mount(&app.vault_server) + .await; - let response = client - .get(&format!("{}/server/99999", &app.address)) + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should return 404 for non-existent server - assert_eq!(response.status().as_u16(), 404); + assert_eq!(resp.status().as_u16(), 404, "Should be 404 when Vault returns 404"); + let body: Value = resp.json().await.unwrap(); + let msg = body["message"].as_str().unwrap_or(""); + assert!( + msg.to_lowercase().contains("vault") || msg.to_lowercase().contains("regenerate"), + "Error message should mention Vault: {}", msg + ); } -/// Test that generating SSH key requires authentication +/// Server has key_status="none" — no key has been generated yet. +/// Must return 404. #[tokio::test] -async fn generate_ssh_key_requires_auth() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_get_public_key_no_active_key_returns_404() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; - let response = client - .post(&format!("{}/server/1/ssh-key/generate", &app.address)) + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should require authentication (401 or 403) - let status = response.status().as_u16(); - assert!(status == 401 || status == 403 || status == 404); + assert_eq!(resp.status().as_u16(), 404); } -/// Test that uploading SSH key validates input +/// Happy path: active key, vault_key_path set, Vault returns the key successfully. #[tokio::test] -async fn upload_ssh_key_validates_input() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_get_public_key_success() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + Some(&format!("secret/users/test_user_id/ssh_keys/{}", 0)), // path value doesn't matter for routing + ) + .await; - // Send invalid key format - let invalid_data = json!({ - "public_key": "not-a-valid-key", - "private_key": "also-not-valid" - }); + let expected_pub_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey"; - let response = client - .post(&format!("{}/server/1/ssh-key/upload", &app.address)) - .header("Content-Type", "application/json") - .body(invalid_data.to_string()) + Mock::given(method("GET")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with( + ResponseTemplate::new(200).set_body_json(vault_key_response( + expected_pub_key, + "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + )), + ) + .mount(&app.vault_server) + .await; + + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should reject invalid key format (400 or 401/403 if auth required first) - let status = response.status().as_u16(); - assert!(status == 400 || status == 401 || status == 403 || status == 404); + assert_eq!(resp.status().as_u16(), 200); + let body: Value = resp.json().await.unwrap(); + assert_eq!( + body["item"]["public_key"].as_str().unwrap_or(""), + expected_pub_key, + "Response should contain the public key from Vault" + ); } -/// Test that getting public key for non-existent server returns error +// ───────────────────────────────────────────────────────────────────────────── +// Tests: POST /server/{id}/ssh-key/generate +// ───────────────────────────────────────────────────────────────────────────── + +/// When Vault is unavailable during generate, the private key MUST be returned +/// inline in the response, and the DB must have key_status=active + vault_key_path=NULL. #[tokio::test] -async fn get_public_key_not_found() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_generate_key_vault_down_returns_private_key_inline() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; - let response = client - .get(&format!("{}/server/99999/ssh-key/public", &app.address)) + // Vault is down — POST returns 500 + Mock::given(method("POST")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with(ResponseTemplate::new(500).set_body_string("vault unavailable")) + .mount(&app.vault_server) + .await; + + let client = reqwest::Client::new(); + let resp = client + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 200, "Generate should succeed even when Vault is down"); + let body: Value = resp.json().await.unwrap(); - // Should return 404 - let status = response.status().as_u16(); - assert!(status == 404 || status == 401 || status == 403); + // Private key must be returned inline so user can save it + assert!( + body["item"]["private_key"].is_string(), + "Private key must be returned inline when Vault is unavailable" + ); + assert!( + body["item"]["public_key"].is_string(), + "Public key must also be present" + ); + + // DB: key_status must be "active" and vault_key_path must be NULL + let row = sqlx::query("SELECT key_status, vault_key_path FROM server WHERE id = $1") + .bind(server_id) + .fetch_one(&app.db_pool) + .await + .expect("DB query failed"); + use sqlx::Row; + let db_key_status: String = row.get("key_status"); + let db_vault_path: Option = row.get("vault_key_path"); + assert_eq!(db_key_status, "active"); + assert!( + db_vault_path.is_none(), + "vault_key_path must be NULL when Vault store failed" + ); } -/// Test that deleting SSH key for non-existent server returns error +/// Happy path: Vault is available — key is stored, no private key in response, vault_key_path saved. #[tokio::test] -async fn delete_ssh_key_not_found() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; - let response = client - .delete(&format!("{}/server/99999/ssh-key", &app.address)) + // Vault is up — POST returns 204 + Mock::given(method("POST")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with(ResponseTemplate::new(204)) + .mount(&app.vault_server) + .await; + + let client = reqwest::Client::new(); + let resp = client + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 200); + let body: Value = resp.json().await.unwrap(); - // Should return 404 or auth error - let status = response.status().as_u16(); - assert!(status == 404 || status == 401 || status == 403); + // Private key must NOT be in response when Vault worked + assert!( + body["item"]["private_key"].is_null() || !body["item"]["private_key"].is_string(), + "Private key must NOT be returned when Vault stored it successfully" + ); + assert!(body["item"]["public_key"].is_string(), "Public key must be present"); + + // DB: vault_key_path must be set + let row = sqlx::query("SELECT key_status, vault_key_path FROM server WHERE id = $1") + .bind(server_id) + .fetch_one(&app.db_pool) + .await + .expect("DB query failed"); + use sqlx::Row; + let db_key_status: String = row.get("key_status"); + let db_vault_path: Option = row.get("vault_key_path"); + assert_eq!(db_key_status, "active"); + assert!( + db_vault_path.is_some(), + "vault_key_path must be saved in DB after successful Vault store" + ); } -/// Test server update endpoint +/// Generating a key when one is already active must return 400. #[tokio::test] -async fn update_server_not_found() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_generate_key_already_active_returns_400() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + Some("secret/users/test_user_id/ssh_keys/1"), + ) + .await; + let client = reqwest::Client::new(); + let resp = client + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .header("Authorization", "Bearer test-token") + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 400); + // Vault must NOT have been called + assert_eq!(app.vault_server.received_requests().await.unwrap().len(), 0); +} - let update_data = json!({ - "name": "My Server", - "connection_mode": "ssh" - }); +// ───────────────────────────────────────────────────────────────────────────── +// Tests: DELETE /server/{id}/ssh-key +// ───────────────────────────────────────────────────────────────────────────── - let response = client - .put(&format!("{}/server/99999", &app.address)) - .header("Content-Type", "application/json") - .body(update_data.to_string()) +/// Deleting an active key must call Vault DELETE, reset key_status to "none", +/// and clear vault_key_path in DB. +#[tokio::test] +async fn test_delete_key_clears_vault_and_db() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, + None => return, + }; + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "active", + Some(&format!("secret/users/test_user_id/ssh_keys/{}", 0)), + ) + .await; + + Mock::given(method("DELETE")) + .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) + .respond_with(ResponseTemplate::new(204)) + .mount(&app.vault_server) + .await; + + let client = reqwest::Client::new(); + let resp = client + .delete(&format!("{}/server/{}/ssh-key", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); - // Should return 404 for non-existent server - let status = response.status().as_u16(); - assert!(status == 404 || status == 401 || status == 403); + assert_eq!(resp.status().as_u16(), 200); + + let row = sqlx::query("SELECT key_status, vault_key_path FROM server WHERE id = $1") + .bind(server_id) + .fetch_one(&app.db_pool) + .await + .expect("DB query failed"); + use sqlx::Row; + let db_key_status: String = row.get("key_status"); + let db_vault_path: Option = row.get("vault_key_path"); + assert_eq!(db_key_status, "none", "key_status must be reset to 'none'"); + assert!(db_vault_path.is_none(), "vault_key_path must be cleared"); } -/// Test get servers by project endpoint +/// Deleting when no key exists must return 400. #[tokio::test] -async fn get_servers_by_project() { - let app = match common::spawn_app().await { - Some(app) => app, +async fn test_delete_key_none_returns_400() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, None => return, }; - let client = reqwest::Client::new(); + let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; - let response = client - .get(&format!("{}/server/project/1", &app.address)) + let client = reqwest::Client::new(); + let resp = client + .delete(&format!("{}/server/{}/ssh-key", &app.address, server_id)) + .header("Authorization", "Bearer test-token") .send() .await - .expect("Failed to execute request."); + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 400); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: Unauthenticated access +// ───────────────────────────────────────────────────────────────────────────── + +/// All SSH key endpoints must reject requests without a Bearer token. +#[tokio::test] +async fn test_ssh_key_endpoints_require_auth() { + let app = match common::spawn_app_with_vault().await { + Some(a) => a, + None => return, + }; + let client = reqwest::Client::new(); + + let endpoints: &[(&str, &str)] = &[ + ("GET", "/server/1/ssh-key/public"), + ("POST", "/server/1/ssh-key/generate"), + ("DELETE", "/server/1/ssh-key"), + ]; - // Should return success or auth error - let status = response.status().as_u16(); - assert!(status == 200 || status == 404 || status == 401 || status == 403); + for (verb, path) in endpoints { + let req = match *verb { + "GET" => client.get(&format!("{}{}", &app.address, path)), + "POST" => client.post(&format!("{}{}", &app.address, path)), + "DELETE" => client.delete(&format!("{}{}", &app.address, path)), + _ => unreachable!(), + }; + let resp = req.send().await.expect("request failed"); + let status = resp.status().as_u16(); + assert!( + status == 400 || status == 401 || status == 403 || status == 404, + "{} {} without auth should return 400/401/403, got {}", + verb, path, status + ); + } } diff --git a/website/stacker.yml b/website/stacker.yml index 4b1854a3..61a9d90f 100644 --- a/website/stacker.yml +++ b/website/stacker.yml @@ -2,6 +2,8 @@ name: website version: 1.0.0 project: identity: stacker-website + _id: 44 + app: type: node path: '.' @@ -12,26 +14,10 @@ services: image: ${DOCKER_USERNAME}/stacker-website:latest ports: - "3456:3456" - environment: - DB_PASSWORD: ${DB_PASSWORD} - OPENAI_API_KEY: ${OPENAI_API_KEY} volumes: - .:/app - depends_on: - - db - db: - name: db - image: postgres:14 - environment: - POSTGRES_DB: stacker_db - POSTGRES_USER: stacker_user - POSTGRES_PASSWORD: ${DB_PASSWORD} - ports: - - "5432:5432" - volumes: - - db-data:/var/lib/postgresql/data -monitors: +monitoring: status_panel: true healthcheck: endpoint: /healthz