#![allow(
clippy::missing_docs_in_private_items,
reason = "test helpers and fixtures do not need doc comments"
)]
use axum::{
body::Body,
http::{header::CONTENT_TYPE, Request, StatusCode},
};
use tower::ServiceExt;
use super::{build_app, write_openapi_spec};
#[test]
fn write_openapi_spec_writes_json_to_path() {
let dir = std::env::temp_dir().join(format!("moadim-openapi-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("openapi.json");
write_openapi_spec(&path);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("openapi"), "spec JSON should be written");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_openapi_spec_logs_on_write_failure() {
let dir = std::env::temp_dir().join(format!("moadim-openapi-fail-{}", uuid::Uuid::new_v4()));
let unwritable = dir.join("openapi.json");
std::fs::create_dir_all(&unwritable).unwrap();
write_openapi_spec(&unwritable);
assert!(
unwritable.is_dir(),
"the write should have failed, leaving the directory untouched"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_openapi_spec_skips_when_parent_dir_is_missing() {
let dir = std::env::temp_dir().join(format!("moadim-openapi-missing-{}", uuid::Uuid::new_v4()));
let path = dir.join("openapi.json");
write_openapi_spec(&path);
assert!(
!path.exists(),
"should not create the parent dir or the file"
);
}
#[test]
fn write_openapi_spec_skips_rewrite_when_unchanged() {
let dir = std::env::temp_dir().join(format!("moadim-openapi-nochurn-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("openapi.json");
write_openapi_spec(&path);
let first = std::fs::metadata(&path).unwrap().modified().unwrap();
write_openapi_spec(&path);
let second = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_eq!(first, second, "unchanged spec should not be rewritten");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn build_app_serves_root() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn build_app_compresses_root_with_gzip() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/")
.header(axum::http::header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(axum::http::header::CONTENT_ENCODING)
.unwrap(),
"gzip"
);
}
#[tokio::test]
async fn build_app_serves_root_uncompressed_without_accept_encoding() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp
.headers()
.get(axum::http::header::CONTENT_ENCODING)
.is_none());
}
#[tokio::test]
async fn build_app_serves_root_with_etag() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let etag = resp
.headers()
.get(axum::http::header::ETAG)
.expect("ETag header present")
.to_str()
.unwrap()
.to_owned();
assert!(etag.starts_with('"') && etag.ends_with('"'));
assert_eq!(
resp.headers()
.get(axum::http::header::CACHE_CONTROL)
.unwrap(),
"no-cache"
);
}
#[tokio::test]
async fn build_app_returns_304_when_if_none_match_matches() {
let app = build_app(crate::routines::new_store());
let first = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let etag = first
.headers()
.get(axum::http::header::ETAG)
.unwrap()
.to_str()
.unwrap()
.to_owned();
let resp = app
.oneshot(
Request::builder()
.uri("/")
.header(axum::http::header::IF_NONE_MATCH, &etag)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
resp.headers().get(axum::http::header::ETAG).unwrap(),
etag.as_str()
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.is_empty(), "304 response must not carry a body");
}
#[tokio::test]
async fn build_app_serves_root_when_if_none_match_stale() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/")
.header(axum::http::header::IF_NONE_MATCH, "\"not-the-real-etag\"")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn build_app_sets_security_headers_on_ui_and_api() {
for uri in ["/", "/api/v1/health"] {
let resp = build_app(crate::routines::new_store())
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
assert_eq!(
resp.headers().get("x-content-type-options").unwrap(),
"nosniff"
);
assert_eq!(
resp.headers().get("referrer-policy").unwrap(),
"no-referrer"
);
assert_eq!(
resp.headers().get("content-security-policy").unwrap(),
"default-src 'self'; \
script-src 'self' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
font-src 'self' https://fonts.gstatic.com; \
img-src 'self' data:; \
connect-src 'self'; \
base-uri 'none'; \
form-action 'none'; \
object-src 'none'; \
frame-ancestors 'none'"
);
}
}
#[tokio::test]
async fn build_app_serves_machines() {
let routines = crate::routines::new_store();
routines.lock().unwrap().insert(
"r1".to_string(),
crate::routines::Routine {
model: None,
id: "r1".to_string(),
schedule: "@daily".to_string(),
title: "R".to_string(),
agent: "claude".to_string(),
prompt: "p".to_string(),
goal: None,
repositories: vec![],
machines: vec!["alpha-box".to_string(), "shared".to_string()],
tags: vec![],
enabled: true,
source: "managed".to_string(),
created_at: 0,
updated_at: 0,
last_manual_trigger_at: None,
last_scheduled_trigger_at: None,
snoozed_until: None,
skip_runs: None,
power_saving: false,
ttl_secs: None,
max_runtime_secs: None,
env: std::collections::HashMap::new(),
},
);
let resp = build_app(routines)
.oneshot(
Request::builder()
.uri("/api/v1/machines")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let machines: Vec<String> = serde_json::from_slice(&bytes).unwrap();
let mut expected = vec![
crate::machine::current_machine(),
"alpha-box".to_string(),
"shared".to_string(),
];
expected.sort();
expected.dedup();
assert_eq!(machines, expected);
}
#[tokio::test]
async fn build_app_serves_ui_at_root() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp.headers().get(CONTENT_TYPE).unwrap();
assert!(ctype.to_str().unwrap().starts_with("text/html"));
}
#[tokio::test]
async fn build_app_redirects_ui_to_root() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/ui").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(resp.headers().get("location").unwrap(), "/");
}
#[tokio::test]
async fn build_app_redirects_client_to_root() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/client")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(resp.headers().get("location").unwrap(), "/");
}
#[tokio::test]
async fn build_app_redirects_client_deep_link_to_root_path() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/client/routines")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(resp.headers().get("location").unwrap(), "/routines");
}
#[tokio::test]
async fn build_app_redirects_client_deep_link_preserving_query() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/client/routines?history=abc")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
resp.headers().get("location").unwrap(),
"/routines?history=abc"
);
}
#[tokio::test]
async fn build_app_spa_fallback_serves_ui_on_client_routes() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/routines")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp.headers().get(CONTENT_TYPE).unwrap();
assert!(ctype.to_str().unwrap().starts_with("text/html"));
}
#[tokio::test]
async fn router_unknown_api_path_returns_json_404_not_spa() {
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.uri("/api/v1/bogus")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let ctype = resp.headers().get(CONTENT_TYPE).unwrap();
assert!(ctype.to_str().unwrap().starts_with("application/json"));
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "not found");
}
#[tokio::test]
async fn router_unknown_api_path_non_get_returns_404() {
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/bogus")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[cfg(test)]
#[path = "http_settings_routes_tests.rs"]
mod http_settings_routes_tests;