diff --git a/backend/src/handlers/keys.rs b/backend/src/handlers/keys.rs index 9e1c648f..4280041e 100644 --- a/backend/src/handlers/keys.rs +++ b/backend/src/handlers/keys.rs @@ -613,6 +613,7 @@ pub async fn create_key( identity, openapi_input, body.ws_frame_injections.as_deref(), + state.config.is_production(), ) .await?; @@ -825,6 +826,25 @@ pub async fn update_key( // credential change having already applied. validate_optional_label_for_update(body.label.as_deref())?; + if let Some(endpoint_url) = body.endpoint_url.as_deref() { + let effective_node_id = match body.node_id.as_deref() { + Some("") => None, + Some(node_id) => Some(node_id), + None => view + .node_id + .as_deref() + .filter(|node_id| !node_id.is_empty()), + }; + if effective_node_id.is_none() { + crate::services::url_validation::validate_user_endpoint_url( + endpoint_url, + state.config.is_production(), + "endpoint_url", + ) + .await?; + } + } + // NOTE: `body.endpoint_url` is intentionally NOT written to the DB // here. For node-routed services we must keep the endpoint URL and // the strict node push atomic — if the push fails (node offline / @@ -1782,8 +1802,15 @@ mod tests { use crate::crypto::aes::EncryptionKeys; use crate::crypto::local_key_provider::LocalKeyProvider; use crate::errors::AppError; + use crate::models::user_api_key::COLLECTION_NAME as USER_API_KEYS; use crate::models::user_api_key::UserApiKey; + use crate::models::user_endpoint::COLLECTION_NAME as USER_ENDPOINTS; + use crate::models::user_service::COLLECTION_NAME as USER_SERVICES; + use crate::telemetry::TelemetryContext; + use crate::test_utils::{connect_test_database, test_app_state, test_auth_user}; + use axum::{Json, extract::State}; use chrono::Utc; + use mongodb::bson::doc; fn test_encryption_keys() -> EncryptionKeys { EncryptionKeys::with_provider(Arc::new(LocalKeyProvider::new([0x22; 32], None))) @@ -1962,4 +1989,76 @@ mod tests { .expect_err("overlong label should be rejected before any mutation"); assert!(matches!(err, AppError::ValidationError(_))); } + + #[tokio::test] + async fn create_key_rejects_empty_header_auth_key_name_before_writes() { + let Some(db) = connect_test_database("keys_post_empty_header_auth_key").await else { + eprintln!("skipping keys handler integration test: no local MongoDB available"); + return; + }; + let state = test_app_state(db.clone()); + let user_id = uuid::Uuid::new_v4().to_string(); + + let body = super::CreateKeyRequest { + service_slug: None, + credential: Some("secret-token".to_string()), + label: "Header Service".to_string(), + endpoint_url: Some("https://api.example.com".to_string()), + slug: Some("header-service".to_string()), + auth_method: Some("header".to_string()), + auth_key_name: Some(String::new()), + node_id: None, + ssh_host: None, + ssh_port: None, + ssh_certificate_auth: None, + ssh_principals: None, + ssh_certificate_ttl_minutes: None, + identity_propagation_mode: None, + identity_include_user_id: None, + identity_include_email: None, + identity_include_name: None, + identity_jwt_audience: None, + forward_access_token: None, + inject_delegation_token: None, + delegation_token_scope: None, + target_org_id: None, + openapi_spec_url: None, + ws_frame_injections: None, + }; + + let err = super::create_key( + State(state), + test_auth_user(&user_id), + TelemetryContext::default(), + Json(body), + ) + .await + .expect_err("POST /api/v1/keys should reject empty header auth_key_name"); + + assert!(matches!( + err, + AppError::ValidationError(message) + if message.contains("auth_method is 'header'") + )); + + let endpoint_count = db + .collection::(USER_ENDPOINTS) + .count_documents(doc! { "user_id": &user_id }) + .await + .unwrap(); + let api_key_count = db + .collection::(USER_API_KEYS) + .count_documents(doc! { "user_id": &user_id }) + .await + .unwrap(); + let service_count = db + .collection::(USER_SERVICES) + .count_documents(doc! { "user_id": &user_id }) + .await + .unwrap(); + + assert_eq!(endpoint_count, 0); + assert_eq!(api_key_count, 0); + assert_eq!(service_count, 0); + } } diff --git a/backend/src/handlers/services.rs b/backend/src/handlers/services.rs index 7d15a054..8ca4033b 100644 --- a/backend/src/handlers/services.rs +++ b/backend/src/handlers/services.rs @@ -21,7 +21,9 @@ use crate::models::user::{COLLECTION_NAME as USERS, User}; use crate::models::ws_frame_injection::WsFrameInjection; use crate::mw::auth::AuthUser; use crate::services::url_validation::{validate_base_url, validate_optional_spec_url}; -use crate::services::{api_docs_service, audit_service, oauth_client_service, ssh_service}; +use crate::services::{ + api_docs_service, audit_service, oauth_client_service, ssh_service, user_service_service, +}; use crate::telemetry::{TelemetryContext, TelemetryEvent, emit_event}; use super::services_helpers::{ @@ -768,15 +770,11 @@ pub async fn create_service( ))); } - // `body` auth has no sensible default for the field name -- the - // proxy needs to know which key to inject into the JSON payload. - // Fail at creation time instead of surfacing as a 500 on the first - // proxied request. - if auth_method == "body" && auth_key_name.is_empty() { + if user_service_service::auth_method_requires_key_name(&auth_method) + && auth_key_name.trim().is_empty() + { return Err(AppError::ValidationError( - "auth_key_name is required when auth_method is 'body' \ - (e.g. 'app_secret' for custom body-auth services)" - .to_string(), + user_service_service::auth_key_name_required_message(&auth_method), )); } diff --git a/backend/src/handlers/user_endpoints.rs b/backend/src/handlers/user_endpoints.rs index 5343d5ba..f7086b20 100644 --- a/backend/src/handlers/user_endpoints.rs +++ b/backend/src/handlers/user_endpoints.rs @@ -4,13 +4,14 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use mongodb::bson::doc; +use mongodb::bson::{self, doc}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use crate::AppState; use crate::errors::{AppError, AppResult}; use crate::models::user_endpoint::{COLLECTION_NAME as USER_ENDPOINTS, UserEndpoint}; +use crate::models::user_service::COLLECTION_NAME as USER_SERVICES; use crate::mw::auth::AuthUser; use crate::services::{ api_docs_service, openapi_parser, org_service, user_endpoint_service, user_service_service, @@ -60,6 +61,36 @@ async fn resolve_endpoint_write_owner( Ok(endpoint.user_id) } +async fn endpoint_is_only_node_routed( + state: &AppState, + owner_id: &str, + endpoint_id: &str, +) -> AppResult { + let services = state + .db + .collection::(USER_SERVICES); + let total_count = services + .count_documents(doc! { "user_id": owner_id, "endpoint_id": endpoint_id }) + .await?; + if total_count == 0 { + return Ok(false); + } + + let direct_count = services + .count_documents(doc! { + "user_id": owner_id, + "endpoint_id": endpoint_id, + "$or": [ + { "node_id": { "$exists": false } }, + { "node_id": bson::Bson::Null }, + { "node_id": "" }, + ], + }) + .await?; + + Ok(direct_count == 0) +} + #[derive(Deserialize, ToSchema)] pub struct UpdateEndpointRequest { pub url: Option, @@ -162,6 +193,17 @@ pub async fn update_endpoint( let actor = auth_user.user_id.to_string(); let owner_id = resolve_endpoint_write_owner(&state, &actor, &endpoint_id).await?; + if let Some(url) = body.url.as_deref() + && !endpoint_is_only_node_routed(&state, &owner_id, &endpoint_id).await? + { + crate::services::url_validation::validate_user_endpoint_url( + url, + state.config.is_production(), + "endpoint_url", + ) + .await?; + } + let spec_update = match body.openapi_spec_url.as_deref() { None => user_endpoint_service::OpenApiSpecUrlUpdate::Leave, Some(s) if s.trim().is_empty() => user_endpoint_service::OpenApiSpecUrlUpdate::Clear, diff --git a/backend/src/services/unified_key_service.rs b/backend/src/services/unified_key_service.rs index 477b4f47..f0b262ef 100644 --- a/backend/src/services/unified_key_service.rs +++ b/backend/src/services/unified_key_service.rs @@ -478,6 +478,7 @@ pub async fn create_key( identity: Option, openapi_spec_url: OpenApiSpecUrlInput<'_>, ws_frame_injections: Option<&[WsFrameInjection]>, + hosted_mode: bool, ) -> AppResult { let node_id = node_id.filter(|nid| !nid.is_empty()); if let Some(rules) = ws_frame_injections { @@ -549,6 +550,15 @@ pub async fn create_key( svc.base_url.clone() }; + if endpoint_url.is_some() && node_id.is_none() { + crate::services::url_validation::validate_user_endpoint_url( + &ep_url, + hosted_mode, + "endpoint_url", + ) + .await?; + } + // Determine credential type let node_managed_credential = node_id.is_some() && credential.is_empty(); @@ -987,6 +997,16 @@ pub async fn create_key( "endpoint_url is required for custom endpoints without node routing".to_string(), )); } + // Skip URL validation for node-routed services: the URL is delivered + // to the node agent and never used by NyxID's outbound HTTP client. + if node_id.is_none() && !ep_url.is_empty() { + crate::services::url_validation::validate_user_endpoint_url( + ep_url, + hosted_mode, + "endpoint_url", + ) + .await?; + } let requested_slug = match slug_override { Some(slug) if !slug.is_empty() => { @@ -1001,6 +1021,12 @@ pub async fn create_key( let akn = auth_key_name.unwrap_or("Authorization").to_string(); let is_no_auth = am == "none"; + if user_service_service::auth_method_requires_key_name(&am) && akn.trim().is_empty() { + return Err(AppError::ValidationError( + user_service_service::auth_key_name_required_message(&am), + )); + } + // Validate: credential required for direct routing unless no-auth if credential.is_empty() && node_id.is_none() && !is_no_auth { return Err(AppError::BadRequest( @@ -3141,6 +3167,7 @@ mod tests { None, OpenApiSpecUrlInput::Inherit, None, + false, ), create_key( &db, @@ -3159,6 +3186,7 @@ mod tests { None, OpenApiSpecUrlInput::Inherit, None, + false, ) ); @@ -3208,6 +3236,7 @@ mod tests { None, OpenApiSpecUrlInput::Inherit, None, + false, ) .await .expect("user A SSH create should succeed"); @@ -3235,6 +3264,7 @@ mod tests { None, OpenApiSpecUrlInput::Inherit, None, + false, ) .await .expect("user B SSH create should succeed"); @@ -3322,6 +3352,7 @@ mod tests { None, OpenApiSpecUrlInput::Inherit, None, + false, ) .await .unwrap(); @@ -3374,6 +3405,7 @@ mod tests { None, OpenApiSpecUrlInput::Inherit, None, + false, ) .await .unwrap(); @@ -3414,6 +3446,7 @@ mod tests { None, OpenApiSpecUrlInput::Inherit, None, + false, ) .await .unwrap(); @@ -3536,6 +3569,7 @@ mod tests { None, OpenApiSpecUrlInput::Inherit, None, + false, ) .await .err() @@ -3567,6 +3601,66 @@ mod tests { assert_eq!(service_count, 0); } + #[tokio::test] + async fn create_key_rejects_header_auth_with_empty_auth_key_name_before_writes() { + let Some(db) = connect_test_database("unified_key_empty_header_auth_key").await else { + eprintln!("skipping unified_key_service integration test: no local MongoDB available"); + return; + }; + + let encryption_keys = test_encryption_keys(); + let user_id = uuid::Uuid::new_v4().to_string(); + + let err = create_key( + &db, + &encryption_keys, + &user_id, + &user_id, + None, + Some("https://api.example.com"), + "secret-token", + "Header Service", + Some("header-service"), + Some("header"), + Some(""), + None, + None, + None, + OpenApiSpecUrlInput::Inherit, + None, + false, + ) + .await + .err() + .expect("empty header auth_key_name should fail"); + + assert!(matches!( + err, + AppError::ValidationError(message) + if message.contains("auth_method is 'header'") + )); + + let endpoint_count = db + .collection::(USER_ENDPOINTS) + .count_documents(doc! { "user_id": &user_id }) + .await + .unwrap(); + let api_key_count = db + .collection::(USER_API_KEYS) + .count_documents(doc! { "user_id": &user_id }) + .await + .unwrap(); + let service_count = db + .collection::(USER_SERVICES) + .count_documents(doc! { "user_id": &user_id }) + .await + .unwrap(); + + assert_eq!(endpoint_count, 0); + assert_eq!(api_key_count, 0); + assert_eq!(service_count, 0); + } + #[test] fn resolve_spec_inherit_uses_catalog_default_for_http_services() { let out = resolve_openapi_spec_url( diff --git a/backend/src/services/url_validation.rs b/backend/src/services/url_validation.rs index 97d86151..1d0408f6 100644 --- a/backend/src/services/url_validation.rs +++ b/backend/src/services/url_validation.rs @@ -80,6 +80,67 @@ pub async fn validate_public_http_url(url: &str, field_name: &str) -> AppResult< Ok(()) } +/// Validate a user-supplied endpoint URL for a custom (non-catalog) service. +/// +/// Always: +/// - Must parse as http(s). +/// - Must not contain query (`?...`) or fragment (`#...`) components. +/// A base URL like `https://api.example.com/v1` is allowed (the proxy +/// treats the path as a prefix); query and fragment are not. +/// - Must not contain userinfo (`user:pass@`). +/// - Must be <= 2048 chars (matches `validate_public_http_url`). +/// +/// When `hosted_mode` is true, this also rejects loopback, private, +/// link-local, CGNAT, unspecified, broadcast, and cloud-metadata targets via +/// DNS resolution. Self-hosted/development deployments skip that DNS/IP-class +/// guard so they can target local services while still enforcing the URL +/// shape above. +pub async fn validate_user_endpoint_url( + url: &str, + hosted_mode: bool, + field_name: &str, +) -> AppResult<()> { + if url.len() > 2048 { + return Err(AppError::ValidationError(format!( + "{field_name} must not exceed 2048 characters" + ))); + } + + let parsed = url::Url::parse(url) + .map_err(|_| AppError::ValidationError(format!("{field_name} must be a valid URL")))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(AppError::ValidationError(format!( + "{field_name} must use http or https" + ))); + } + if parsed.host_str().is_none() { + return Err(AppError::ValidationError(format!( + "{field_name} must include a hostname" + ))); + } + if parsed.query().is_some() { + return Err(AppError::ValidationError(format!( + "{field_name} must not contain a query string" + ))); + } + if parsed.fragment().is_some() { + return Err(AppError::ValidationError(format!( + "{field_name} must not contain a fragment" + ))); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(AppError::ValidationError(format!( + "{field_name} must not contain userinfo (user:pass@)" + ))); + } + + if hosted_mode { + validate_public_http_url(url, field_name).await?; + } + + Ok(()) +} + /// Validate that a URL has a valid scheme and hostname. /// /// Cloud metadata endpoints (169.254.169.254, metadata.google.internal) @@ -195,7 +256,7 @@ fn is_rfc6598_cgnat(ipv4: std::net::Ipv4Addr) -> bool { mod tests { use super::{ reject_url_userinfo, validate_base_url, validate_optional_spec_url, - validate_public_http_url, + validate_public_http_url, validate_user_endpoint_url, }; #[test] @@ -280,4 +341,74 @@ mod tests { .is_err() ); } + + #[tokio::test] + async fn validate_user_endpoint_url_rejects_query_and_fragment() { + assert!( + validate_user_endpoint_url("https://api.example.com?x=1", false, "endpoint_url") + .await + .is_err() + ); + assert!( + validate_user_endpoint_url("https://api.example.com#frag", false, "endpoint_url") + .await + .is_err() + ); + } + + #[tokio::test] + async fn validate_user_endpoint_url_allows_path() { + // Base path `/v1` is a legitimate prefix. + assert!( + validate_user_endpoint_url("https://api.example.com/v1", false, "endpoint_url") + .await + .is_ok() + ); + } + + #[tokio::test] + async fn validate_user_endpoint_url_rejects_loopback_in_hosted_mode() { + assert!( + validate_user_endpoint_url("http://localhost:9999", true, "endpoint_url") + .await + .is_err() + ); + assert!( + validate_user_endpoint_url("http://127.0.0.1:9999", true, "endpoint_url") + .await + .is_err() + ); + } + + #[tokio::test] + async fn validate_user_endpoint_url_allows_loopback_in_self_hosted_mode() { + assert!( + validate_user_endpoint_url("http://localhost:9999", false, "endpoint_url") + .await + .is_ok() + ); + assert!( + validate_user_endpoint_url("http://127.0.0.1:9999", false, "endpoint_url") + .await + .is_ok() + ); + } + + #[tokio::test] + async fn validate_user_endpoint_url_rejects_userinfo() { + assert!( + validate_user_endpoint_url("https://user:pass@api.example.com", false, "endpoint_url",) + .await + .is_err() + ); + } + + #[tokio::test] + async fn validate_user_endpoint_url_rejects_invalid_scheme() { + assert!( + validate_user_endpoint_url("ftp://example.com", false, "endpoint_url") + .await + .is_err() + ); + } } diff --git a/backend/src/services/user_service_service.rs b/backend/src/services/user_service_service.rs index 314688aa..a726c795 100644 --- a/backend/src/services/user_service_service.rs +++ b/backend/src/services/user_service_service.rs @@ -169,6 +169,17 @@ fn validate_auth_method(method: &str) -> AppResult<()> { Ok(()) } +pub(crate) fn auth_method_requires_key_name(auth_method: &str) -> bool { + matches!(auth_method, "header" | "query" | "path" | "body") +} + +pub(crate) fn auth_key_name_required_message(auth_method: &str) -> String { + format!( + "auth_key_name is required when auth_method is '{auth_method}' \ + (e.g. 'X-API-Key' for header, 'key' for query, 'app_secret' for body)" + ) +} + /// List all active user services for a user. pub async fn list_user_services( db: &mongodb::Database, @@ -453,13 +464,10 @@ pub async fn create_user_service( )); } - // `body` auth must specify which JSON field to inject into. - if auth_method == "body" && auth_key_name.is_empty() { - return Err(AppError::ValidationError( - "auth_key_name is required when auth_method is 'body' \ - (e.g. 'app_secret' for custom body-auth services)" - .to_string(), - )); + if auth_method_requires_key_name(auth_method) && auth_key_name.trim().is_empty() { + return Err(AppError::ValidationError(auth_key_name_required_message( + auth_method, + ))); } // `body` auth credential injection happens inside the backend proxy's @@ -624,18 +632,19 @@ pub async fn update_user_service( } } - // Cross-field validation for `body` auth method. We check the effective - // post-update state: incoming values override current values. + // Cross-field validation for credential injection methods. We check the + // effective post-update state: incoming values override current values. let effective_auth_method = auth_method.unwrap_or(¤t.auth_method); - if effective_auth_method == "body" { + if auth_method_requires_key_name(effective_auth_method) { let effective_auth_key_name = auth_key_name.unwrap_or(¤t.auth_key_name); - if effective_auth_key_name.is_empty() { - return Err(AppError::ValidationError( - "auth_key_name is required when auth_method is 'body' \ - (e.g. 'app_secret' for custom body-auth services)" - .to_string(), - )); + if effective_auth_key_name.trim().is_empty() { + return Err(AppError::ValidationError(auth_key_name_required_message( + effective_auth_method, + ))); } + } + + if effective_auth_method == "body" { // Normalize legacy `current.node_id == Some("")` to `None`. // Matches the normalization in `validate_update_inputs` (fifteenth- // round Codex P1) and in the `PUT /keys` handler so the @@ -862,44 +871,22 @@ pub async fn validate_update_inputs( None => current.node_id.as_deref().filter(|n| !n.is_empty()), }; - if effective_auth_method == "body" { - if effective_auth_key_name.is_empty() { - return Err(AppError::ValidationError( - "auth_key_name is required when auth_method is 'body' \ - (e.g. 'app_secret' for custom body-auth services)" - .to_string(), - )); - } - if effective_node_id.is_some() { - return Err(AppError::ValidationError( - "auth_method 'body' is not supported for node-routed services. \ - Credential body injection only works for direct (non-node) routing." - .to_string(), - )); - } - } - - // header / query / path all inject the credential under a caller- - // supplied key name; an empty key would produce an unauthenticated - // request (blank header, `?=` query, `//` path). - // Services originally created with `auth_method: "none"` store an - // empty `auth_key_name`, so a PUT that upgrades them without also - // sending `auth_key_name` would slip through pre-existing - // validation. Reject here so direct routing and node pushes can - // both assume a non-empty injection key downstream - // (thirty-second-round Codex P2). bearer/basic are intentionally - // excluded because the handler synthesizes an `Authorization` - // default for them. - if matches!(effective_auth_method, "header" | "query" | "path") - && effective_auth_key_name.is_empty() + if auth_method_requires_key_name(effective_auth_method) + && effective_auth_key_name.trim().is_empty() { - return Err(AppError::ValidationError(format!( - "auth_key_name is required when auth_method is '{effective_auth_method}'. \ - Supply a non-empty key name (e.g. 'X-API-Key' for header, \ - 'api_key' for query, 'bot' for path)." + return Err(AppError::ValidationError(auth_key_name_required_message( + effective_auth_method, ))); } + if effective_auth_method == "body" && effective_node_id.is_some() { + return Err(AppError::ValidationError( + "auth_method 'body' is not supported for node-routed services. \ + Credential body injection only works for direct (non-node) routing." + .to_string(), + )); + } + if effective_auth_method == "token_exchange" { if effective_node_id.is_some() { return Err(AppError::ValidationError( @@ -1446,6 +1433,7 @@ mod tests { WsFrameDirection, WsFrameInjection, WsFrameKind, WsFrameTrigger, }; use crate::test_utils::{connect_test_database, test_user_service}; + use mongodb::bson::doc; fn sample_identity_config() -> IdentityConfig { IdentityConfig { @@ -1562,6 +1550,153 @@ mod tests { } } + async fn assert_create_user_service_rejects_empty_auth_key_name(method: &str) { + let Some(db) = connect_test_database(&format!("user_service_empty_{method}")).await else { + eprintln!("skipping user_service_service integration test: no local MongoDB available"); + return; + }; + + let user_id = uuid::Uuid::new_v4().to_string(); + let err = create_user_service( + &db, + &user_id, + &user_id, + &format!("svc-{method}"), + "endpoint-1", + Some("api-key-1"), + method, + "", + None, + None, + 0, + "http", + None, + None, + None, + &IdentityConfig::none(), + None, + ) + .await + .expect_err("empty auth_key_name should be rejected"); + + assert!(matches!( + err, + AppError::ValidationError(message) + if message.contains(&format!("auth_method is '{method}'")) + )); + } + + #[tokio::test] + async fn create_user_service_rejects_header_with_empty_auth_key_name() { + assert_create_user_service_rejects_empty_auth_key_name("header").await; + } + + #[tokio::test] + async fn create_user_service_rejects_query_with_empty_auth_key_name() { + assert_create_user_service_rejects_empty_auth_key_name("query").await; + } + + #[tokio::test] + async fn create_user_service_rejects_path_with_empty_auth_key_name() { + assert_create_user_service_rejects_empty_auth_key_name("path").await; + } + + #[tokio::test] + async fn create_user_service_allows_bearer_with_empty_auth_key_name() { + let Some(db) = connect_test_database("user_service_bearer_empty_auth_key_name").await + else { + eprintln!("skipping user_service_service integration test: no local MongoDB available"); + return; + }; + + let user_id = uuid::Uuid::new_v4().to_string(); + let endpoint_id = uuid::Uuid::new_v4().to_string(); + let api_key_id = uuid::Uuid::new_v4().to_string(); + db.collection::(USER_ENDPOINTS) + .insert_one(doc! { "_id": &endpoint_id, "user_id": &user_id }) + .await + .unwrap(); + db.collection::(USER_API_KEYS) + .insert_one(doc! { "_id": &api_key_id, "user_id": &user_id }) + .await + .unwrap(); + + let service = create_user_service( + &db, + &user_id, + &user_id, + "bearer-empty-key-name", + &endpoint_id, + Some(&api_key_id), + "bearer", + "", + None, + None, + 0, + "http", + None, + None, + None, + &IdentityConfig::none(), + None, + ) + .await + .expect("bearer auth should not require auth_key_name"); + + assert_eq!(service.auth_method, "bearer"); + assert_eq!(service.auth_key_name, ""); + } + + #[tokio::test] + async fn update_user_service_rejects_switch_to_header_without_auth_key_name() { + let Some(db) = + connect_test_database("user_service_update_header_empty_auth_key_name").await + else { + eprintln!("skipping user_service_service integration test: no local MongoDB available"); + return; + }; + + let user_id = uuid::Uuid::new_v4().to_string(); + let service_id = uuid::Uuid::new_v4().to_string(); + let mut service = test_user_service( + &service_id, + &user_id, + "header-update", + "endpoint-1", + None, + None, + ); + service.api_key_id = Some("api-key-1".to_string()); + db.collection::(COLLECTION_NAME) + .insert_one(&service) + .await + .unwrap(); + + let err = update_user_service( + &db, + &user_id, + &user_id, + &service_id, + Some("header"), + None, + None, + None, + None, + None, + None, + None, + None, + ) + .await + .expect_err("switching to header without auth_key_name should fail"); + + assert!(matches!( + err, + AppError::ValidationError(message) + if message.contains("auth_method is 'header'") + )); + } + #[tokio::test] async fn update_user_service_round_trips_ws_frame_injections() { let Some(db) = connect_test_database("user_service_ws_frames").await else {