Adds GET /api/v1/admin/system returning disk (scoped to storage_dir via statvfs), memory, CPU, and a server-side alerts array that fires at >90% disk or memory. Disk uses nix::sys::statvfs directly rather than sysinfo's Disks API to avoid mountpoint-matching gymnastics for the storage_dir. A new `Storage::local_root() -> Option<&Path>` trait method exposes the root; the default returns None so a future S3Storage gets `disk: null` in the response instead of fabricated numbers. CPU is sampled inline (refresh → 250ms sleep → refresh → read) so the endpoint adds 250ms of latency per call. No background-cache yet — admin traffic is low-volume and the moving parts aren't worth it until polling shows up. Alerts are evaluated server-side so the frontend can render them without re-implementing the thresholds.
97 lines
3.1 KiB
Rust
97 lines
3.1 KiB
Rust
//! PR 4 (feat/admin-system-api) integration tests.
|
|
//!
|
|
//! Shape-only assertions — we don't mock the system, just call the
|
|
//! endpoint and check the response envelope. Threshold-triggering of
|
|
//! alerts would require faking statvfs / sysinfo, which is more
|
|
//! plumbing than the test gives back.
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use axum::Router;
|
|
use sqlx::PgPool;
|
|
use tower::ServiceExt;
|
|
|
|
use mangalord::repo;
|
|
|
|
async fn seed_admin(pool: &PgPool, app: &Router) -> String {
|
|
let (username, cookie) = common::register_user(app).await;
|
|
let u = repo::user::find_by_username(pool, &username)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
repo::user::set_is_admin(pool, u.id, true).await.unwrap();
|
|
cookie
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn requires_admin(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_u, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/admin/system", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn unauthenticated_request_is_rejected(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/admin/system"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn returns_disk_memory_cpu_alerts_shape(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let cookie = seed_admin(&pool, &h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/admin/system", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
|
|
// Disk: harness uses LocalStorage on a tempdir, so disk SHOULD be
|
|
// populated. Validate the field shape and percent range.
|
|
let disk = body
|
|
.get("disk")
|
|
.expect("disk key present")
|
|
.as_object()
|
|
.expect("disk is an object (LocalStorage exposes a path)");
|
|
assert!(disk["total_bytes"].as_u64().unwrap() > 0);
|
|
let pct = disk["percent_used"].as_f64().unwrap();
|
|
assert!(
|
|
(0.0..=100.0).contains(&pct),
|
|
"percent_used outside [0,100]: {pct}"
|
|
);
|
|
|
|
let mem = body.get("memory").expect("memory key").as_object().unwrap();
|
|
assert!(mem["total_bytes"].as_u64().unwrap() > 0);
|
|
let mpct = mem["percent_used"].as_f64().unwrap();
|
|
assert!((0.0..=100.0).contains(&mpct));
|
|
|
|
let cpu = body.get("cpu").expect("cpu key").as_object().unwrap();
|
|
let cpu_pct = cpu["percent_used"].as_f64().unwrap();
|
|
assert!(
|
|
(0.0..=100.0).contains(&cpu_pct),
|
|
"cpu out of range: {cpu_pct}"
|
|
);
|
|
|
|
let alerts = body.get("alerts").expect("alerts key").as_array().unwrap();
|
|
// Don't assert on length — the box may genuinely be >90% on memory
|
|
// when the test runs. Just confirm shape of any present entry.
|
|
for alert in alerts {
|
|
assert!(alert["level"].is_string());
|
|
assert!(alert["message"].is_string());
|
|
}
|
|
}
|