WP-30/31/32: contract and failing tests for backup management; OS scan skips container dirs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:05:22 +02:00
parent 5026ce22de
commit 39af18b336
16 changed files with 1456 additions and 1 deletions

34
backend/Cargo.lock generated
View File

@ -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"

View File

@ -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
);
}

View File

@ -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]

View File

@ -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<dyn BackupTargetRepository>,
pub strategies: Arc<dyn BackupStrategyRepository>,
pub records: Arc<dyn BackupRecordRepository>,
pub storage: Arc<dyn BackupStorage>,
pub collector: Arc<dyn BackupCollector>,
pub encryptor: Arc<dyn FileEncryptor>,
pub cipher: Arc<dyn Cipher>,
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<BackupRecord>,
}
impl BackupService {
pub fn new(deps: BackupDeps) -> Self {
Self { d: deps }
}
// ---- targets ----
pub async fn list_targets(&self) -> Result<Vec<BackupTarget>, DomainError> {
todo!()
}
pub async fn get_target(&self, _id: Uuid) -> Result<BackupTarget, DomainError> {
todo!()
}
pub async fn create_target(&self, _t: BackupTarget) -> Result<BackupTarget, DomainError> {
todo!()
}
/// Empty password keeps the stored one.
pub async fn update_target(&self, _t: BackupTarget) -> Result<BackupTarget, DomainError> {
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<Vec<StrategyStatus>, DomainError> {
todo!()
}
pub async fn get_strategy(&self, _id: Uuid) -> Result<BackupStrategy, DomainError> {
todo!()
}
pub async fn create_strategy(&self, _s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
todo!()
}
/// Empty passphrase keeps the stored one; `None` removes it.
pub async fn update_strategy(&self, _s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
todo!()
}
pub async fn delete_strategy(&self, _id: Uuid) -> Result<(), DomainError> {
todo!()
}
pub async fn records(&self, _strategy_id: Uuid) -> Result<Vec<BackupRecord>, DomainError> {
todo!()
}
/// Enabled strategies whose cron fired since their last backup.
pub async fn due_strategies(
&self,
_now: DateTime<Utc>,
_grace_secs: i64,
) -> Result<Vec<Uuid>, DomainError> {
todo!()
}
/// Collect, encrypt, upload, verify, record, apply retention.
pub async fn run_strategy(
&self,
_id: Uuid,
_log: &dyn JobLog,
) -> Result<BackupRecord, DomainError> {
todo!()
}
}
/// Job handler for `JobKind::Backup`; params = strategy id.
pub struct BackupJob(pub Arc<BackupService>);
#[async_trait]
impl JobHandler for BackupJob {
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
todo!()
}
}

View File

@ -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};

View File

@ -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<Vec<BackupTarget>>);
#[async_trait]
impl BackupTargetRepository for MemTargets {
async fn list(&self) -> Result<Vec<BackupTarget>, DomainError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(&self, id: Uuid) -> Result<Option<BackupTarget>, 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<Vec<BackupStrategy>>);
#[async_trait]
impl BackupStrategyRepository for MemStrategies {
async fn list(&self) -> Result<Vec<BackupStrategy>, DomainError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(&self, id: Uuid) -> Result<Option<BackupStrategy>, 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<Vec<BackupRecord>>);
#[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<Vec<BackupRecord>, 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<HashMap<Uuid, Vec<RemoteFile>>>,
pub fail: bool,
pub ops: Mutex<Vec<String>>,
}
#[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<Vec<RemoteFile>, 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<PathBuf, DomainError> {
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<PathBuf, DomainError> {
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,
}
}

View File

@ -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<Vec<String>>);
#[async_trait]
impl JobLog for VecLog {
async fn line(&self, text: &str) {
self.0.lock().unwrap().push(text.into());
}
}
struct F {
targets: Arc<MemTargets>,
strategies: Arc<MemStrategies>,
records: Arc<MemRecords>,
storage: Arc<MemStorage>,
work: tempfile::TempDir,
}
fn fixture(fail_storage: bool) -> (F, Arc<BackupService>) {
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"));
}

View File

@ -1,4 +1,5 @@
mod auth_service_tests;
mod backup_tests;
mod cluster_tests;
mod inventory_tests;
mod jobs_tests;

View File

@ -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<Self> {
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<u16>,
/// 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<String>,
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<Utc>) -> 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<Utc>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteFile {
pub name: String,
pub size_bytes: u64,
}

View File

@ -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;

View File

@ -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<Vec<Finding>, DomainError>;
async fn counts(&self, kind: Option<TargetKind>) -> Result<SeverityCounts, DomainError>;
}
#[async_trait]
pub trait BackupTargetRepository: Send + Sync {
async fn list(&self) -> Result<Vec<BackupTarget>, DomainError>;
async fn get(&self, id: Uuid) -> Result<Option<BackupTarget>, 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<Vec<BackupStrategy>, DomainError>;
async fn get(&self, id: Uuid) -> Result<Option<BackupStrategy>, 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<Vec<BackupRecord>, 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<Vec<RemoteFile>, 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<PathBuf, DomainError>;
}
/// 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<PathBuf, DomainError>;
}

View File

@ -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<Vec<RawFinding>, 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,

View File

@ -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')
})

View File

@ -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
}

View File

@ -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)
})
})

View File

@ -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')
})
})