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:
@ -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),
|
||||
}
|
||||
|
||||
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.
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod ports;
|
||||
pub mod settings;
|
||||
pub mod user;
|
||||
|
||||
pub use error::DomainError;
|
||||
|
||||
@ -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<String, 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];
|
||||
Reference in New Issue
Block a user