diff --git a/backend/Cargo.lock b/backend/Cargo.lock index dfa559c..59ec1eb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -101,10 +101,12 @@ dependencies = [ "chrono", "cron", "domain", + "hex", "rand", "serde", "serde_json", "sha2", + "tempfile", "tokio", "uuid", ] @@ -1305,6 +1307,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1813,6 +1821,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -2379,6 +2400,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.20" diff --git a/backend/crates/api/tests/backups.rs b/backend/crates/api/tests/backups.rs new file mode 100644 index 0000000..62901c0 --- /dev/null +++ b/backend/crates/api/tests/backups.rs @@ -0,0 +1,168 @@ +//! WP-30/31/32: /api/backups +mod common; + +use axum::http::StatusCode; +use common::{get, post, send_json, test_app_with_admin}; +use serde_json::json; + +const ADMIN: &str = "admin@example.com"; +const PW: &str = "admin-password-123"; + +fn smb() -> serde_json::Value { + json!({"name": "NAS", "kind": "smb", "host": "nas.local", "share": "backups", "path": "softvisor", "username": "backup", "password": "smb-secret"}) +} + +#[tokio::test] +async fn target_and_strategy_lifecycle_with_manual_run() { + let app = test_app_with_admin().await; + let token = common::login(&app, ADMIN, PW).await.access; + + let res = post(&app, "/api/backups/targets", smb(), Some(&token)).await; + assert_eq!(res.status, StatusCode::CREATED, "{}", res.json); + let tid = res.json["id"].as_str().unwrap().to_string(); + assert!(res.json.get("password").is_none()); + assert_eq!(res.json["password_set"], true); + assert_eq!( + post( + &app, + &format!("/api/backups/targets/{tid}/test"), + json!({}), + Some(&token) + ) + .await + .status, + StatusCode::NO_CONTENT + ); + let bad = json!({"name": "x", "kind": "ftp", "host": "", "path": ""}); + assert_eq!( + post(&app, "/api/backups/targets", bad, Some(&token)) + .await + .status, + StatusCode::UNPROCESSABLE_ENTITY + ); + + let strategy = json!({"name": "Gitea DB", "source": {"type": "postgres_dump", "namespace": "gitea", "pod": "gitea-postgresql-0"}, + "schedule": "0 0 2 * * *", "target_id": tid, "retention": 3, "passphrase": "a-long-passphrase!", "enabled": true}); + let res = post(&app, "/api/backups/strategies", strategy, Some(&token)).await; + assert_eq!(res.status, StatusCode::CREATED, "{}", res.json); + let sid = res.json["id"].as_str().unwrap().to_string(); + assert!(res.json.get("passphrase").is_none()); + assert_eq!(res.json["encrypted"], true); + + // target in use -> 409 + assert_eq!( + send_json( + &app, + "DELETE", + &format!("/api/backups/targets/{tid}"), + json!({}), + Some(&token) + ) + .await + .status, + StatusCode::CONFLICT + ); + + let run = post( + &app, + &format!("/api/backups/strategies/{sid}/run"), + json!({}), + Some(&token), + ) + .await; + assert_eq!(run.status, StatusCode::ACCEPTED, "{}", run.json); + assert_eq!(run.json["kind"], "backup"); + let id = run.json["id"].as_str().unwrap(); + let mut status = String::new(); + for _ in 0..100 { + let r = get(&app, &format!("/api/jobs/{id}"), Some(&token)).await; + status = r.json["status"].as_str().unwrap().into(); + if status != "running" { + assert_eq!(status, "success", "{}", r.json["log"]); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + } + assert_eq!(status, "success"); + + let recs = get( + &app, + &format!("/api/backups/strategies/{sid}/records"), + Some(&token), + ) + .await; + let recs = recs.json.as_array().unwrap(); + assert_eq!(recs.len(), 1); + assert!(recs[0]["filename"] + .as_str() + .unwrap() + .ends_with(".sql.gz.enc")); + + let list = get(&app, "/api/backups/strategies", Some(&token)).await; + assert_eq!(list.json[0]["target_name"], "NAS"); + assert_eq!(list.json[0]["last_backup"]["filename"], recs[0]["filename"]); + + let upd = send_json(&app, "PUT", &format!("/api/backups/strategies/{sid}"), json!({"name": "Gitea DB", "source": {"type": "postgres_dump", "namespace": "gitea", "pod": "gitea-postgresql-0"}, + "schedule": "0 0 3 * * *", "target_id": tid, "retention": 3, "passphrase": "", "enabled": false}), Some(&token)).await; + assert_eq!(upd.status, StatusCode::OK, "{}", upd.json); + assert_eq!(upd.json["enabled"], false); + assert_eq!( + upd.json["encrypted"], true, + "empty passphrase keeps the stored one" + ); + + assert_eq!( + send_json( + &app, + "DELETE", + &format!("/api/backups/strategies/{sid}"), + json!({}), + Some(&token) + ) + .await + .status, + StatusCode::NO_CONTENT + ); + assert_eq!( + send_json( + &app, + "DELETE", + &format!("/api/backups/targets/{tid}"), + json!({}), + Some(&token) + ) + .await + .status, + StatusCode::NO_CONTENT + ); +} + +#[tokio::test] +async fn backups_are_admin_only_for_writes() { + let app = test_app_with_admin().await; + let admin = common::login(&app, ADMIN, PW).await.access; + post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await; + let user = common::login(&app, "u@x.de", "user-password-123") + .await + .access; + assert_eq!( + get(&app, "/api/backups/targets", Some(&user)).await.status, + StatusCode::OK + ); + assert_eq!( + get(&app, "/api/backups/strategies", Some(&user)) + .await + .status, + StatusCode::OK + ); + assert_eq!( + post(&app, "/api/backups/targets", smb(), Some(&user)) + .await + .status, + StatusCode::FORBIDDEN + ); + assert_eq!( + get(&app, "/api/backups/targets", None).await.status, + StatusCode::UNAUTHORIZED + ); +} diff --git a/backend/crates/application/Cargo.toml b/backend/crates/application/Cargo.toml index 450e1e4..b84f0d5 100644 --- a/backend/crates/application/Cargo.toml +++ b/backend/crates/application/Cargo.toml @@ -15,9 +15,11 @@ tokio.workspace = true rand.workspace = true serde.workspace = true sha2.workspace = true +hex = "0.4" uuid.workspace = true [dev-dependencies] +tempfile = "3" tokio.workspace = true [lints] diff --git a/backend/crates/application/src/backup_service.rs b/backend/crates/application/src/backup_service.rs new file mode 100644 index 0000000..4024fc4 --- /dev/null +++ b/backend/crates/application/src/backup_service.rs @@ -0,0 +1,115 @@ +//! Backup targets, strategies and execution. +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use domain::backup::{BackupRecord, BackupStrategy, BackupTarget}; +use domain::ports::{ + BackupCollector, BackupRecordRepository, BackupStorage, BackupStrategyRepository, + BackupTargetRepository, Cipher, FileEncryptor, +}; +use domain::DomainError; +use uuid::Uuid; + +use crate::jobs::{JobHandler, JobLog}; + +pub struct BackupDeps { + pub targets: Arc, + pub strategies: Arc, + pub records: Arc, + pub storage: Arc, + pub collector: Arc, + pub encryptor: Arc, + pub cipher: Arc, + pub work_dir: PathBuf, +} + +pub struct BackupService { + d: BackupDeps, +} + +/// Strategy with its most recent record, for overviews. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct StrategyStatus { + pub strategy: BackupStrategy, + pub target_name: String, + pub last_backup: Option, +} + +impl BackupService { + pub fn new(deps: BackupDeps) -> Self { + Self { d: deps } + } + + // ---- targets ---- + pub async fn list_targets(&self) -> Result, DomainError> { + todo!() + } + pub async fn get_target(&self, _id: Uuid) -> Result { + todo!() + } + pub async fn create_target(&self, _t: BackupTarget) -> Result { + todo!() + } + /// Empty password keeps the stored one. + pub async fn update_target(&self, _t: BackupTarget) -> Result { + todo!() + } + /// Fails with `Conflict` while a strategy still uses the target. + pub async fn delete_target(&self, _id: Uuid) -> Result<(), DomainError> { + todo!() + } + pub async fn test_target(&self, _id: Uuid) -> Result<(), DomainError> { + todo!() + } + + // ---- strategies ---- + pub async fn list_strategies(&self) -> Result, DomainError> { + todo!() + } + pub async fn get_strategy(&self, _id: Uuid) -> Result { + todo!() + } + pub async fn create_strategy(&self, _s: BackupStrategy) -> Result { + todo!() + } + /// Empty passphrase keeps the stored one; `None` removes it. + pub async fn update_strategy(&self, _s: BackupStrategy) -> Result { + todo!() + } + pub async fn delete_strategy(&self, _id: Uuid) -> Result<(), DomainError> { + todo!() + } + pub async fn records(&self, _strategy_id: Uuid) -> Result, DomainError> { + todo!() + } + + /// Enabled strategies whose cron fired since their last backup. + pub async fn due_strategies( + &self, + _now: DateTime, + _grace_secs: i64, + ) -> Result, DomainError> { + todo!() + } + + /// Collect, encrypt, upload, verify, record, apply retention. + pub async fn run_strategy( + &self, + _id: Uuid, + _log: &dyn JobLog, + ) -> Result { + todo!() + } +} + +/// Job handler for `JobKind::Backup`; params = strategy id. +pub struct BackupJob(pub Arc); + +#[async_trait] +impl JobHandler for BackupJob { + async fn run(&self, _params: Option, _log: &dyn JobLog) -> Result<(), String> { + todo!() + } +} diff --git a/backend/crates/application/src/lib.rs b/backend/crates/application/src/lib.rs index d203e24..15d15da 100644 --- a/backend/crates/application/src/lib.rs +++ b/backend/crates/application/src/lib.rs @@ -1,5 +1,6 @@ //! Application layer: use cases orchestrating the domain through its ports. pub mod auth_service; +pub mod backup_service; pub mod cluster_service; pub mod inventory_service; pub mod jobs; @@ -10,6 +11,7 @@ pub mod user_service; pub mod vuln_service; pub use auth_service::AuthService; +pub use backup_service::{BackupDeps, BackupJob, BackupService}; pub use cluster_service::ClusterService; pub use inventory_service::{InventoryService, PackageRefreshJob}; pub use jobs::{JobHandler, JobLog, JobRunner}; diff --git a/backend/crates/application/src/test_fakes.rs b/backend/crates/application/src/test_fakes.rs index d8e2c4e..0c185f0 100644 --- a/backend/crates/application/src/test_fakes.rs +++ b/backend/crates/application/src/test_fakes.rs @@ -651,3 +651,246 @@ impl FindingRepository for MemFindings { Ok(c) } } + +use domain::backup::{ + BackupRecord, BackupSource, BackupStrategy, BackupTarget, RemoteFile, StorageKind, +}; +use domain::ports::{ + BackupCollector, BackupRecordRepository, BackupStorage, BackupStrategyRepository, + BackupTargetRepository, FileEncryptor, LineSink as _LineSink, +}; +use std::path::{Path, PathBuf}; + +#[derive(Default)] +pub struct MemTargets(pub Mutex>); +#[async_trait] +impl BackupTargetRepository for MemTargets { + async fn list(&self) -> Result, DomainError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn get(&self, id: Uuid) -> Result, DomainError> { + Ok(self.0.lock().unwrap().iter().find(|t| t.id == id).cloned()) + } + async fn insert(&self, t: &BackupTarget) -> Result<(), DomainError> { + self.0.lock().unwrap().push(t.clone()); + Ok(()) + } + async fn update(&self, t: &BackupTarget) -> Result<(), DomainError> { + let mut v = self.0.lock().unwrap(); + let x = v + .iter_mut() + .find(|x| x.id == t.id) + .ok_or(DomainError::NotFound)?; + *x = t.clone(); + Ok(()) + } + async fn delete(&self, id: Uuid) -> Result<(), DomainError> { + let mut v = self.0.lock().unwrap(); + let before = v.len(); + v.retain(|t| t.id != id); + (v.len() < before) + .then_some(()) + .ok_or(DomainError::NotFound) + } +} + +#[derive(Default)] +pub struct MemStrategies(pub Mutex>); +#[async_trait] +impl BackupStrategyRepository for MemStrategies { + async fn list(&self) -> Result, DomainError> { + Ok(self.0.lock().unwrap().clone()) + } + async fn get(&self, id: Uuid) -> Result, DomainError> { + Ok(self.0.lock().unwrap().iter().find(|t| t.id == id).cloned()) + } + async fn insert(&self, s: &BackupStrategy) -> Result<(), DomainError> { + self.0.lock().unwrap().push(s.clone()); + Ok(()) + } + async fn update(&self, s: &BackupStrategy) -> Result<(), DomainError> { + let mut v = self.0.lock().unwrap(); + let x = v + .iter_mut() + .find(|x| x.id == s.id) + .ok_or(DomainError::NotFound)?; + *x = s.clone(); + Ok(()) + } + async fn delete(&self, id: Uuid) -> Result<(), DomainError> { + let mut v = self.0.lock().unwrap(); + let before = v.len(); + v.retain(|t| t.id != id); + (v.len() < before) + .then_some(()) + .ok_or(DomainError::NotFound) + } +} + +#[derive(Default)] +pub struct MemRecords(pub Mutex>); +#[async_trait] +impl BackupRecordRepository for MemRecords { + async fn insert(&self, r: &BackupRecord) -> Result<(), DomainError> { + self.0.lock().unwrap().push(r.clone()); + Ok(()) + } + async fn list_for( + &self, + strategy_id: Uuid, + limit: u32, + ) -> Result, DomainError> { + let mut v: Vec<_> = self + .0 + .lock() + .unwrap() + .iter() + .filter(|r| r.strategy_id == strategy_id) + .cloned() + .collect(); + v.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + v.truncate(limit as usize); + Ok(v) + } + async fn delete_by_filename( + &self, + strategy_id: Uuid, + filename: &str, + ) -> Result<(), DomainError> { + self.0 + .lock() + .unwrap() + .retain(|r| !(r.strategy_id == strategy_id && r.filename == filename)); + Ok(()) + } +} + +/// In-memory remote storage keyed by target id; `fail` makes every call fail. +#[derive(Default)] +pub struct MemStorage { + pub files: Mutex>>, + pub fail: bool, + pub ops: Mutex>, +} +#[async_trait] +impl BackupStorage for MemStorage { + async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> { + self.ops.lock().unwrap().push(format!("test {}", t.name)); + if self.fail { + Err(DomainError::Unavailable("connection refused".into())) + } else { + Ok(()) + } + } + async fn upload( + &self, + t: &BackupTarget, + local: &Path, + remote_name: &str, + ) -> Result<(), DomainError> { + if self.fail { + return Err(DomainError::Unavailable("upload failed".into())); + } + let size = std::fs::metadata(local).map(|m| m.len()).unwrap_or(0); + self.ops + .lock() + .unwrap() + .push(format!("upload {remote_name}")); + self.files + .lock() + .unwrap() + .entry(t.id) + .or_default() + .push(RemoteFile { + name: remote_name.into(), + size_bytes: size, + }); + Ok(()) + } + async fn list(&self, t: &BackupTarget) -> Result, DomainError> { + Ok(self + .files + .lock() + .unwrap() + .get(&t.id) + .cloned() + .unwrap_or_default()) + } + async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> { + self.ops + .lock() + .unwrap() + .push(format!("delete {remote_name}")); + self.files + .lock() + .unwrap() + .entry(t.id) + .or_default() + .retain(|f| f.name != remote_name); + Ok(()) + } +} + +/// Writes a small file describing the source. +pub struct FakeCollector; +#[async_trait] +impl BackupCollector for FakeCollector { + async fn collect( + &self, + source: &BackupSource, + work_dir: &Path, + out: &dyn _LineSink, + ) -> Result { + out.line(&format!("collecting {source:?}")); + let p = work_dir.join(format!("archive.{}", source.extension())); + std::fs::write(&p, format!("fake archive of {source:?}")) + .map_err(|e| DomainError::Storage(e.to_string()))?; + Ok(p) + } +} + +pub struct FakeEncryptor; +#[async_trait] +impl FileEncryptor for FakeEncryptor { + async fn encrypt(&self, input: &Path, passphrase: &str) -> Result { + let out = input.with_extension(format!( + "{}.enc", + input.extension().and_then(|e| e.to_str()).unwrap_or("") + )); + let data = std::fs::read(input).map_err(|e| DomainError::Storage(e.to_string()))?; + std::fs::write(&out, [b"ENC:", passphrase.as_bytes(), b":", &data].concat()) + .map_err(|e| DomainError::Storage(e.to_string()))?; + Ok(out) + } +} + +pub fn target(name: &str) -> BackupTarget { + BackupTarget { + id: Uuid::new_v4(), + name: name.into(), + kind: StorageKind::Smb, + host: "nas.local".into(), + port: None, + share: "backups".into(), + path: "softvisor".into(), + username: "backup".into(), + password: "smb-secret".into(), + tls: false, + } +} + +pub fn strategy(name: &str, target_id: Uuid) -> BackupStrategy { + BackupStrategy { + id: Uuid::new_v4(), + name: name.into(), + source: BackupSource::PostgresDump { + namespace: "gitea".into(), + pod: "gitea-postgresql-0".into(), + }, + schedule: "0 0 2 * * *".into(), + target_id, + retention: 2, + passphrase: None, + enabled: true, + } +} diff --git a/backend/crates/application/src/tests/backup_tests.rs b/backend/crates/application/src/tests/backup_tests.rs new file mode 100644 index 0000000..97a41ab --- /dev/null +++ b/backend/crates/application/src/tests/backup_tests.rs @@ -0,0 +1,374 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::{Duration, TimeZone, Utc}; +use domain::backup::{BackupSource, StorageKind}; +use domain::DomainError; + +use crate::jobs::{JobHandler, JobLog}; +use crate::test_fakes::{ + strategy, target, FakeCipher, FakeCollector, FakeEncryptor, MemRecords, MemStorage, + MemStrategies, MemTargets, +}; +use crate::{BackupDeps, BackupJob, BackupService}; + +#[derive(Default)] +struct VecLog(Mutex>); +#[async_trait] +impl JobLog for VecLog { + async fn line(&self, text: &str) { + self.0.lock().unwrap().push(text.into()); + } +} + +struct F { + targets: Arc, + strategies: Arc, + records: Arc, + storage: Arc, + work: tempfile::TempDir, +} + +fn fixture(fail_storage: bool) -> (F, Arc) { + let targets = Arc::new(MemTargets::default()); + let strategies = Arc::new(MemStrategies::default()); + let records = Arc::new(MemRecords::default()); + let storage = Arc::new(MemStorage { + fail: fail_storage, + ..Default::default() + }); + let work = tempfile::tempdir().unwrap(); + let svc = BackupService::new(BackupDeps { + targets: targets.clone(), + strategies: strategies.clone(), + records: records.clone(), + storage: storage.clone(), + collector: Arc::new(FakeCollector), + encryptor: Arc::new(FakeEncryptor), + cipher: Arc::new(FakeCipher), + work_dir: work.path().to_path_buf(), + }); + ( + F { + targets, + strategies, + records, + storage, + work, + }, + Arc::new(svc), + ) +} + +#[tokio::test] +async fn targets_crud_with_encrypted_password_and_masked_update() { + let (f, svc) = fixture(false); + let t = svc.create_target(target("NAS")).await.unwrap(); + assert_eq!(t.password, "smb-secret", "returned in plain text"); + let stored = f.targets.0.lock().unwrap()[0].clone(); + assert!(stored.password.starts_with("enc:"), "stored encrypted"); + assert_eq!(svc.get_target(t.id).await.unwrap().password, "smb-secret"); + + // update with empty password keeps the old one + let upd = svc + .update_target(domain::backup::BackupTarget { + password: String::new(), + host: "nas2.local".into(), + ..t.clone() + }) + .await + .unwrap(); + assert_eq!(upd.host, "nas2.local"); + assert_eq!(upd.password, "smb-secret"); + let upd = svc + .update_target(domain::backup::BackupTarget { + password: "new-secret".into(), + ..t.clone() + }) + .await + .unwrap(); + assert_eq!(upd.password, "new-secret"); + + let mut bad = target("x"); + bad.share = String::new(); + assert!(matches!( + svc.create_target(bad).await.unwrap_err(), + DomainError::Validation(_) + )); + let mut ftp = target("ftp"); + ftp.kind = StorageKind::Ftp; + ftp.share = String::new(); + svc.create_target(ftp).await.unwrap(); + assert_eq!(svc.list_targets().await.unwrap().len(), 2); + + svc.test_target(t.id).await.unwrap(); + assert_eq!(f.storage.ops.lock().unwrap()[0], "test NAS"); + svc.delete_target(t.id).await.unwrap(); + assert_eq!( + svc.get_target(t.id).await.unwrap_err(), + DomainError::NotFound + ); +} + +#[tokio::test] +async fn strategies_crud_validation_and_target_protection() { + let (f, svc) = fixture(false); + let t = svc.create_target(target("NAS")).await.unwrap(); + let mut s = strategy("Gitea DB", t.id); + s.passphrase = Some("a-long-passphrase!".into()); + let created = svc.create_strategy(s.clone()).await.unwrap(); + assert!(f.strategies.0.lock().unwrap()[0] + .passphrase + .as_ref() + .unwrap() + .starts_with("enc:")); + assert_eq!( + svc.get_strategy(created.id) + .await + .unwrap() + .passphrase + .as_deref(), + Some("a-long-passphrase!") + ); + + // validation: cron, retention, unknown target, source names + let bad = domain::backup::BackupStrategy { + schedule: "nope".into(), + ..s.clone() + }; + assert!(matches!( + svc.create_strategy(bad).await.unwrap_err(), + DomainError::Validation(_) + )); + let bad = domain::backup::BackupStrategy { + retention: 0, + ..s.clone() + }; + assert!(matches!( + svc.create_strategy(bad).await.unwrap_err(), + DomainError::Validation(_) + )); + let bad = domain::backup::BackupStrategy { + target_id: uuid::Uuid::new_v4(), + ..s.clone() + }; + assert_eq!( + svc.create_strategy(bad).await.unwrap_err(), + DomainError::NotFound + ); + let bad = domain::backup::BackupStrategy { + source: BackupSource::HostPath { + path: "relative/../x".into(), + }, + ..s.clone() + }; + assert!(matches!( + svc.create_strategy(bad).await.unwrap_err(), + DomainError::Validation(_) + )); + + // target in use cannot be deleted + assert!(matches!( + svc.delete_target(t.id).await.unwrap_err(), + DomainError::Conflict(_) + )); + + // update: empty passphrase keeps, None removes + let upd = svc + .update_strategy(domain::backup::BackupStrategy { + passphrase: Some(String::new()), + retention: 5, + ..created.clone() + }) + .await + .unwrap(); + assert_eq!(upd.passphrase.as_deref(), Some("a-long-passphrase!")); + assert_eq!(upd.retention, 5); + let upd = svc + .update_strategy(domain::backup::BackupStrategy { + passphrase: None, + ..created.clone() + }) + .await + .unwrap(); + assert_eq!(upd.passphrase, None); + + let list = svc.list_strategies().await.unwrap(); + assert_eq!(list[0].target_name, "NAS"); + assert_eq!(list[0].last_backup, None); + svc.delete_strategy(created.id).await.unwrap(); + svc.delete_target(t.id).await.unwrap(); +} + +#[tokio::test] +async fn run_uploads_archive_records_it_and_applies_retention() { + let (f, svc) = fixture(false); + let t = svc.create_target(target("NAS")).await.unwrap(); + let s = svc + .create_strategy(strategy("Gitea DB", t.id)) + .await + .unwrap(); + let log = VecLog::default(); + let r1 = svc.run_strategy(s.id, &log).await.unwrap(); + assert!( + r1.filename.starts_with("gitea-db_") && r1.filename.ends_with(".sql.gz"), + "{}", + r1.filename + ); + assert!(r1.size_bytes > 0); + assert_eq!(r1.sha256.len(), 64); + let lines = log.0.lock().unwrap().join("\n"); + assert!(lines.contains("collecting"), "{lines}"); + assert!(lines.contains("uploaded"), "{lines}"); + assert!( + f.work.path().read_dir().unwrap().next().is_none(), + "work dir cleaned up" + ); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + svc.run_strategy(s.id, &VecLog::default()).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + let r3 = svc.run_strategy(s.id, &VecLog::default()).await.unwrap(); + + // retention 2: oldest removed remotely and from records + let remote = f.storage.files.lock().unwrap().get(&t.id).cloned().unwrap(); + assert_eq!(remote.len(), 2, "{remote:?}"); + assert!(remote.iter().all(|x| x.name != r1.filename)); + assert!(f + .storage + .ops + .lock() + .unwrap() + .contains(&format!("delete {}", r1.filename))); + let recs = svc.records(s.id).await.unwrap(); + assert_eq!(recs.len(), 2); + assert_eq!(recs[0].filename, r3.filename, "newest first"); + assert_eq!( + svc.list_strategies().await.unwrap()[0] + .last_backup + .as_ref() + .unwrap() + .filename, + r3.filename + ); +} + +#[tokio::test] +async fn encrypted_strategy_uploads_enc_file() { + let (f, svc) = fixture(false); + let t = svc.create_target(target("NAS")).await.unwrap(); + let mut s = strategy("Repos", t.id); + s.source = BackupSource::VolumeClaim { + namespace: "gitea".into(), + pvc: "gitea-shared-storage".into(), + }; + s.passphrase = Some("correct horse battery".into()); + let s = svc.create_strategy(s).await.unwrap(); + let r = svc.run_strategy(s.id, &VecLog::default()).await.unwrap(); + assert!(r.filename.ends_with(".tar.gz.enc"), "{}", r.filename); + let remote = f.storage.files.lock().unwrap().get(&t.id).cloned().unwrap(); + assert_eq!(remote[0].name, r.filename); + assert_eq!(remote[0].size_bytes, r.size_bytes); +} + +#[tokio::test] +async fn upload_failure_fails_run_and_cleans_up() { + let (f, svc) = fixture(true); + let t = svc.create_target(target("NAS")).await.unwrap(); + let s = svc.create_strategy(strategy("x", t.id)).await.unwrap(); + let log = VecLog::default(); + assert!(matches!( + svc.run_strategy(s.id, &log).await.unwrap_err(), + DomainError::Unavailable(_) + )); + assert!(f.records.0.lock().unwrap().is_empty()); + assert!( + f.work.path().read_dir().unwrap().next().is_none(), + "work dir cleaned up" + ); + assert!(matches!( + svc.test_target(t.id).await.unwrap_err(), + DomainError::Unavailable(_) + )); +} + +#[tokio::test] +async fn due_strategies_follow_cron_and_last_backup() { + let (f, svc) = fixture(false); + let t = svc.create_target(target("NAS")).await.unwrap(); + let daily = svc.create_strategy(strategy("daily", t.id)).await.unwrap(); // 02:00 + let mut off = strategy("off", t.id); + off.enabled = false; + let off = svc.create_strategy(off).await.unwrap(); + let now = Utc.with_ymd_and_hms(2026, 9, 3, 2, 0, 30).unwrap(); + // never ran: due within grace after 02:00 + assert_eq!(svc.due_strategies(now, 120).await.unwrap(), vec![daily.id]); + assert!(svc + .due_strategies(now + Duration::hours(1), 120) + .await + .unwrap() + .is_empty()); + // ran yesterday -> due again at 02:00 today, even if the tick is late + f.records + .0 + .lock() + .unwrap() + .push(domain::backup::BackupRecord { + id: uuid::Uuid::new_v4(), + strategy_id: daily.id, + filename: "daily_x".into(), + size_bytes: 1, + sha256: String::new(), + created_at: now - Duration::days(1), + }); + assert_eq!( + svc.due_strategies(now + Duration::hours(1), 120) + .await + .unwrap(), + vec![daily.id] + ); + // ran just now -> not due + f.records + .0 + .lock() + .unwrap() + .push(domain::backup::BackupRecord { + id: uuid::Uuid::new_v4(), + strategy_id: daily.id, + filename: "daily_y".into(), + size_bytes: 1, + sha256: String::new(), + created_at: now, + }); + assert!(svc + .due_strategies(now + Duration::hours(1), 120) + .await + .unwrap() + .is_empty()); + let _ = off; +} + +#[tokio::test] +async fn backup_job_runs_strategy_from_params() { + let (_, svc) = fixture(false); + let t = svc.create_target(target("NAS")).await.unwrap(); + let s = svc.create_strategy(strategy("job", t.id)).await.unwrap(); + let job = BackupJob(svc.clone()); + let log = VecLog::default(); + job.run(Some(s.id.to_string()), &log).await.unwrap(); + assert!(log.0.lock().unwrap().iter().any(|l| l.contains("job_"))); + assert!(job + .run(None, &VecLog::default()) + .await + .unwrap_err() + .contains("strategy")); + assert!(job + .run(Some("not-a-uuid".into()), &VecLog::default()) + .await + .is_err()); + assert!(job + .run(Some(uuid::Uuid::new_v4().to_string()), &VecLog::default()) + .await + .unwrap_err() + .contains("not found")); +} diff --git a/backend/crates/application/src/tests/mod.rs b/backend/crates/application/src/tests/mod.rs index fb33967..38d2460 100644 --- a/backend/crates/application/src/tests/mod.rs +++ b/backend/crates/application/src/tests/mod.rs @@ -1,4 +1,5 @@ mod auth_service_tests; +mod backup_tests; mod cluster_tests; mod inventory_tests; mod jobs_tests; diff --git a/backend/crates/domain/src/backup.rs b/backend/crates/domain/src/backup.rs new file mode 100644 index 0000000..7b403aa --- /dev/null +++ b/backend/crates/domain/src/backup.rs @@ -0,0 +1,196 @@ +//! Backup targets (where), sources (what), strategies (when/how) and records (what was produced). +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::DomainError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StorageKind { + Smb, + Ftp, +} + +impl StorageKind { + pub fn as_str(self) -> &'static str { + match self { + StorageKind::Smb => "smb", + StorageKind::Ftp => "ftp", + } + } + pub fn parse(s: &str) -> Option { + match s { + "smb" => Some(StorageKind::Smb), + "ftp" => Some(StorageKind::Ftp), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackupTarget { + pub id: Uuid, + pub name: String, + pub kind: StorageKind, + pub host: String, + pub port: Option, + /// SMB share name; unused for FTP. + #[serde(default)] + pub share: String, + /// Directory on the share / server. + #[serde(default)] + pub path: String, + #[serde(default)] + pub username: String, + /// Plain text in memory, encrypted at rest. + #[serde(default)] + pub password: String, + /// FTPS (explicit TLS) for FTP targets. + #[serde(default)] + pub tls: bool, +} + +impl BackupTarget { + pub fn validate(&self) -> Result<(), DomainError> { + let err = |m: &str| Err(DomainError::Validation(m.into())); + if self.name.trim().is_empty() { + return err("target name is required"); + } + if self.host.trim().is_empty() || self.host.contains(char::is_whitespace) { + return err("host is invalid"); + } + if self.kind == StorageKind::Smb && self.share.trim().is_empty() { + return err("smb share is required"); + } + if self.path.contains("..") { + return err("path must not contain '..'"); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BackupSource { + /// Content of a persistent volume claim (e.g. Gitea repositories, LFS, attachments). + VolumeClaim { namespace: String, pvc: String }, + /// `pg_dumpall` executed inside the database pod. + PostgresDump { namespace: String, pod: String }, + /// All resources of a namespace as YAML. + KubernetesManifests { namespace: String }, + /// A directory on the host. + HostPath { path: String }, +} + +impl BackupSource { + pub fn validate(&self) -> Result<(), DomainError> { + let name = |s: &str, what: &str| { + crate::cluster::validate_k8s_name(s) + .map_err(|_| DomainError::Validation(format!("invalid {what}"))) + }; + match self { + BackupSource::VolumeClaim { namespace, pvc } => { + name(namespace, "namespace").and(name(pvc, "pvc")) + } + BackupSource::PostgresDump { namespace, pod } => { + name(namespace, "namespace").and(name(pod, "pod")) + } + BackupSource::KubernetesManifests { namespace } => name(namespace, "namespace"), + BackupSource::HostPath { path } => (path.starts_with('/') && !path.contains("..")) + .then_some(()) + .ok_or_else(|| DomainError::Validation("host path must be absolute".into())), + } + } + + /// File extension of the produced archive (before optional `.enc`). + pub fn extension(&self) -> &'static str { + match self { + BackupSource::VolumeClaim { .. } | BackupSource::HostPath { .. } => "tar.gz", + BackupSource::PostgresDump { .. } => "sql.gz", + BackupSource::KubernetesManifests { .. } => "yaml.gz", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackupStrategy { + pub id: Uuid, + pub name: String, + pub source: BackupSource, + /// 6-field cron expression. + pub schedule: String, + pub target_id: Uuid, + /// Keep the newest N backups on the target. + pub retention: u32, + /// Encrypt archives with this passphrase (AES-256, openssl compatible). Encrypted at rest. + pub passphrase: Option, + pub enabled: bool, +} + +impl BackupStrategy { + pub fn validate(&self) -> Result<(), DomainError> { + if self.name.trim().is_empty() { + return Err(DomainError::Validation("strategy name is required".into())); + } + if !(1..=365).contains(&self.retention) { + return Err(DomainError::Validation( + "retention must be between 1 and 365".into(), + )); + } + if self + .passphrase + .as_ref() + .is_some_and(|p| p.chars().count() < 12) + { + return Err(DomainError::Validation( + "passphrase must have at least 12 characters".into(), + )); + } + self.source.validate() + } + + /// Filename prefix on the target: lowercase name with non-alphanumerics replaced by '-'. + pub fn slug(&self) -> String { + let mut s: String = self + .name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + while s.contains("--") { + s = s.replace("--", "-"); + } + s.trim_matches('-').to_string() + } + + pub fn filename(&self, at: DateTime) -> String { + let enc = if self.passphrase.is_some() { + ".enc" + } else { + "" + }; + format!( + "{}_{}.{}{enc}", + self.slug(), + at.format("%Y%m%d-%H%M%S"), + self.source.extension() + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackupRecord { + pub id: Uuid, + pub strategy_id: Uuid, + pub filename: String, + pub size_bytes: u64, + pub sha256: String, + pub created_at: DateTime, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteFile { + pub name: String, + pub size_bytes: u64, +} diff --git a/backend/crates/domain/src/lib.rs b/backend/crates/domain/src/lib.rs index 9036b91..6d1b4f4 100644 --- a/backend/crates/domain/src/lib.rs +++ b/backend/crates/domain/src/lib.rs @@ -1,6 +1,7 @@ //! Domain layer: entities, value objects, errors and the ports (traits) the application //! layer depends on. No I/O here. pub mod auth; +pub mod backup; pub mod cluster; pub mod error; pub mod host; diff --git a/backend/crates/domain/src/ports.rs b/backend/crates/domain/src/ports.rs index 842984a..2fdf67c 100644 --- a/backend/crates/domain/src/ports.rs +++ b/backend/crates/domain/src/ports.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use uuid::Uuid; use crate::auth::{AccessClaims, AuthEvent, RefreshToken}; +use crate::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, RemoteFile}; use crate::cluster::{ClusterOverview, WorkloadRef}; use crate::host::{Inventory, OsInfo, Package}; use crate::jobs::{JobKind, JobRun, JobStatus}; @@ -10,6 +11,7 @@ use crate::settings::SmtpSettings; use crate::user::{User, UserUpdate}; use crate::vuln::{Finding, FindingFilter, FindingStatus, RawFinding, SeverityCounts, TargetKind}; use crate::DomainError; +use std::path::{Path, PathBuf}; #[async_trait] pub trait UserRepository: Send + Sync { @@ -148,3 +150,68 @@ pub trait FindingRepository: Send + Sync { async fn list(&self, filter: &FindingFilter) -> Result, DomainError>; async fn counts(&self, kind: Option) -> Result; } + +#[async_trait] +pub trait BackupTargetRepository: Send + Sync { + async fn list(&self) -> Result, DomainError>; + async fn get(&self, id: Uuid) -> Result, DomainError>; + async fn insert(&self, t: &BackupTarget) -> Result<(), DomainError>; + async fn update(&self, t: &BackupTarget) -> Result<(), DomainError>; + async fn delete(&self, id: Uuid) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait BackupStrategyRepository: Send + Sync { + async fn list(&self) -> Result, DomainError>; + async fn get(&self, id: Uuid) -> Result, DomainError>; + async fn insert(&self, s: &BackupStrategy) -> Result<(), DomainError>; + async fn update(&self, s: &BackupStrategy) -> Result<(), DomainError>; + async fn delete(&self, id: Uuid) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait BackupRecordRepository: Send + Sync { + async fn insert(&self, r: &BackupRecord) -> Result<(), DomainError>; + /// Newest first. + async fn list_for( + &self, + strategy_id: Uuid, + limit: u32, + ) -> Result, DomainError>; + async fn delete_by_filename( + &self, + strategy_id: Uuid, + filename: &str, + ) -> Result<(), DomainError>; +} + +/// Remote storage (SMB share or FTP server). +#[async_trait] +pub trait BackupStorage: Send + Sync { + async fn test(&self, target: &BackupTarget) -> Result<(), DomainError>; + async fn upload( + &self, + target: &BackupTarget, + local: &Path, + remote_name: &str, + ) -> Result<(), DomainError>; + async fn list(&self, target: &BackupTarget) -> Result, DomainError>; + async fn delete(&self, target: &BackupTarget, remote_name: &str) -> Result<(), DomainError>; +} + +/// Produces a compressed archive for a source in `work_dir`; returns the file path. +#[async_trait] +pub trait BackupCollector: Send + Sync { + async fn collect( + &self, + source: &BackupSource, + work_dir: &Path, + out: &dyn LineSink, + ) -> Result; +} + +/// Encrypts a file with a passphrase; returns the encrypted file path. +#[async_trait] +pub trait FileEncryptor: Send + Sync { + async fn encrypt(&self, input: &Path, passphrase: &str) -> Result; +} diff --git a/backend/crates/infrastructure/src/trivy.rs b/backend/crates/infrastructure/src/trivy.rs index 08eba6a..88b18f7 100644 --- a/backend/crates/infrastructure/src/trivy.rs +++ b/backend/crates/infrastructure/src/trivy.rs @@ -104,6 +104,10 @@ impl TrivyScanner { } } +/// Skipped by the OS scan: containerd/snap content (scanned per image instead) and pseudo filesystems. +pub const ROOTFS_SKIP_DIRS: &str = + "/var/snap/microk8s/common,/snap,/var/lib/snapd,/var/lib/containerd,/var/lib/docker,/proc,/sys,/dev,/run,/tmp"; + const COMMON: [&str; 7] = [ "--format", "json", @@ -133,7 +137,8 @@ impl VulnerabilityScanner for TrivyScanner { async fn scan_os(&self, out: &dyn LineSink) -> Result, DomainError> { let mut args = vec!["rootfs"]; args.extend(COMMON); - args.push("/"); + // container image layers, snaps and pseudo filesystems are not part of the host OS + args.extend(["--skip-dirs", ROOTFS_SKIP_DIRS, "/"]); self.scan(&args, out).await } async fn scan_image( @@ -287,6 +292,18 @@ mod tests { }, "rootfs" | "image" => { assert!(args.contains(&"--format") && args.contains(&"json")); + if args[0] == "rootfs" { + let skip = args + .iter() + .position(|a| *a == "--skip-dirs") + .map(|i| args[i + 1]) + .unwrap_or(""); + assert!( + skip.contains("/var/snap/microk8s/common") && skip.contains("/proc"), + "{skip}" + ); + assert_eq!(*args.last().unwrap(), "/"); + } crate::host::Output { stdout: SAMPLE.into(), success: true, diff --git a/frontend/e2e/backups.spec.ts b/frontend/e2e/backups.spec.ts new file mode 100644 index 0000000..455a8b4 --- /dev/null +++ b/frontend/e2e/backups.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test' + +test('admin creates a target and a strategy, runs it and sees the backup', async ({ page }) => { + await page.goto('/login') + await page.getByLabel('Email').fill('admin@example.com') + await page.getByLabel('Password').fill('admin-password-123') + await page.getByRole('button', { name: 'Sign in' }).click() + await page.getByRole('link', { name: 'Backups' }).click() + + await page.getByRole('button', { name: 'New target' }).click() + const name = `NAS ${Date.now()}` + await page.getByLabel('Name').fill(name) + await page.getByLabel('Host').fill('nas.local') + await page.getByLabel('Share').fill('backups') + await page.getByLabel('Directory').fill('softvisor') + await page.getByLabel('Username').fill('backup') + await page.getByLabel('Password').fill('smb-secret') + await page.getByRole('button', { name: 'Save target' }).click() + const targetRow = page.getByRole('row', { name: new RegExp(name) }) + await expect(targetRow).toBeVisible() + await targetRow.getByRole('button', { name: 'Test' }).click() + await expect(page.getByRole('status')).toContainText('Connection ok') + + await page.getByRole('button', { name: 'New strategy' }).click() + await page.getByLabel('Strategy name').fill('Gitea database') + await page.getByLabel('Source type').selectOption('postgres_dump') + await page.getByLabel('Namespace').fill('gitea') + await page.getByLabel('Pod').fill('gitea-postgresql-0') + await page.getByLabel('Schedule').fill('0 0 2 * * *') + await page.getByLabel('Target').selectOption({ label: name }) + await page.getByLabel('Keep last').fill('3') + await page.getByRole('button', { name: 'Save strategy' }).click() + const row = page.getByRole('row', { name: /Gitea database/ }) + await expect(row).toBeVisible() + await row.getByRole('button', { name: 'Run now' }).click() + await expect(row).toContainText(/gitea-database_.*\.sql\.gz/, { timeout: 20_000 }) + await row.getByRole('button', { name: 'History' }).click() + await expect(page.getByTestId('backup-records')).toContainText('sql.gz') +}) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b8a1288..20847f0 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -163,3 +163,71 @@ export interface VulnSummary { last_scan: string | null scanner: string } + +export type StorageKind = 'smb' | 'ftp' + +export interface BackupTargetView { + id: string + name: string + kind: StorageKind + host: string + port: number | null + share: string + path: string + username: string + tls: boolean + password_set: boolean +} + +export interface BackupTargetPayload { + name: string + kind: StorageKind + host: string + port: number | null + share: string + path: string + username: string + password: string + tls: boolean +} + +export type BackupSource = + | { type: 'volume_claim'; namespace: string; pvc: string } + | { type: 'postgres_dump'; namespace: string; pod: string } + | { type: 'kubernetes_manifests'; namespace: string } + | { type: 'host_path'; path: string } + +export interface BackupStrategyView { + id: string + name: string + source: BackupSource + schedule: string + target_id: string + retention: number + encrypted: boolean + enabled: boolean +} + +export interface BackupStrategyPayload { + name: string + source: BackupSource + schedule: string + target_id: string + retention: number + passphrase: string | null + enabled: boolean +} + +export interface BackupRecord { + id: string + strategy_id: string + filename: string + size_bytes: number + sha256: string + created_at: string +} + +export interface StrategyStatus extends BackupStrategyView { + target_name: string + last_backup: BackupRecord | null +} diff --git a/frontend/src/components/StrategyForm.test.ts b/frontend/src/components/StrategyForm.test.ts new file mode 100644 index 0000000..f6aa402 --- /dev/null +++ b/frontend/src/components/StrategyForm.test.ts @@ -0,0 +1,71 @@ +import { mount } from '@vue/test-utils' +import StrategyForm from './StrategyForm.vue' + +const targets = [ + { id: 't1', name: 'NAS' }, + { id: 't2', name: 'FTP' }, +] + +describe('StrategyForm', () => { + it('builds a postgres dump strategy', async () => { + const w = mount(StrategyForm, { props: { targets } }) + await w.find('input[name=name]').setValue('Gitea DB') + await w.find('select[name=source_type]').setValue('postgres_dump') + await w.find('input[name=namespace]').setValue('gitea') + await w.find('input[name=pod]').setValue('gitea-postgresql-0') + await w.find('input[name=schedule]').setValue('0 0 2 * * *') + await w.find('select[name=target_id]').setValue('t2') + await w.find('input[name=retention]').setValue('7') + await w.find('input[name=passphrase]').setValue('a-long-passphrase!') + await w.find('form').trigger('submit') + expect(w.emitted('submit')![0][0]).toEqual({ + name: 'Gitea DB', + source: { type: 'postgres_dump', namespace: 'gitea', pod: 'gitea-postgresql-0' }, + schedule: '0 0 2 * * *', + target_id: 't2', + retention: 7, + passphrase: 'a-long-passphrase!', + enabled: true, + }) + }) + + it('switches source fields by type', async () => { + const w = mount(StrategyForm, { props: { targets } }) + await w.find('select[name=source_type]').setValue('host_path') + expect(w.find('input[name=path]').exists()).toBe(true) + expect(w.find('input[name=namespace]').exists()).toBe(false) + await w.find('select[name=source_type]').setValue('volume_claim') + expect(w.find('input[name=pvc]').exists()).toBe(true) + }) + + it('sends null passphrase when empty on create and keeps it on edit', async () => { + const w = mount(StrategyForm, { props: { targets } }) + await w.find('input[name=name]').setValue('x') + await w.find('input[name=path]').exists() + await w.find('select[name=source_type]').setValue('kubernetes_manifests') + await w.find('input[name=namespace]').setValue('gitea') + await w.find('form').trigger('submit') + expect((w.emitted('submit')![0][0] as { passphrase: unknown }).passphrase).toBeNull() + + const e = mount(StrategyForm, { + props: { + targets, + current: { + id: 's1', + name: 'Old', + source: { type: 'host_path', path: '/srv' }, + schedule: '0 0 1 * * *', + target_id: 't1', + retention: 2, + encrypted: true, + enabled: false, + }, + }, + }) + expect(e.text()).toContain('leave empty to keep') + await e.find('form').trigger('submit') + const p = e.emitted('submit')![0][0] as { passphrase: unknown; enabled: boolean } + expect(p.passphrase).toBe('') + expect(p.enabled).toBe(false) + }) +}) diff --git a/frontend/src/components/TargetForm.test.ts b/frontend/src/components/TargetForm.test.ts new file mode 100644 index 0000000..bea59e8 --- /dev/null +++ b/frontend/src/components/TargetForm.test.ts @@ -0,0 +1,57 @@ +import { mount } from '@vue/test-utils' +import TargetForm from './TargetForm.vue' + +describe('TargetForm', () => { + it('emits an smb target payload', async () => { + const w = mount(TargetForm, { props: {} }) + await w.find('input[name=name]').setValue('NAS') + await w.find('select[name=kind]').setValue('smb') + await w.find('input[name=host]').setValue('nas.local') + await w.find('input[name=share]').setValue('backups') + await w.find('input[name=path]').setValue('softvisor') + await w.find('input[name=username]').setValue('backup') + await w.find('input[name=password]').setValue('secret') + await w.find('form').trigger('submit') + expect(w.emitted('submit')![0][0]).toEqual({ + name: 'NAS', + kind: 'smb', + host: 'nas.local', + port: null, + share: 'backups', + path: 'softvisor', + username: 'backup', + password: 'secret', + tls: false, + }) + }) + + it('shows ftp-only fields and hides share for ftp', async () => { + const w = mount(TargetForm, { props: {} }) + await w.find('select[name=kind]').setValue('ftp') + expect(w.find('input[name=share]').exists()).toBe(false) + expect(w.find('input[name=tls]').exists()).toBe(true) + expect(w.find('input[name=port]').exists()).toBe(true) + }) + + it('prefills an existing target and keeps the password empty', () => { + const w = mount(TargetForm, { + props: { + current: { + id: '1', + name: 'NAS', + kind: 'smb', + host: 'nas.local', + port: null, + share: 'backups', + path: 'x', + username: 'u', + tls: false, + password_set: true, + }, + }, + }) + expect((w.find('input[name=host]').element as HTMLInputElement).value).toBe('nas.local') + expect((w.find('input[name=password]').element as HTMLInputElement).value).toBe('') + expect(w.text()).toContain('leave empty to keep') + }) +})