WP-02: contracts and failing tests for settings, mail, jobs and scheduler
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:08:23 +02:00
parent 12269051c4
commit 6113af0a19
21 changed files with 1115 additions and 0 deletions

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

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