From 6113af0a193d6cbe5a8611dacabee3c7ee8866aa Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Wed, 2 Sep 2026 22:08:23 +0200 Subject: [PATCH] WP-02: contracts and failing tests for settings, mail, jobs and scheduler Co-Authored-By: Claude Fable 5.1 --- backend/Cargo.lock | 22 +++ backend/crates/api/tests/jobs.rs | 104 +++++++++++ backend/crates/api/tests/settings.rs | 172 ++++++++++++++++++ backend/crates/application/Cargo.toml | 3 + backend/crates/application/src/jobs.rs | 73 ++++++++ backend/crates/application/src/lib.rs | 5 + backend/crates/application/src/scheduler.rs | 30 +++ .../application/src/settings_service.rs | 58 ++++++ backend/crates/application/src/test_fakes.rs | 120 ++++++++++++ .../application/src/tests/jobs_tests.rs | 104 +++++++++++ backend/crates/application/src/tests/mod.rs | 3 + .../application/src/tests/scheduler_tests.rs | 33 ++++ .../application/src/tests/settings_tests.rs | 98 ++++++++++ backend/crates/domain/src/error.rs | 4 + backend/crates/domain/src/jobs.rs | 78 ++++++++ backend/crates/domain/src/lib.rs | 2 + backend/crates/domain/src/ports.rs | 36 ++++ backend/crates/domain/src/settings.rs | 44 +++++ frontend/e2e/settings-jobs.spec.ts | 41 +++++ frontend/src/components/SmtpForm.test.ts | 33 ++++ frontend/src/pages/JobsPage.test.ts | 52 ++++++ 21 files changed, 1115 insertions(+) create mode 100644 backend/crates/api/tests/jobs.rs create mode 100644 backend/crates/api/tests/settings.rs create mode 100644 backend/crates/application/src/jobs.rs create mode 100644 backend/crates/application/src/scheduler.rs create mode 100644 backend/crates/application/src/settings_service.rs create mode 100644 backend/crates/application/src/tests/jobs_tests.rs create mode 100644 backend/crates/application/src/tests/scheduler_tests.rs create mode 100644 backend/crates/application/src/tests/settings_tests.rs create mode 100644 backend/crates/domain/src/jobs.rs create mode 100644 backend/crates/domain/src/settings.rs create mode 100644 frontend/e2e/settings-jobs.spec.ts create mode 100644 frontend/src/components/SmtpForm.test.ts create mode 100644 frontend/src/pages/JobsPage.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 14414bb..d81d17b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -63,8 +63,10 @@ dependencies = [ "async-trait", "base64", "chrono", + "cron", "domain", "rand", + "serde_json", "sha2", "tokio", "uuid", @@ -301,6 +303,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "cron" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740" +dependencies = [ + "chrono", + "once_cell", + "winnow", +] + [[package]] name = "crossbeam-queue" version = "0.3.13" @@ -2466,6 +2479,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +dependencies = [ + "memchr", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/backend/crates/api/tests/jobs.rs b/backend/crates/api/tests/jobs.rs new file mode 100644 index 0000000..7e62533 --- /dev/null +++ b/backend/crates/api/tests/jobs.rs @@ -0,0 +1,104 @@ +//! WP-02: /api/jobs (run history, manual run). +mod common; + +use axum::http::StatusCode; +use common::{get, post, test_app_with_admin}; +use serde_json::json; + +const ADMIN: &str = "admin@example.com"; +const PW: &str = "admin-password-123"; + +#[tokio::test] +async fn admin_runs_a_job_and_sees_it_in_the_list_with_log() { + let app = test_app_with_admin().await; + let token = common::login(&app, ADMIN, PW).await.access; + + let res = post( + &app, + "/api/jobs/run", + json!({"kind": "package_refresh"}), + Some(&token), + ) + .await; + assert_eq!(res.status, StatusCode::ACCEPTED, "{}", res.json); + let id = res.json["id"].as_str().unwrap().to_string(); + assert_eq!(res.json["kind"], "package_refresh"); + assert_eq!(res.json["triggered_by"], ADMIN); + + // the test handler finishes quickly + let mut status = String::new(); + for _ in 0..50 { + let run = get(&app, &format!("/api/jobs/{id}"), Some(&token)).await; + status = run.json["status"].as_str().unwrap().to_string(); + if status != "running" { + assert!(run.json["log"] + .as_str() + .unwrap() + .contains("test handler ran")); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert_eq!(status, "success"); + + let list = get(&app, "/api/jobs?limit=10", Some(&token)).await; + assert_eq!(list.json.as_array().unwrap().len(), 1); + let kinds = get(&app, "/api/jobs/kinds", Some(&token)).await; + assert!(kinds + .json + .as_array() + .unwrap() + .contains(&json!("package_refresh"))); +} + +#[tokio::test] +async fn unknown_kind_is_404_and_run_is_admin_only() { + let app = test_app_with_admin().await; + let admin = common::login(&app, ADMIN, PW).await.access; + assert_eq!( + post(&app, "/api/jobs/run", json!({"kind": "nope"}), Some(&admin)) + .await + .status, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + post( + &app, + "/api/jobs/run", + json!({"kind": "backup"}), + Some(&admin) + ) + .await + .status, + StatusCode::NOT_FOUND + ); + 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!( + post( + &app, + "/api/jobs/run", + json!({"kind": "package_refresh"}), + Some(&user) + ) + .await + .status, + StatusCode::FORBIDDEN + ); + assert_eq!( + get(&app, "/api/jobs", Some(&user)).await.status, + StatusCode::OK + ); + assert_eq!( + get( + &app, + "/api/jobs/00000000-0000-0000-0000-000000000000", + Some(&user) + ) + .await + .status, + StatusCode::NOT_FOUND + ); +} diff --git a/backend/crates/api/tests/settings.rs b/backend/crates/api/tests/settings.rs new file mode 100644 index 0000000..2e9e2d6 --- /dev/null +++ b/backend/crates/api/tests/settings.rs @@ -0,0 +1,172 @@ +//! WP-02: /api/settings (SMTP, schedules). +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 smtp() -> serde_json::Value { + json!({"host": "mail.example.com", "port": 587, "security": "starttls", "username": "bot", + "password": "s3cret", "from": "monitoring@example.com", "notify_to": ["ops@example.com"]}) +} + +#[tokio::test] +async fn smtp_settings_roundtrip_without_exposing_password() { + let app = test_app_with_admin().await; + let token = common::login(&app, ADMIN, PW).await.access; + + let res = get(&app, "/api/settings/smtp", Some(&token)).await; + assert_eq!(res.status, StatusCode::OK); + assert_eq!(res.json["configured"], false); + + let res = send_json(&app, "PUT", "/api/settings/smtp", smtp(), Some(&token)).await; + assert_eq!(res.status, StatusCode::NO_CONTENT, "{}", res.json); + + let res = get(&app, "/api/settings/smtp", Some(&token)).await; + assert_eq!(res.json["configured"], true); + assert_eq!(res.json["smtp"]["host"], "mail.example.com"); + assert_eq!(res.json["smtp"]["password_set"], true); + assert!(res.json["smtp"].get("password").is_none()); + + // empty password keeps the stored one + let mut upd = smtp(); + upd["password"] = json!(""); + upd["port"] = json!(2525); + assert_eq!( + send_json(&app, "PUT", "/api/settings/smtp", upd, Some(&token)) + .await + .status, + StatusCode::NO_CONTENT + ); + let res = get(&app, "/api/settings/smtp", Some(&token)).await; + assert_eq!(res.json["smtp"]["port"], 2525); + assert_eq!(res.json["smtp"]["password_set"], true); +} + +#[tokio::test] +async fn smtp_validation_and_test_mail() { + let app = test_app_with_admin().await; + let token = common::login(&app, ADMIN, PW).await.access; + let mut bad = smtp(); + bad["from"] = json!("nope"); + assert_eq!( + send_json(&app, "PUT", "/api/settings/smtp", bad, Some(&token)) + .await + .status, + StatusCode::UNPROCESSABLE_ENTITY + ); + + // not configured yet -> 422 + let res = post( + &app, + "/api/settings/smtp/test", + json!({"to": "me@example.com"}), + Some(&token), + ) + .await; + assert_eq!(res.status, StatusCode::UNPROCESSABLE_ENTITY); + + send_json(&app, "PUT", "/api/settings/smtp", smtp(), Some(&token)).await; + let res = post( + &app, + "/api/settings/smtp/test", + json!({"to": "me@example.com"}), + Some(&token), + ) + .await; + assert_eq!(res.status, StatusCode::NO_CONTENT, "{}", res.json); + let sent = api::test_support::sent_mails(&app); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].0, vec!["me@example.com".to_string()]); +} + +#[tokio::test] +async fn schedules_list_and_update() { + let app = test_app_with_admin().await; + let token = common::login(&app, ADMIN, PW).await.access; + let res = get(&app, "/api/settings/schedules", Some(&token)).await; + assert_eq!(res.status, StatusCode::OK); + let list = res.json.as_array().unwrap(); + let pr = list + .iter() + .find(|s| s["kind"] == "package_refresh") + .unwrap(); + assert_eq!(pr["cron"], "0 0 * * * *"); + + let res = send_json( + &app, + "PUT", + "/api/settings/schedules/package_refresh", + json!({"cron": "0 */30 * * * *"}), + Some(&token), + ) + .await; + assert_eq!(res.status, StatusCode::NO_CONTENT, "{}", res.json); + let res = send_json( + &app, + "PUT", + "/api/settings/schedules/package_refresh", + json!({"cron": "bad"}), + Some(&token), + ) + .await; + assert_eq!(res.status, StatusCode::UNPROCESSABLE_ENTITY); + let res = send_json( + &app, + "PUT", + "/api/settings/schedules/package_refresh", + json!({"cron": null}), + Some(&token), + ) + .await; + assert_eq!(res.status, StatusCode::NO_CONTENT); + let res = get(&app, "/api/settings/schedules", Some(&token)).await; + let pr = res + .json + .as_array() + .unwrap() + .iter() + .find(|s| s["kind"] == "package_refresh") + .unwrap() + .clone(); + assert!(pr["cron"].is_null()); + assert_eq!( + send_json( + &app, + "PUT", + "/api/settings/schedules/nope", + json!({"cron": null}), + Some(&token) + ) + .await + .status, + StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn settings_writes_are_admin_only_reads_for_all() { + 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/settings/smtp", Some(&user)).await.status, + StatusCode::OK + ); + assert_eq!( + send_json(&app, "PUT", "/api/settings/smtp", smtp(), Some(&user)) + .await + .status, + StatusCode::FORBIDDEN + ); + assert_eq!( + get(&app, "/api/settings/smtp", None).await.status, + StatusCode::UNAUTHORIZED + ); +} diff --git a/backend/crates/application/Cargo.toml b/backend/crates/application/Cargo.toml index d157f67..5ae98fa 100644 --- a/backend/crates/application/Cargo.toml +++ b/backend/crates/application/Cargo.toml @@ -9,6 +9,9 @@ domain.workspace = true async-trait.workspace = true base64.workspace = true chrono.workspace = true +cron = "0.15" +serde_json.workspace = true +tokio.workspace = true rand.workspace = true sha2.workspace = true uuid.workspace = true diff --git a/backend/crates/application/src/jobs.rs b/backend/crates/application/src/jobs.rs new file mode 100644 index 0000000..54ec8dc --- /dev/null +++ b/backend/crates/application/src/jobs.rs @@ -0,0 +1,73 @@ +//! Job runner: starts a handler for a job kind, persists its log and result. +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use domain::jobs::{JobKind, JobRun}; +use domain::ports::JobRunRepository; +use domain::DomainError; +use uuid::Uuid; + +/// Sink for log lines of a running job. +#[async_trait] +pub trait JobLog: Send + Sync { + async fn line(&self, text: &str); +} + +#[async_trait] +pub trait JobHandler: Send + Sync { + async fn run(&self, params: Option, log: &dyn JobLog) -> Result<(), String>; +} + +pub struct JobRunner { + runs: Arc, + handlers: HashMap>, +} + +impl JobRunner { + pub fn new(runs: Arc) -> Self { + Self { + runs, + handlers: HashMap::new(), + } + } + + pub fn register(mut self, kind: JobKind, handler: Arc) -> Self { + self.handlers.insert(kind, handler); + self + } + + pub fn kinds(&self) -> Vec { + let mut k: Vec<_> = self.handlers.keys().copied().collect(); + k.sort_by_key(|k| k.as_str()); + k + } + + /// Start a job in the background. Fails with `Conflict` if the kind is already running. + pub async fn start( + &self, + _kind: JobKind, + _params: Option, + _triggered_by: &str, + ) -> Result { + todo!() + } + + /// Run a job and wait for it to finish (used by tests and the scheduler). + pub async fn run_and_wait( + &self, + _kind: JobKind, + _params: Option, + _triggered_by: &str, + ) -> Result { + todo!() + } + + pub async fn get(&self, _id: Uuid) -> Result { + todo!() + } + + pub async fn list(&self, _limit: u32) -> Result, DomainError> { + todo!() + } +} diff --git a/backend/crates/application/src/lib.rs b/backend/crates/application/src/lib.rs index 8a75c52..f6f81aa 100644 --- a/backend/crates/application/src/lib.rs +++ b/backend/crates/application/src/lib.rs @@ -1,8 +1,13 @@ //! Application layer: use cases orchestrating the domain through its ports. pub mod auth_service; +pub mod jobs; +pub mod scheduler; +pub mod settings_service; pub mod user_service; pub use auth_service::AuthService; +pub use jobs::{JobHandler, JobLog, JobRunner}; +pub use settings_service::SettingsService; pub use user_service::UserService; #[cfg(test)] diff --git a/backend/crates/application/src/scheduler.rs b/backend/crates/application/src/scheduler.rs new file mode 100644 index 0000000..24d0bfe --- /dev/null +++ b/backend/crates/application/src/scheduler.rs @@ -0,0 +1,30 @@ +//! Cron scheduler: decides which scheduled job kinds are due. +use chrono::{DateTime, Utc}; +use domain::jobs::JobKind; +use domain::DomainError; + +/// Validate a 6-field cron expression (seconds first). +pub fn validate_cron(_expr: &str) -> Result<(), DomainError> { + todo!() +} + +/// Next fire time strictly after `after`. +pub fn next_fire(_expr: &str, _after: DateTime) -> Option> { + todo!() +} + +/// A job is due if a fire time exists in `(last_run, now]`. With no last run, it is due +/// if a fire time falls within the last `grace` seconds, so a fresh start does not +/// immediately run every job. +pub fn is_due( + _expr: &str, + _last_run: Option>, + _now: DateTime, + _grace_secs: i64, +) -> bool { + todo!() +} + +pub struct Scheduler { + pub kinds: Vec, +} diff --git a/backend/crates/application/src/settings_service.rs b/backend/crates/application/src/settings_service.rs new file mode 100644 index 0000000..a9c94d6 --- /dev/null +++ b/backend/crates/application/src/settings_service.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use domain::jobs::JobKind; +use domain::ports::{Cipher, Mailer, SettingsRepository}; +use domain::settings::SmtpSettings; +use domain::DomainError; + +pub struct SettingsService { + repo: Arc, + cipher: Arc, + mailer: Arc, +} + +impl SettingsService { + pub fn new( + repo: Arc, + cipher: Arc, + mailer: Arc, + ) -> Self { + let _ = (&repo, &cipher, &mailer); + Self { + repo, + cipher, + mailer, + } + } + + pub async fn smtp(&self) -> Result, DomainError> { + todo!() + } + + pub async fn set_smtp(&self, _smtp: SmtpSettings) -> Result<(), DomainError> { + todo!() + } + + /// Send a mail to the configured recipients (or `to` if given) using the stored SMTP settings. + pub async fn send_mail( + &self, + _to: Option>, + _subject: &str, + _body: &str, + ) -> Result<(), DomainError> { + todo!() + } + + /// Cron expression (6 fields, seconds first) for a scheduled job kind, or None if disabled. + pub async fn schedule(&self, _kind: JobKind) -> Result, DomainError> { + todo!() + } + + pub async fn set_schedule( + &self, + _kind: JobKind, + _cron: Option, + ) -> Result<(), DomainError> { + todo!() + } +} diff --git a/backend/crates/application/src/test_fakes.rs b/backend/crates/application/src/test_fakes.rs index 315527e..9df949f 100644 --- a/backend/crates/application/src/test_fakes.rs +++ b/backend/crates/application/src/test_fakes.rs @@ -185,3 +185,123 @@ pub fn fixture() -> Fixture { svc, } } + +use domain::jobs::{JobKind, JobRun, JobStatus}; +use domain::ports::{Cipher, JobRunRepository, Mailer, SettingsRepository}; +use domain::settings::SmtpSettings; + +#[derive(Default)] +pub struct MemSettings(pub Mutex>); + +#[async_trait] +impl SettingsRepository for MemSettings { + async fn get(&self, key: &str) -> Result, DomainError> { + Ok(self.0.lock().unwrap().get(key).cloned()) + } + async fn set(&self, key: &str, value: &str) -> Result<(), DomainError> { + self.0.lock().unwrap().insert(key.into(), value.into()); + Ok(()) + } +} + +/// Reversible "encryption" so tests can assert the stored value is not plain text. +pub struct FakeCipher; +impl Cipher for FakeCipher { + fn encrypt(&self, plain: &str) -> Result { + Ok(format!("enc:{}", plain.chars().rev().collect::())) + } + fn decrypt(&self, c: &str) -> Result { + c.strip_prefix("enc:") + .map(|s| s.chars().rev().collect()) + .ok_or(DomainError::Storage("bad cipher text".into())) + } +} + +#[derive(Default)] +pub struct MemMailer(pub Mutex, String, String)>>); + +#[async_trait] +impl Mailer for MemMailer { + async fn send( + &self, + _smtp: &SmtpSettings, + to: &[String], + subject: &str, + body: &str, + ) -> Result<(), DomainError> { + self.0 + .lock() + .unwrap() + .push((to.to_vec(), subject.into(), body.into())); + Ok(()) + } +} + +#[derive(Default)] +pub struct MemJobRuns(pub Mutex>); + +#[async_trait] +impl JobRunRepository for MemJobRuns { + async fn insert(&self, run: &JobRun) -> Result<(), DomainError> { + self.0.lock().unwrap().push(run.clone()); + Ok(()) + } + async fn append_log(&self, id: Uuid, line: &str) -> Result<(), DomainError> { + let mut v = self.0.lock().unwrap(); + let r = v + .iter_mut() + .find(|r| r.id == id) + .ok_or(DomainError::NotFound)?; + r.log.push_str(line); + r.log.push('\n'); + Ok(()) + } + async fn finish(&self, id: Uuid, status: JobStatus) -> Result<(), DomainError> { + let mut v = self.0.lock().unwrap(); + let r = v + .iter_mut() + .find(|r| r.id == id) + .ok_or(DomainError::NotFound)?; + r.status = status; + r.finished_at = Some(Utc::now()); + Ok(()) + } + async fn get(&self, id: Uuid) -> Result, DomainError> { + Ok(self.0.lock().unwrap().iter().find(|r| r.id == id).cloned()) + } + async fn list(&self, limit: u32) -> Result, DomainError> { + let v = self.0.lock().unwrap(); + Ok(v.iter().rev().take(limit as usize).cloned().collect()) + } + async fn find_running(&self, kind: JobKind) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .find(|r| r.kind == kind && r.status == JobStatus::Running) + .cloned()) + } + async fn last_finished(&self, kind: JobKind) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .rev() + .find(|r| r.kind == kind && r.status != JobStatus::Running) + .cloned()) + } +} + +pub fn smtp() -> SmtpSettings { + SmtpSettings { + host: "mail.example.com".into(), + port: 587, + security: domain::settings::SmtpSecurity::StartTls, + username: "bot".into(), + password: "s3cret".into(), + from: "monitoring@example.com".into(), + notify_to: vec!["ops@example.com".into()], + } +} diff --git a/backend/crates/application/src/tests/jobs_tests.rs b/backend/crates/application/src/tests/jobs_tests.rs new file mode 100644 index 0000000..8015b62 --- /dev/null +++ b/backend/crates/application/src/tests/jobs_tests.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::jobs::{JobKind, JobStatus}; +use domain::DomainError; +use uuid::Uuid; + +use crate::jobs::{JobHandler, JobLog, JobRunner}; +use crate::test_fakes::MemJobRuns; + +struct Echo; +#[async_trait] +impl JobHandler for Echo { + async fn run(&self, params: Option, log: &dyn JobLog) -> Result<(), String> { + log.line("starting").await; + match params.as_deref() { + Some("fail") => Err("boom".into()), + Some("slow") => { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Ok(()) + } + _ => { + log.line("done").await; + Ok(()) + } + } + } +} + +fn runner() -> (Arc, JobRunner) { + let runs = Arc::new(MemJobRuns::default()); + ( + runs.clone(), + JobRunner::new(runs).register(JobKind::PackageRefresh, Arc::new(Echo)), + ) +} + +#[tokio::test] +async fn run_and_wait_persists_log_and_success() { + let (_, r) = runner(); + let run = r + .run_and_wait(JobKind::PackageRefresh, None, "test") + .await + .unwrap(); + assert_eq!(run.status, JobStatus::Success); + assert_eq!(run.log, "starting\ndone\n"); + assert!(run.finished_at.is_some()); + assert_eq!(run.triggered_by, "test"); +} + +#[tokio::test] +async fn handler_error_marks_run_failed_and_logs_the_error() { + let (_, r) = runner(); + let run = r + .run_and_wait(JobKind::PackageRefresh, Some("fail".into()), "test") + .await + .unwrap(); + assert_eq!(run.status, JobStatus::Failed); + assert!(run.log.contains("boom")); +} + +#[tokio::test] +async fn start_returns_immediately_and_rejects_concurrent_runs_of_same_kind() { + let (_, r) = runner(); + let run = r + .start(JobKind::PackageRefresh, Some("slow".into()), "test") + .await + .unwrap(); + assert_eq!(run.status, JobStatus::Running); + let err = r + .start(JobKind::PackageRefresh, None, "test") + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Conflict(_))); + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + assert_eq!(r.get(run.id).await.unwrap().status, JobStatus::Success); +} + +#[tokio::test] +async fn unknown_kind_and_unknown_id_are_errors() { + let (_, r) = runner(); + assert!(matches!( + r.start(JobKind::Backup, None, "test").await.unwrap_err(), + DomainError::NotFound + )); + assert_eq!( + r.get(Uuid::new_v4()).await.unwrap_err(), + DomainError::NotFound + ); + assert_eq!(r.kinds(), vec![JobKind::PackageRefresh]); +} + +#[tokio::test] +async fn list_returns_newest_first_with_limit() { + let (_, r) = runner(); + for _ in 0..3 { + r.run_and_wait(JobKind::PackageRefresh, None, "test") + .await + .unwrap(); + } + let list = r.list(2).await.unwrap(); + assert_eq!(list.len(), 2); + assert!(list[0].started_at >= list[1].started_at); +} diff --git a/backend/crates/application/src/tests/mod.rs b/backend/crates/application/src/tests/mod.rs index adce4e6..c763fdb 100644 --- a/backend/crates/application/src/tests/mod.rs +++ b/backend/crates/application/src/tests/mod.rs @@ -1,2 +1,5 @@ mod auth_service_tests; +mod jobs_tests; +mod scheduler_tests; +mod settings_tests; mod user_service_tests; diff --git a/backend/crates/application/src/tests/scheduler_tests.rs b/backend/crates/application/src/tests/scheduler_tests.rs new file mode 100644 index 0000000..4f5efee --- /dev/null +++ b/backend/crates/application/src/tests/scheduler_tests.rs @@ -0,0 +1,33 @@ +use chrono::{Duration, TimeZone, Utc}; + +use crate::scheduler::{is_due, next_fire, validate_cron}; + +#[test] +fn validates_six_field_cron() { + assert!(validate_cron("0 0 * * * *").is_ok()); + assert!(validate_cron("0 */15 * * * *").is_ok()); + assert!(validate_cron("garbage").is_err()); + assert!(validate_cron("").is_err()); +} + +#[test] +fn next_fire_is_strictly_after() { + let t = Utc.with_ymd_and_hms(2026, 9, 2, 10, 0, 0).unwrap(); + assert_eq!( + next_fire("0 0 * * * *", t).unwrap(), + Utc.with_ymd_and_hms(2026, 9, 2, 11, 0, 0).unwrap() + ); + assert_eq!(next_fire("bad", t), None); +} + +#[test] +fn due_when_a_fire_time_lies_between_last_run_and_now() { + let now = Utc.with_ymd_and_hms(2026, 9, 2, 10, 0, 30).unwrap(); + let hourly = "0 0 * * * *"; + assert!(is_due(hourly, Some(now - Duration::minutes(5)), now, 300)); + assert!(!is_due(hourly, Some(now - Duration::seconds(10)), now, 300)); + // never ran: due only if a fire time is within the grace window + assert!(is_due(hourly, None, now, 300)); + assert!(!is_due(hourly, None, now + Duration::minutes(10), 300)); + assert!(!is_due("bad", None, now, 300)); +} diff --git a/backend/crates/application/src/tests/settings_tests.rs b/backend/crates/application/src/tests/settings_tests.rs new file mode 100644 index 0000000..d73c79f --- /dev/null +++ b/backend/crates/application/src/tests/settings_tests.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; + +use domain::jobs::JobKind; +use domain::DomainError; + +use crate::test_fakes::{smtp, FakeCipher, MemMailer, MemSettings}; +use crate::SettingsService; + +struct F { + repo: Arc, + mailer: Arc, + svc: SettingsService, +} + +fn f() -> F { + let repo = Arc::new(MemSettings::default()); + let mailer = Arc::new(MemMailer::default()); + let svc = SettingsService::new(repo.clone(), Arc::new(FakeCipher), mailer.clone()); + F { repo, mailer, svc } +} + +#[tokio::test] +async fn smtp_is_stored_encrypted_and_read_back() { + let f = f(); + assert_eq!(f.svc.smtp().await.unwrap(), None); + f.svc.set_smtp(smtp()).await.unwrap(); + let stored = f.repo.0.lock().unwrap().get("smtp").cloned().unwrap(); + assert!(stored.starts_with("enc:")); + assert!(!stored.contains("s3cret")); + assert_eq!(f.svc.smtp().await.unwrap(), Some(smtp())); +} + +#[tokio::test] +async fn smtp_is_validated() { + let f = f(); + let mut bad = smtp(); + bad.host = " ".into(); + assert!(matches!( + f.svc.set_smtp(bad).await.unwrap_err(), + DomainError::Validation(_) + )); +} + +#[tokio::test] +async fn send_mail_uses_notify_recipients_by_default() { + let f = f(); + assert!(matches!( + f.svc.send_mail(None, "s", "b").await.unwrap_err(), + DomainError::Validation(_) + )); + f.svc.set_smtp(smtp()).await.unwrap(); + f.svc.send_mail(None, "Hello", "World").await.unwrap(); + f.svc + .send_mail(Some(vec!["me@example.com".into()]), "Test", "x") + .await + .unwrap(); + let sent = f.mailer.0.lock().unwrap(); + assert_eq!(sent[0].0, vec!["ops@example.com".to_string()]); + assert_eq!(sent[0].1, "Hello"); + assert_eq!(sent[1].0, vec!["me@example.com".to_string()]); +} + +#[tokio::test] +async fn schedules_have_defaults_and_can_be_changed_or_disabled() { + let f = f(); + assert_eq!( + f.svc + .schedule(JobKind::PackageRefresh) + .await + .unwrap() + .as_deref(), + Some("0 0 * * * *") + ); + assert_eq!(f.svc.schedule(JobKind::Backup).await.unwrap(), None); + f.svc + .set_schedule(JobKind::PackageRefresh, Some("0 */30 * * * *".into())) + .await + .unwrap(); + assert_eq!( + f.svc + .schedule(JobKind::PackageRefresh) + .await + .unwrap() + .as_deref(), + Some("0 */30 * * * *") + ); + f.svc + .set_schedule(JobKind::PackageRefresh, None) + .await + .unwrap(); + assert_eq!(f.svc.schedule(JobKind::PackageRefresh).await.unwrap(), None); + let err = f + .svc + .set_schedule(JobKind::PackageRefresh, Some("not a cron".into())) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Validation(_))); +} diff --git a/backend/crates/domain/src/error.rs b/backend/crates/domain/src/error.rs index 57033a1..1431f5d 100644 --- a/backend/crates/domain/src/error.rs +++ b/backend/crates/domain/src/error.rs @@ -16,6 +16,10 @@ pub enum DomainError { LastAdmin, #[error("validation failed: {0}")] Validation(String), + #[error("conflict: {0}")] + Conflict(String), + #[error("external service unavailable: {0}")] + Unavailable(String), #[error("storage error: {0}")] Storage(String), } diff --git a/backend/crates/domain/src/jobs.rs b/backend/crates/domain/src/jobs.rs new file mode 100644 index 0000000..d83eace --- /dev/null +++ b/backend/crates/domain/src/jobs.rs @@ -0,0 +1,78 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobKind { + PackageRefresh, + PackageUpgrade, + VulnerabilityScan, + Backup, +} + +impl JobKind { + pub const ALL: [JobKind; 4] = [ + JobKind::PackageRefresh, + JobKind::PackageUpgrade, + JobKind::VulnerabilityScan, + JobKind::Backup, + ]; + + pub fn as_str(self) -> &'static str { + match self { + JobKind::PackageRefresh => "package_refresh", + JobKind::PackageUpgrade => "package_upgrade", + JobKind::VulnerabilityScan => "vulnerability_scan", + JobKind::Backup => "backup", + } + } + + pub fn parse(s: &str) -> Option { + JobKind::ALL.into_iter().find(|k| k.as_str() == s) + } + + /// Kinds that run on a schedule; the others are triggered manually or by their owner. + pub fn default_schedule(self) -> Option<&'static str> { + match self { + JobKind::PackageRefresh => Some("0 0 * * * *"), + JobKind::VulnerabilityScan => Some("0 0 3 * * *"), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum JobStatus { + Running, + Success, + Failed, +} + +impl JobStatus { + pub fn as_str(self) -> &'static str { + match self { + JobStatus::Running => "running", + JobStatus::Success => "success", + JobStatus::Failed => "failed", + } + } + pub fn parse(s: &str) -> Option { + [JobStatus::Running, JobStatus::Success, JobStatus::Failed] + .into_iter() + .find(|k| k.as_str() == s) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JobRun { + pub id: Uuid, + pub kind: JobKind, + pub params: Option, + pub status: JobStatus, + pub started_at: DateTime, + pub finished_at: Option>, + pub log: String, + pub triggered_by: String, +} diff --git a/backend/crates/domain/src/lib.rs b/backend/crates/domain/src/lib.rs index 2b4fb0e..19298fe 100644 --- a/backend/crates/domain/src/lib.rs +++ b/backend/crates/domain/src/lib.rs @@ -2,7 +2,9 @@ //! layer depends on. No I/O here. pub mod auth; pub mod error; +pub mod jobs; pub mod ports; +pub mod settings; pub mod user; pub use error::DomainError; diff --git a/backend/crates/domain/src/ports.rs b/backend/crates/domain/src/ports.rs index 1f09527..817cf9a 100644 --- a/backend/crates/domain/src/ports.rs +++ b/backend/crates/domain/src/ports.rs @@ -3,6 +3,8 @@ use async_trait::async_trait; use uuid::Uuid; use crate::auth::{AccessClaims, AuthEvent, RefreshToken}; +use crate::jobs::{JobKind, JobRun, JobStatus}; +use crate::settings::SmtpSettings; use crate::user::{User, UserUpdate}; use crate::DomainError; @@ -40,3 +42,37 @@ pub trait AccessTokenIssuer: Send + Sync { fn issue(&self, user: &User) -> Result; fn verify(&self, token: &str) -> Result; } + +#[async_trait] +pub trait SettingsRepository: Send + Sync { + async fn get(&self, key: &str) -> Result, DomainError>; + async fn set(&self, key: &str, value: &str) -> Result<(), DomainError>; +} + +/// Symmetric encryption for secrets at rest. +pub trait Cipher: Send + Sync { + fn encrypt(&self, plain: &str) -> Result; + fn decrypt(&self, cipher_text: &str) -> Result; +} + +#[async_trait] +pub trait Mailer: Send + Sync { + async fn send( + &self, + smtp: &SmtpSettings, + to: &[String], + subject: &str, + body: &str, + ) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait JobRunRepository: Send + Sync { + async fn insert(&self, run: &JobRun) -> Result<(), DomainError>; + async fn append_log(&self, id: Uuid, line: &str) -> Result<(), DomainError>; + async fn finish(&self, id: Uuid, status: JobStatus) -> Result<(), DomainError>; + async fn get(&self, id: Uuid) -> Result, DomainError>; + async fn list(&self, limit: u32) -> Result, DomainError>; + async fn find_running(&self, kind: JobKind) -> Result, DomainError>; + async fn last_finished(&self, kind: JobKind) -> Result, DomainError>; +} diff --git a/backend/crates/domain/src/settings.rs b/backend/crates/domain/src/settings.rs new file mode 100644 index 0000000..657bd05 --- /dev/null +++ b/backend/crates/domain/src/settings.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SmtpSecurity { + None, + StartTls, + Tls, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmtpSettings { + pub host: String, + pub port: u16, + pub security: SmtpSecurity, + pub username: String, + pub password: String, + pub from: String, + /// Default recipients for notifications. + pub notify_to: Vec, +} + +impl SmtpSettings { + pub fn validate(&self) -> Result<(), crate::DomainError> { + let err = |m: &str| Err(crate::DomainError::Validation(m.into())); + if self.host.trim().is_empty() { + return err("smtp host is required"); + } + if self.port == 0 { + return err("smtp port is required"); + } + if !self.from.contains('@') { + return err("from address is invalid"); + } + if self.notify_to.iter().any(|a| !a.contains('@')) { + return err("notification recipient is invalid"); + } + Ok(()) + } +} + +/// Settings keys. Secrets are encrypted at rest by the application layer. +pub const KEY_SMTP: &str = "smtp"; +pub const SECRET_KEYS: &[&str] = &[KEY_SMTP]; diff --git a/frontend/e2e/settings-jobs.spec.ts b/frontend/e2e/settings-jobs.spec.ts new file mode 100644 index 0000000..c0fec2f --- /dev/null +++ b/frontend/e2e/settings-jobs.spec.ts @@ -0,0 +1,41 @@ +import { test, expect, type Page } from '@playwright/test' + +async function loginAdmin(page: 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 expect(page).toHaveURL('/') +} + +test('admin saves SMTP settings and a schedule', async ({ page }) => { + await loginAdmin(page) + await page.getByRole('link', { name: 'Settings' }).click() + await page.getByLabel('SMTP host').fill('mail.example.com') + await page.getByLabel('Port').fill('587') + await page.getByLabel('Username').fill('bot') + await page.getByLabel('Password').fill('secret-password') + await page.getByLabel('From address').fill('monitoring@example.com') + await page.getByLabel('Notification recipients').fill('ops@example.com') + await page.getByRole('button', { name: 'Save SMTP settings' }).click() + await expect(page.getByRole('status')).toContainText('SMTP settings saved') + await page.reload() + await expect(page.getByLabel('SMTP host')).toHaveValue('mail.example.com') + await expect(page.getByText('leave empty to keep')).toBeVisible() + + const cron = page.getByLabel('Package refresh schedule') + await cron.fill('0 */30 * * * *') + await page.getByRole('button', { name: 'Save schedules' }).click() + await expect(page.getByRole('status')).toContainText('Schedules saved') +}) + +test('admin runs a job manually and sees the log', async ({ page }) => { + await loginAdmin(page) + await page.getByRole('link', { name: 'Jobs' }).click() + await page.getByLabel('Job').selectOption('package_refresh') + await page.getByRole('button', { name: 'Run now' }).click() + const row = page.getByRole('row', { name: /package_refresh/ }).first() + await expect(row).toContainText(/success|running/) + await row.getByRole('button', { name: 'Log' }).click() + await expect(page.getByTestId('job-log')).not.toBeEmpty() +}) diff --git a/frontend/src/components/SmtpForm.test.ts b/frontend/src/components/SmtpForm.test.ts new file mode 100644 index 0000000..145d5b5 --- /dev/null +++ b/frontend/src/components/SmtpForm.test.ts @@ -0,0 +1,33 @@ +import { mount } from '@vue/test-utils' +import SmtpForm from './SmtpForm.vue' + +const current = { + host: 'mail.example.com', + port: 587, + security: 'starttls' as const, + username: 'bot', + from: 'mon@example.com', + notify_to: ['ops@example.com', 'dev@example.com'], + password_set: true, +} + +describe('SmtpForm', () => { + it('prefills current settings and emits the payload with recipients as a list', async () => { + const w = mount(SmtpForm, { props: { current } }) + expect((w.find('input[name=host]').element as HTMLInputElement).value).toBe('mail.example.com') + expect((w.find('input[name=notify_to]').element as HTMLInputElement).value).toBe( + 'ops@example.com, dev@example.com', + ) + await w.find('input[name=notify_to]').setValue('a@x.de; b@x.de') + await w.find('form').trigger('submit') + const payload = w.emitted('submit')![0][0] as Record + expect(payload.notify_to).toEqual(['a@x.de', 'b@x.de']) + expect(payload.password).toBe('') + expect(payload.port).toBe(587) + }) + + it('shows a hint that a password is stored', () => { + const w = mount(SmtpForm, { props: { current } }) + expect(w.text()).toContain('leave empty to keep') + }) +}) diff --git a/frontend/src/pages/JobsPage.test.ts b/frontend/src/pages/JobsPage.test.ts new file mode 100644 index 0000000..8343766 --- /dev/null +++ b/frontend/src/pages/JobsPage.test.ts @@ -0,0 +1,52 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import JobsPage from './JobsPage.vue' +import { useAuthStore } from '../stores/auth' +import { api } from '../api/client' + +vi.mock('../api/client', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }, + ApiError: class extends Error {}, +})) + +const run = { + id: 'r1', + kind: 'package_refresh', + status: 'success', + started_at: '2026-09-02T10:00:00Z', + finished_at: '2026-09-02T10:00:05Z', + log: 'hello\n', + triggered_by: 'admin@example.com', +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(api.get).mockImplementation(async (path: string) => { + if (path.startsWith('/api/jobs/kinds')) return ['package_refresh'] + return [run] + }) +}) + +describe('JobsPage', () => { + it('lists runs and lets an admin run a job', async () => { + const auth = useAuthStore() + auth.user = { id: '1', email: 'a', display_name: 'A', role: 'admin', is_active: true } + vi.mocked(api.post).mockResolvedValue({ ...run, id: 'r2', status: 'running' }) + const w = mount(JobsPage) + await flushPromises() + expect(w.text()).toContain('package_refresh') + expect(w.text()).toContain('success') + await w.find('select[name=kind]').setValue('package_refresh') + await w.find('button[name=run]').trigger('click') + await flushPromises() + expect(api.post).toHaveBeenCalledWith('/api/jobs/run', { kind: 'package_refresh' }) + }) + + it('hides the run control for non-admins', async () => { + const auth = useAuthStore() + auth.user = { id: '1', email: 'a', display_name: 'A', role: 'user', is_active: true } + const w = mount(JobsPage) + await flushPromises() + expect(w.find('button[name=run]').exists()).toBe(false) + }) +})