first commit
This commit is contained in:
commit
32a7ba8e8f
168 files changed
+123856
No files matched your search
@@ -0,0 +1,228 @@
|
||||
//! 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("<html lang=\"en\">"));
|
||||
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("<script ").count(), 1);
|
||||
assert!(!html.contains("https://"));
|
||||
if let Some(previous) = &documentation_html {
|
||||
assert_eq!(html, previous);
|
||||
} else {
|
||||
documentation_html = Some(html.to_owned());
|
||||
}
|
||||
}
|
||||
let documentation_html = documentation_html.unwrap();
|
||||
for (path, mime) in [
|
||||
(env!("DOCS_JS_ROUTE"), "text/javascript"),
|
||||
(env!("DOCS_CSS_ROUTE"), "text/css"),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response.headers()["content-type"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with(mime)
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()["cache-control"].to_str().unwrap(),
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(bytes.len() > 1000);
|
||||
assert!(documentation_html.contains(&format!("\"{path}\"")));
|
||||
if path.ends_with(".js") {
|
||||
let javascript = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(javascript.contains("validatorUrl: null"));
|
||||
assert!(javascript.contains("queryConfigEnabled: false"));
|
||||
assert!(javascript.contains("persistAuthorization: false"));
|
||||
assert!(
|
||||
javascript.contains("API documentation can send requests only to this instance.")
|
||||
);
|
||||
assert!(javascript.ends_with(include_str!("../docs/api-ui/init.js")));
|
||||
} else {
|
||||
let stylesheet = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(stylesheet.ends_with(include_str!("../docs/api-ui/style.css")));
|
||||
}
|
||||
}
|
||||
let response = app
|
||||
.oneshot(Request::get("/redoc").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
|
||||
assert_eq!(response.headers()["location"], "/docs");
|
||||
}
|
||||
Reference in new issue
Block a user