WP-40/41/42: dashboard, security hardening, deployment and operations docs
Dashboard endpoint and page aggregating inventory, vulnerabilities, cluster health, backups and recent jobs. Security headers (CSP, nosniff, DENY, referrer policy), 1 MB body limit, configurable login rate limit, audit steps in CI. Installer script, systemd unit, install/architecture docs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
77
backend/crates/api/tests/dashboard.rs
Normal file
77
backend/crates/api/tests/dashboard.rs
Normal file
@ -0,0 +1,77 @@
|
||||
//! WP-40: GET /api/dashboard aggregates the state of all areas.
|
||||
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";
|
||||
|
||||
async fn wait(app: &axum::Router, token: &str, id: &str) {
|
||||
for _ in 0..100 {
|
||||
let r = get(app, &format!("/api/jobs/{id}"), Some(token)).await;
|
||||
if r.json["status"] != "running" {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_reflects_inventory_vulnerabilities_cluster_backups_and_jobs() {
|
||||
let app = test_app_with_admin().await;
|
||||
let token = common::login(&app, ADMIN, PW).await.access;
|
||||
|
||||
let d = get(&app, "/api/dashboard", Some(&token)).await;
|
||||
assert_eq!(d.status, StatusCode::OK, "{}", d.json);
|
||||
assert!(d.json["inventory"]["refreshed_at"].is_null());
|
||||
assert_eq!(d.json["vulnerabilities"]["total"]["critical"], 0);
|
||||
assert_eq!(d.json["cluster"]["reachable"], true);
|
||||
assert_eq!(d.json["cluster"]["workloads"], 5);
|
||||
assert_eq!(d.json["cluster"]["unhealthy"], 0);
|
||||
assert_eq!(d.json["backups"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(d.json["recent_jobs"].as_array().unwrap().len(), 0);
|
||||
|
||||
for kind in ["package_refresh", "vulnerability_scan"] {
|
||||
let run = post(&app, "/api/jobs/run", json!({"kind": kind}), Some(&token)).await;
|
||||
wait(&app, &token, run.json["id"].as_str().unwrap()).await;
|
||||
}
|
||||
let t = post(&app, "/api/backups/targets", json!({"name": "NAS", "kind": "smb", "host": "nas", "share": "b", "username": "u", "password": "p"}), Some(&token)).await;
|
||||
let s = post(&app, "/api/backups/strategies", json!({"name": "DB", "source": {"type": "host_path", "path": "/tmp"}, "schedule": "0 0 2 * * *", "target_id": t.json["id"], "retention": 2}), Some(&token)).await;
|
||||
assert_eq!(s.status, StatusCode::CREATED, "{}", s.json);
|
||||
|
||||
let d = get(&app, "/api/dashboard", Some(&token)).await;
|
||||
assert!(d.json["inventory"]["refreshed_at"].is_string());
|
||||
assert_eq!(
|
||||
d.json["inventory"]["os"]["name"],
|
||||
"Debian GNU/Linux 12 (bookworm)"
|
||||
);
|
||||
assert_eq!(d.json["inventory"]["upgradable"], 3);
|
||||
assert_eq!(d.json["inventory"]["security"], 2);
|
||||
assert_eq!(d.json["inventory"]["reboot_required"], true);
|
||||
assert!(
|
||||
d.json["vulnerabilities"]["total"]["critical"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
>= 2
|
||||
);
|
||||
assert!(d.json["vulnerabilities"]["last_scan"].is_string());
|
||||
let b = &d.json["backups"][0];
|
||||
assert_eq!(b["name"], "DB");
|
||||
assert!(b["last_backup"].is_null());
|
||||
assert_eq!(b["enabled"], true);
|
||||
let jobs = d.json["recent_jobs"].as_array().unwrap();
|
||||
assert_eq!(jobs.len(), 2);
|
||||
assert_eq!(jobs[0]["kind"], "vulnerability_scan", "newest first");
|
||||
assert!(
|
||||
jobs[0].get("log").is_none(),
|
||||
"no logs in the dashboard payload"
|
||||
);
|
||||
assert_eq!(d.json["failed_jobs_24h"], 0);
|
||||
|
||||
assert_eq!(
|
||||
get(&app, "/api/dashboard", None).await.status,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
72
backend/crates/api/tests/security.rs
Normal file
72
backend/crates/api/tests/security.rs
Normal file
@ -0,0 +1,72 @@
|
||||
//! WP-41: security headers and cookie hardening.
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{get, post, test_app_with_admin};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_carry_security_headers() {
|
||||
let app = test_app_with_admin().await;
|
||||
let res = get(&app, "/healthz", None).await;
|
||||
let h = |k: &str| {
|
||||
res.headers
|
||||
.get(k)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(h("x-content-type-options"), "nosniff");
|
||||
assert_eq!(h("x-frame-options"), "DENY");
|
||||
assert_eq!(h("referrer-policy"), "same-origin");
|
||||
let csp = h("content-security-policy");
|
||||
assert!(csp.contains("default-src 'self'"), "{csp}");
|
||||
assert!(csp.contains("frame-ancestors 'none'"), "{csp}");
|
||||
assert_eq!(h("cache-control"), "no-store");
|
||||
assert!(res.headers.get("server").is_none());
|
||||
|
||||
// API errors are JSON, not HTML, and still carry the headers
|
||||
let res = get(&app, "/api/users", None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(res.json["error"], "unauthorized");
|
||||
assert_eq!(
|
||||
res.headers.get("x-content-type-options").unwrap(),
|
||||
"nosniff"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_cookie_is_strict_and_scoped() {
|
||||
let app = test_app_with_admin().await;
|
||||
let res = post(
|
||||
&app,
|
||||
"/api/auth/login",
|
||||
json!({"email": "admin@example.com", "password": "admin-password-123"}),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let cookie = res.headers.get("set-cookie").unwrap().to_str().unwrap();
|
||||
assert!(
|
||||
cookie.contains("HttpOnly")
|
||||
&& cookie.contains("SameSite=Strict")
|
||||
&& cookie.contains("Path=/api/auth")
|
||||
);
|
||||
// cross-site style request without the cookie cannot refresh
|
||||
assert_eq!(
|
||||
post(&app, "/api/auth/refresh", json!({}), None)
|
||||
.await
|
||||
.status,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_json_bodies_are_rejected() {
|
||||
let app = test_app_with_admin().await;
|
||||
let token = common::login(&app, "admin@example.com", "admin-password-123")
|
||||
.await
|
||||
.access;
|
||||
let big = "x".repeat(2 * 1024 * 1024);
|
||||
let res = post(&app, "/api/users", json!({"email": "a@b.de", "display_name": big, "password": "user-password-123", "role": "user"}), Some(&token)).await;
|
||||
assert_eq!(res.status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
Reference in New Issue
Block a user