//! Published documentation must match live routes and remain local to this server. use axum::{ Router, body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use mikrotik_backup::{AppState, api, config::Config, db::Db}; use serde_json::Value; use std::collections::{HashMap, HashSet}; use tower::ServiceExt; fn fixture(prefix: &str) -> (tempfile::TempDir, Router) { let dir = tempfile::tempdir().unwrap(); let config = Config::from_values(&HashMap::from([ ("DATA_DIR".into(), dir.path().display().to_string()), ("DEFAULT_ADMIN_PASSWORD".into(), "docs-test-password".into()), ("API_PREFIX".into(), prefix.into()), ("APP_NAME".into(), "MikroTik API contract test".into()), ])) .unwrap(); let db = Db::open(&config).unwrap(); (dir, api::router(AppState::new(config, db))) } fn check_references(value: &Value, document: &Value) { match value { Value::Object(object) => { if let Some(reference) = object.get("$ref").and_then(Value::as_str) { assert!(reference.starts_with("#/components/")); assert!( document.pointer(&reference[1..]).is_some(), "Broken ref {reference}" ); } for child in object.values() { check_references(child, document); } } Value::Array(items) => { for child in items { check_references(child, document); } } _ => {} } } #[tokio::test] async fn openapi_covers_live_routes_and_honors_custom_prefix() { let (_dir, app) = fixture("/v2/api"); let response = app .clone() .oneshot(Request::get("/openapi.json").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()["content-type"], "application/json"); let bytes = response.into_body().collect().await.unwrap().to_bytes(); let document: Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(document["openapi"], "3.1.0"); assert_eq!(document["info"]["title"], "MikroTik API contract test"); assert_eq!(document["info"]["version"], env!("CARGO_PKG_VERSION")); assert_eq!(document["servers"][0]["url"], "/v2/api"); assert_eq!( document["tags"], serde_json::json!([ {"name": "Account"}, {"name": "Devices"}, {"name": "Backups"}, {"name": "Settings"}, {"name": "Logs"}, {"name": "System"} ]) ); assert!( document["info"]["description"] .as_str() .unwrap() .starts_with("Documentation for the live API.") ); assert_eq!( document["paths"]["/auth/login"]["post"]["summary"], "Log in" ); assert_eq!( document["paths"]["/routers"]["get"]["tags"], serde_json::json!(["Devices"]) ); assert_eq!( document["components"]["securitySchemes"]["OAuth2PasswordBearer"]["flows"]["password"]["tokenUrl"], "/v2/api/auth/login" ); check_references(&document, &document); let mut operation_ids = HashSet::new(); let mut operation_count = 0; for (path, methods) in document["paths"].as_object().unwrap() { let concrete = ["id", "backup_id", "left", "right", "days"] .into_iter() .fold(path.clone(), |path, name| { path.replace(&format!("{{{name}}}"), "1") }); for (method, operation) in methods.as_object().unwrap() { operation_count += 1; assert!(operation_ids.insert(operation["operationId"].as_str().unwrap())); let public = ["/health", "/auth/login", "/auth/register"].contains(&path.as_str()); assert_eq!(operation.get("security").is_some(), public); let response = app .clone() .oneshot( Request::builder() .method(method.to_ascii_uppercase().as_str()) .uri(format!("/v2/api{concrete}")) .header("content-type", "application/json") .body(Body::from("{}")) .unwrap(), ) .await .unwrap(); assert_ne!( response.status(), StatusCode::NOT_FOUND, "Undocumented route mismatch: {method} {path}" ); assert_ne!( response.status(), StatusCode::METHOD_NOT_ALLOWED, "Undocumented method mismatch: {method} {path}" ); if !public { assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "Protected route {method} {path}" ); } } } assert_eq!(operation_count, 36); assert_eq!( document["components"]["schemas"]["Device"]["properties"]["ssh_password"]["type"], "null" ); assert!( document["components"]["schemas"]["DeviceUpdate"]["properties"] .get("use_global_switchos_credentials") .is_some() ); } #[tokio::test] async fn docs_and_embedded_assets_work_without_a_cdn() { let (_dir, app) = fixture("/api"); let mut documentation_html = None; for path in ["/docs", "/docs/"] { let response = app .clone() .oneshot(Request::get(path).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert!( response.headers()["content-security-policy"] .to_str() .unwrap() .contains("script-src 'self'") ); let bytes = response.into_body().collect().await.unwrap().to_bytes(); let html = std::str::from_utf8(&bytes).unwrap(); assert!(html.contains("")); assert!(html.contains("aria-label=\"API documentation\"")); assert!(html.contains("Back to the application")); assert!(html.contains(env!("DOCS_JS_ROUTE"))); assert!(html.contains(env!("DOCS_CSS_ROUTE"))); assert!(!html.contains("?v=")); assert_eq!(html.matches("