openzeppelin_relayer/api/routes/
plugin.rs

1//! This module defines the HTTP routes for plugin operations.
2//! It includes handlers for calling plugin methods.
3//! The routes are integrated with the Actix-web framework and interact with the plugin controller.
4use std::collections::HashMap;
5
6use crate::{
7    api::controllers::plugin,
8    metrics::PLUGIN_CALLS,
9    models::{
10        ApiError, ApiResponse, DefaultAppState, PaginationQuery, PluginCallRequest,
11        UpdatePluginRequest,
12    },
13    repositories::PluginRepositoryTrait,
14};
15use actix_web::{get, patch, post, web, HttpRequest, HttpResponse, Responder, ResponseError};
16use url::form_urlencoded;
17
18/// List plugins
19#[get("/plugins")]
20async fn list_plugins(
21    query: web::Query<PaginationQuery>,
22    data: web::ThinData<DefaultAppState>,
23) -> impl Responder {
24    plugin::list_plugins(query.into_inner(), data).await
25}
26
27/// Extracts HTTP headers from the request into a HashMap.
28fn extract_headers(http_req: &HttpRequest) -> HashMap<String, Vec<String>> {
29    let mut headers: HashMap<String, Vec<String>> = HashMap::new();
30    for (name, value) in http_req.headers().iter() {
31        if let Ok(value_str) = value.to_str() {
32            headers
33                .entry(name.as_str().to_string())
34                .or_default()
35                .push(value_str.to_string());
36        }
37    }
38    headers
39}
40
41/// Extracts query parameters from the request into a HashMap.
42/// Supports multiple values for the same key (e.g., ?tag=a&tag=b)
43fn extract_query_params(http_req: &HttpRequest) -> HashMap<String, Vec<String>> {
44    let mut query_params: HashMap<String, Vec<String>> = HashMap::new();
45    let query_string = http_req.query_string();
46
47    if query_string.is_empty() {
48        return query_params;
49    }
50
51    // Parse query string to support multiple values for same key (e.g., ?tag=a&tag=b)
52    // This also URL-decodes percent-encoded sequences and '+' characters.
53    // Note: actix-web's Query<HashMap> only keeps the last value, so we parse manually.
54    for (key, value) in form_urlencoded::parse(query_string.as_bytes()) {
55        query_params
56            .entry(key.into_owned())
57            .or_default()
58            .push(value.into_owned());
59    }
60
61    query_params
62}
63
64/// Resolves the effective route from path and query parameters.
65/// Path route takes precedence; if empty, falls back to `route` query parameter.
66fn resolve_route(path_route: &str, http_req: &HttpRequest) -> String {
67    // Early return to avoid unnecessary query parameter parsing when path_route is provided
68    if !path_route.is_empty() {
69        return path_route.to_string();
70    }
71
72    // Only parse query parameters when path_route is empty (lazy evaluation)
73    let query_params = extract_query_params(http_req);
74    query_params
75        .get("route")
76        .and_then(|values| values.first())
77        .cloned()
78        .unwrap_or_default()
79}
80
81fn build_plugin_call_request_from_post_body(
82    route: &str,
83    http_req: &HttpRequest,
84    body: &[u8],
85) -> Result<PluginCallRequest, HttpResponse> {
86    // Parse the body as generic JSON first
87    let body_json: serde_json::Value = match serde_json::from_slice(body) {
88        Ok(json) => json,
89        Err(e) => {
90            tracing::error!("Failed to parse request body as JSON: {}", e);
91            return Err(HttpResponse::BadRequest()
92                .json(ApiResponse::<()>::error(format!("Invalid JSON: {e}"))));
93        }
94    };
95
96    // Check if the body already has a "params" field
97    if body_json.get("params").is_some() {
98        // Body already has params field, deserialize normally
99        match serde_json::from_value::<PluginCallRequest>(body_json) {
100            Ok(mut req) => {
101                req.headers = Some(extract_headers(http_req));
102                req.route = Some(route.to_string());
103                Ok(req)
104            }
105            Err(e) => {
106                tracing::error!("Failed to deserialize PluginCallRequest: {}", e);
107                Err(
108                    HttpResponse::BadRequest().json(ApiResponse::<()>::error(format!(
109                        "Invalid request format: {e}"
110                    ))),
111                )
112            }
113        }
114    } else {
115        // Body doesn't have params field, wrap entire body as params
116        Ok(PluginCallRequest {
117            params: body_json,
118            headers: Some(extract_headers(http_req)),
119            route: Some(route.to_string()),
120            method: None,
121            query: None,
122        })
123    }
124}
125
126/// Calls a plugin method.
127#[post("/plugins/{plugin_id}/call{route:.*}")]
128async fn plugin_call(
129    params: web::Path<(String, String)>,
130    http_req: HttpRequest,
131    body: web::Bytes,
132    data: web::ThinData<DefaultAppState>,
133) -> Result<HttpResponse, ApiError> {
134    let (plugin_id, path_route) = params.into_inner();
135    let route = resolve_route(&path_route, &http_req);
136
137    let mut plugin_call_request =
138        match build_plugin_call_request_from_post_body(&route, &http_req, body.as_ref()) {
139            Ok(req) => req,
140            Err(resp) => {
141                // Track failed request (400 Bad Request)
142                PLUGIN_CALLS
143                    .with_label_values(&[plugin_id.as_str(), "POST", "400"])
144                    .inc();
145                return Ok(resp);
146            }
147        };
148    plugin_call_request.method = Some("POST".to_string());
149    plugin_call_request.query = Some(extract_query_params(&http_req));
150
151    let result = plugin::call_plugin(plugin_id.clone(), plugin_call_request, data).await;
152
153    // Track the request with appropriate status
154    let status_code = match &result {
155        Ok(response) => response.status(),
156        Err(e) => e.error_response().status(),
157    };
158    let status = status_code.as_str();
159    PLUGIN_CALLS
160        .with_label_values(&[plugin_id.as_str(), "POST", status])
161        .inc();
162
163    result
164}
165
166/// Calls a plugin method via GET request.
167#[get("/plugins/{plugin_id}/call{route:.*}")]
168async fn plugin_call_get(
169    params: web::Path<(String, String)>,
170    http_req: HttpRequest,
171    data: web::ThinData<DefaultAppState>,
172) -> Result<HttpResponse, ApiError> {
173    let (plugin_id, path_route) = params.into_inner();
174    let route = resolve_route(&path_route, &http_req);
175
176    // Check if GET requests are allowed for this plugin
177    let plugin = match data.plugin_repository.get_by_id(&plugin_id).await? {
178        Some(p) => p,
179        None => {
180            // Track 404
181            PLUGIN_CALLS
182                .with_label_values(&[plugin_id.as_str(), "GET", "404"])
183                .inc();
184            return Err(ApiError::NotFound(format!(
185                "Plugin with id {plugin_id} not found"
186            )));
187        }
188    };
189
190    if !plugin.allow_get_invocation {
191        // Track 405 Method Not Allowed
192        PLUGIN_CALLS
193            .with_label_values(&[plugin_id.as_str(), "GET", "405"])
194            .inc();
195        return Ok(HttpResponse::MethodNotAllowed().json(ApiResponse::<()>::error(
196            "GET requests are not enabled for this plugin. Set 'allow_get_invocation: true' in plugin configuration to enable.",
197        )));
198    }
199
200    // For GET requests, use empty params object
201    let plugin_call_request = PluginCallRequest {
202        params: serde_json::json!({}),
203        headers: Some(extract_headers(&http_req)),
204        route: Some(route),
205        method: Some("GET".to_string()),
206        query: Some(extract_query_params(&http_req)),
207    };
208
209    let result = plugin::call_plugin(plugin_id.clone(), plugin_call_request, data).await;
210
211    // Track the request with appropriate status
212    let status_code = match &result {
213        Ok(response) => response.status(),
214        Err(e) => e.error_response().status(),
215    };
216    let status = status_code.as_str();
217    PLUGIN_CALLS
218        .with_label_values(&[plugin_id.as_str(), "GET", status])
219        .inc();
220
221    result
222}
223
224/// Get plugin by ID
225#[get("/plugins/{plugin_id}")]
226async fn get_plugin(
227    path: web::Path<String>,
228    data: web::ThinData<DefaultAppState>,
229) -> impl Responder {
230    let plugin_id = path.into_inner();
231    plugin::get_plugin(plugin_id, data).await
232}
233
234/// Update plugin configuration
235#[patch("/plugins/{plugin_id}")]
236async fn update_plugin(
237    path: web::Path<String>,
238    body: web::Json<UpdatePluginRequest>,
239    data: web::ThinData<DefaultAppState>,
240) -> impl Responder {
241    let plugin_id = path.into_inner();
242    plugin::update_plugin(plugin_id, body.into_inner(), data).await
243}
244
245/// Initializes the routes for the plugins module.
246pub fn init(cfg: &mut web::ServiceConfig) {
247    // Register routes with literal segments before routes with path parameters
248    cfg.service(plugin_call); // POST /plugins/{plugin_id}/call
249    cfg.service(plugin_call_get); // GET /plugins/{plugin_id}/call
250    cfg.service(get_plugin); // GET /plugins/{plugin_id}
251    cfg.service(update_plugin); // PATCH /plugins/{plugin_id}
252    cfg.service(list_plugins); // GET /plugins
253}
254
255#[cfg(test)]
256mod tests {
257    use std::time::Duration;
258
259    use super::*;
260    use crate::{models::PluginModel, services::plugins::PluginCallResponse};
261    use actix_web::{test, App, HttpResponse};
262
263    // ============================================================================
264    // TEST HELPERS AND INFRASTRUCTURE
265    // ============================================================================
266
267    /// Helper struct to capture requests passed to handlers for verification
268    #[derive(Clone, Default)]
269    struct CapturedRequest {
270        inner: std::sync::Arc<std::sync::Mutex<Option<PluginCallRequest>>>,
271    }
272
273    impl CapturedRequest {
274        fn capture(&self, req: PluginCallRequest) {
275            *self.inner.lock().unwrap() = Some(req);
276        }
277
278        fn get(&self) -> Option<PluginCallRequest> {
279            self.inner.lock().unwrap().clone()
280        }
281
282        fn clear(&self) {
283            *self.inner.lock().unwrap() = None;
284        }
285    }
286
287    /// Capturing handler for POST requests that records the PluginCallRequest for verification
288    async fn capturing_plugin_call_handler(
289        params: web::Path<(String, String)>,
290        http_req: HttpRequest,
291        body: web::Bytes,
292        captured: web::Data<CapturedRequest>,
293    ) -> impl Responder {
294        let (_plugin_id, path_route) = params.into_inner();
295        let route = resolve_route(&path_route, &http_req);
296        match build_plugin_call_request_from_post_body(&route, &http_req, body.as_ref()) {
297            Ok(mut req) => {
298                req.method = Some("POST".to_string());
299                req.query = Some(extract_query_params(&http_req));
300                captured.capture(req);
301                HttpResponse::Ok().json(PluginCallResponse {
302                    result: serde_json::Value::Null,
303                    metadata: None,
304                })
305            }
306            Err(resp) => resp,
307        }
308    }
309
310    /// Capturing handler for GET requests that records the PluginCallRequest for verification
311    /// This simulates what plugin_call_get does: creates a PluginCallRequest with method="GET"
312    async fn capturing_plugin_call_get_handler(
313        params: web::Path<(String, String)>,
314        http_req: HttpRequest,
315        captured: web::Data<CapturedRequest>,
316    ) -> impl Responder {
317        let (_plugin_id, path_route) = params.into_inner();
318        let route = resolve_route(&path_route, &http_req);
319        // Simulate what plugin_call_get does for GET requests
320        let plugin_call_request = PluginCallRequest {
321            params: serde_json::json!({}),
322            headers: Some(extract_headers(&http_req)),
323            route: Some(route),
324            method: Some("GET".to_string()),
325            query: Some(extract_query_params(&http_req)),
326        };
327        captured.capture(plugin_call_request);
328        HttpResponse::Ok().json(PluginCallResponse {
329            result: serde_json::Value::Null,
330            metadata: None,
331        })
332    }
333
334    // ============================================================================
335    // UNIT TESTS FOR HELPER FUNCTIONS
336    // ============================================================================
337    async fn mock_list_plugins() -> impl Responder {
338        HttpResponse::Ok().json(vec![
339            PluginModel {
340                id: "test-plugin".to_string(),
341                path: "test-path".to_string(),
342                timeout: Duration::from_secs(69),
343                emit_logs: false,
344                emit_traces: false,
345                forward_logs: false,
346                allow_get_invocation: false,
347                config: None,
348                raw_response: false,
349            },
350            PluginModel {
351                id: "test-plugin2".to_string(),
352                path: "test-path2".to_string(),
353                timeout: Duration::from_secs(69),
354                emit_logs: false,
355                emit_traces: false,
356                forward_logs: false,
357                allow_get_invocation: false,
358                config: None,
359                raw_response: false,
360            },
361        ])
362    }
363
364    async fn mock_plugin_call() -> impl Responder {
365        HttpResponse::Ok().json(PluginCallResponse {
366            result: serde_json::Value::Null,
367            metadata: None,
368        })
369    }
370
371    #[actix_web::test]
372    async fn test_plugin_call() {
373        let app = test::init_service(
374            App::new()
375                .service(
376                    web::resource("/plugins/{plugin_id}/call")
377                        .route(web::post().to(mock_plugin_call)),
378                )
379                .configure(init),
380        )
381        .await;
382
383        let req = test::TestRequest::post()
384            .uri("/plugins/test-plugin/call")
385            .insert_header(("Content-Type", "application/json"))
386            .set_json(serde_json::json!({
387                "params": serde_json::Value::Null,
388            }))
389            .to_request();
390        let resp = test::call_service(&app, req).await;
391
392        assert!(resp.status().is_success());
393
394        let body = test::read_body(resp).await;
395        let plugin_call_response: PluginCallResponse = serde_json::from_slice(&body).unwrap();
396        assert!(plugin_call_response.result.is_null());
397    }
398
399    #[actix_web::test]
400    async fn test_list_plugins() {
401        let app = test::init_service(
402            App::new()
403                .service(web::resource("/plugins").route(web::get().to(mock_list_plugins)))
404                .configure(init),
405        )
406        .await;
407
408        let req = test::TestRequest::get().uri("/plugins").to_request();
409        let resp = test::call_service(&app, req).await;
410
411        assert!(resp.status().is_success());
412
413        let body = test::read_body(resp).await;
414        let plugin_call_response: Vec<PluginModel> = serde_json::from_slice(&body).unwrap();
415
416        assert_eq!(plugin_call_response.len(), 2);
417        assert_eq!(plugin_call_response[0].id, "test-plugin");
418        assert_eq!(plugin_call_response[0].path, "test-path");
419        assert_eq!(plugin_call_response[1].id, "test-plugin2");
420        assert_eq!(plugin_call_response[1].path, "test-path2");
421    }
422
423    #[actix_web::test]
424    async fn test_plugin_call_extracts_headers() {
425        // Test that custom headers are extracted and passed to the plugin
426        let app = test::init_service(
427            App::new()
428                .service(
429                    web::resource("/plugins/{plugin_id}/call")
430                        .route(web::post().to(mock_plugin_call)),
431                )
432                .configure(init),
433        )
434        .await;
435
436        let req = test::TestRequest::post()
437            .uri("/plugins/test-plugin/call")
438            .insert_header(("Content-Type", "application/json"))
439            .insert_header(("X-Custom-Header", "custom-value"))
440            .insert_header(("Authorization", "Bearer test-token"))
441            .insert_header(("X-Request-Id", "req-12345"))
442            // Add duplicate header to test multi-value
443            .insert_header(("Accept", "application/json"))
444            .set_json(serde_json::json!({
445                "params": {"test": "data"},
446            }))
447            .to_request();
448
449        let resp = test::call_service(&app, req).await;
450        assert!(resp.status().is_success());
451    }
452
453    #[actix_web::test]
454    async fn test_extract_headers_unit() {
455        // Unit test for extract_headers using TestRequest
456        use actix_web::test::TestRequest;
457
458        let req = TestRequest::default()
459            .insert_header(("X-Custom-Header", "value1"))
460            .insert_header(("Authorization", "Bearer token"))
461            .insert_header(("Content-Type", "application/json"))
462            .to_http_request();
463
464        let headers = extract_headers(&req);
465
466        assert_eq!(
467            headers.get("x-custom-header"),
468            Some(&vec!["value1".to_string()])
469        );
470        assert_eq!(
471            headers.get("authorization"),
472            Some(&vec!["Bearer token".to_string()])
473        );
474        assert_eq!(
475            headers.get("content-type"),
476            Some(&vec!["application/json".to_string()])
477        );
478    }
479
480    #[actix_web::test]
481    async fn test_extract_headers_multi_value() {
482        use actix_web::test::TestRequest;
483
484        // actix-web combines duplicate headers, but we can test the structure
485        let req = TestRequest::default()
486            .insert_header(("X-Values", "value1"))
487            .to_http_request();
488
489        let headers = extract_headers(&req);
490
491        // Verify structure is Vec<String>
492        let values = headers.get("x-values").unwrap();
493        assert_eq!(values.len(), 1);
494        assert_eq!(values[0], "value1");
495    }
496
497    #[actix_web::test]
498    async fn test_extract_headers_empty() {
499        use actix_web::test::TestRequest;
500
501        let req = TestRequest::default().to_http_request();
502        let headers = extract_headers(&req);
503
504        let _ = headers.len();
505    }
506
507    #[actix_web::test]
508    async fn test_extract_headers_skips_non_utf8_value() {
509        use actix_web::http::header::{HeaderName, HeaderValue};
510        use actix_web::test::TestRequest;
511
512        let non_utf8 = HeaderValue::from_bytes(&[0x80]).unwrap();
513        let req = TestRequest::default()
514            .insert_header((HeaderName::from_static("x-non-utf8"), non_utf8))
515            .insert_header(("X-Ok", "ok"))
516            .to_http_request();
517
518        let headers = extract_headers(&req);
519
520        assert_eq!(headers.get("x-ok"), Some(&vec!["ok".to_string()]));
521        assert!(headers.get("x-non-utf8").is_none());
522    }
523
524    #[actix_web::test]
525    async fn test_extract_query_params() {
526        use actix_web::test::TestRequest;
527
528        // Test basic query parameters
529        let req = TestRequest::default()
530            .uri("/test?foo=bar&baz=qux")
531            .to_http_request();
532
533        let query_params = extract_query_params(&req);
534
535        assert_eq!(query_params.get("foo"), Some(&vec!["bar".to_string()]));
536        assert_eq!(query_params.get("baz"), Some(&vec!["qux".to_string()]));
537    }
538
539    #[actix_web::test]
540    async fn test_extract_query_params_multiple_values() {
541        use actix_web::test::TestRequest;
542
543        // Test multiple values for same key
544        let req = TestRequest::default()
545            .uri("/test?tag=a&tag=b&tag=c")
546            .to_http_request();
547
548        let query_params = extract_query_params(&req);
549
550        assert_eq!(
551            query_params.get("tag"),
552            Some(&vec!["a".to_string(), "b".to_string(), "c".to_string()])
553        );
554    }
555
556    #[actix_web::test]
557    async fn test_extract_query_params_empty() {
558        use actix_web::test::TestRequest;
559
560        // Test empty query string
561        let req = TestRequest::default().uri("/test").to_http_request();
562
563        let query_params = extract_query_params(&req);
564
565        assert!(query_params.is_empty());
566    }
567
568    #[actix_web::test]
569    async fn test_extract_query_params_decoding_and_flags() {
570        use actix_web::test::TestRequest;
571
572        // percent decoding + '+' decoding + duplicate keys + keys without values
573        let req = TestRequest::default()
574            .uri("/test?foo=hello%20world&bar=a+b&tag=a%2Bb&flag&tag=c")
575            .to_http_request();
576
577        let query_params = extract_query_params(&req);
578
579        assert_eq!(
580            query_params.get("foo"),
581            Some(&vec!["hello world".to_string()])
582        );
583        assert_eq!(query_params.get("bar"), Some(&vec!["a b".to_string()]));
584        assert_eq!(
585            query_params.get("tag"),
586            Some(&vec!["a+b".to_string(), "c".to_string()])
587        );
588        assert_eq!(query_params.get("flag"), Some(&vec!["".to_string()]));
589    }
590
591    #[actix_web::test]
592    async fn test_extract_query_params_decodes_keys_and_handles_empty_values() {
593        use actix_web::test::TestRequest;
594
595        let req = TestRequest::default()
596            .uri("/test?na%6De=al%69ce&empty=&=noval&tag=a&tag=")
597            .to_http_request();
598
599        let query_params = extract_query_params(&req);
600
601        assert_eq!(query_params.get("name"), Some(&vec!["alice".to_string()]));
602        assert_eq!(query_params.get("empty"), Some(&vec!["".to_string()]));
603        assert_eq!(query_params.get(""), Some(&vec!["noval".to_string()]));
604        assert_eq!(
605            query_params.get("tag"),
606            Some(&vec!["a".to_string(), "".to_string()])
607        );
608    }
609
610    #[actix_web::test]
611    async fn test_build_plugin_call_request_invalid_json() {
612        use actix_web::test::TestRequest;
613
614        let http_req = TestRequest::default().to_http_request();
615
616        let result = build_plugin_call_request_from_post_body("/verify", &http_req, b"{bad");
617        assert!(result.is_err());
618        assert_eq!(
619            result.err().unwrap().status(),
620            actix_web::http::StatusCode::BAD_REQUEST
621        );
622    }
623
624    #[actix_web::test]
625    async fn test_build_plugin_call_request_wraps_body_without_params_field() {
626        use actix_web::test::TestRequest;
627
628        let http_req = TestRequest::default()
629            .insert_header(("X-Custom", "v1"))
630            .to_http_request();
631
632        let body = serde_json::to_vec(&serde_json::json!({"user": "alice"})).unwrap();
633        let req = build_plugin_call_request_from_post_body("/route", &http_req, &body).unwrap();
634
635        assert_eq!(req.params, serde_json::json!({"user": "alice"}));
636        assert_eq!(req.route, Some("/route".to_string()));
637        assert!(req.headers.as_ref().unwrap().contains_key("x-custom"));
638    }
639
640    #[actix_web::test]
641    async fn test_build_plugin_call_request_uses_params_field_when_present() {
642        use actix_web::test::TestRequest;
643
644        let http_req = TestRequest::default()
645            .insert_header(("X-Custom", "v1"))
646            .to_http_request();
647
648        let body = serde_json::to_vec(&serde_json::json!({"params": {"k": "v"}})).unwrap();
649        let req = build_plugin_call_request_from_post_body("/route", &http_req, &body).unwrap();
650
651        assert_eq!(req.params, serde_json::json!({"k": "v"}));
652        assert_eq!(req.route, Some("/route".to_string()));
653        assert!(req.headers.as_ref().unwrap().contains_key("x-custom"));
654    }
655
656    #[actix_web::test]
657    async fn test_build_plugin_call_request_with_empty_body() {
658        use actix_web::test::TestRequest;
659
660        let http_req = TestRequest::default().to_http_request();
661        let body = b"{}";
662        let req = build_plugin_call_request_from_post_body("/test", &http_req, body).unwrap();
663
664        assert_eq!(req.params, serde_json::json!({}));
665        assert_eq!(req.route, Some("/test".to_string()));
666    }
667
668    #[actix_web::test]
669    async fn test_build_plugin_call_request_with_null_params() {
670        use actix_web::test::TestRequest;
671
672        let http_req = TestRequest::default().to_http_request();
673        let body = serde_json::to_vec(&serde_json::json!({"params": null})).unwrap();
674        let req = build_plugin_call_request_from_post_body("/test", &http_req, &body).unwrap();
675
676        assert_eq!(req.params, serde_json::Value::Null);
677        assert_eq!(req.route, Some("/test".to_string()));
678    }
679
680    #[actix_web::test]
681    async fn test_build_plugin_call_request_with_array_params() {
682        use actix_web::test::TestRequest;
683
684        let http_req = TestRequest::default().to_http_request();
685        let body = serde_json::to_vec(&serde_json::json!({"params": [1, 2, 3]})).unwrap();
686        let req = build_plugin_call_request_from_post_body("/test", &http_req, &body).unwrap();
687
688        assert_eq!(req.params, serde_json::json!([1, 2, 3]));
689        assert_eq!(req.route, Some("/test".to_string()));
690    }
691
692    #[actix_web::test]
693    async fn test_build_plugin_call_request_with_string_params() {
694        use actix_web::test::TestRequest;
695
696        let http_req = TestRequest::default().to_http_request();
697        let body = serde_json::to_vec(&serde_json::json!({"params": "test-string"})).unwrap();
698        let req = build_plugin_call_request_from_post_body("/test", &http_req, &body).unwrap();
699
700        assert_eq!(req.params, serde_json::json!("test-string"));
701        assert_eq!(req.route, Some("/test".to_string()));
702    }
703
704    #[actix_web::test]
705    async fn test_build_plugin_call_request_with_additional_fields() {
706        use actix_web::test::TestRequest;
707
708        let http_req = TestRequest::default().to_http_request();
709        // Test that additional fields in the body are ignored when params field exists
710        // (they should be ignored during deserialization)
711        let body = serde_json::to_vec(&serde_json::json!({
712            "params": {"k": "v"},
713            "extra_field": "ignored"
714        }))
715        .unwrap();
716        let req = build_plugin_call_request_from_post_body("/test", &http_req, &body).unwrap();
717
718        assert_eq!(req.params, serde_json::json!({"k": "v"}));
719        assert_eq!(req.route, Some("/test".to_string()));
720    }
721
722    #[actix_web::test]
723    async fn test_plugin_call_get_not_found() {
724        use crate::api::controllers::plugin;
725        use crate::utils::mocks::mockutils::create_mock_app_state;
726
727        // Test the controller directly since route handler has type constraints
728        let app_state = create_mock_app_state(None, None, None, None, None, None).await;
729        let _plugin_call_request = PluginCallRequest {
730            params: serde_json::json!({}),
731            headers: None,
732            route: None,
733            method: Some("GET".to_string()),
734            query: None,
735        };
736
737        // The controller will return NotFound when plugin doesn't exist
738        let result = plugin::call_plugin(
739            "non-existent-plugin".to_string(),
740            _plugin_call_request,
741            web::ThinData(app_state),
742        )
743        .await;
744
745        assert!(result.is_err());
746        // Verify it's a NotFound error
747        if let Err(crate::models::ApiError::NotFound(_)) = result {
748            // Expected error type
749        } else {
750            panic!("Expected NotFound error, got different error");
751        }
752    }
753
754    #[actix_web::test]
755    async fn test_plugin_call_get_allowed() {
756        use crate::utils::mocks::mockutils::create_mock_app_state;
757        use std::time::Duration;
758
759        let plugin = PluginModel {
760            id: "test-plugin".to_string(),
761            path: "test-path".to_string(),
762            timeout: Duration::from_secs(60),
763            emit_logs: false,
764            emit_traces: false,
765            raw_response: false,
766            allow_get_invocation: true,
767            config: None,
768            forward_logs: false,
769        };
770
771        let app_state =
772            create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
773
774        // Verify the plugin exists and has allow_get_invocation=true
775        let plugin_repo = app_state.plugin_repository.clone();
776        let found_plugin = plugin_repo.get_by_id("test-plugin").await.unwrap();
777        assert!(found_plugin.is_some());
778        assert!(found_plugin.unwrap().allow_get_invocation);
779    }
780
781    #[actix_web::test]
782    async fn test_extract_query_params_with_only_question_mark() {
783        use actix_web::test::TestRequest;
784
785        let req = TestRequest::default().uri("/test?").to_http_request();
786        let query_params = extract_query_params(&req);
787
788        assert!(query_params.is_empty());
789    }
790
791    #[actix_web::test]
792    async fn test_extract_query_params_with_ampersand_only() {
793        use actix_web::test::TestRequest;
794
795        let req = TestRequest::default().uri("/test?&").to_http_request();
796        let query_params = extract_query_params(&req);
797
798        // form_urlencoded::parse skips empty keys, so this should be empty
799        // or contain an empty key depending on implementation
800        // Let's test that it handles it gracefully without panicking
801        let _ = query_params.len();
802    }
803
804    #[actix_web::test]
805    async fn test_extract_query_params_with_special_characters() {
806        use actix_web::test::TestRequest;
807
808        let req = TestRequest::default()
809            .uri("/test?key=value%20with%20spaces&symbol=%26%3D%3F")
810            .to_http_request();
811        let query_params = extract_query_params(&req);
812
813        assert_eq!(
814            query_params.get("key"),
815            Some(&vec!["value with spaces".to_string()])
816        );
817        assert_eq!(query_params.get("symbol"), Some(&vec!["&=?".to_string()]));
818    }
819
820    #[actix_web::test]
821    async fn test_extract_headers_case_insensitive() {
822        use actix_web::test::TestRequest;
823
824        let req = TestRequest::default()
825            .insert_header(("X-Custom-Header", "value1"))
826            .insert_header(("x-custom-header", "value2"))
827            .to_http_request();
828
829        let headers = extract_headers(&req);
830
831        // Headers should be normalized to lowercase
832        let values = headers.get("x-custom-header");
833        assert!(values.is_some());
834        // Note: actix-web may combine duplicate headers, so we just verify it exists
835        assert!(!values.unwrap().is_empty());
836    }
837
838    #[actix_web::test]
839    async fn test_extract_headers_with_empty_value() {
840        use actix_web::test::TestRequest;
841
842        let req = TestRequest::default()
843            .insert_header(("X-Empty", ""))
844            .insert_header(("X-Normal", "normal-value"))
845            .to_http_request();
846
847        let headers = extract_headers(&req);
848
849        assert_eq!(headers.get("x-empty"), Some(&vec!["".to_string()]));
850        assert_eq!(
851            headers.get("x-normal"),
852            Some(&vec!["normal-value".to_string()])
853        );
854    }
855
856    #[actix_web::test]
857    async fn test_build_plugin_call_request_with_empty_route() {
858        use actix_web::test::TestRequest;
859
860        let http_req = TestRequest::default().to_http_request();
861        let body = serde_json::to_vec(&serde_json::json!({"user": "alice"})).unwrap();
862        let req = build_plugin_call_request_from_post_body("", &http_req, &body).unwrap();
863
864        assert_eq!(req.route, Some("".to_string()));
865        assert_eq!(req.params, serde_json::json!({"user": "alice"}));
866    }
867
868    #[actix_web::test]
869    async fn test_build_plugin_call_request_with_root_route() {
870        use actix_web::test::TestRequest;
871
872        let http_req = TestRequest::default().to_http_request();
873        let body = serde_json::to_vec(&serde_json::json!({"user": "alice"})).unwrap();
874        let req = build_plugin_call_request_from_post_body("/", &http_req, &body).unwrap();
875
876        assert_eq!(req.route, Some("/".to_string()));
877    }
878
879    #[actix_web::test]
880    async fn test_extract_query_params_with_unicode() {
881        use actix_web::test::TestRequest;
882
883        let req = TestRequest::default()
884            .uri("/test?name=%E4%B8%AD%E6%96%87&value=test")
885            .to_http_request();
886
887        let query_params = extract_query_params(&req);
888
889        // Should decode UTF-8 encoded characters
890        assert_eq!(query_params.get("name"), Some(&vec!["中文".to_string()]));
891        assert_eq!(query_params.get("value"), Some(&vec!["test".to_string()]));
892    }
893
894    // ============================================================================
895    // INTEGRATION TESTS WITH CAPTURING HANDLERS
896    // ============================================================================
897
898    /// Verifies that headers are correctly extracted and passed to the plugin request
899    #[actix_web::test]
900    async fn test_headers_actually_extracted_and_passed() {
901        let captured = web::Data::new(CapturedRequest::default());
902        let app = test::init_service(
903            App::new()
904                .app_data(captured.clone())
905                .service(
906                    web::resource("/plugins/{plugin_id}/call{route:.*}")
907                        .route(web::post().to(capturing_plugin_call_handler)),
908                )
909                .configure(init),
910        )
911        .await;
912
913        let req = test::TestRequest::post()
914            .uri("/plugins/test-plugin/call/verify")
915            .insert_header(("Content-Type", "application/json"))
916            .insert_header(("X-Custom-Header", "custom-value"))
917            .insert_header(("Authorization", "Bearer token123"))
918            .insert_header(("X-Request-Id", "req-12345"))
919            .set_json(serde_json::json!({"test": "data"}))
920            .to_request();
921
922        let resp = test::call_service(&app, req).await;
923        assert!(resp.status().is_success());
924
925        // Verify headers were actually captured and passed correctly
926        let captured_req = captured.get().expect("Request should have been captured");
927        let headers = captured_req.headers.expect("Headers should be present");
928
929        assert!(
930            headers.contains_key("x-custom-header"),
931            "X-Custom-Header should be extracted (lowercased)"
932        );
933        assert!(
934            headers.contains_key("authorization"),
935            "Authorization header should be extracted"
936        );
937        assert_eq!(
938            headers.get("x-custom-header").unwrap()[0],
939            "custom-value",
940            "Header value should match"
941        );
942        assert_eq!(
943            headers.get("authorization").unwrap()[0],
944            "Bearer token123",
945            "Authorization header value should match"
946        );
947        assert_eq!(
948            headers.get("x-request-id").unwrap()[0],
949            "req-12345",
950            "X-Request-Id header value should match"
951        );
952    }
953
954    /// Verifies that the route field is correctly extracted from the URL path
955    #[actix_web::test]
956    async fn test_route_field_correctly_set() {
957        let captured = web::Data::new(CapturedRequest::default());
958        let app = test::init_service(
959            App::new()
960                .app_data(captured.clone())
961                .service(
962                    web::resource("/plugins/{plugin_id}/call{route:.*}")
963                        .route(web::post().to(capturing_plugin_call_handler)),
964                )
965                .configure(init),
966        )
967        .await;
968
969        let test_cases = vec![
970            ("/plugins/test/call", ""),
971            ("/plugins/test/call/verify", "/verify"),
972            ("/plugins/test/call/api/v1/verify", "/api/v1/verify"),
973            (
974                "/plugins/test/call/settle/transaction",
975                "/settle/transaction",
976            ),
977        ];
978
979        for (uri, expected_route) in test_cases {
980            captured.clear();
981            let req = test::TestRequest::post()
982                .uri(uri)
983                .insert_header(("Content-Type", "application/json"))
984                .set_json(serde_json::json!({}))
985                .to_request();
986
987            test::call_service(&app, req).await;
988
989            let captured_req = captured.get().expect("Request should have been captured");
990            assert_eq!(
991                captured_req.route,
992                Some(expected_route.to_string()),
993                "Route should be '{}' for URI '{}'",
994                expected_route,
995                uri
996            );
997        }
998    }
999
1000    /// Verifies that route can be specified via query parameter when path route is empty
1001    #[actix_web::test]
1002    async fn test_route_from_query_parameter() {
1003        let captured = web::Data::new(CapturedRequest::default());
1004        let app = test::init_service(
1005            App::new()
1006                .app_data(captured.clone())
1007                .service(
1008                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1009                        .route(web::post().to(capturing_plugin_call_handler)),
1010                )
1011                .configure(init),
1012        )
1013        .await;
1014
1015        // Test route from query parameter
1016        let req = test::TestRequest::post()
1017            .uri("/plugins/test/call?route=/verify")
1018            .insert_header(("Content-Type", "application/json"))
1019            .set_json(serde_json::json!({}))
1020            .to_request();
1021
1022        test::call_service(&app, req).await;
1023
1024        let captured_req = captured.get().expect("Request should have been captured");
1025        assert_eq!(
1026            captured_req.route,
1027            Some("/verify".to_string()),
1028            "Route should be extracted from query parameter"
1029        );
1030    }
1031
1032    /// Verifies that path route takes precedence over query parameter
1033    #[actix_web::test]
1034    async fn test_path_route_takes_precedence_over_query() {
1035        let captured = web::Data::new(CapturedRequest::default());
1036        let app = test::init_service(
1037            App::new()
1038                .app_data(captured.clone())
1039                .service(
1040                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1041                        .route(web::post().to(capturing_plugin_call_handler)),
1042                )
1043                .configure(init),
1044        )
1045        .await;
1046
1047        // Test that path route takes precedence over query param
1048        let req = test::TestRequest::post()
1049            .uri("/plugins/test/call/settle?route=/verify")
1050            .insert_header(("Content-Type", "application/json"))
1051            .set_json(serde_json::json!({}))
1052            .to_request();
1053
1054        test::call_service(&app, req).await;
1055
1056        let captured_req = captured.get().expect("Request should have been captured");
1057        assert_eq!(
1058            captured_req.route,
1059            Some("/settle".to_string()),
1060            "Path route should take precedence over query parameter"
1061        );
1062    }
1063
1064    /// Verifies that query parameters are correctly extracted and passed
1065    #[actix_web::test]
1066    async fn test_query_params_extracted_and_passed() {
1067        let captured = web::Data::new(CapturedRequest::default());
1068        let app = test::init_service(
1069            App::new()
1070                .app_data(captured.clone())
1071                .service(
1072                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1073                        .route(web::post().to(capturing_plugin_call_handler)),
1074                )
1075                .configure(init),
1076        )
1077        .await;
1078
1079        let req = test::TestRequest::post()
1080            .uri("/plugins/test/call?token=abc123&action=verify&tag=a&tag=b")
1081            .insert_header(("Content-Type", "application/json"))
1082            .set_json(serde_json::json!({}))
1083            .to_request();
1084
1085        test::call_service(&app, req).await;
1086
1087        let captured_req = captured.get().expect("Request should have been captured");
1088        let query = captured_req.query.expect("Query params should be present");
1089
1090        assert_eq!(
1091            query.get("token"),
1092            Some(&vec!["abc123".to_string()]),
1093            "Token query param should be extracted"
1094        );
1095        assert_eq!(
1096            query.get("action"),
1097            Some(&vec!["verify".to_string()]),
1098            "Action query param should be extracted"
1099        );
1100        assert_eq!(
1101            query.get("tag"),
1102            Some(&vec!["a".to_string(), "b".to_string()]),
1103            "Multiple tag query params should be extracted as vector"
1104        );
1105    }
1106
1107    /// Verifies that the method field is correctly set to "POST" for POST requests
1108    #[actix_web::test]
1109    async fn test_method_field_set_correctly_for_post() {
1110        let captured = web::Data::new(CapturedRequest::default());
1111        let app = test::init_service(
1112            App::new()
1113                .app_data(captured.clone())
1114                .service(
1115                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1116                        .route(web::post().to(capturing_plugin_call_handler)),
1117                )
1118                .configure(init),
1119        )
1120        .await;
1121
1122        let req = test::TestRequest::post()
1123            .uri("/plugins/test/call")
1124            .insert_header(("Content-Type", "application/json"))
1125            .set_json(serde_json::json!({}))
1126            .to_request();
1127
1128        test::call_service(&app, req).await;
1129
1130        let captured_req = captured.get().expect("Request should have been captured");
1131        assert_eq!(
1132            captured_req.method,
1133            Some("POST".to_string()),
1134            "Method should be set to POST for POST requests"
1135        );
1136    }
1137
1138    /// Verifies that invalid JSON returns a proper 400 Bad Request error with helpful message
1139    #[actix_web::test]
1140    async fn test_invalid_json_returns_proper_error() {
1141        use actix_web::test::TestRequest;
1142
1143        let http_req = TestRequest::default().to_http_request();
1144
1145        // Test case 1: Invalid JSON syntax
1146        let result =
1147            build_plugin_call_request_from_post_body("/test", &http_req, b"{invalid json here}");
1148        assert!(result.is_err(), "Invalid JSON should return error");
1149        let err_response = result.unwrap_err();
1150        assert_eq!(
1151            err_response.status(),
1152            actix_web::http::StatusCode::BAD_REQUEST,
1153            "Invalid JSON should return 400 Bad Request"
1154        );
1155        let body_bytes = actix_web::body::to_bytes(err_response.into_body())
1156            .await
1157            .unwrap();
1158        let body_str = std::str::from_utf8(&body_bytes).unwrap();
1159        assert!(
1160            body_str.contains("Invalid JSON"),
1161            "Error message should contain 'Invalid JSON', got: {}",
1162            body_str
1163        );
1164
1165        // Test case 2: Single quotes (not valid JSON)
1166        let result =
1167            build_plugin_call_request_from_post_body("/test", &http_req, b"{'key': 'value'}");
1168        assert!(
1169            result.is_err(),
1170            "Invalid JSON with single quotes should return error"
1171        );
1172
1173        // Test case 3: Unquoted keys
1174        let result = build_plugin_call_request_from_post_body("/test", &http_req, b"{key: value}");
1175        assert!(
1176            result.is_err(),
1177            "Invalid JSON with unquoted keys should return error"
1178        );
1179    }
1180
1181    /// Verifies query parameter edge cases: keys without values, empty values, etc.
1182    #[actix_web::test]
1183    async fn test_query_params_with_edge_cases() {
1184        use actix_web::test::TestRequest;
1185
1186        // Test: key without value (flag parameter)
1187        let req = TestRequest::default()
1188            .uri("/test?flag&key=value")
1189            .to_http_request();
1190        let params = extract_query_params(&req);
1191
1192        assert_eq!(
1193            params.get("flag"),
1194            Some(&vec!["".to_string()]),
1195            "Flag parameter without value should have empty string value"
1196        );
1197        assert_eq!(
1198            params.get("key"),
1199            Some(&vec!["value".to_string()]),
1200            "Key with value should be extracted correctly"
1201        );
1202
1203        // Test: empty value vs no value
1204        let req = TestRequest::default()
1205            .uri("/test?empty=&flag")
1206            .to_http_request();
1207        let params = extract_query_params(&req);
1208
1209        assert_eq!(
1210            params.get("empty"),
1211            Some(&vec!["".to_string()]),
1212            "Empty value should be preserved"
1213        );
1214        assert_eq!(
1215            params.get("flag"),
1216            Some(&vec!["".to_string()]),
1217            "Flag without value should also have empty string"
1218        );
1219    }
1220
1221    /// Verifies that header values preserve their original case (keys are lowercased)
1222    #[actix_web::test]
1223    async fn test_extract_headers_preserves_original_case_in_values() {
1224        use actix_web::test::TestRequest;
1225
1226        let req = TestRequest::default()
1227            .insert_header(("X-Mixed-Case", "Value-With-CAPS"))
1228            .insert_header(("X-Lower", "lowercase-value"))
1229            .insert_header(("X-Upper", "UPPERCASE-VALUE"))
1230            .to_http_request();
1231
1232        let headers = extract_headers(&req);
1233
1234        // Keys are normalized to lowercase, but values should preserve case
1235        let mixed_case_values = headers.get("x-mixed-case").unwrap();
1236        assert_eq!(
1237            mixed_case_values[0], "Value-With-CAPS",
1238            "Header values should preserve original case"
1239        );
1240
1241        let lower_values = headers.get("x-lower").unwrap();
1242        assert_eq!(lower_values[0], "lowercase-value");
1243
1244        let upper_values = headers.get("x-upper").unwrap();
1245        assert_eq!(upper_values[0], "UPPERCASE-VALUE");
1246    }
1247
1248    /// Verifies handling of very long query strings
1249    #[actix_web::test]
1250    async fn test_very_long_query_string() {
1251        use actix_web::test::TestRequest;
1252
1253        // Test with a reasonably long query string (1000 chars)
1254        let long_value = "a".repeat(1000);
1255        let uri = format!("/test?data={}", long_value);
1256
1257        let req = TestRequest::default().uri(&uri).to_http_request();
1258
1259        let params = extract_query_params(&req);
1260        assert_eq!(
1261            params.get("data").unwrap()[0].len(),
1262            1000,
1263            "Long query parameter value should be handled correctly"
1264        );
1265        assert_eq!(
1266            params.get("data").unwrap()[0],
1267            long_value,
1268            "Long query parameter value should match"
1269        );
1270    }
1271
1272    /// Verifies that complex nested JSON structures in params are preserved correctly
1273    #[actix_web::test]
1274    async fn test_params_field_with_complex_nested_structure() {
1275        use actix_web::test::TestRequest;
1276
1277        let http_req = TestRequest::default().to_http_request();
1278        let complex_json = serde_json::json!({
1279            "params": {
1280                "user": {
1281                    "name": "alice",
1282                    "metadata": {
1283                        "tags": ["a", "b", "c"],
1284                        "score": 100
1285                    }
1286                },
1287                "action": "verify"
1288            }
1289        });
1290
1291        let body = serde_json::to_vec(&complex_json).unwrap();
1292        let req = build_plugin_call_request_from_post_body("/test", &http_req, &body).unwrap();
1293
1294        // Verify nested structure is preserved
1295        assert_eq!(
1296            req.params.get("user").unwrap().get("name").unwrap(),
1297            "alice",
1298            "Nested user name should be preserved"
1299        );
1300        assert!(
1301            req.params.get("user").unwrap().get("metadata").is_some(),
1302            "Nested metadata should be preserved"
1303        );
1304        let metadata = req.params.get("user").unwrap().get("metadata").unwrap();
1305        assert_eq!(
1306            metadata.get("score").unwrap(),
1307            100,
1308            "Nested score should be preserved"
1309        );
1310        assert!(
1311            metadata.get("tags").is_some(),
1312            "Nested tags array should be preserved"
1313        );
1314    }
1315
1316    /// Integration test: Verifies GET restriction logic by testing the repository behavior
1317    /// that the route handler uses. This test verifies that plugin_call_get correctly checks
1318    /// allow_get_invocation and would return 405 Method Not Allowed when GET is disabled,
1319    /// and 404 when plugin doesn't exist.
1320    #[actix_web::test]
1321    async fn test_get_restriction_logic_through_route_handler() {
1322        use crate::utils::mocks::mockutils::create_mock_app_state;
1323        use std::time::Duration;
1324
1325        // Create plugin with allow_get_invocation = false
1326        let plugin_disabled = PluginModel {
1327            id: "plugin-no-get".to_string(),
1328            path: "test-path".to_string(),
1329            timeout: Duration::from_secs(60),
1330            emit_logs: false,
1331            emit_traces: false,
1332            raw_response: false,
1333            allow_get_invocation: false,
1334            config: None,
1335            forward_logs: false,
1336        };
1337
1338        // Create plugin with allow_get_invocation = true
1339        let plugin_enabled = PluginModel {
1340            id: "plugin-with-get".to_string(),
1341            path: "test-path".to_string(),
1342            timeout: Duration::from_secs(60),
1343            emit_logs: false,
1344            emit_traces: false,
1345            raw_response: false,
1346            allow_get_invocation: true,
1347            config: None,
1348            forward_logs: false,
1349        };
1350
1351        let app_state = create_mock_app_state(
1352            None,
1353            None,
1354            None,
1355            None,
1356            Some(vec![plugin_disabled.clone(), plugin_enabled.clone()]),
1357            None,
1358        )
1359        .await;
1360
1361        // Test 1: GET request to non-existent plugin should return 404
1362        // We verify the repository logic that the handler uses
1363        let plugin_repo = app_state.plugin_repository.clone();
1364        let found_plugin = plugin_repo
1365            .get_by_id("non-existent")
1366            .await
1367            .expect("Repository call should succeed");
1368        assert!(
1369            found_plugin.is_none(),
1370            "Non-existent plugin should return None (would trigger 404 in route handler)"
1371        );
1372
1373        // Test 2: GET request to plugin with allow_get_invocation=false should be rejected
1374        let found_plugin = plugin_repo
1375            .get_by_id("plugin-no-get")
1376            .await
1377            .expect("Repository call should succeed");
1378        assert!(found_plugin.is_some(), "Plugin should exist in repository");
1379        let plugin = found_plugin.unwrap();
1380        assert!(
1381            !plugin.allow_get_invocation,
1382            "Plugin should have allow_get_invocation=false (would trigger 405 in route handler)"
1383        );
1384
1385        // Test 3: GET request to plugin with allow_get_invocation=true should be allowed
1386        let found_plugin = plugin_repo
1387            .get_by_id("plugin-with-get")
1388            .await
1389            .expect("Repository call should succeed");
1390        assert!(found_plugin.is_some(), "Plugin should exist in repository");
1391        assert!(
1392            found_plugin.unwrap().allow_get_invocation,
1393            "Plugin should have allow_get_invocation=true (would proceed in route handler)"
1394        );
1395    }
1396
1397    /// Verifies that error responses contain appropriate status codes and messages
1398    #[actix_web::test]
1399    async fn test_error_responses_contain_appropriate_messages() {
1400        use crate::models::ApiResponse;
1401        use actix_web::test::TestRequest;
1402
1403        let http_req = TestRequest::default().to_http_request();
1404
1405        // Test invalid JSON error response
1406        let result = build_plugin_call_request_from_post_body("/test", &http_req, b"{invalid}");
1407
1408        assert!(result.is_err());
1409        let err_response = result.unwrap_err();
1410        assert_eq!(
1411            err_response.status(),
1412            actix_web::http::StatusCode::BAD_REQUEST,
1413            "Should return 400 Bad Request"
1414        );
1415
1416        // Verify error response structure
1417        let body_bytes = actix_web::body::to_bytes(err_response.into_body())
1418            .await
1419            .unwrap();
1420        let api_response: ApiResponse<()> =
1421            serde_json::from_slice(&body_bytes).expect("Response should be valid JSON");
1422        assert!(
1423            !api_response.success,
1424            "Error response should have success=false"
1425        );
1426        assert!(
1427            api_response.error.is_some(),
1428            "Error response should contain error message"
1429        );
1430        assert!(
1431            api_response.error.unwrap().contains("Invalid JSON"),
1432            "Error message should mention 'Invalid JSON'"
1433        );
1434    }
1435
1436    /// Verifies query parameters with special characters and URL encoding
1437    #[actix_web::test]
1438    async fn test_query_params_with_special_characters_and_encoding() {
1439        use actix_web::test::TestRequest;
1440
1441        // Test various special characters and encoding scenarios
1442        let req = TestRequest::default()
1443            .uri("/test?key=value%20with%20spaces&symbol=%26%3D%3F&unicode=%E4%B8%AD%E6%96%87")
1444            .to_http_request();
1445
1446        let params = extract_query_params(&req);
1447
1448        assert_eq!(
1449            params.get("key"),
1450            Some(&vec!["value with spaces".to_string()]),
1451            "URL-encoded spaces should be decoded"
1452        );
1453        assert_eq!(
1454            params.get("symbol"),
1455            Some(&vec!["&=?".to_string()]),
1456            "URL-encoded special characters should be decoded"
1457        );
1458        assert_eq!(
1459            params.get("unicode"),
1460            Some(&vec!["中文".to_string()]),
1461            "URL-encoded Unicode should be decoded"
1462        );
1463    }
1464
1465    /// Verifies that params without wrapper are correctly wrapped
1466    #[actix_web::test]
1467    async fn test_params_without_wrapper_correctly_wrapped() {
1468        use actix_web::test::TestRequest;
1469
1470        let http_req = TestRequest::default().to_http_request();
1471
1472        // Body without "params" field should be wrapped
1473        let body_without_params = serde_json::json!({
1474            "user": "alice",
1475            "action": "transfer",
1476            "amount": 100
1477        });
1478
1479        let body = serde_json::to_vec(&body_without_params).unwrap();
1480        let req = build_plugin_call_request_from_post_body("/test", &http_req, &body).unwrap();
1481
1482        // The entire body should become the params field
1483        assert_eq!(
1484            req.params.get("user").unwrap(),
1485            "alice",
1486            "User field should be in params"
1487        );
1488        assert_eq!(
1489            req.params.get("action").unwrap(),
1490            "transfer",
1491            "Action field should be in params"
1492        );
1493        assert_eq!(
1494            req.params.get("amount").unwrap(),
1495            100,
1496            "Amount field should be in params"
1497        );
1498    }
1499
1500    /// Verifies that the method field is correctly set to "GET" for GET requests
1501    #[actix_web::test]
1502    async fn test_method_field_set_correctly_for_get() {
1503        let captured = web::Data::new(CapturedRequest::default());
1504        let app = test::init_service(
1505            App::new()
1506                .app_data(captured.clone())
1507                .service(
1508                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1509                        .route(web::get().to(capturing_plugin_call_get_handler)),
1510                )
1511                .configure(init),
1512        )
1513        .await;
1514
1515        let req = test::TestRequest::get()
1516            .uri("/plugins/test/call/verify?token=abc123")
1517            .to_request();
1518
1519        test::call_service(&app, req).await;
1520
1521        let captured_req = captured.get().expect("Request should have been captured");
1522        assert_eq!(
1523            captured_req.method,
1524            Some("GET".to_string()),
1525            "Method should be set to GET for GET requests"
1526        );
1527        assert_eq!(
1528            captured_req.params,
1529            serde_json::json!({}),
1530            "GET requests should have empty params object"
1531        );
1532    }
1533
1534    /// Verifies that invalid JSON returns 400 Bad Request when sent through the capturing handler
1535    /// This tests the error flow through the actual request processing logic
1536    #[actix_web::test]
1537    async fn test_invalid_json_through_route_handler() {
1538        let captured = web::Data::new(CapturedRequest::default());
1539        let app = test::init_service(
1540            App::new()
1541                .app_data(captured.clone())
1542                .service(
1543                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1544                        .route(web::post().to(capturing_plugin_call_handler)),
1545                )
1546                .configure(init),
1547        )
1548        .await;
1549
1550        let req = test::TestRequest::post()
1551            .uri("/plugins/test-plugin/call")
1552            .insert_header(("Content-Type", "application/json"))
1553            .set_payload("{invalid json syntax}")
1554            .to_request();
1555
1556        let resp = test::call_service(&app, req).await;
1557
1558        assert_eq!(
1559            resp.status(),
1560            actix_web::http::StatusCode::BAD_REQUEST,
1561            "Invalid JSON should return 400 Bad Request through route handler"
1562        );
1563
1564        // Verify error response structure
1565        let body = test::read_body(resp).await;
1566        let body_str = std::str::from_utf8(&body).unwrap();
1567        assert!(
1568            body_str.contains("Invalid JSON"),
1569            "Error message should contain 'Invalid JSON', got: {}",
1570            body_str
1571        );
1572
1573        // Verify that invalid request was not captured
1574        assert!(
1575            captured.get().is_none(),
1576            "Invalid JSON request should not be captured"
1577        );
1578    }
1579
1580    /// Verifies that PluginCallRequest with params field handles various param types correctly
1581    /// Since PluginCallRequest deserialization is lenient, we test that params can be any JSON type
1582    #[actix_web::test]
1583    async fn test_plugin_call_request_with_various_param_types() {
1584        use actix_web::test::TestRequest;
1585
1586        let http_req = TestRequest::default().to_http_request();
1587
1588        // Test case 1: params as string
1589        let body_with_string_params = serde_json::json!({
1590            "params": "this is a string, not an object"
1591        });
1592        let body = serde_json::to_vec(&body_with_string_params).unwrap();
1593        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1594        assert!(result.is_ok(), "Params as string should be valid");
1595        let req = result.unwrap();
1596        assert_eq!(
1597            req.params,
1598            serde_json::json!("this is a string, not an object"),
1599            "String params should be preserved"
1600        );
1601
1602        // Test case 2: params as array
1603        let body_with_array_params = serde_json::json!({
1604            "params": [1, 2, 3, "four"]
1605        });
1606        let body = serde_json::to_vec(&body_with_array_params).unwrap();
1607        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1608        assert!(result.is_ok(), "Params as array should be valid");
1609        let req = result.unwrap();
1610        assert_eq!(
1611            req.params,
1612            serde_json::json!([1, 2, 3, "four"]),
1613            "Array params should be preserved"
1614        );
1615
1616        // Test case 3: params as number
1617        let body_with_number_params = serde_json::json!({
1618            "params": 42
1619        });
1620        let body = serde_json::to_vec(&body_with_number_params).unwrap();
1621        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1622        assert!(result.is_ok(), "Params as number should be valid");
1623        let req = result.unwrap();
1624        assert_eq!(
1625            req.params,
1626            serde_json::json!(42),
1627            "Number params should be preserved"
1628        );
1629
1630        // Test case 4: params as boolean
1631        let body_with_bool_params = serde_json::json!({
1632            "params": true
1633        });
1634        let body = serde_json::to_vec(&body_with_bool_params).unwrap();
1635        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1636        assert!(result.is_ok(), "Params as boolean should be valid");
1637        let req = result.unwrap();
1638        assert_eq!(
1639            req.params,
1640            serde_json::json!(true),
1641            "Boolean params should be preserved"
1642        );
1643    }
1644
1645    /// Verifies that requests without Content-Type header are handled gracefully
1646    #[actix_web::test]
1647    async fn test_request_without_content_type_header() {
1648        let captured = web::Data::new(CapturedRequest::default());
1649        let app = test::init_service(
1650            App::new()
1651                .app_data(captured.clone())
1652                .service(
1653                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1654                        .route(web::post().to(capturing_plugin_call_handler)),
1655                )
1656                .configure(init),
1657        )
1658        .await;
1659
1660        // POST request without Content-Type header
1661        let req = test::TestRequest::post()
1662            .uri("/plugins/test-plugin/call")
1663            .set_payload("{\"test\": \"data\"}")
1664            .to_request();
1665
1666        let resp = test::call_service(&app, req).await;
1667
1668        // Should still process the request (Content-Type is not strictly required for JSON parsing)
1669        // The request should succeed and be captured
1670        assert!(
1671            resp.status().is_success(),
1672            "Request without Content-Type should be handled gracefully, got status: {}",
1673            resp.status()
1674        );
1675
1676        // Verify the request was captured and processed
1677        let captured_req = captured.get();
1678        assert!(
1679            captured_req.is_some(),
1680            "Request without Content-Type should still be processed"
1681        );
1682    }
1683
1684    /// Verifies handling of very large request bodies (stress test)
1685    #[actix_web::test]
1686    async fn test_very_large_request_body() {
1687        use actix_web::test::TestRequest;
1688
1689        let http_req = TestRequest::default().to_http_request();
1690
1691        // Create a large JSON body (10KB of data)
1692        let large_data = "x".repeat(10000);
1693        let large_json = serde_json::json!({
1694            "params": {
1695                "large_data": large_data,
1696                "count": 10000
1697            }
1698        });
1699
1700        let body = serde_json::to_vec(&large_json).unwrap();
1701        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1702
1703        assert!(result.is_ok(), "Large request body should be handled");
1704        let req = result.unwrap();
1705        assert_eq!(
1706            req.params
1707                .get("large_data")
1708                .unwrap()
1709                .as_str()
1710                .unwrap()
1711                .len(),
1712            10000,
1713            "Large data should be preserved correctly"
1714        );
1715    }
1716
1717    /// Verifies that nested routes with special characters are handled correctly
1718    #[actix_web::test]
1719    async fn test_nested_routes_with_special_characters() {
1720        use actix_web::test::TestRequest;
1721
1722        let http_req = TestRequest::default().to_http_request();
1723        let body = serde_json::to_vec(&serde_json::json!({"test": "data"})).unwrap();
1724
1725        let test_cases = vec![
1726            ("/api/v1/verify", "/api/v1/verify"),
1727            ("/settle/transaction", "/settle/transaction"),
1728            ("/path-with-dashes", "/path-with-dashes"),
1729            ("/path_with_underscores", "/path_with_underscores"),
1730            ("/path.with.dots", "/path.with.dots"),
1731            ("/path%20with%20spaces", "/path%20with%20spaces"), // URL encoded spaces
1732        ];
1733
1734        for (route, expected_route) in test_cases {
1735            let req = build_plugin_call_request_from_post_body(route, &http_req, &body).unwrap();
1736            assert_eq!(
1737                req.route,
1738                Some(expected_route.to_string()),
1739                "Route '{}' should be preserved as '{}'",
1740                route,
1741                expected_route
1742            );
1743        }
1744    }
1745
1746    #[actix_web::test]
1747    async fn test_plugin_call_route_handler_post() {
1748        use crate::utils::mocks::mockutils::create_mock_app_state;
1749        use std::time::Duration;
1750
1751        let plugin = PluginModel {
1752            id: "test-plugin".to_string(),
1753            path: "test-path".to_string(),
1754            timeout: Duration::from_secs(60),
1755            emit_logs: false,
1756            emit_traces: false,
1757            raw_response: false,
1758            allow_get_invocation: false,
1759            config: None,
1760            forward_logs: false,
1761        };
1762
1763        let app_state =
1764            create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
1765
1766        let app = test::init_service(
1767            App::new()
1768                .app_data(web::Data::new(web::ThinData(app_state)))
1769                .configure(init),
1770        )
1771        .await;
1772
1773        let req = test::TestRequest::post()
1774            .uri("/plugins/test-plugin/call")
1775            .insert_header(("Content-Type", "application/json"))
1776            .set_json(serde_json::json!({"params": {"test": "data"}}))
1777            .to_request();
1778
1779        let resp = test::call_service(&app, req).await;
1780
1781        // Plugin execution fails in test environment (no ts-node), but route handler is executed
1782        // Verify the route handler was called (returns 500 due to plugin execution failure)
1783        assert!(
1784            resp.status().is_server_error() || resp.status().is_client_error(),
1785            "Route handler should be executed, got status: {}",
1786            resp.status()
1787        );
1788    }
1789
1790    /// Integration test: Verifies that the actual plugin_call_get route handler processes
1791    /// GET requests when allowed.
1792    #[actix_web::test]
1793    async fn test_plugin_call_get_route_handler_allowed() {
1794        use crate::utils::mocks::mockutils::create_mock_app_state;
1795        use std::time::Duration;
1796
1797        let plugin = PluginModel {
1798            id: "test-plugin-with-get".to_string(),
1799            path: "test-path".to_string(),
1800            timeout: Duration::from_secs(60),
1801            emit_logs: false,
1802            emit_traces: false,
1803            raw_response: false,
1804            allow_get_invocation: true, // GET allowed
1805            config: None,
1806            forward_logs: false,
1807        };
1808
1809        let app_state =
1810            create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
1811
1812        let app = test::init_service(
1813            App::new()
1814                .app_data(web::Data::new(web::ThinData(app_state)))
1815                .configure(init),
1816        )
1817        .await;
1818
1819        let req = test::TestRequest::get()
1820            .uri("/plugins/test-plugin-with-get/call?token=abc123")
1821            .to_request();
1822
1823        let resp = test::call_service(&app, req).await;
1824
1825        // Plugin execution fails in test environment (no ts-node), but route handler is executed
1826        // Verify the route handler was called (returns 500 due to plugin execution failure)
1827        assert!(
1828            resp.status().is_server_error() || resp.status().is_client_error(),
1829            "Route handler should be executed, got status: {}",
1830            resp.status()
1831        );
1832    }
1833
1834    // ============================================================================
1835    // GET PLUGIN ROUTE TESTS
1836    // ============================================================================
1837
1838    /// Mock handler for get plugin that returns a plugin by ID
1839    async fn mock_get_plugin(path: web::Path<String>) -> impl Responder {
1840        let plugin_id = path.into_inner();
1841
1842        if plugin_id == "not-found" {
1843            return HttpResponse::NotFound().json(ApiResponse::<()>::error(format!(
1844                "Plugin with id {} not found",
1845                plugin_id
1846            )));
1847        }
1848
1849        let plugin = PluginModel {
1850            id: plugin_id,
1851            path: "test-path.ts".to_string(),
1852            timeout: Duration::from_secs(30),
1853            emit_logs: true,
1854            emit_traces: false,
1855            raw_response: false,
1856            allow_get_invocation: true,
1857            config: None,
1858            forward_logs: true,
1859        };
1860
1861        HttpResponse::Ok().json(ApiResponse::success(plugin))
1862    }
1863
1864    #[actix_web::test]
1865    async fn test_get_plugin_route_success() {
1866        let app = test::init_service(
1867            App::new()
1868                .service(
1869                    web::resource("/plugins/{plugin_id}").route(web::get().to(mock_get_plugin)),
1870                )
1871                .configure(init),
1872        )
1873        .await;
1874
1875        let req = test::TestRequest::get()
1876            .uri("/plugins/my-plugin")
1877            .to_request();
1878
1879        let resp = test::call_service(&app, req).await;
1880        assert!(resp.status().is_success());
1881
1882        let body = test::read_body(resp).await;
1883        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
1884        assert!(response.success);
1885        assert_eq!(response.data.as_ref().unwrap().id, "my-plugin");
1886        assert!(response.data.as_ref().unwrap().emit_logs);
1887        assert!(response.data.as_ref().unwrap().forward_logs);
1888    }
1889
1890    #[actix_web::test]
1891    async fn test_get_plugin_route_not_found() {
1892        let app = test::init_service(
1893            App::new()
1894                .service(
1895                    web::resource("/plugins/{plugin_id}").route(web::get().to(mock_get_plugin)),
1896                )
1897                .configure(init),
1898        )
1899        .await;
1900
1901        let req = test::TestRequest::get()
1902            .uri("/plugins/not-found")
1903            .to_request();
1904
1905        let resp = test::call_service(&app, req).await;
1906        assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND);
1907    }
1908
1909    // ============================================================================
1910    // UPDATE PLUGIN ROUTE TESTS
1911    // ============================================================================
1912
1913    /// Mock handler for update plugin that returns the updated plugin
1914    async fn mock_update_plugin(
1915        path: web::Path<String>,
1916        body: web::Json<UpdatePluginRequest>,
1917    ) -> impl Responder {
1918        let plugin_id = path.into_inner();
1919        let update = body.into_inner();
1920
1921        // Simulate successful update
1922        let updated_plugin = PluginModel {
1923            id: plugin_id,
1924            path: "test-path".to_string(),
1925            timeout: Duration::from_secs(update.timeout.unwrap_or(30)),
1926            emit_logs: update.emit_logs.unwrap_or(false),
1927            emit_traces: update.emit_traces.unwrap_or(false),
1928            raw_response: update.raw_response.unwrap_or(false),
1929            allow_get_invocation: update.allow_get_invocation.unwrap_or(false),
1930            config: update.config.flatten(),
1931            forward_logs: update.forward_logs.unwrap_or(false),
1932        };
1933
1934        HttpResponse::Ok().json(ApiResponse::success(updated_plugin))
1935    }
1936
1937    #[actix_web::test]
1938    async fn test_update_plugin_route_success() {
1939        let app = test::init_service(
1940            App::new()
1941                .service(
1942                    web::resource("/plugins/{plugin_id}")
1943                        .route(web::patch().to(mock_update_plugin)),
1944                )
1945                .configure(init),
1946        )
1947        .await;
1948
1949        let req = test::TestRequest::patch()
1950            .uri("/plugins/test-plugin")
1951            .insert_header(("Content-Type", "application/json"))
1952            .set_json(serde_json::json!({
1953                "timeout": 60,
1954                "emit_logs": true,
1955                "forward_logs": true
1956            }))
1957            .to_request();
1958
1959        let resp = test::call_service(&app, req).await;
1960        assert!(resp.status().is_success());
1961
1962        let body = test::read_body(resp).await;
1963        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
1964        assert!(response.success);
1965        assert_eq!(response.data.as_ref().unwrap().id, "test-plugin");
1966        assert_eq!(
1967            response.data.as_ref().unwrap().timeout,
1968            Duration::from_secs(60)
1969        );
1970        assert!(response.data.as_ref().unwrap().emit_logs);
1971        assert!(response.data.as_ref().unwrap().forward_logs);
1972    }
1973
1974    #[actix_web::test]
1975    async fn test_update_plugin_route_with_config() {
1976        let app = test::init_service(
1977            App::new()
1978                .service(
1979                    web::resource("/plugins/{plugin_id}")
1980                        .route(web::patch().to(mock_update_plugin)),
1981                )
1982                .configure(init),
1983        )
1984        .await;
1985
1986        let req = test::TestRequest::patch()
1987            .uri("/plugins/my-plugin")
1988            .insert_header(("Content-Type", "application/json"))
1989            .set_json(serde_json::json!({
1990                "config": {
1991                    "feature_enabled": true,
1992                    "api_key": "secret123"
1993                }
1994            }))
1995            .to_request();
1996
1997        let resp = test::call_service(&app, req).await;
1998        assert!(resp.status().is_success());
1999
2000        let body = test::read_body(resp).await;
2001        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
2002        assert!(response.success);
2003        assert!(response.data.as_ref().unwrap().config.is_some());
2004        let config = response.data.as_ref().unwrap().config.as_ref().unwrap();
2005        assert_eq!(
2006            config.get("feature_enabled"),
2007            Some(&serde_json::json!(true))
2008        );
2009        assert_eq!(config.get("api_key"), Some(&serde_json::json!("secret123")));
2010    }
2011
2012    #[actix_web::test]
2013    async fn test_update_plugin_route_clear_config() {
2014        let app = test::init_service(
2015            App::new()
2016                .service(
2017                    web::resource("/plugins/{plugin_id}")
2018                        .route(web::patch().to(mock_update_plugin)),
2019                )
2020                .configure(init),
2021        )
2022        .await;
2023
2024        let req = test::TestRequest::patch()
2025            .uri("/plugins/test-plugin")
2026            .insert_header(("Content-Type", "application/json"))
2027            .set_json(serde_json::json!({
2028                "config": null
2029            }))
2030            .to_request();
2031
2032        let resp = test::call_service(&app, req).await;
2033        assert!(resp.status().is_success());
2034
2035        let body = test::read_body(resp).await;
2036        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
2037        assert!(response.success);
2038        assert!(response.data.as_ref().unwrap().config.is_none());
2039    }
2040
2041    #[actix_web::test]
2042    async fn test_update_plugin_route_empty_body() {
2043        let app = test::init_service(
2044            App::new()
2045                .service(
2046                    web::resource("/plugins/{plugin_id}")
2047                        .route(web::patch().to(mock_update_plugin)),
2048                )
2049                .configure(init),
2050        )
2051        .await;
2052
2053        // Empty JSON object - no fields to update
2054        let req = test::TestRequest::patch()
2055            .uri("/plugins/test-plugin")
2056            .insert_header(("Content-Type", "application/json"))
2057            .set_json(serde_json::json!({}))
2058            .to_request();
2059
2060        let resp = test::call_service(&app, req).await;
2061        assert!(resp.status().is_success());
2062    }
2063
2064    #[actix_web::test]
2065    async fn test_update_plugin_route_all_fields() {
2066        let app = test::init_service(
2067            App::new()
2068                .service(
2069                    web::resource("/plugins/{plugin_id}")
2070                        .route(web::patch().to(mock_update_plugin)),
2071                )
2072                .configure(init),
2073        )
2074        .await;
2075
2076        let req = test::TestRequest::patch()
2077            .uri("/plugins/full-update-plugin")
2078            .insert_header(("Content-Type", "application/json"))
2079            .set_json(serde_json::json!({
2080                "timeout": 120,
2081                "emit_logs": true,
2082                "emit_traces": true,
2083                "raw_response": true,
2084                "allow_get_invocation": true,
2085                "forward_logs": true,
2086                "config": {
2087                    "key": "value"
2088                }
2089            }))
2090            .to_request();
2091
2092        let resp = test::call_service(&app, req).await;
2093        assert!(resp.status().is_success());
2094
2095        let body = test::read_body(resp).await;
2096        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
2097        let plugin = response.data.unwrap();
2098
2099        assert_eq!(plugin.timeout, Duration::from_secs(120));
2100        assert!(plugin.emit_logs);
2101        assert!(plugin.emit_traces);
2102        assert!(plugin.raw_response);
2103        assert!(plugin.allow_get_invocation);
2104        assert!(plugin.forward_logs);
2105        assert!(plugin.config.is_some());
2106    }
2107
2108    #[actix_web::test]
2109    async fn test_update_plugin_route_invalid_json() {
2110        let app = test::init_service(
2111            App::new()
2112                .service(
2113                    web::resource("/plugins/{plugin_id}")
2114                        .route(web::patch().to(mock_update_plugin)),
2115                )
2116                .configure(init),
2117        )
2118        .await;
2119
2120        let req = test::TestRequest::patch()
2121            .uri("/plugins/test-plugin")
2122            .insert_header(("Content-Type", "application/json"))
2123            .set_payload("{ invalid json }")
2124            .to_request();
2125
2126        let resp = test::call_service(&app, req).await;
2127        assert!(resp.status().is_client_error());
2128    }
2129
2130    #[actix_web::test]
2131    async fn test_update_plugin_route_unknown_field_rejected() {
2132        let app = test::init_service(
2133            App::new()
2134                .service(
2135                    web::resource("/plugins/{plugin_id}")
2136                        .route(web::patch().to(mock_update_plugin)),
2137                )
2138                .configure(init),
2139        )
2140        .await;
2141
2142        // UpdatePluginRequest has deny_unknown_fields, so this should fail
2143        let req = test::TestRequest::patch()
2144            .uri("/plugins/test-plugin")
2145            .insert_header(("Content-Type", "application/json"))
2146            .set_json(serde_json::json!({
2147                "timeout": 60,
2148                "unknown_field": "should_fail"
2149            }))
2150            .to_request();
2151
2152        let resp = test::call_service(&app, req).await;
2153        assert!(resp.status().is_client_error());
2154    }
2155}