diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2026-01-22 22:07:32 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-10 18:44:26 +0100 |
| commit | 064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch) | |
| tree | a2023f9ccd297ed8a41a3a0cc5699c2add09244d /tests | |
witryna 0.1.0 — initial releasev0.1.0
Minimalist Git-based static site deployment orchestrator.
Webhook-triggered builds in Podman/Docker containers with atomic
symlink publishing, SIGHUP hot-reload, and zero-downtime deploys.
See README.md for usage, CHANGELOG.md for details.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/integration/auth.rs | 58 | ||||
| -rw-r--r-- | tests/integration/cache.rs | 125 | ||||
| -rw-r--r-- | tests/integration/cleanup.rs | 92 | ||||
| -rw-r--r-- | tests/integration/cli_run.rs | 277 | ||||
| -rw-r--r-- | tests/integration/cli_status.rs | 313 | ||||
| -rw-r--r-- | tests/integration/concurrent.rs | 111 | ||||
| -rw-r--r-- | tests/integration/deploy.rs | 78 | ||||
| -rw-r--r-- | tests/integration/edge_cases.rs | 69 | ||||
| -rw-r--r-- | tests/integration/env_vars.rs | 162 | ||||
| -rw-r--r-- | tests/integration/git_helpers.rs | 275 | ||||
| -rw-r--r-- | tests/integration/harness.rs | 356 | ||||
| -rw-r--r-- | tests/integration/health.rs | 17 | ||||
| -rw-r--r-- | tests/integration/hooks.rs | 137 | ||||
| -rw-r--r-- | tests/integration/logs.rs | 73 | ||||
| -rw-r--r-- | tests/integration/main.rs | 31 | ||||
| -rw-r--r-- | tests/integration/not_found.rs | 17 | ||||
| -rw-r--r-- | tests/integration/overrides.rs | 59 | ||||
| -rw-r--r-- | tests/integration/packaging.rs | 49 | ||||
| -rw-r--r-- | tests/integration/polling.rs | 114 | ||||
| -rw-r--r-- | tests/integration/rate_limit.rs | 114 | ||||
| -rw-r--r-- | tests/integration/runtime.rs | 61 | ||||
| -rw-r--r-- | tests/integration/secrets.rs | 74 | ||||
| -rw-r--r-- | tests/integration/sighup.rs | 149 |
23 files changed, 2811 insertions, 0 deletions
diff --git a/tests/integration/auth.rs b/tests/integration/auth.rs new file mode 100644 index 0000000..78984d8 --- /dev/null +++ b/tests/integration/auth.rs @@ -0,0 +1,58 @@ +use crate::harness::{SiteBuilder, TestServer, server_with_site, test_config_with_site}; + +#[tokio::test] +async fn invalid_auth_returns_401() { + let server = server_with_site().await; + + let cases: Vec<(&str, Option<&str>)> = vec![ + ("no header", None), + ("wrong token", Some("Bearer wrong-token")), + ("wrong scheme", Some("Basic dXNlcjpwYXNz")), + ("empty header", Some("")), + ("bearer without token", Some("Bearer ")), + ]; + + for (label, header_value) in &cases { + let mut req = TestServer::client().post(server.url("/my-site")); + if let Some(value) = header_value { + req = req.header("Authorization", *value); + } + + let resp = req.send().await.unwrap(); + assert_eq!( + resp.status().as_u16(), + 401, + "expected 401 for case: {label}" + ); + let body = resp.text().await.unwrap(); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + json["error"], "unauthorized", + "expected JSON error for case: {label}" + ); + } +} + +#[tokio::test] +async fn disabled_auth_allows_unauthenticated_requests() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("open-site", "https://example.com/repo.git", "").build(); + let server = TestServer::start(test_config_with_site(dir, site)).await; + + // POST without Authorization header → 202 + let resp = TestServer::client() + .post(server.url("/open-site")) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // POST with arbitrary Authorization header → 202 (token ignored) + let resp = TestServer::client() + .post(server.url("/open-site")) + .header("Authorization", "Bearer anything") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); +} diff --git a/tests/integration/cache.rs b/tests/integration/cache.rs new file mode 100644 index 0000000..42d2a15 --- /dev/null +++ b/tests/integration/cache.rs @@ -0,0 +1,125 @@ +use crate::git_helpers::create_local_repo; +use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; +use crate::runtime::{skip_without_git, skip_without_runtime}; +use std::time::Duration; +use witryna::config::sanitize_cache_dir_name; + +#[tokio::test] +async fn cache_dir_persists_across_builds() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + let site = SiteBuilder::new("cache-site", &repo_url, "cache-token") + .overrides( + "alpine:latest", + "mkdir -p /tmp/test-cache && echo 'cached' > /tmp/test-cache/marker && mkdir -p out && cp /tmp/test-cache/marker out/marker", + "out", + ) + .cache_dirs(vec!["/tmp/test-cache".to_owned()]) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + // --- Build 1: create marker in cache --- + let resp = TestServer::client() + .post(server.url("/cache-site")) + .header("Authorization", "Bearer cache-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for build to complete + let builds_dir = base_dir.join("builds/cache-site"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!( + start.elapsed() <= max_wait, + "build 1 timed out after {max_wait:?}" + ); + if builds_dir.join("current").is_symlink() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Verify built output + let target = tokio::fs::read_link(builds_dir.join("current")) + .await + .unwrap(); + assert!( + target.join("marker").exists(), + "marker should exist in build output" + ); + + // Host-side verification: cache directory should exist with marker + let sanitized = sanitize_cache_dir_name("/tmp/test-cache"); + let host_cache_dir = base_dir.join("cache/cache-site").join(&sanitized); + assert!( + host_cache_dir.join("marker").exists(), + "marker should exist in host cache dir: {}", + host_cache_dir.display() + ); + + // --- Build 2: verify marker was already there (cache persisted) --- + // Wait for build lock to release + let start = std::time::Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(10) { + break; + } + if !server + .state + .build_scheduler + .in_progress + .contains("cache-site") + { + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + let resp = TestServer::client() + .post(server.url("/cache-site")) + .header("Authorization", "Bearer cache-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for second build to complete (symlink target changes) + let first_target = target; + let start = std::time::Instant::now(); + loop { + assert!( + start.elapsed() <= max_wait, + "build 2 timed out after {max_wait:?}" + ); + if let Ok(new_target) = tokio::fs::read_link(builds_dir.join("current")).await + && new_target != first_target + { + // Verify marker still in output + assert!( + new_target.join("marker").exists(), + "marker should exist in second build output (cache persisted)" + ); + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Host-side: cache dir still has marker + assert!( + host_cache_dir.join("marker").exists(), + "marker should still exist in host cache dir after build 2" + ); +} diff --git a/tests/integration/cleanup.rs b/tests/integration/cleanup.rs new file mode 100644 index 0000000..e0cc902 --- /dev/null +++ b/tests/integration/cleanup.rs @@ -0,0 +1,92 @@ +use crate::git_helpers::create_local_repo; +use crate::harness::{SiteBuilder, TestServer}; +use crate::runtime::{skip_without_git, skip_without_runtime}; +use std::time::Duration; +use witryna::config::Config; + +#[tokio::test] +async fn old_builds_cleaned_up() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + let site = SiteBuilder::new("cleanup-site", &repo_url, "test-token") + .overrides( + "alpine:latest", + "mkdir -p out && echo '<h1>test</h1>' > out/index.html", + "out", + ) + .build(); + + // Keep only 2 builds + let config = Config { + listen_address: "127.0.0.1:0".to_owned(), + container_runtime: crate::harness::test_config(base_dir.clone()).container_runtime, + base_dir: base_dir.clone(), + log_dir: base_dir.join("logs"), + log_level: "debug".to_owned(), + rate_limit_per_minute: 100, + max_builds_to_keep: 2, + git_timeout: None, + sites: vec![site], + }; + + let server = TestServer::start(config).await; + let builds_dir = base_dir.join("builds/cleanup-site"); + + // Run 3 builds sequentially + for i in 0..3 { + let resp = TestServer::client() + .post(server.url("/cleanup-site")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202, "build {i} should be accepted"); + + // Wait for build to complete + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!(start.elapsed() <= max_wait, "build {i} timed out"); + + // Check that the site is no longer building + if !server + .state + .build_scheduler + .in_progress + .contains("cleanup-site") + { + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + // Small delay between builds to ensure different timestamps + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Count timestamped build directories (excluding "current" symlink) + let mut count = 0; + if builds_dir.is_dir() { + let mut entries = tokio::fs::read_dir(&builds_dir).await.unwrap(); + while let Some(entry) = entries.next_entry().await.unwrap() { + let name = entry.file_name(); + if name != "current" && name != "current.tmp" { + count += 1; + } + } + } + + assert!( + count <= 2, + "should have at most 2 builds after cleanup, got {count}" + ); +} diff --git a/tests/integration/cli_run.rs b/tests/integration/cli_run.rs new file mode 100644 index 0000000..0ea8d20 --- /dev/null +++ b/tests/integration/cli_run.rs @@ -0,0 +1,277 @@ +use crate::git_helpers::create_bare_repo; +use crate::runtime::{detect_container_runtime, skip_without_git, skip_without_runtime}; +use std::process::Stdio; +use tempfile::TempDir; +use tokio::process::Command; + +/// Build the binary path for the witryna executable. +fn witryna_bin() -> std::path::PathBuf { + // cargo test sets CARGO_BIN_EXE_witryna when the binary exists, + // but for integration tests we use the debug build path directly. + let mut path = std::path::PathBuf::from(env!("CARGO_BIN_EXE_witryna")); + if !path.exists() { + // Fallback to target/debug/witryna + path = std::path::PathBuf::from("target/debug/witryna"); + } + path +} + +/// Write a minimal witryna.toml config file. +async fn write_config( + dir: &std::path::Path, + site_name: &str, + repo_url: &str, + base_dir: &std::path::Path, + log_dir: &std::path::Path, + command: &str, + public: &str, +) -> std::path::PathBuf { + let config_path = dir.join("witryna.toml"); + let runtime = detect_container_runtime(); + let config = format!( + r#"listen_address = "127.0.0.1:0" +container_runtime = "{runtime}" +base_dir = "{base_dir}" +log_dir = "{log_dir}" +log_level = "debug" + +[[sites]] +name = "{site_name}" +repo_url = "{repo_url}" +branch = "main" +webhook_token = "unused" +image = "alpine:latest" +command = "{command}" +public = "{public}" +"#, + base_dir = base_dir.display(), + log_dir = log_dir.display(), + ); + tokio::fs::write(&config_path, config).await.unwrap(); + config_path +} + +// --------------------------------------------------------------------------- +// Tier 1: no container runtime needed +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn cli_run_site_not_found_exits_nonzero() { + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&base_dir).await.unwrap(); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + // Write config with no sites matching "nonexistent" + let config_path = tempdir.path().join("witryna.toml"); + let config = format!( + r#"listen_address = "127.0.0.1:0" +container_runtime = "podman" +base_dir = "{}" +log_dir = "{}" +log_level = "info" +sites = [] +"#, + base_dir.display(), + log_dir.display(), + ); + tokio::fs::write(&config_path, config).await.unwrap(); + + let output = Command::new(witryna_bin()) + .args([ + "--config", + config_path.to_str().unwrap(), + "run", + "nonexistent", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + !output.status.success(), + "should exit non-zero for unknown site" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not found"), + "stderr should mention site not found, got: {stderr}" + ); +} + +#[tokio::test] +async fn cli_run_build_failure_exits_nonzero() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&base_dir).await.unwrap(); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_bare_repo(&repo_dir, "main").await; + + let config_path = write_config( + tempdir.path(), + "fail-site", + &repo_url, + &base_dir, + &log_dir, + "exit 42", + "dist", + ) + .await; + + let output = Command::new(witryna_bin()) + .args([ + "--config", + config_path.to_str().unwrap(), + "run", + "fail-site", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!( + !output.status.success(), + "should exit non-zero on build failure" + ); + + // Verify a log file was created + let logs_dir = log_dir.join("fail-site"); + if logs_dir.is_dir() { + let mut entries = tokio::fs::read_dir(&logs_dir).await.unwrap(); + let mut found_log = false; + while let Some(entry) = entries.next_entry().await.unwrap() { + if entry.file_name().to_string_lossy().ends_with(".log") { + found_log = true; + break; + } + } + assert!(found_log, "should have a .log file after failed build"); + } +} + +// --------------------------------------------------------------------------- +// Tier 2: requires git + container runtime +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn cli_run_builds_site_successfully() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&base_dir).await.unwrap(); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_bare_repo(&repo_dir, "main").await; + + let config_path = write_config( + tempdir.path(), + "test-site", + &repo_url, + &base_dir, + &log_dir, + "mkdir -p out && echo hello > out/index.html", + "out", + ) + .await; + + let output = Command::new(witryna_bin()) + .args([ + "--config", + config_path.to_str().unwrap(), + "run", + "test-site", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "should exit 0 on success, stderr: {stderr}" + ); + + // Verify symlink exists + let current = base_dir.join("builds/test-site/current"); + assert!(current.is_symlink(), "current symlink should exist"); + + // Verify published content + let target = tokio::fs::read_link(¤t).await.unwrap(); + let content = tokio::fs::read_to_string(target.join("index.html")) + .await + .unwrap(); + assert!(content.contains("hello"), "published content should match"); + + // Verify log file exists + let logs_dir = log_dir.join("test-site"); + assert!(logs_dir.is_dir(), "logs directory should exist"); +} + +#[tokio::test] +async fn cli_run_verbose_shows_build_output() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = TempDir::new().unwrap(); + let base_dir = tempdir.path().join("data"); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&base_dir).await.unwrap(); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_bare_repo(&repo_dir, "main").await; + + let config_path = write_config( + tempdir.path(), + "verbose-site", + &repo_url, + &base_dir, + &log_dir, + "echo VERBOSE_MARKER && mkdir -p out && echo ok > out/index.html", + "out", + ) + .await; + + let output = Command::new(witryna_bin()) + .args([ + "--config", + config_path.to_str().unwrap(), + "run", + "verbose-site", + "--verbose", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "should exit 0, stderr: {stderr}"); + + // In verbose mode, build output should appear in stderr + assert!( + stderr.contains("VERBOSE_MARKER"), + "stderr should contain build output in verbose mode, got: {stderr}" + ); +} diff --git a/tests/integration/cli_status.rs b/tests/integration/cli_status.rs new file mode 100644 index 0000000..25135fb --- /dev/null +++ b/tests/integration/cli_status.rs @@ -0,0 +1,313 @@ +use std::process::Stdio; +use tempfile::TempDir; +use tokio::process::Command; + +/// Build the binary path for the witryna executable. +fn witryna_bin() -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_BIN_EXE_witryna")); + if !path.exists() { + path = std::path::PathBuf::from("target/debug/witryna"); + } + path +} + +/// Write a minimal witryna.toml config for status tests. +async fn write_status_config( + dir: &std::path::Path, + sites: &[&str], + log_dir: &std::path::Path, +) -> std::path::PathBuf { + let base_dir = dir.join("data"); + tokio::fs::create_dir_all(&base_dir).await.unwrap(); + + let mut sites_toml = String::new(); + for name in sites { + sites_toml.push_str(&format!( + r#" +[[sites]] +name = "{name}" +repo_url = "https://example.com/{name}.git" +branch = "main" +webhook_token = "unused" +"# + )); + } + + let config_path = dir.join("witryna.toml"); + let config = format!( + r#"listen_address = "127.0.0.1:0" +container_runtime = "podman" +base_dir = "{base_dir}" +log_dir = "{log_dir}" +log_level = "info" +{sites_toml}"#, + base_dir = base_dir.display(), + log_dir = log_dir.display(), + ); + tokio::fs::write(&config_path, config).await.unwrap(); + config_path +} + +/// Write a fake build log with a valid header. +async fn write_test_build_log( + log_dir: &std::path::Path, + site_name: &str, + timestamp: &str, + status: &str, + commit: &str, + image: &str, + duration: &str, +) { + let site_log_dir = log_dir.join(site_name); + tokio::fs::create_dir_all(&site_log_dir).await.unwrap(); + + let content = format!( + "=== BUILD LOG ===\n\ + Site: {site_name}\n\ + Timestamp: {timestamp}\n\ + Git Commit: {commit}\n\ + Image: {image}\n\ + Duration: {duration}\n\ + Status: {status}\n\ + \n\ + === STDOUT ===\n\ + build output\n\ + \n\ + === STDERR ===\n" + ); + + let log_file = site_log_dir.join(format!("{timestamp}.log")); + tokio::fs::write(&log_file, content).await.unwrap(); +} + +/// Write a fake hook log with a valid header. +async fn write_test_hook_log( + log_dir: &std::path::Path, + site_name: &str, + timestamp: &str, + status: &str, +) { + let site_log_dir = log_dir.join(site_name); + tokio::fs::create_dir_all(&site_log_dir).await.unwrap(); + + let content = format!( + "=== HOOK LOG ===\n\ + Site: {site_name}\n\ + Timestamp: {timestamp}\n\ + Command: hook-cmd\n\ + Duration: 1s\n\ + Status: {status}\n\ + \n\ + === STDOUT ===\n\ + \n\ + === STDERR ===\n" + ); + + let log_file = site_log_dir.join(format!("{timestamp}-hook.log")); + tokio::fs::write(&log_file, content).await.unwrap(); +} + +// --------------------------------------------------------------------------- +// Tier 1: no container runtime / git needed +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn cli_status_no_builds() { + let tempdir = TempDir::new().unwrap(); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + let config_path = write_status_config(tempdir.path(), &["empty-site"], &log_dir).await; + + let output = Command::new(witryna_bin()) + .args(["--config", config_path.to_str().unwrap(), "status"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("SITE"), "should have table header"); + assert!( + stdout.contains("(no builds)"), + "should show (no builds), got: {stdout}" + ); +} + +#[tokio::test] +async fn cli_status_single_build() { + let tempdir = TempDir::new().unwrap(); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + write_test_build_log( + &log_dir, + "my-site", + "20260126-143000-123456", + "success", + "abc123d", + "node:20-alpine", + "45s", + ) + .await; + + let config_path = write_status_config(tempdir.path(), &["my-site"], &log_dir).await; + + let output = Command::new(witryna_bin()) + .args(["--config", config_path.to_str().unwrap(), "status"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("my-site"), "should show site name"); + assert!(stdout.contains("success"), "should show status"); + assert!(stdout.contains("abc123d"), "should show commit"); + assert!(stdout.contains("45s"), "should show duration"); +} + +#[tokio::test] +async fn cli_status_json_output() { + let tempdir = TempDir::new().unwrap(); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + write_test_build_log( + &log_dir, + "json-site", + "20260126-143000-123456", + "success", + "abc123d", + "node:20-alpine", + "45s", + ) + .await; + + let config_path = write_status_config(tempdir.path(), &["json-site"], &log_dir).await; + + let output = Command::new(witryna_bin()) + .args([ + "--config", + config_path.to_str().unwrap(), + "status", + "--json", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["site_name"], "json-site"); + assert_eq!(arr[0]["status"], "success"); + assert_eq!(arr[0]["git_commit"], "abc123d"); + assert_eq!(arr[0]["duration"], "45s"); +} + +#[tokio::test] +async fn cli_status_site_filter() { + let tempdir = TempDir::new().unwrap(); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + // Create logs for two sites + write_test_build_log( + &log_dir, + "site-a", + "20260126-143000-000000", + "success", + "aaa1111", + "alpine:latest", + "10s", + ) + .await; + + write_test_build_log( + &log_dir, + "site-b", + "20260126-150000-000000", + "success", + "bbb2222", + "alpine:latest", + "20s", + ) + .await; + + let config_path = write_status_config(tempdir.path(), &["site-a", "site-b"], &log_dir).await; + + let output = Command::new(witryna_bin()) + .args([ + "--config", + config_path.to_str().unwrap(), + "status", + "--site", + "site-a", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("site-a"), "should show filtered site"); + assert!( + !stdout.contains("site-b"), + "should NOT show other site, got: {stdout}" + ); +} + +#[tokio::test] +async fn cli_status_hook_failed() { + let tempdir = TempDir::new().unwrap(); + let log_dir = tempdir.path().join("logs"); + tokio::fs::create_dir_all(&log_dir).await.unwrap(); + + // Build succeeded, but hook failed + write_test_build_log( + &log_dir, + "hook-site", + "20260126-143000-123456", + "success", + "abc123d", + "alpine:latest", + "12s", + ) + .await; + + write_test_hook_log( + &log_dir, + "hook-site", + "20260126-143000-123456", + "failed (exit code 1)", + ) + .await; + + let config_path = write_status_config(tempdir.path(), &["hook-site"], &log_dir).await; + + let output = Command::new(witryna_bin()) + .args(["--config", config_path.to_str().unwrap(), "status"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap(); + + assert!(output.status.success(), "should exit 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hook failed"), + "should show 'hook failed', got: {stdout}" + ); +} diff --git a/tests/integration/concurrent.rs b/tests/integration/concurrent.rs new file mode 100644 index 0000000..e7f2b64 --- /dev/null +++ b/tests/integration/concurrent.rs @@ -0,0 +1,111 @@ +use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; + +#[tokio::test] +async fn concurrent_build_gets_queued() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); + let server = TestServer::start(test_config_with_site(dir, site)).await; + + // Pre-inject a build in progress via AppState + server + .state + .build_scheduler + .in_progress + .insert("my-site".to_owned()); + + let resp = TestServer::client() + .post(server.url("/my-site")) + .header("Authorization", "Bearer secret-token") + .send() + .await + .unwrap(); + + assert_eq!(resp.status().as_u16(), 202); + let body = resp.text().await.unwrap(); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["status"], "queued"); +} + +#[tokio::test] +async fn concurrent_build_queue_collapse() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); + let server = TestServer::start(test_config_with_site(dir, site)).await; + + // Pre-inject a build in progress and a queued rebuild + server + .state + .build_scheduler + .in_progress + .insert("my-site".to_owned()); + server + .state + .build_scheduler + .queued + .insert("my-site".to_owned()); + + // Third request should collapse (202, no body) + let resp = TestServer::client() + .post(server.url("/my-site")) + .header("Authorization", "Bearer secret-token") + .send() + .await + .unwrap(); + + assert_eq!(resp.status().as_u16(), 202); + let body = resp.text().await.unwrap(); + assert!(body.is_empty()); +} + +#[tokio::test] +async fn concurrent_different_sites_both_accepted() { + let dir = tempfile::tempdir().unwrap().keep(); + let sites = vec![ + SiteBuilder::new("site-one", "https://example.com/one.git", "token-one").build(), + SiteBuilder::new("site-two", "https://example.com/two.git", "token-two").build(), + ]; + let config = crate::harness::test_config_with_sites(dir, sites); + let server = TestServer::start(config).await; + + // First site — accepted + let resp1 = TestServer::client() + .post(server.url("/site-one")) + .header("Authorization", "Bearer token-one") + .send() + .await + .unwrap(); + assert_eq!(resp1.status().as_u16(), 202); + + // Second site — also accepted (different build lock) + let resp2 = TestServer::client() + .post(server.url("/site-two")) + .header("Authorization", "Bearer token-two") + .send() + .await + .unwrap(); + assert_eq!(resp2.status().as_u16(), 202); +} + +#[tokio::test] +async fn build_in_progress_checked_after_auth() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); + let server = TestServer::start(test_config_with_site(dir, site)).await; + + // Pre-mark site as building + server + .state + .build_scheduler + .in_progress + .insert("my-site".to_owned()); + + // Request with wrong token should return 401 (auth checked before build status) + let resp = TestServer::client() + .post(server.url("/my-site")) + .header("Authorization", "Bearer wrong-token") + .send() + .await + .unwrap(); + + assert_eq!(resp.status().as_u16(), 401); +} diff --git a/tests/integration/deploy.rs b/tests/integration/deploy.rs new file mode 100644 index 0000000..b74dbe6 --- /dev/null +++ b/tests/integration/deploy.rs @@ -0,0 +1,78 @@ +use crate::git_helpers::create_local_repo; +use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; +use crate::runtime::{skip_without_git, skip_without_runtime}; +use std::time::Duration; + +#[tokio::test] +async fn valid_deployment_returns_202_and_builds() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + // Create a local git repo with witryna.yaml + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + let site = SiteBuilder::new("test-site", &repo_url, "test-token") + .overrides( + "alpine:latest", + "mkdir -p out && echo '<h1>test</h1>' > out/index.html", + "out", + ) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + // Trigger deployment + let resp = TestServer::client() + .post(server.url("/test-site")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + + assert_eq!(resp.status().as_u16(), 202); + + // Wait for build to complete (check for current symlink) + let builds_dir = base_dir.join("builds/test-site"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!( + start.elapsed() <= max_wait, + "build timed out after {max_wait:?}" + ); + + let current = builds_dir.join("current"); + if current.is_symlink() { + break; + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Verify clone directory + assert!( + base_dir.join("clones/test-site/.git").is_dir(), + ".git directory should exist" + ); + + // Verify current symlink points to a real directory + let symlink_target = tokio::fs::read_link(builds_dir.join("current")) + .await + .expect("failed to read symlink"); + assert!( + symlink_target.is_dir(), + "symlink target should be a directory" + ); + + // Verify built assets + assert!( + symlink_target.join("index.html").exists(), + "built index.html should exist" + ); +} diff --git a/tests/integration/edge_cases.rs b/tests/integration/edge_cases.rs new file mode 100644 index 0000000..248c36f --- /dev/null +++ b/tests/integration/edge_cases.rs @@ -0,0 +1,69 @@ +use crate::harness::{TestServer, test_config}; + +#[tokio::test] +async fn path_traversal_rejected() { + let server = TestServer::start(test_config(tempfile::tempdir().unwrap().keep())).await; + + let traversal_attempts = [ + "../etc/passwd", + "..%2F..%2Fetc%2Fpasswd", + "valid-site/../other", + ]; + + for attempt in &traversal_attempts { + let resp = TestServer::client() + .post(server.url(attempt)) + .header("Authorization", "Bearer test-token") + .send() + .await; + + if let Ok(resp) = resp { + let status = resp.status().as_u16(); + assert!( + status == 400 || status == 404, + "path traversal '{attempt}' should be rejected, got {status}" + ); + } + } +} + +#[tokio::test] +async fn very_long_site_name_rejected() { + let server = TestServer::start(test_config(tempfile::tempdir().unwrap().keep())).await; + + let long_name = "a".repeat(1000); + let resp = TestServer::client() + .post(server.url(&long_name)) + .header("Authorization", "Bearer test-token") + .send() + .await; + + if let Ok(resp) = resp { + let status = resp.status().as_u16(); + assert!( + status == 400 || status == 404 || status == 414, + "long site name should be rejected gracefully, got {status}" + ); + } +} + +#[tokio::test] +async fn service_healthy_after_errors() { + let server = TestServer::start(test_config(tempfile::tempdir().unwrap().keep())).await; + + // Make requests to non-existent sites (causes 404s in the app) + for _ in 0..5 { + let _ = TestServer::client() + .post(server.url("/nonexistent")) + .send() + .await; + } + + // Server should still be healthy + let resp = TestServer::client() + .get(server.url("/health")) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 200); +} diff --git a/tests/integration/env_vars.rs b/tests/integration/env_vars.rs new file mode 100644 index 0000000..44f74fa --- /dev/null +++ b/tests/integration/env_vars.rs @@ -0,0 +1,162 @@ +use crate::git_helpers::create_local_repo; +use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; +use crate::runtime::{skip_without_git, skip_without_runtime}; +use std::collections::HashMap; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Tier 2 (requires container runtime + git) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn env_vars_passed_to_container_build() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + let env_vars = HashMap::from([("MY_TEST_VAR".to_owned(), "test_value_123".to_owned())]); + + let site = SiteBuilder::new("env-test", &repo_url, "test-token") + .overrides( + "alpine:latest", + "sh -c \"mkdir -p out && echo $MY_TEST_VAR > out/env.txt\"", + "out", + ) + .env(env_vars) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + let resp = TestServer::client() + .post(server.url("/env-test")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for build to complete + let builds_dir = base_dir.join("builds/env-test"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!(start.elapsed() <= max_wait, "build timed out"); + if builds_dir.join("current").is_symlink() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Verify the env var was available in the container + let current_target = tokio::fs::read_link(builds_dir.join("current")) + .await + .expect("current symlink should exist"); + let content = tokio::fs::read_to_string(current_target.join("env.txt")) + .await + .expect("env.txt should exist"); + assert_eq!( + content.trim(), + "test_value_123", + "env var should be passed to container build" + ); +} + +#[tokio::test] +async fn env_vars_passed_to_post_deploy_hook() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + let env_vars = HashMap::from([ + ("HOOK_VAR".to_owned(), "hook_value_456".to_owned()), + ("DEPLOY_ENV".to_owned(), "production".to_owned()), + ]); + + let site = SiteBuilder::new("hook-env-test", &repo_url, "test-token") + .overrides( + "alpine:latest", + "mkdir -p out && echo test > out/index.html", + "out", + ) + .env(env_vars) + .post_deploy(vec![ + "sh".to_owned(), + "-c".to_owned(), + "env > \"$WITRYNA_BUILD_DIR/env-dump.txt\"".to_owned(), + ]) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + let resp = TestServer::client() + .post(server.url("/hook-env-test")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for build + hook to complete (poll for env-dump.txt) + let builds_dir = base_dir.join("builds/hook-env-test"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + let env_dump_path = loop { + assert!(start.elapsed() <= max_wait, "build timed out"); + if builds_dir.join("current").is_symlink() { + let target = tokio::fs::read_link(builds_dir.join("current")) + .await + .expect("current symlink should exist"); + let dump = target.join("env-dump.txt"); + if dump.exists() { + break dump; + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + + let content = tokio::fs::read_to_string(&env_dump_path) + .await + .expect("env-dump.txt should be readable"); + + // Verify custom env vars + assert!( + content.contains("HOOK_VAR=hook_value_456"), + "HOOK_VAR should be in hook environment" + ); + assert!( + content.contains("DEPLOY_ENV=production"), + "DEPLOY_ENV should be in hook environment" + ); + + // Verify standard witryna env vars are also present + assert!( + content.contains("WITRYNA_SITE=hook-env-test"), + "WITRYNA_SITE should be set" + ); + assert!( + content.contains("WITRYNA_BUILD_DIR="), + "WITRYNA_BUILD_DIR should be set" + ); + assert!( + content.contains("WITRYNA_PUBLIC_DIR="), + "WITRYNA_PUBLIC_DIR should be set" + ); + assert!( + content.contains("WITRYNA_BUILD_TIMESTAMP="), + "WITRYNA_BUILD_TIMESTAMP should be set" + ); +} diff --git a/tests/integration/git_helpers.rs b/tests/integration/git_helpers.rs new file mode 100644 index 0000000..578806a --- /dev/null +++ b/tests/integration/git_helpers.rs @@ -0,0 +1,275 @@ +use std::path::Path; +use tokio::process::Command; + +/// Create a git Command isolated from parent git environment. +/// Prevents interference when tests run inside git hooks +/// (e.g., pre-commit hook running `cargo test`). +fn git_cmd() -> Command { + let mut cmd = Command::new("git"); + cmd.env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE"); + cmd +} + +/// Check if git is available on this system. +pub fn is_git_available() -> bool { + std::process::Command::new("git") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Create a local bare git repository with an initial commit. +/// Returns a `file://` URL usable by `git clone --depth 1`. +pub async fn create_local_repo(parent_dir: &Path, branch: &str) -> String { + let bare_repo = parent_dir.join("origin.git"); + tokio::fs::create_dir_all(&bare_repo).await.unwrap(); + + // Init bare repo + let output = git_cmd() + .args(["init", "--bare", "--initial-branch", branch]) + .current_dir(&bare_repo) + .output() + .await + .unwrap(); + assert!(output.status.success(), "git init --bare failed"); + + // Create working copy for initial commit + let work_dir = parent_dir.join("work"); + let output = git_cmd() + .args([ + "clone", + bare_repo.to_str().unwrap(), + work_dir.to_str().unwrap(), + ]) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Configure git user + for args in [ + &["config", "user.email", "test@test.local"][..], + &["config", "user.name", "Test"], + ] { + let out = git_cmd() + .args(args) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(out.status.success()); + } + + // Checkout target branch + let output = git_cmd() + .args(["checkout", "-B", branch]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(output.status.success(), "git checkout failed"); + + // Create witryna.yaml + initial content + tokio::fs::write( + work_dir.join("witryna.yaml"), + "image: alpine:latest\ncommand: \"mkdir -p out && echo '<h1>test</h1>' > out/index.html\"\npublic: out\n", + ) + .await + .unwrap(); + + tokio::fs::create_dir_all(work_dir.join("out")) + .await + .unwrap(); + tokio::fs::write(work_dir.join("out/index.html"), "<h1>initial</h1>") + .await + .unwrap(); + + // Stage and commit + let output = git_cmd() + .args(["add", "-A"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(output.status.success(), "git add failed"); + + let output = git_cmd() + .args(["commit", "-m", "Initial commit"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Push + let output = git_cmd() + .args(["push", "-u", "origin", branch]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Cleanup working copy + let _ = tokio::fs::remove_dir_all(&work_dir).await; + + format!("file://{}", bare_repo.to_str().unwrap()) +} + +/// Create a local bare repo without a witryna.yaml (for override-only tests). +pub async fn create_bare_repo(parent_dir: &Path, branch: &str) -> String { + let bare_repo = parent_dir.join("bare-origin.git"); + tokio::fs::create_dir_all(&bare_repo).await.unwrap(); + + let output = git_cmd() + .args(["init", "--bare", "--initial-branch", branch]) + .current_dir(&bare_repo) + .output() + .await + .unwrap(); + assert!(output.status.success()); + + let work_dir = parent_dir.join("bare-work"); + let output = git_cmd() + .args([ + "clone", + bare_repo.to_str().unwrap(), + work_dir.to_str().unwrap(), + ]) + .output() + .await + .unwrap(); + assert!(output.status.success()); + + for args in [ + &["config", "user.email", "test@test.local"][..], + &["config", "user.name", "Test"], + ] { + git_cmd() + .args(args) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + } + + let output = git_cmd() + .args(["checkout", "-B", branch]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(output.status.success()); + + tokio::fs::write(work_dir.join("README.md"), "# Test\n") + .await + .unwrap(); + + git_cmd() + .args(["add", "-A"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + + let output = git_cmd() + .args(["commit", "-m", "Initial commit"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(output.status.success()); + + let output = git_cmd() + .args(["push", "-u", "origin", branch]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(output.status.success()); + + let _ = tokio::fs::remove_dir_all(&work_dir).await; + + format!("file://{}", bare_repo.to_str().unwrap()) +} + +/// Push a new commit to a bare repo (clone, commit, push). +pub async fn push_new_commit(bare_repo_url: &str, parent_dir: &Path, branch: &str) { + let work_dir = parent_dir.join("push-work"); + let _ = tokio::fs::remove_dir_all(&work_dir).await; + + let output = git_cmd() + .args([ + "clone", + "--branch", + branch, + bare_repo_url, + work_dir.to_str().unwrap(), + ]) + .output() + .await + .unwrap(); + assert!(output.status.success(), "clone for push failed"); + + for args in [ + &["config", "user.email", "test@test.local"][..], + &["config", "user.name", "Test"], + ] { + git_cmd() + .args(args) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + tokio::fs::write(work_dir.join("update.txt"), format!("update-{timestamp}")) + .await + .unwrap(); + + git_cmd() + .args(["add", "-A"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + + let output = git_cmd() + .args(["commit", "-m", "Test update"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(output.status.success(), "commit failed"); + + let output = git_cmd() + .args(["push", "origin", branch]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(output.status.success(), "push failed"); + + let _ = tokio::fs::remove_dir_all(&work_dir).await; +} diff --git a/tests/integration/harness.rs b/tests/integration/harness.rs new file mode 100644 index 0000000..c015fa8 --- /dev/null +++ b/tests/integration/harness.rs @@ -0,0 +1,356 @@ +use governor::{Quota, RateLimiter}; +use std::collections::HashMap; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::sync::{RwLock, oneshot}; +use witryna::build_guard::BuildScheduler; +use witryna::config::{BuildOverrides, Config, SiteConfig}; +use witryna::polling::PollingManager; +use witryna::server::AppState; + +/// A running test server with its own temp directory and shutdown handle. +pub struct TestServer { + pub base_url: String, + pub state: AppState, + /// Kept alive for RAII cleanup of the config file written during startup. + #[allow(dead_code)] + pub tempdir: TempDir, + shutdown_tx: Option<oneshot::Sender<()>>, +} + +impl TestServer { + /// Start a new test server with the given config. + /// Binds to `127.0.0.1:0` (OS-assigned port). + pub async fn start(config: Config) -> Self { + Self::start_with_rate_limit(config, 1000).await + } + + /// Start a new test server with a specific rate limit. + pub async fn start_with_rate_limit(mut config: Config, rate_limit: u32) -> Self { + let tempdir = TempDir::new().expect("failed to create temp dir"); + let config_path = tempdir.path().join("witryna.toml"); + + // Write a minimal config file so SIGHUP reload has something to read + let config_toml = build_config_toml(&config); + tokio::fs::write(&config_path, &config_toml) + .await + .expect("failed to write test config"); + + config + .resolve_secrets() + .await + .expect("failed to resolve secrets"); + + let quota = Quota::per_minute(NonZeroU32::new(rate_limit).expect("rate limit must be > 0")); + + let state = AppState { + config: Arc::new(RwLock::new(config)), + config_path: Arc::new(config_path), + build_scheduler: Arc::new(BuildScheduler::new()), + rate_limiter: Arc::new(RateLimiter::dashmap(quota)), + polling_manager: Arc::new(PollingManager::new()), + }; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind to random port"); + let port = listener.local_addr().unwrap().port(); + let base_url = format!("http://127.0.0.1:{port}"); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + + let server_state = state.clone(); + tokio::spawn(async move { + witryna::test_support::run_server(server_state, listener, async { + let _ = shutdown_rx.await; + }) + .await + .expect("server failed"); + }); + + Self { + base_url, + state, + tempdir, + shutdown_tx: Some(shutdown_tx), + } + } + + /// Get an async reqwest client. + pub fn client() -> reqwest::Client { + reqwest::Client::new() + } + + /// Build a URL for the given path. + pub fn url(&self, path: &str) -> String { + format!("{}/{}", self.base_url, path.trim_start_matches('/')) + } + + /// Shut down the server gracefully. + pub fn shutdown(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// Build a default test config pointing to the given base dir. +pub fn test_config(base_dir: PathBuf) -> Config { + let log_dir = base_dir.join("logs"); + Config { + listen_address: "127.0.0.1:0".to_owned(), + container_runtime: "podman".to_owned(), + base_dir, + log_dir, + log_level: "debug".to_owned(), + rate_limit_per_minute: 10, + max_builds_to_keep: 5, + git_timeout: None, + sites: vec![], + } +} + +/// Build a test config with a single site. +pub fn test_config_with_site(base_dir: PathBuf, site: SiteConfig) -> Config { + let log_dir = base_dir.join("logs"); + Config { + listen_address: "127.0.0.1:0".to_owned(), + container_runtime: detect_container_runtime(), + base_dir, + log_dir, + log_level: "debug".to_owned(), + rate_limit_per_minute: 10, + max_builds_to_keep: 5, + git_timeout: None, + sites: vec![site], + } +} + +/// Build a test config with multiple sites. +pub fn test_config_with_sites(base_dir: PathBuf, sites: Vec<SiteConfig>) -> Config { + let log_dir = base_dir.join("logs"); + Config { + listen_address: "127.0.0.1:0".to_owned(), + container_runtime: detect_container_runtime(), + base_dir, + log_dir, + log_level: "debug".to_owned(), + rate_limit_per_minute: 10, + max_builds_to_keep: 5, + git_timeout: None, + sites, + } +} + +/// Builder for test `SiteConfig` instances. +/// +/// Replaces `simple_site`, `site_with_overrides`, `site_with_hook`, and +/// `site_with_cache` with a single fluent API. +pub struct SiteBuilder { + name: String, + repo_url: String, + token: String, + webhook_token_file: Option<PathBuf>, + image: Option<String>, + command: Option<String>, + public: Option<String>, + cache_dirs: Option<Vec<String>>, + post_deploy: Option<Vec<String>>, + env: Option<HashMap<String, String>>, + container_workdir: Option<String>, +} + +impl SiteBuilder { + pub fn new(name: &str, repo_url: &str, token: &str) -> Self { + Self { + name: name.to_owned(), + repo_url: repo_url.to_owned(), + token: token.to_owned(), + webhook_token_file: None, + image: None, + command: None, + public: None, + cache_dirs: None, + post_deploy: None, + env: None, + container_workdir: None, + } + } + + /// Set complete build overrides (image, command, public dir). + pub fn overrides(mut self, image: &str, command: &str, public: &str) -> Self { + self.image = Some(image.to_owned()); + self.command = Some(command.to_owned()); + self.public = Some(public.to_owned()); + self + } + + pub fn webhook_token_file(mut self, path: PathBuf) -> Self { + self.webhook_token_file = Some(path); + self + } + + pub fn post_deploy(mut self, hook: Vec<String>) -> Self { + self.post_deploy = Some(hook); + self + } + + pub fn env(mut self, env_vars: HashMap<String, String>) -> Self { + self.env = Some(env_vars); + self + } + + pub fn cache_dirs(mut self, dirs: Vec<String>) -> Self { + self.cache_dirs = Some(dirs); + self + } + + #[allow(dead_code)] + pub fn container_workdir(mut self, path: &str) -> Self { + self.container_workdir = Some(path.to_owned()); + self + } + + pub fn build(self) -> SiteConfig { + SiteConfig { + name: self.name, + repo_url: self.repo_url, + branch: "main".to_owned(), + webhook_token: self.token, + webhook_token_file: self.webhook_token_file, + build_overrides: BuildOverrides { + image: self.image, + command: self.command, + public: self.public, + }, + poll_interval: None, + build_timeout: None, + cache_dirs: self.cache_dirs, + post_deploy: self.post_deploy, + env: self.env, + container_memory: None, + container_cpus: None, + container_pids_limit: None, + container_network: "none".to_owned(), + git_depth: None, + container_workdir: self.container_workdir, + config_file: None, + } + } +} + +/// Start a server with a single pre-configured site for simple tests. +/// +/// Uses `my-site` with token `secret-token` — suitable for auth, 404, and basic endpoint tests. +pub async fn server_with_site() -> TestServer { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); + TestServer::start(test_config_with_site(dir, site)).await +} + +/// Detect the first available container runtime. +fn detect_container_runtime() -> String { + for runtime in &["podman", "docker"] { + if std::process::Command::new(runtime) + .args(["info"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + return (*runtime).to_owned(); + } + } + // Fallback — tests that need a runtime will skip themselves + "podman".to_owned() +} + +/// Serialize a Config into a minimal TOML string for writing to disk. +fn build_config_toml(config: &Config) -> String { + use std::fmt::Write as _; + + let runtime_line = format!("container_runtime = \"{}\"\n", config.container_runtime); + + let mut toml = format!( + r#"listen_address = "{}" +{}base_dir = "{}" +log_dir = "{}" +log_level = "{}" +rate_limit_per_minute = {} +max_builds_to_keep = {} +"#, + config.listen_address, + runtime_line, + config.base_dir.display(), + config.log_dir.display(), + config.log_level, + config.rate_limit_per_minute, + config.max_builds_to_keep, + ); + + if let Some(timeout) = config.git_timeout { + let _ = writeln!(toml, "git_timeout = \"{}s\"", timeout.as_secs()); + } + + for site in &config.sites { + let _ = writeln!(toml, "\n[[sites]]"); + let _ = writeln!(toml, "name = \"{}\"", site.name); + let _ = writeln!(toml, "repo_url = \"{}\"", site.repo_url); + let _ = writeln!(toml, "branch = \"{}\"", site.branch); + if !site.webhook_token.is_empty() { + let _ = writeln!(toml, "webhook_token = \"{}\"", site.webhook_token); + } + if let Some(path) = &site.webhook_token_file { + let _ = writeln!(toml, "webhook_token_file = \"{}\"", path.display()); + } + + if let Some(image) = &site.build_overrides.image { + let _ = writeln!(toml, "image = \"{image}\""); + } + if let Some(command) = &site.build_overrides.command { + let _ = writeln!(toml, "command = \"{command}\""); + } + if let Some(public) = &site.build_overrides.public { + let _ = writeln!(toml, "public = \"{public}\""); + } + if let Some(interval) = site.poll_interval { + let _ = writeln!(toml, "poll_interval = \"{}s\"", interval.as_secs()); + } + if let Some(timeout) = site.build_timeout { + let _ = writeln!(toml, "build_timeout = \"{}s\"", timeout.as_secs()); + } + if let Some(depth) = site.git_depth { + let _ = writeln!(toml, "git_depth = {depth}"); + } + if let Some(workdir) = &site.container_workdir { + let _ = writeln!(toml, "container_workdir = \"{workdir}\""); + } + if let Some(dirs) = &site.cache_dirs { + let quoted: Vec<_> = dirs.iter().map(|d| format!("\"{d}\"")).collect(); + let _ = writeln!(toml, "cache_dirs = [{}]", quoted.join(", ")); + } + if let Some(hook) = &site.post_deploy { + let quoted: Vec<_> = hook.iter().map(|a| format!("\"{a}\"")).collect(); + let _ = writeln!(toml, "post_deploy = [{}]", quoted.join(", ")); + } + if let Some(env_vars) = &site.env { + let _ = writeln!(toml, "\n[sites.env]"); + for (key, value) in env_vars { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + let _ = writeln!(toml, "{key} = \"{escaped}\""); + } + } + } + + toml +} diff --git a/tests/integration/health.rs b/tests/integration/health.rs new file mode 100644 index 0000000..c8895c1 --- /dev/null +++ b/tests/integration/health.rs @@ -0,0 +1,17 @@ +use crate::harness::{TestServer, test_config}; + +#[tokio::test] +async fn health_endpoint_returns_200() { + let server = TestServer::start(test_config(tempfile::tempdir().unwrap().keep())).await; + + let resp = TestServer::client() + .get(server.url("/health")) + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status().as_u16(), 200); + let body = resp.text().await.expect("failed to read body"); + let json: serde_json::Value = serde_json::from_str(&body).expect("invalid JSON"); + assert_eq!(json["status"], "ok"); +} diff --git a/tests/integration/hooks.rs b/tests/integration/hooks.rs new file mode 100644 index 0000000..86684cc --- /dev/null +++ b/tests/integration/hooks.rs @@ -0,0 +1,137 @@ +use crate::git_helpers::create_local_repo; +use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; +use crate::runtime::{skip_without_git, skip_without_runtime}; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Tier 2 (requires container runtime + git) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn post_deploy_hook_runs_after_build() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + // The hook creates a "hook-ran" marker file in the build output directory + let site = SiteBuilder::new("hook-test", &repo_url, "test-token") + .overrides( + "alpine:latest", + "mkdir -p out && echo '<h1>hook</h1>' > out/index.html", + "out", + ) + .post_deploy(vec!["touch".to_owned(), "hook-ran".to_owned()]) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + let resp = TestServer::client() + .post(server.url("/hook-test")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for build + hook to complete + let builds_dir = base_dir.join("builds/hook-test"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!(start.elapsed() <= max_wait, "build timed out"); + if builds_dir.join("current").is_symlink() { + // Give the hook a moment to finish after symlink switch + tokio::time::sleep(Duration::from_secs(3)).await; + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Verify the hook ran — marker file should exist in the build directory + let current_target = tokio::fs::read_link(builds_dir.join("current")) + .await + .expect("current symlink should exist"); + assert!( + current_target.join("hook-ran").exists(), + "hook marker file should exist in build directory" + ); +} + +#[tokio::test] +async fn post_deploy_hook_failure_nonfatal() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + // The hook will fail (exit 1), but the deploy should still succeed + let site = SiteBuilder::new("hook-fail", &repo_url, "test-token") + .overrides( + "alpine:latest", + "mkdir -p out && echo '<h1>ok</h1>' > out/index.html", + "out", + ) + .post_deploy(vec!["false".to_owned()]) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + let resp = TestServer::client() + .post(server.url("/hook-fail")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for build to complete + let builds_dir = base_dir.join("builds/hook-fail"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!(start.elapsed() <= max_wait, "build timed out"); + if builds_dir.join("current").is_symlink() { + tokio::time::sleep(Duration::from_secs(3)).await; + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Deploy succeeded despite hook failure + let current_target = tokio::fs::read_link(builds_dir.join("current")) + .await + .expect("current symlink should exist"); + assert!( + current_target.join("index.html").exists(), + "built assets should exist despite hook failure" + ); + + // Hook log should have been written with failure status + let logs_dir = base_dir.join("logs/hook-fail"); + let mut found_hook_log = false; + let mut entries = tokio::fs::read_dir(&logs_dir).await.unwrap(); + while let Some(entry) = entries.next_entry().await.unwrap() { + let name = entry.file_name(); + if name.to_string_lossy().ends_with("-hook.log") { + found_hook_log = true; + let content = tokio::fs::read_to_string(entry.path()).await.unwrap(); + assert!(content.contains("=== HOOK LOG ===")); + assert!(content.contains("Status: failed")); + break; + } + } + assert!(found_hook_log, "hook log should exist for failed hook"); +} diff --git a/tests/integration/logs.rs b/tests/integration/logs.rs new file mode 100644 index 0000000..4ecdb87 --- /dev/null +++ b/tests/integration/logs.rs @@ -0,0 +1,73 @@ +use crate::git_helpers::create_local_repo; +use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; +use crate::runtime::{skip_without_git, skip_without_runtime}; +use std::time::Duration; + +#[tokio::test] +async fn build_log_created_after_deployment() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + let site = SiteBuilder::new("log-site", &repo_url, "test-token") + .overrides( + "alpine:latest", + "mkdir -p out && echo '<h1>test</h1>' > out/index.html", + "out", + ) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + // Trigger deployment + let resp = TestServer::client() + .post(server.url("/log-site")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for build to complete + let builds_dir = base_dir.join("builds/log-site"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!(start.elapsed() <= max_wait, "build timed out"); + if builds_dir.join("current").is_symlink() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Verify logs directory and log file + let logs_dir = base_dir.join("logs/log-site"); + assert!(logs_dir.is_dir(), "logs directory should exist"); + + let mut entries = tokio::fs::read_dir(&logs_dir).await.unwrap(); + let mut found_log = false; + while let Some(entry) = entries.next_entry().await.unwrap() { + let name = entry.file_name(); + if name.to_string_lossy().ends_with(".log") { + found_log = true; + let content = tokio::fs::read_to_string(entry.path()).await.unwrap(); + assert!( + content.contains("=== BUILD LOG ==="), + "log should have header" + ); + assert!( + content.contains("Site: log-site"), + "log should contain site name" + ); + break; + } + } + assert!(found_log, "should have at least one .log file"); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs new file mode 100644 index 0000000..7ee422e --- /dev/null +++ b/tests/integration/main.rs @@ -0,0 +1,31 @@ +#![cfg(feature = "integration")] +#![allow( + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::expect_used, + clippy::print_stderr +)] + +mod git_helpers; +mod harness; +mod runtime; + +mod auth; +mod cache; +mod cleanup; +mod cli_run; +mod cli_status; +mod concurrent; +mod deploy; +mod edge_cases; +mod env_vars; +mod health; +mod hooks; +mod logs; +mod not_found; +mod overrides; +mod packaging; +mod polling; +mod rate_limit; +mod secrets; +mod sighup; diff --git a/tests/integration/not_found.rs b/tests/integration/not_found.rs new file mode 100644 index 0000000..a86d570 --- /dev/null +++ b/tests/integration/not_found.rs @@ -0,0 +1,17 @@ +use crate::harness::{TestServer, test_config}; + +#[tokio::test] +async fn unknown_site_returns_404() { + let server = TestServer::start(test_config(tempfile::tempdir().unwrap().keep())).await; + + let resp = TestServer::client() + .post(server.url("/nonexistent")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status().as_u16(), 404); + let body = resp.text().await.unwrap(); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["error"], "not_found"); +} diff --git a/tests/integration/overrides.rs b/tests/integration/overrides.rs new file mode 100644 index 0000000..f34bf9c --- /dev/null +++ b/tests/integration/overrides.rs @@ -0,0 +1,59 @@ +use crate::git_helpers::create_bare_repo; +use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; +use crate::runtime::{skip_without_git, skip_without_runtime}; +use std::time::Duration; + +#[tokio::test] +async fn complete_override_builds_without_witryna_yaml() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + // Create a repo without witryna.yaml + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_bare_repo(&repo_dir, "main").await; + + // Complete overrides — witryna.yaml not needed + let site = SiteBuilder::new("override-site", &repo_url, "test-token") + .overrides( + "alpine:latest", + "mkdir -p out && echo '<h1>override</h1>' > out/index.html", + "out", + ) + .build(); + + let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await; + + let resp = TestServer::client() + .post(server.url("/override-site")) + .header("Authorization", "Bearer test-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 202); + + // Wait for build + let builds_dir = base_dir.join("builds/override-site"); + let max_wait = Duration::from_secs(120); + let start = std::time::Instant::now(); + + loop { + assert!(start.elapsed() <= max_wait, "build timed out"); + if builds_dir.join("current").is_symlink() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Verify output + let target = tokio::fs::read_link(builds_dir.join("current")) + .await + .unwrap(); + let content = tokio::fs::read_to_string(target.join("index.html")) + .await + .unwrap(); + assert!(content.contains("<h1>override</h1>")); +} diff --git a/tests/integration/packaging.rs b/tests/integration/packaging.rs new file mode 100644 index 0000000..6a86bc5 --- /dev/null +++ b/tests/integration/packaging.rs @@ -0,0 +1,49 @@ +use std::path::Path; + +#[test] +fn docker_override_exists_and_valid() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/systemd/docker.conf"); + assert!(path.exists(), "docker.conf template missing"); + let content = std::fs::read_to_string(&path).unwrap(); + assert!( + content.contains("SupplementaryGroups=docker"), + "docker.conf must grant docker group" + ); + assert!( + content.contains("ReadWritePaths=/var/run/docker.sock"), + "docker.conf must allow docker socket access" + ); + assert!( + content.contains("[Service]"), + "docker.conf must be a systemd unit override" + ); +} + +#[test] +fn podman_override_exists_and_valid() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/systemd/podman.conf"); + assert!(path.exists(), "podman.conf template missing"); + let content = std::fs::read_to_string(&path).unwrap(); + assert!( + content.contains("RestrictNamespaces=no"), + "podman.conf must disable RestrictNamespaces" + ); + assert!( + content.contains("XDG_RUNTIME_DIR=/run/user/%U"), + "podman.conf must set XDG_RUNTIME_DIR with %U" + ); + assert!( + content.contains("[Service]"), + "podman.conf must be a systemd unit override" + ); +} + +#[test] +fn override_templates_are_not_empty() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/systemd"); + for name in ["docker.conf", "podman.conf"] { + let path = dir.join(name); + let meta = std::fs::metadata(&path).unwrap(); + assert!(meta.len() > 0, "{name} must not be empty"); + } +} diff --git a/tests/integration/polling.rs b/tests/integration/polling.rs new file mode 100644 index 0000000..a4447cc --- /dev/null +++ b/tests/integration/polling.rs @@ -0,0 +1,114 @@ +use crate::git_helpers::{create_local_repo, push_new_commit}; +use crate::harness::TestServer; +use crate::runtime::{skip_without_git, skip_without_runtime}; +use serial_test::serial; +use std::time::Duration; +use witryna::config::{BuildOverrides, Config, SiteConfig}; + +fn polling_site(name: &str, repo_url: &str) -> SiteConfig { + SiteConfig { + name: name.to_owned(), + repo_url: repo_url.to_owned(), + branch: "main".to_owned(), + webhook_token: "poll-token".to_owned(), + webhook_token_file: None, + build_overrides: BuildOverrides { + image: Some("alpine:latest".to_owned()), + command: Some("mkdir -p out && echo '<h1>polled</h1>' > out/index.html".to_owned()), + public: Some("out".to_owned()), + }, + poll_interval: Some(Duration::from_secs(2)), + build_timeout: None, + cache_dirs: None, + post_deploy: None, + env: None, + container_memory: None, + container_cpus: None, + container_pids_limit: None, + container_network: "none".to_owned(), + git_depth: None, + container_workdir: None, + config_file: None, + } +} + +#[tokio::test] +#[serial] +async fn polling_triggers_build_on_new_commits() { + skip_without_git!(); + skip_without_runtime!(); + + let tempdir = tempfile::tempdir().unwrap(); + let base_dir = tempdir.path().to_path_buf(); + + let repo_dir = tempdir.path().join("repos"); + tokio::fs::create_dir_all(&repo_dir).await.unwrap(); + let repo_url = create_local_repo(&repo_dir, "main").await; + + let site = polling_site("poll-site", &repo_url); + + let config = Config { + listen_address: "127.0.0.1:0".to_owned(), + container_runtime: crate::harness::test_config(base_dir.clone()).container_runtime, + base_dir: base_dir.clone(), + log_dir: base_dir.join("logs"), + log_level: "debug".to_owned(), + rate_limit_per_minute: 100, + max_builds_to_keep: 5, + git_timeout: None, + sites: vec![site], + }; + + let server = TestServer::start(config).await; + + // Start polling + server + .state + .polling_manager + .start_polling(server.state.clone()) + .await; + + // Wait for the initial poll cycle to trigger a build + let builds_dir = base_dir.join("builds/poll-site"); + let max_wait = Duration::from_secs(30); + let start = std::time::Instant::now(); + + loop { + if start.elapsed() > max_wait { + // Polling may not have triggered yet — acceptable in CI + eprintln!("SOFT FAIL: polling did not trigger build within {max_wait:?}"); + return; + } + if builds_dir.join("current").is_symlink() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + let first_target = tokio::fs::read_link(builds_dir.join("current")) + .await + .unwrap(); + + // Push a new commit + push_new_commit(&repo_url, &tempdir.path().join("push"), "main").await; + + // Wait for polling to detect and rebuild + let max_wait = Duration::from_secs(30); + let start = std::time::Instant::now(); + + loop { + if start.elapsed() > max_wait { + eprintln!("SOFT FAIL: polling did not detect new commit within {max_wait:?}"); + return; + } + + if let Ok(target) = tokio::fs::read_link(builds_dir.join("current")).await + && target != first_target + { + // New build detected + return; + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } +} diff --git a/tests/integration/rate_limit.rs b/tests/integration/rate_limit.rs new file mode 100644 index 0000000..81378a2 --- /dev/null +++ b/tests/integration/rate_limit.rs @@ -0,0 +1,114 @@ +use crate::harness::{SiteBuilder, TestServer, test_config_with_site, test_config_with_sites}; + +#[tokio::test] +async fn rate_limit_exceeded_returns_429() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); + let config = test_config_with_site(dir, site); + + // Rate limit of 2 per minute + let server = TestServer::start_with_rate_limit(config, 2).await; + + // First request — accepted (or 202) + let resp1 = TestServer::client() + .post(server.url("/my-site")) + .header("Authorization", "Bearer secret-token") + .send() + .await + .unwrap(); + let status1 = resp1.status().as_u16(); + assert!( + status1 == 202 || status1 == 409, + "expected 202 or 409, got {status1}" + ); + + // Second request + let resp2 = TestServer::client() + .post(server.url("/my-site")) + .header("Authorization", "Bearer secret-token") + .send() + .await + .unwrap(); + let status2 = resp2.status().as_u16(); + assert!( + status2 == 202 || status2 == 409, + "expected 202 or 409, got {status2}" + ); + + // Third request should hit rate limit + let resp3 = TestServer::client() + .post(server.url("/my-site")) + .header("Authorization", "Bearer secret-token") + .send() + .await + .unwrap(); + assert_eq!(resp3.status().as_u16(), 429); + let body = resp3.text().await.unwrap(); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["error"], "rate_limit_exceeded"); +} + +#[tokio::test] +async fn rate_limit_different_tokens_independent() { + let dir = tempfile::tempdir().unwrap().keep(); + let sites = vec![ + SiteBuilder::new("site-one", "https://example.com/one.git", "token-one").build(), + SiteBuilder::new("site-two", "https://example.com/two.git", "token-two").build(), + ]; + let config = test_config_with_sites(dir, sites); + + // Rate limit of 1 per minute + let server = TestServer::start_with_rate_limit(config, 1).await; + + // token-one: first request succeeds + let resp1 = TestServer::client() + .post(server.url("/site-one")) + .header("Authorization", "Bearer token-one") + .send() + .await + .unwrap(); + assert_eq!(resp1.status().as_u16(), 202); + + // token-one: second request hits rate limit + let resp2 = TestServer::client() + .post(server.url("/site-one")) + .header("Authorization", "Bearer token-one") + .send() + .await + .unwrap(); + assert_eq!(resp2.status().as_u16(), 429); + + // token-two: still has its own budget + let resp3 = TestServer::client() + .post(server.url("/site-two")) + .header("Authorization", "Bearer token-two") + .send() + .await + .unwrap(); + assert_eq!(resp3.status().as_u16(), 202); +} + +#[tokio::test] +async fn rate_limit_checked_after_auth() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); + let config = test_config_with_site(dir, site); + let server = TestServer::start_with_rate_limit(config, 1).await; + + // Exhaust rate limit + let _ = TestServer::client() + .post(server.url("/my-site")) + .header("Authorization", "Bearer secret-token") + .send() + .await + .unwrap(); + + // Wrong token should get 401, not 429 + let resp = TestServer::client() + .post(server.url("/my-site")) + .header("Authorization", "Bearer wrong-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 401); +} diff --git a/tests/integration/runtime.rs b/tests/integration/runtime.rs new file mode 100644 index 0000000..d5a9635 --- /dev/null +++ b/tests/integration/runtime.rs @@ -0,0 +1,61 @@ +/// Check if a container runtime (podman or docker) is available and responsive. +pub fn is_container_runtime_available() -> bool { + for runtime in &["podman", "docker"] { + if std::process::Command::new(runtime) + .args(["info"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + return true; + } + } + false +} + +/// Macro that skips the current test with an explicit message when +/// no container runtime is available. +/// +/// Usage: `skip_without_runtime!();` +macro_rules! skip_without_runtime { + () => { + if !crate::runtime::is_container_runtime_available() { + eprintln!("SKIPPED: no container runtime (podman/docker) found"); + return; + } + }; +} + +/// Macro that skips the current test with an explicit message when +/// git is not available. +macro_rules! skip_without_git { + () => { + if !crate::git_helpers::is_git_available() { + eprintln!("SKIPPED: git not found"); + return; + } + }; +} + +/// Return the name of an available container runtime ("podman" or "docker"), +/// falling back to "podman" when neither is responsive. +pub fn detect_container_runtime() -> &'static str { + for runtime in &["podman", "docker"] { + if std::process::Command::new(runtime) + .args(["info"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + return runtime; + } + } + "podman" +} + +pub(crate) use skip_without_git; +pub(crate) use skip_without_runtime; diff --git a/tests/integration/secrets.rs b/tests/integration/secrets.rs new file mode 100644 index 0000000..f07c2a0 --- /dev/null +++ b/tests/integration/secrets.rs @@ -0,0 +1,74 @@ +use crate::harness::{self, SiteBuilder, TestServer}; + +/// Tier 1: env-var token resolves and auth works +#[tokio::test] +async fn env_var_token_auth() { + let var_name = "WITRYNA_INTEG_SECRET_01"; + let token_value = "env-resolved-secret-token"; + // SAFETY: test-only, called before spawning server + unsafe { std::env::set_var(var_name, token_value) }; + + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new( + "secret-site", + "https://example.com/repo.git", + &format!("${{{var_name}}}"), + ) + .build(); + let config = harness::test_config_with_site(dir, site); + let server = TestServer::start(config).await; + + // Valid token → 404 (site exists but no real repo) + let resp = TestServer::client() + .post(server.url("secret-site")) + .header("Authorization", format!("Bearer {token_value}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 202); + + // Wrong token → 401 + let resp = TestServer::client() + .post(server.url("secret-site")) + .header("Authorization", "Bearer wrong-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); + + // SAFETY: test-only cleanup + unsafe { std::env::remove_var(var_name) }; +} + +/// Tier 1: file-based token resolves and auth works +#[tokio::test] +async fn file_token_auth() { + let token_value = "file-resolved-secret-token"; + let dir = tempfile::tempdir().unwrap().keep(); + let token_path = std::path::PathBuf::from(&dir).join("webhook_token"); + std::fs::write(&token_path, format!(" {token_value} \n")).unwrap(); + + let site = SiteBuilder::new("file-site", "https://example.com/repo.git", "") + .webhook_token_file(token_path) + .build(); + let config = harness::test_config_with_site(dir, site); + let server = TestServer::start(config).await; + + // Valid token → 202 + let resp = TestServer::client() + .post(server.url("file-site")) + .header("Authorization", format!("Bearer {token_value}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 202); + + // Wrong token → 401 + let resp = TestServer::client() + .post(server.url("file-site")) + .header("Authorization", "Bearer wrong-token") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} diff --git a/tests/integration/sighup.rs b/tests/integration/sighup.rs new file mode 100644 index 0000000..23c0dfd --- /dev/null +++ b/tests/integration/sighup.rs @@ -0,0 +1,149 @@ +use crate::harness::{SiteBuilder, TestServer, test_config_with_site}; +use serial_test::serial; +use std::time::Duration; + +/// Send SIGHUP to the current process. +fn send_sighup_to_self() { + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + + kill(Pid::this(), Signal::SIGHUP).expect("failed to send SIGHUP"); +} + +/// Install the SIGHUP handler and wait for it to be registered. +async fn install_sighup_handler(server: &TestServer) { + witryna::test_support::setup_sighup_handler(&server.state); + // Yield to allow the spawned handler task to register the signal listener + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; +} + +#[tokio::test] +#[serial] +async fn sighup_reload_keeps_server_healthy() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "test-token").build(); + let server = TestServer::start(test_config_with_site(dir, site)).await; + + install_sighup_handler(&server).await; + + // Verify server is healthy before SIGHUP + let resp = TestServer::client() + .get(server.url("/health")) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 200); + + // Send SIGHUP (reload config) + send_sighup_to_self(); + + // Give the handler time to process + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // Server should still be healthy + let resp = TestServer::client() + .get(server.url("/health")) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 200); +} + +#[tokio::test] +#[serial] +async fn rapid_sighup_does_not_crash() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "test-token").build(); + let server = TestServer::start(test_config_with_site(dir, site)).await; + + install_sighup_handler(&server).await; + + // Send multiple SIGHUPs in quick succession + for _ in 0..3 { + send_sighup_to_self(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + // Wait for stabilization + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // Server should survive + let resp = TestServer::client() + .get(server.url("/health")) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 200); +} + +#[tokio::test] +#[serial] +async fn sighup_preserves_listen_address() { + let dir = tempfile::tempdir().unwrap().keep(); + let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "test-token").build(); + let server = TestServer::start(test_config_with_site(dir, site)).await; + + install_sighup_handler(&server).await; + + // Verify server is healthy before SIGHUP + let resp = TestServer::client() + .get(server.url("/health")) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 200); + + // Rewrite the on-disk config with a different listen_address (unreachable port) + // and an additional site to verify reloadable fields are updated + let config_path = server.state.config_path.as_ref(); + let new_toml = format!( + r#"listen_address = "127.0.0.1:19999" +container_runtime = "podman" +base_dir = "{}" +log_dir = "{}" +log_level = "debug" + +[[sites]] +name = "my-site" +repo_url = "https://example.com/repo.git" +branch = "main" +webhook_token = "test-token" + +[[sites]] +name = "new-site" +repo_url = "https://example.com/new.git" +branch = "main" +webhook_token = "new-token" +"#, + server.state.config.read().await.base_dir.display(), + server.state.config.read().await.log_dir.display(), + ); + tokio::fs::write(config_path, &new_toml).await.unwrap(); + + // Send SIGHUP to reload + send_sighup_to_self(); + tokio::time::sleep(Duration::from_millis(500)).await; + + // Server should still respond on the original port (listen_address preserved) + let resp = TestServer::client() + .get(server.url("/health")) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 200); + + // Verify the reloadable field (sites) was updated + let config = server.state.config.read().await; + assert_eq!(config.sites.len(), 2, "sites should have been reloaded"); + assert!( + config.find_site("new-site").is_some(), + "new-site should exist after reload" + ); + + // Verify non-reloadable field was preserved (not overwritten with "127.0.0.1:19999") + assert_ne!( + config.listen_address, "127.0.0.1:19999", + "listen_address should be preserved from original config" + ); +} |
