WP-02: contracts and failing tests for settings, mail, jobs and scheduler
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
22
backend/Cargo.lock
generated
22
backend/Cargo.lock
generated
@ -63,8 +63,10 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"cron",
|
||||||
"domain",
|
"domain",
|
||||||
"rand",
|
"rand",
|
||||||
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@ -301,6 +303,17 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
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]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.13"
|
version = "0.3.13"
|
||||||
@ -2466,6 +2479,15 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.6.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
104
backend/crates/api/tests/jobs.rs
Normal file
104
backend/crates/api/tests/jobs.rs
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
172
backend/crates/api/tests/settings.rs
Normal file
172
backend/crates/api/tests/settings.rs
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,9 @@ domain.workspace = true
|
|||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
cron = "0.15"
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|||||||
73
backend/crates/application/src/jobs.rs
Normal file
73
backend/crates/application/src/jobs.rs
Normal file
@ -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<String>, log: &dyn JobLog) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JobRunner {
|
||||||
|
runs: Arc<dyn JobRunRepository>,
|
||||||
|
handlers: HashMap<JobKind, Arc<dyn JobHandler>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobRunner {
|
||||||
|
pub fn new(runs: Arc<dyn JobRunRepository>) -> Self {
|
||||||
|
Self {
|
||||||
|
runs,
|
||||||
|
handlers: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(mut self, kind: JobKind, handler: Arc<dyn JobHandler>) -> Self {
|
||||||
|
self.handlers.insert(kind, handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kinds(&self) -> Vec<JobKind> {
|
||||||
|
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<String>,
|
||||||
|
_triggered_by: &str,
|
||||||
|
) -> Result<JobRun, DomainError> {
|
||||||
|
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<String>,
|
||||||
|
_triggered_by: &str,
|
||||||
|
) -> Result<JobRun, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, _id: Uuid) -> Result<JobRun, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(&self, _limit: u32) -> Result<Vec<JobRun>, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,13 @@
|
|||||||
//! Application layer: use cases orchestrating the domain through its ports.
|
//! Application layer: use cases orchestrating the domain through its ports.
|
||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
|
pub mod jobs;
|
||||||
|
pub mod scheduler;
|
||||||
|
pub mod settings_service;
|
||||||
pub mod user_service;
|
pub mod user_service;
|
||||||
|
|
||||||
pub use auth_service::AuthService;
|
pub use auth_service::AuthService;
|
||||||
|
pub use jobs::{JobHandler, JobLog, JobRunner};
|
||||||
|
pub use settings_service::SettingsService;
|
||||||
pub use user_service::UserService;
|
pub use user_service::UserService;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
30
backend/crates/application/src/scheduler.rs
Normal file
30
backend/crates/application/src/scheduler.rs
Normal file
@ -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<Utc>) -> Option<DateTime<Utc>> {
|
||||||
|
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<DateTime<Utc>>,
|
||||||
|
_now: DateTime<Utc>,
|
||||||
|
_grace_secs: i64,
|
||||||
|
) -> bool {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Scheduler {
|
||||||
|
pub kinds: Vec<JobKind>,
|
||||||
|
}
|
||||||
58
backend/crates/application/src/settings_service.rs
Normal file
58
backend/crates/application/src/settings_service.rs
Normal file
@ -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<dyn SettingsRepository>,
|
||||||
|
cipher: Arc<dyn Cipher>,
|
||||||
|
mailer: Arc<dyn Mailer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsService {
|
||||||
|
pub fn new(
|
||||||
|
repo: Arc<dyn SettingsRepository>,
|
||||||
|
cipher: Arc<dyn Cipher>,
|
||||||
|
mailer: Arc<dyn Mailer>,
|
||||||
|
) -> Self {
|
||||||
|
let _ = (&repo, &cipher, &mailer);
|
||||||
|
Self {
|
||||||
|
repo,
|
||||||
|
cipher,
|
||||||
|
mailer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn smtp(&self) -> Result<Option<SmtpSettings>, 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<Vec<String>>,
|
||||||
|
_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<Option<String>, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_schedule(
|
||||||
|
&self,
|
||||||
|
_kind: JobKind,
|
||||||
|
_cron: Option<String>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -185,3 +185,123 @@ pub fn fixture() -> Fixture {
|
|||||||
svc,
|
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<HashMap<String, String>>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SettingsRepository for MemSettings {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<String>, 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<String, DomainError> {
|
||||||
|
Ok(format!("enc:{}", plain.chars().rev().collect::<String>()))
|
||||||
|
}
|
||||||
|
fn decrypt(&self, c: &str) -> Result<String, DomainError> {
|
||||||
|
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<Vec<(Vec<String>, 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<Vec<JobRun>>);
|
||||||
|
|
||||||
|
#[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<Option<JobRun>, DomainError> {
|
||||||
|
Ok(self.0.lock().unwrap().iter().find(|r| r.id == id).cloned())
|
||||||
|
}
|
||||||
|
async fn list(&self, limit: u32) -> Result<Vec<JobRun>, 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<Option<JobRun>, 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<Option<JobRun>, 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()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
104
backend/crates/application/src/tests/jobs_tests.rs
Normal file
104
backend/crates/application/src/tests/jobs_tests.rs
Normal file
@ -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<String>, 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<MemJobRuns>, 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);
|
||||||
|
}
|
||||||
@ -1,2 +1,5 @@
|
|||||||
mod auth_service_tests;
|
mod auth_service_tests;
|
||||||
|
mod jobs_tests;
|
||||||
|
mod scheduler_tests;
|
||||||
|
mod settings_tests;
|
||||||
mod user_service_tests;
|
mod user_service_tests;
|
||||||
|
|||||||
33
backend/crates/application/src/tests/scheduler_tests.rs
Normal file
33
backend/crates/application/src/tests/scheduler_tests.rs
Normal file
@ -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));
|
||||||
|
}
|
||||||
98
backend/crates/application/src/tests/settings_tests.rs
Normal file
98
backend/crates/application/src/tests/settings_tests.rs
Normal file
@ -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<MemSettings>,
|
||||||
|
mailer: Arc<MemMailer>,
|
||||||
|
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(_)));
|
||||||
|
}
|
||||||
@ -16,6 +16,10 @@ pub enum DomainError {
|
|||||||
LastAdmin,
|
LastAdmin,
|
||||||
#[error("validation failed: {0}")]
|
#[error("validation failed: {0}")]
|
||||||
Validation(String),
|
Validation(String),
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
#[error("external service unavailable: {0}")]
|
||||||
|
Unavailable(String),
|
||||||
#[error("storage error: {0}")]
|
#[error("storage error: {0}")]
|
||||||
Storage(String),
|
Storage(String),
|
||||||
}
|
}
|
||||||
|
|||||||
78
backend/crates/domain/src/jobs.rs
Normal file
78
backend/crates/domain/src/jobs.rs
Normal file
@ -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> {
|
||||||
|
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> {
|
||||||
|
[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<String>,
|
||||||
|
pub status: JobStatus,
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
pub finished_at: Option<DateTime<Utc>>,
|
||||||
|
pub log: String,
|
||||||
|
pub triggered_by: String,
|
||||||
|
}
|
||||||
@ -2,7 +2,9 @@
|
|||||||
//! layer depends on. No I/O here.
|
//! layer depends on. No I/O here.
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod jobs;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
|
pub mod settings;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
|||||||
@ -3,6 +3,8 @@ use async_trait::async_trait;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::{AccessClaims, AuthEvent, RefreshToken};
|
use crate::auth::{AccessClaims, AuthEvent, RefreshToken};
|
||||||
|
use crate::jobs::{JobKind, JobRun, JobStatus};
|
||||||
|
use crate::settings::SmtpSettings;
|
||||||
use crate::user::{User, UserUpdate};
|
use crate::user::{User, UserUpdate};
|
||||||
use crate::DomainError;
|
use crate::DomainError;
|
||||||
|
|
||||||
@ -40,3 +42,37 @@ pub trait AccessTokenIssuer: Send + Sync {
|
|||||||
fn issue(&self, user: &User) -> Result<String, DomainError>;
|
fn issue(&self, user: &User) -> Result<String, DomainError>;
|
||||||
fn verify(&self, token: &str) -> Result<AccessClaims, DomainError>;
|
fn verify(&self, token: &str) -> Result<AccessClaims, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SettingsRepository: Send + Sync {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<String>, 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<String, DomainError>;
|
||||||
|
fn decrypt(&self, cipher_text: &str) -> Result<String, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Option<JobRun>, DomainError>;
|
||||||
|
async fn list(&self, limit: u32) -> Result<Vec<JobRun>, DomainError>;
|
||||||
|
async fn find_running(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError>;
|
||||||
|
async fn last_finished(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError>;
|
||||||
|
}
|
||||||
|
|||||||
44
backend/crates/domain/src/settings.rs
Normal file
44
backend/crates/domain/src/settings.rs
Normal file
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
41
frontend/e2e/settings-jobs.spec.ts
Normal file
41
frontend/e2e/settings-jobs.spec.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
33
frontend/src/components/SmtpForm.test.ts
Normal file
33
frontend/src/components/SmtpForm.test.ts
Normal file
@ -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<string, unknown>
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
52
frontend/src/pages/JobsPage.test.ts
Normal file
52
frontend/src/pages/JobsPage.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user