WP-02: encrypted settings, SMTP mail, job runner and cron scheduler
AES-256-GCM secret storage keyed by MASTER_KEY, SMTP settings with test mail (lettre), persisted job runs with log and status, JobRunner with per-kind concurrency guard, 6-field cron schedules with defaults, scheduler loop. Settings and Jobs pages in the UI. FAKE_HOST mode for dev machines. Tests: 30 application, 8 infrastructure, 23 API, 16 Vitest, 6 Playwright. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -13,6 +13,7 @@ domain.workspace = true
|
||||
application.workspace = true
|
||||
infrastructure.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
dotenvy.workspace = true
|
||||
|
||||
@ -5,6 +5,10 @@ use std::net::SocketAddr;
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
/// 64 hex chars; encrypts secrets at rest.
|
||||
pub master_key: String,
|
||||
/// Use fake host adapters (dev machines without apt/kubectl).
|
||||
pub fake_host: bool,
|
||||
pub bind: SocketAddr,
|
||||
pub bootstrap_admin: Option<(String, String)>,
|
||||
pub cookie_secure: bool,
|
||||
@ -20,10 +24,14 @@ impl Config {
|
||||
jwt_secret.len() >= 32,
|
||||
"JWT_SECRET must be at least 32 characters"
|
||||
);
|
||||
let master_key = env("MASTER_KEY")
|
||||
.ok_or_else(|| anyhow::anyhow!("MASTER_KEY is required (64 hex characters)"))?;
|
||||
Ok(Self {
|
||||
database_url: env("DATABASE_URL")
|
||||
.unwrap_or_else(|| "sqlite://data/monitoring.db?mode=rwc".into()),
|
||||
jwt_secret,
|
||||
master_key,
|
||||
fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"),
|
||||
bind: env("BIND")
|
||||
.unwrap_or_else(|| "127.0.0.1:8080".into())
|
||||
.parse()?,
|
||||
|
||||
@ -24,6 +24,8 @@ impl IntoResponse for ApiError {
|
||||
InvalidToken => (StatusCode::UNAUTHORIZED, "invalid_token"),
|
||||
InactiveUser => (StatusCode::FORBIDDEN, "inactive_user"),
|
||||
Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"),
|
||||
Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Unavailable(_) => (StatusCode::BAD_GATEWAY, "unavailable"),
|
||||
Storage(msg) => {
|
||||
tracing::error!("storage error: {msg}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||
|
||||
103
backend/crates/api/src/jobs.rs
Normal file
103
backend/crates/api/src/jobs.rs
Normal file
@ -0,0 +1,103 @@
|
||||
//! /api/jobs: run history and manual job runs.
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use domain::jobs::{JobKind, JobRun, JobStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extract::{AdminUser, AuthUser};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list))
|
||||
.route("/kinds", get(kinds))
|
||||
.route("/run", post(run))
|
||||
.route("/{id}", get(get_one))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct JobRunDto {
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = String)]
|
||||
pub kind: JobKind,
|
||||
pub params: Option<String>,
|
||||
#[schema(value_type = String)]
|
||||
pub status: JobStatus,
|
||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub log: String,
|
||||
pub triggered_by: String,
|
||||
}
|
||||
|
||||
impl From<JobRun> for JobRunDto {
|
||||
fn from(r: JobRun) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
params: r.params,
|
||||
status: r.status,
|
||||
started_at: r.started_at,
|
||||
finished_at: r.finished_at,
|
||||
log: r.log,
|
||||
triggered_by: r.triggered_by,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct RunRequest {
|
||||
#[schema(value_type = String, example = "package_refresh")]
|
||||
pub kind: JobKind,
|
||||
pub params: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/jobs", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = Vec<JobRunDto>)))]
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<Vec<JobRunDto>>, ApiError> {
|
||||
Ok(Json(
|
||||
state
|
||||
.jobs
|
||||
.list(q.limit.unwrap_or(50).min(500))
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/jobs/kinds", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = Vec<String>)))]
|
||||
async fn kinds(State(state): State<AppState>, _: AuthUser) -> Json<Vec<JobKind>> {
|
||||
Json(state.jobs.kinds())
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/jobs/{id}", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = JobRunDto), (status = 404)))]
|
||||
async fn get_one(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<JobRunDto>, ApiError> {
|
||||
Ok(Json(state.jobs.get(id).await?.into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/jobs/run", tag = "jobs", security(("bearer" = [])), request_body = RunRequest, responses((status = 202, body = JobRunDto), (status = 404), (status = 409)))]
|
||||
async fn run(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(admin): AdminUser,
|
||||
Json(req): Json<RunRequest>,
|
||||
) -> Result<(StatusCode, Json<JobRunDto>), ApiError> {
|
||||
let run = state.jobs.start(req.kind, req.params, &admin.email).await?;
|
||||
Ok((StatusCode::ACCEPTED, Json(run.into())))
|
||||
}
|
||||
@ -3,17 +3,22 @@ pub mod auth;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod jobs;
|
||||
pub mod openapi;
|
||||
pub mod rate_limit;
|
||||
pub mod settings;
|
||||
pub mod test_support;
|
||||
pub mod users;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{AuthService, UserService};
|
||||
use application::scheduler::Scheduler;
|
||||
use application::{AuthService, JobRunner, SettingsService, UserService};
|
||||
use axum::{routing::get, Json, Router};
|
||||
use domain::ports::Mailer;
|
||||
use infrastructure::{
|
||||
Argon2Hasher, DbPool, JwtIssuer, SqliteAuditLog, SqliteRefreshTokens, SqliteUsers,
|
||||
AesGcmCipher, Argon2Hasher, DbPool, JwtIssuer, LettreMailer, SqliteAuditLog, SqliteJobRuns,
|
||||
SqliteRefreshTokens, SqliteSettings, SqliteUsers,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
@ -25,30 +30,63 @@ pub struct AppState {
|
||||
pub cfg: Config,
|
||||
pub auth: Arc<AuthService>,
|
||||
pub users: Arc<UserService>,
|
||||
pub settings: Arc<SettingsService>,
|
||||
pub jobs: Arc<JobRunner>,
|
||||
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Wire the services on top of a connected database.
|
||||
pub fn new(cfg: Config, pool: DbPool) -> Self {
|
||||
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
|
||||
let fake = cfg.fake_host;
|
||||
Self::with_adapters(cfg, pool, Arc::new(LettreMailer), move |r| {
|
||||
if fake {
|
||||
test_support::register_test_jobs(r)
|
||||
} else {
|
||||
r
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Wiring with replaceable adapters (used by tests and the fake-host mode).
|
||||
pub fn with_adapters(
|
||||
cfg: Config,
|
||||
pool: DbPool,
|
||||
mailer: Arc<dyn Mailer>,
|
||||
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
|
||||
) -> anyhow::Result<Self> {
|
||||
let users = Arc::new(SqliteUsers(pool.clone()));
|
||||
let hasher = Arc::new(Argon2Hasher);
|
||||
let auth = AuthService::new(
|
||||
users.clone(),
|
||||
Arc::new(SqliteRefreshTokens(pool.clone())),
|
||||
Arc::new(SqliteAuditLog(pool)),
|
||||
Arc::new(SqliteAuditLog(pool.clone())),
|
||||
hasher.clone(),
|
||||
Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
|
||||
);
|
||||
Self {
|
||||
let cipher = Arc::new(AesGcmCipher::from_hex(&cfg.master_key)?);
|
||||
let settings = Arc::new(SettingsService::new(
|
||||
Arc::new(SqliteSettings(pool.clone())),
|
||||
cipher,
|
||||
mailer,
|
||||
));
|
||||
let jobs = Arc::new(register_jobs(JobRunner::new(Arc::new(SqliteJobRuns(pool)))));
|
||||
Ok(Self {
|
||||
cfg,
|
||||
auth: Arc::new(auth),
|
||||
users: Arc::new(UserService::new(users, hasher)),
|
||||
settings,
|
||||
jobs,
|
||||
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
||||
10,
|
||||
std::time::Duration::from_secs(60),
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the cron scheduler on the current runtime.
|
||||
pub fn start_scheduler(&self) {
|
||||
tokio::spawn(Scheduler::new(self.jobs.clone(), self.settings.clone()).run());
|
||||
}
|
||||
|
||||
pub async fn bootstrap(&self) -> anyhow::Result<()> {
|
||||
@ -69,6 +107,8 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.route("/api/openapi.json", get(openapi::spec))
|
||||
.nest("/api/auth", auth::router())
|
||||
.nest("/api/users", users::router())
|
||||
.nest("/api/settings", settings::router())
|
||||
.nest("/api/jobs", jobs::router())
|
||||
.fallback_service(spa)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
|
||||
@ -17,8 +17,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let pool = infrastructure::connect(&cfg.database_url).await?;
|
||||
let state = AppState::new(cfg.clone(), pool);
|
||||
let state = AppState::new(cfg.clone(), pool)?;
|
||||
state.bootstrap().await?;
|
||||
state.start_scheduler();
|
||||
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
|
||||
tracing::info!("listening on http://{}", cfg.bind);
|
||||
let app = build_app(state).into_make_service_with_connect_info::<std::net::SocketAddr>();
|
||||
|
||||
@ -24,6 +24,8 @@ impl Modify for BearerAuth {
|
||||
paths(
|
||||
crate::auth::login, crate::auth::refresh, crate::auth::logout, crate::auth::me,
|
||||
crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password,
|
||||
crate::settings::get_smtp, crate::settings::put_smtp, crate::settings::test_smtp, crate::settings::list_schedules, crate::settings::put_schedule,
|
||||
crate::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run,
|
||||
),
|
||||
modifiers(&BearerAuth)
|
||||
)]
|
||||
|
||||
178
backend/crates/api/src/settings.rs
Normal file
178
backend/crates/api/src/settings.rs
Normal file
@ -0,0 +1,178 @@
|
||||
//! /api/settings: SMTP configuration and job schedules.
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use domain::jobs::JobKind;
|
||||
use domain::settings::{SmtpSecurity, SmtpSettings};
|
||||
use domain::DomainError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extract::{AdminUser, AuthUser};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/smtp", get(get_smtp).put(put_smtp))
|
||||
.route("/smtp/test", post(test_smtp))
|
||||
.route("/schedules", get(list_schedules))
|
||||
.route("/schedules/{kind}", put(put_schedule))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SmtpView {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
#[schema(value_type = String, example = "starttls")]
|
||||
pub security: SmtpSecurity,
|
||||
pub username: String,
|
||||
pub from: String,
|
||||
pub notify_to: Vec<String>,
|
||||
pub password_set: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SmtpResponse {
|
||||
pub configured: bool,
|
||||
pub smtp: Option<SmtpView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SmtpRequest {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
#[schema(value_type = String, example = "starttls")]
|
||||
pub security: SmtpSecurity,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
/// Empty keeps the currently stored password.
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub notify_to: Vec<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/settings/smtp", tag = "settings", security(("bearer" = [])), responses((status = 200, body = SmtpResponse)))]
|
||||
async fn get_smtp(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
) -> Result<Json<SmtpResponse>, ApiError> {
|
||||
let smtp = state.settings.smtp().await?.map(|s| SmtpView {
|
||||
host: s.host,
|
||||
port: s.port,
|
||||
security: s.security,
|
||||
username: s.username,
|
||||
from: s.from,
|
||||
notify_to: s.notify_to,
|
||||
password_set: !s.password.is_empty(),
|
||||
});
|
||||
Ok(Json(SmtpResponse {
|
||||
configured: smtp.is_some(),
|
||||
smtp,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(put, path = "/api/settings/smtp", tag = "settings", security(("bearer" = [])), request_body = SmtpRequest, responses((status = 204), (status = 422)))]
|
||||
async fn put_smtp(
|
||||
State(state): State<AppState>,
|
||||
_: AdminUser,
|
||||
Json(req): Json<SmtpRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let password = if req.password.is_empty() {
|
||||
state
|
||||
.settings
|
||||
.smtp()
|
||||
.await?
|
||||
.map(|s| s.password)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
req.password
|
||||
};
|
||||
state
|
||||
.settings
|
||||
.set_smtp(SmtpSettings {
|
||||
host: req.host.trim().into(),
|
||||
port: req.port,
|
||||
security: req.security,
|
||||
username: req.username,
|
||||
password,
|
||||
from: req.from.trim().into(),
|
||||
notify_to: req
|
||||
.notify_to
|
||||
.into_iter()
|
||||
.map(|a| a.trim().to_string())
|
||||
.filter(|a| !a.is_empty())
|
||||
.collect(),
|
||||
})
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct TestMailRequest {
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/settings/smtp/test", tag = "settings", security(("bearer" = [])), request_body = TestMailRequest, responses((status = 204), (status = 422), (status = 502)))]
|
||||
async fn test_smtp(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(admin): AdminUser,
|
||||
Json(req): Json<TestMailRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let body = format!(
|
||||
"This is a test mail from SoftVisor Monitoring, requested by {}.",
|
||||
admin.email
|
||||
);
|
||||
state
|
||||
.settings
|
||||
.send_mail(Some(vec![req.to]), "SoftVisor Monitoring test mail", &body)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ScheduleView {
|
||||
#[schema(value_type = String, example = "package_refresh")]
|
||||
pub kind: JobKind,
|
||||
pub cron: Option<String>,
|
||||
pub default_cron: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ScheduleRequest {
|
||||
pub cron: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/settings/schedules", tag = "settings", security(("bearer" = [])), responses((status = 200, body = Vec<ScheduleView>)))]
|
||||
async fn list_schedules(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
) -> Result<Json<Vec<ScheduleView>>, ApiError> {
|
||||
let mut out = Vec::new();
|
||||
for kind in JobKind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.default_schedule().is_some())
|
||||
{
|
||||
out.push(ScheduleView {
|
||||
kind,
|
||||
cron: state.settings.schedule(kind).await?,
|
||||
default_cron: kind.default_schedule().map(String::from),
|
||||
});
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
#[utoipa::path(put, path = "/api/settings/schedules/{kind}", tag = "settings", security(("bearer" = [])), request_body = ScheduleRequest, responses((status = 204), (status = 404), (status = 422)))]
|
||||
async fn put_schedule(
|
||||
State(state): State<AppState>,
|
||||
_: AdminUser,
|
||||
Path(kind): Path<String>,
|
||||
Json(req): Json<ScheduleRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let kind = JobKind::parse(&kind).ok_or(DomainError::NotFound)?;
|
||||
state.settings.set_schedule(kind, req.cron).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@ -1,11 +1,22 @@
|
||||
//! Helpers to build a fully wired app on an in-memory database for integration tests.
|
||||
use crate::{build_app, AppState, Config};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{JobHandler, JobLog, JobRunner};
|
||||
use async_trait::async_trait;
|
||||
use axum::Router;
|
||||
use domain::jobs::JobKind;
|
||||
use domain::ports::Mailer;
|
||||
use domain::settings::SmtpSettings;
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::{build_app, AppState, Config};
|
||||
|
||||
pub fn test_config() -> Config {
|
||||
Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
jwt_secret: "test-secret-test-secret-test-secret-1234".into(),
|
||||
master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(),
|
||||
fake_host: true,
|
||||
bind: "127.0.0.1:0".parse().unwrap(),
|
||||
bootstrap_admin: None,
|
||||
cookie_secure: false,
|
||||
@ -13,6 +24,45 @@ pub fn test_config() -> Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mails "sent" by all test apps of this process.
|
||||
static SENT: Mutex<Vec<(Vec<String>, String, String)>> = Mutex::new(Vec::new());
|
||||
|
||||
pub struct RecordingMailer;
|
||||
|
||||
#[async_trait]
|
||||
impl Mailer for RecordingMailer {
|
||||
async fn send(
|
||||
&self,
|
||||
_: &SmtpSettings,
|
||||
to: &[String],
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
SENT.lock()
|
||||
.unwrap()
|
||||
.push((to.to_vec(), subject.into(), body.into()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sent_mails(_app: &Router) -> Vec<(Vec<String>, String, String)> {
|
||||
SENT.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub struct TestJob;
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for TestJob {
|
||||
async fn run(&self, _params: Option<String>, log: &dyn JobLog) -> Result<(), String> {
|
||||
log.line("test handler ran").await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_test_jobs(runner: JobRunner) -> JobRunner {
|
||||
runner.register(JobKind::PackageRefresh, Arc::new(TestJob))
|
||||
}
|
||||
|
||||
pub async fn build_test_app() -> Router {
|
||||
build_test_app_with(test_config()).await
|
||||
}
|
||||
@ -29,7 +79,8 @@ async fn build_test_app_with(cfg: Config) -> Router {
|
||||
let pool = infrastructure::connect(&cfg.database_url)
|
||||
.await
|
||||
.expect("db");
|
||||
let state = AppState::new(cfg, pool);
|
||||
let state = AppState::with_adapters(cfg, pool, Arc::new(RecordingMailer), register_test_jobs)
|
||||
.expect("state");
|
||||
state.bootstrap().await.expect("bootstrap");
|
||||
build_app(state)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user