diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 585e448..2230bde 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -83,12 +83,13 @@ struct SecurityAddon; impl utoipa::Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { - if let Some(components) = openapi.components.as_mut() { - components.add_security_scheme( + openapi + .components + .get_or_insert_with(Default::default) + .add_security_scheme( "bearer_token", SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), ); - } } } @@ -476,8 +477,35 @@ async fn server_health() -> Json { }) } -async fn server_openapi() -> Json { - Json(ApiDoc::openapi()) +async fn server_openapi(State(state): State) -> Json { + let mut doc = ApiDoc::openapi(); + if !state.requires_bearer_auth() { + strip_security(&mut doc); + } + Json(doc) +} + +fn strip_security(doc: &mut utoipa::openapi::OpenApi) { + if let Some(components) = doc.components.as_mut() { + components.security_schemes.clear(); + } + for path_item in doc.paths.paths.values_mut() { + for op in [ + path_item.get.as_mut(), + path_item.post.as_mut(), + path_item.put.as_mut(), + path_item.delete.as_mut(), + path_item.options.as_mut(), + path_item.head.as_mut(), + path_item.patch.as_mut(), + path_item.trace.as_mut(), + ] + .into_iter() + .flatten() + { + op.security = None; + } + } } async fn require_bearer_auth( diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs index 51d4280..f47ccdf 100644 --- a/crates/omnigraph-server/tests/openapi.rs +++ b/crates/omnigraph-server/tests/openapi.rs @@ -49,6 +49,19 @@ async fn app_for_loaded_repo() -> (tempfile::TempDir, Router) { (temp, app) } +async fn app_for_loaded_repo_with_auth(token: &str) -> (tempfile::TempDir, Router) { + let temp = init_loaded_repo().await; + let repo = repo_path(temp.path()); + let db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap(); + let state = AppState::new_with_bearer_token( + repo.to_string_lossy().to_string(), + db, + Some(token.to_string()), + ); + let app = build_app(state); + (temp, app) +} + async fn json_response(app: &Router, request: Request) -> (StatusCode, Value) { let response = app.clone().oneshot(request).await.unwrap(); let status = response.status(); @@ -832,12 +845,95 @@ fn openapi_spec_round_trips_through_json() { } // --------------------------------------------------------------------------- -// Endpoint live round-trip: the doc served matches the static generation +// Open-mode vs auth-mode: served spec reflects runtime config // --------------------------------------------------------------------------- #[tokio::test] -async fn openapi_endpoint_matches_static_generation() { +async fn open_mode_spec_has_no_security_schemes() { let (_temp, app) = app_for_loaded_repo().await; + let request = Request::builder() + .method(Method::GET) + .uri("/openapi.json") + .body(Body::empty()) + .unwrap(); + let (_, json) = json_response(&app, request).await; + let schemes = &json["components"]["securitySchemes"]; + assert!( + schemes.is_null() || schemes.as_object().is_some_and(|m| m.is_empty()), + "open-mode spec should have no security schemes" + ); +} + +#[tokio::test] +async fn open_mode_spec_has_no_operation_security() { + let (_temp, app) = app_for_loaded_repo().await; + let request = Request::builder() + .method(Method::GET) + .uri("/openapi.json") + .body(Body::empty()) + .unwrap(); + let (_, json) = json_response(&app, request).await; + let paths = json["paths"].as_object().unwrap(); + for (path, methods) in paths { + for (method, operation) in methods.as_object().unwrap() { + let security = &operation["security"]; + assert!( + security.is_null(), + "open-mode: {method} {path} should have no security requirement" + ); + } + } +} + +#[tokio::test] +async fn auth_mode_spec_includes_bearer_token_security_scheme() { + let (_temp, app) = app_for_loaded_repo_with_auth("secret").await; + let request = Request::builder() + .method(Method::GET) + .uri("/openapi.json") + .body(Body::empty()) + .unwrap(); + let (_, json) = json_response(&app, request).await; + let scheme = &json["components"]["securitySchemes"]["bearer_token"]; + assert_eq!(scheme["type"].as_str().unwrap(), "http"); + assert_eq!(scheme["scheme"].as_str().unwrap(), "bearer"); +} + +#[tokio::test] +async fn auth_mode_spec_has_security_on_protected_operations() { + let (_temp, app) = app_for_loaded_repo_with_auth("secret").await; + let request = Request::builder() + .method(Method::GET) + .uri("/openapi.json") + .body(Body::empty()) + .unwrap(); + let (_, json) = json_response(&app, request).await; + let protected_paths = [ + ("/read", "post"), + ("/change", "post"), + ("/snapshot", "get"), + ("/branches", "get"), + ("/runs", "get"), + ("/commits", "get"), + ]; + for (path, method) in protected_paths { + let security = &json["paths"][path][method]["security"]; + let arr = security + .as_array() + .unwrap_or_else(|| panic!("auth-mode: {method} {path} missing security")); + let has_bearer = arr + .iter() + .any(|s| s.as_object().unwrap().contains_key("bearer_token")); + assert!( + has_bearer, + "auth-mode: {method} {path} should require bearer_token" + ); + } +} + +#[tokio::test] +async fn auth_mode_spec_matches_static_generation() { + let (_temp, app) = app_for_loaded_repo_with_auth("secret").await; let request = Request::builder() .method(Method::GET) .uri("/openapi.json") @@ -845,5 +941,24 @@ async fn openapi_endpoint_matches_static_generation() { .unwrap(); let (_, served) = json_response(&app, request).await; let static_doc = openapi_json(); - assert_eq!(served, static_doc, "served spec must match static generation"); + assert_eq!( + served, static_doc, + "auth-mode served spec must match static generation" + ); +} + +#[tokio::test] +async fn auth_mode_healthz_still_has_no_security() { + let (_temp, app) = app_for_loaded_repo_with_auth("secret").await; + let request = Request::builder() + .method(Method::GET) + .uri("/openapi.json") + .body(Body::empty()) + .unwrap(); + let (_, json) = json_response(&app, request).await; + let healthz = &json["paths"]["/healthz"]["get"]; + assert!( + healthz.get("security").is_none() || healthz["security"].is_null(), + "auth-mode: /healthz should still have no security" + ); }