summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/integration/auth.rs58
-rw-r--r--tests/integration/cache.rs125
-rw-r--r--tests/integration/cleanup.rs92
-rw-r--r--tests/integration/cli_run.rs277
-rw-r--r--tests/integration/cli_status.rs313
-rw-r--r--tests/integration/concurrent.rs111
-rw-r--r--tests/integration/deploy.rs78
-rw-r--r--tests/integration/edge_cases.rs69
-rw-r--r--tests/integration/env_vars.rs162
-rw-r--r--tests/integration/git_helpers.rs275
-rw-r--r--tests/integration/harness.rs356
-rw-r--r--tests/integration/health.rs17
-rw-r--r--tests/integration/hooks.rs137
-rw-r--r--tests/integration/logs.rs73
-rw-r--r--tests/integration/main.rs31
-rw-r--r--tests/integration/not_found.rs17
-rw-r--r--tests/integration/overrides.rs59
-rw-r--r--tests/integration/packaging.rs49
-rw-r--r--tests/integration/polling.rs114
-rw-r--r--tests/integration/rate_limit.rs114
-rw-r--r--tests/integration/runtime.rs61
-rw-r--r--tests/integration/secrets.rs74
-rw-r--r--tests/integration/sighup.rs149
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(&current).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"
+ );
+}