roboticus_api/
dashboard.rs1use axum::extract::State;
2use axum::response::{Html, IntoResponse, Response};
3
4use crate::api::AppState;
5
6pub async fn dashboard_handler(State(state): State<AppState>) -> Response {
7 let config = state.config.read().await;
8 let key = config.server.api_key.as_deref();
9
10 let nonce = uuid::Uuid::new_v4().to_string();
12 let html = build_dashboard_html(key, &nonce);
13
14 let csp = format!(
15 "default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'"
16 );
17
18 let mut response = Html(html).into_response();
19 if let Ok(csp_value) = axum::http::HeaderValue::from_str(&csp) {
20 response.headers_mut().insert(
21 axum::http::header::HeaderName::from_static("content-security-policy"),
22 csp_value,
23 );
24 } else {
25 tracing::error!("CSP header contained non-ASCII; applying fallback CSP");
27 response.headers_mut().insert(
28 axum::http::header::HeaderName::from_static("content-security-policy"),
29 axum::http::HeaderValue::from_static(
30 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'"
31 ),
32 );
33 }
34 response
35}
36
37pub fn build_dashboard_html(_api_key: Option<&str>, nonce: &str) -> String {
38 let html = include_str!("dashboard_spa.html");
39 let canonical = if let Some(idx) = html.find("</html>") {
40 &html[..idx + "</html>".len()]
41 } else {
42 html
43 };
44 let with_vars = canonical.replace("var BASE = '';", "var BASE = ''; var API_KEY = null;");
45 with_vars.replace("<script>", &format!("<script nonce=\"{nonce}\">"))
47}
48
49#[cfg(test)]
50mod tests {
51 use super::*;
52
53 const TEST_NONCE: &str = "test-nonce-value";
54
55 #[test]
56 fn dashboard_html_contains_title() {
57 let html = build_dashboard_html(None, TEST_NONCE);
58 assert!(html.contains("<title>Roboticus Dashboard</title>"));
59 assert!(html.contains("/api/health"));
60 }
61
62 #[test]
63 fn build_dashboard_html_contains_all_sections() {
64 let html = build_dashboard_html(None, TEST_NONCE);
65 assert!(html.contains("Roboticus"));
66 assert!(html.contains("Overview"));
67 assert!(html.contains("/api/sessions"));
68 assert!(html.contains("/api/memory/episodic"));
69 assert!(html.contains("/api/cron/jobs"));
70 assert!(html.contains("/api/stats/costs"));
71 assert!(html.contains("/api/skills"));
72 assert!(html.contains("/api/wallet/balance"));
73 assert!(html.contains("/api/breaker/status"));
74 }
75
76 #[test]
77 fn dashboard_html_contains_catalog_controls() {
78 let html = build_dashboard_html(None, TEST_NONCE);
79 assert!(html.contains("/api/skills/catalog"));
80 assert!(html.contains("/api/skills/catalog/install"));
81 assert!(html.contains("/api/skills/catalog/activate"));
82 assert!(html.contains("btn-catalog-install"));
83 assert!(html.contains("btn-catalog-install-activate"));
84 assert!(html.contains("cat-skill-check"));
85 }
86
87 #[test]
88 fn dashboard_html_without_key_has_api_health() {
89 let html = build_dashboard_html(None, TEST_NONCE);
90 assert!(html.contains("<title>Roboticus Dashboard</title>"));
91 assert!(html.contains("/api/health"));
92 }
93
94 #[test]
95 fn dashboard_never_injects_api_key() {
96 let html = build_dashboard_html(Some("test-dashboard-key"), TEST_NONCE);
97 assert!(
98 html.contains("API_KEY = null"),
99 "API key must never be embedded"
100 );
101 }
102
103 #[test]
104 fn dashboard_null_api_key_always() {
105 let html = build_dashboard_html(None, TEST_NONCE);
106 assert!(html.contains("API_KEY = null"));
107 }
108
109 #[test]
110 fn dashboard_html_contains_single_html_close_tag() {
111 let html = build_dashboard_html(None, TEST_NONCE);
112 assert_eq!(html.matches("</html>").count(), 1);
113 }
114
115 #[test]
116 fn dashboard_html_injects_nonce_into_script_tags() {
117 let html = build_dashboard_html(None, TEST_NONCE);
118 assert!(
119 html.contains(&format!("<script nonce=\"{TEST_NONCE}\">")),
120 "script tags must include nonce attribute"
121 );
122 assert!(
123 !html.contains("<script>"),
124 "no bare <script> tags should remain"
125 );
126 }
127
128 #[test]
129 fn dashboard_nonce_is_unique_per_call() {
130 let nonce_a = uuid::Uuid::new_v4().to_string();
131 let nonce_b = uuid::Uuid::new_v4().to_string();
132 assert_ne!(nonce_a, nonce_b);
133
134 let html_a = build_dashboard_html(None, &nonce_a);
135 let html_b = build_dashboard_html(None, &nonce_b);
136 assert!(html_a.contains(&nonce_a));
137 assert!(html_b.contains(&nonce_b));
138 assert!(!html_a.contains(&nonce_b));
139 }
140}