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 '{expected_route}' for URI '{uri}'"
994            );
995        }
996    }
997
998    /// Verifies that route can be specified via query parameter when path route is empty
999    #[actix_web::test]
1000    async fn test_route_from_query_parameter() {
1001        let captured = web::Data::new(CapturedRequest::default());
1002        let app = test::init_service(
1003            App::new()
1004                .app_data(captured.clone())
1005                .service(
1006                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1007                        .route(web::post().to(capturing_plugin_call_handler)),
1008                )
1009                .configure(init),
1010        )
1011        .await;
1012
1013        // Test route from query parameter
1014        let req = test::TestRequest::post()
1015            .uri("/plugins/test/call?route=/verify")
1016            .insert_header(("Content-Type", "application/json"))
1017            .set_json(serde_json::json!({}))
1018            .to_request();
1019
1020        test::call_service(&app, req).await;
1021
1022        let captured_req = captured.get().expect("Request should have been captured");
1023        assert_eq!(
1024            captured_req.route,
1025            Some("/verify".to_string()),
1026            "Route should be extracted from query parameter"
1027        );
1028    }
1029
1030    /// Verifies that path route takes precedence over query parameter
1031    #[actix_web::test]
1032    async fn test_path_route_takes_precedence_over_query() {
1033        let captured = web::Data::new(CapturedRequest::default());
1034        let app = test::init_service(
1035            App::new()
1036                .app_data(captured.clone())
1037                .service(
1038                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1039                        .route(web::post().to(capturing_plugin_call_handler)),
1040                )
1041                .configure(init),
1042        )
1043        .await;
1044
1045        // Test that path route takes precedence over query param
1046        let req = test::TestRequest::post()
1047            .uri("/plugins/test/call/settle?route=/verify")
1048            .insert_header(("Content-Type", "application/json"))
1049            .set_json(serde_json::json!({}))
1050            .to_request();
1051
1052        test::call_service(&app, req).await;
1053
1054        let captured_req = captured.get().expect("Request should have been captured");
1055        assert_eq!(
1056            captured_req.route,
1057            Some("/settle".to_string()),
1058            "Path route should take precedence over query parameter"
1059        );
1060    }
1061
1062    /// Verifies that query parameters are correctly extracted and passed
1063    #[actix_web::test]
1064    async fn test_query_params_extracted_and_passed() {
1065        let captured = web::Data::new(CapturedRequest::default());
1066        let app = test::init_service(
1067            App::new()
1068                .app_data(captured.clone())
1069                .service(
1070                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1071                        .route(web::post().to(capturing_plugin_call_handler)),
1072                )
1073                .configure(init),
1074        )
1075        .await;
1076
1077        let req = test::TestRequest::post()
1078            .uri("/plugins/test/call?token=abc123&action=verify&tag=a&tag=b")
1079            .insert_header(("Content-Type", "application/json"))
1080            .set_json(serde_json::json!({}))
1081            .to_request();
1082
1083        test::call_service(&app, req).await;
1084
1085        let captured_req = captured.get().expect("Request should have been captured");
1086        let query = captured_req.query.expect("Query params should be present");
1087
1088        assert_eq!(
1089            query.get("token"),
1090            Some(&vec!["abc123".to_string()]),
1091            "Token query param should be extracted"
1092        );
1093        assert_eq!(
1094            query.get("action"),
1095            Some(&vec!["verify".to_string()]),
1096            "Action query param should be extracted"
1097        );
1098        assert_eq!(
1099            query.get("tag"),
1100            Some(&vec!["a".to_string(), "b".to_string()]),
1101            "Multiple tag query params should be extracted as vector"
1102        );
1103    }
1104
1105    /// Verifies that the method field is correctly set to "POST" for POST requests
1106    #[actix_web::test]
1107    async fn test_method_field_set_correctly_for_post() {
1108        let captured = web::Data::new(CapturedRequest::default());
1109        let app = test::init_service(
1110            App::new()
1111                .app_data(captured.clone())
1112                .service(
1113                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1114                        .route(web::post().to(capturing_plugin_call_handler)),
1115                )
1116                .configure(init),
1117        )
1118        .await;
1119
1120        let req = test::TestRequest::post()
1121            .uri("/plugins/test/call")
1122            .insert_header(("Content-Type", "application/json"))
1123            .set_json(serde_json::json!({}))
1124            .to_request();
1125
1126        test::call_service(&app, req).await;
1127
1128        let captured_req = captured.get().expect("Request should have been captured");
1129        assert_eq!(
1130            captured_req.method,
1131            Some("POST".to_string()),
1132            "Method should be set to POST for POST requests"
1133        );
1134    }
1135
1136    /// Verifies that invalid JSON returns a proper 400 Bad Request error with helpful message
1137    #[actix_web::test]
1138    async fn test_invalid_json_returns_proper_error() {
1139        use actix_web::test::TestRequest;
1140
1141        let http_req = TestRequest::default().to_http_request();
1142
1143        // Test case 1: Invalid JSON syntax
1144        let result =
1145            build_plugin_call_request_from_post_body("/test", &http_req, b"{invalid json here}");
1146        assert!(result.is_err(), "Invalid JSON should return error");
1147        let err_response = result.unwrap_err();
1148        assert_eq!(
1149            err_response.status(),
1150            actix_web::http::StatusCode::BAD_REQUEST,
1151            "Invalid JSON should return 400 Bad Request"
1152        );
1153        let body_bytes = actix_web::body::to_bytes(err_response.into_body())
1154            .await
1155            .unwrap();
1156        let body_str = std::str::from_utf8(&body_bytes).unwrap();
1157        assert!(
1158            body_str.contains("Invalid JSON"),
1159            "Error message should contain 'Invalid JSON', got: {body_str}"
1160        );
1161
1162        // Test case 2: Single quotes (not valid JSON)
1163        let result =
1164            build_plugin_call_request_from_post_body("/test", &http_req, b"{'key': 'value'}");
1165        assert!(
1166            result.is_err(),
1167            "Invalid JSON with single quotes should return error"
1168        );
1169
1170        // Test case 3: Unquoted keys
1171        let result = build_plugin_call_request_from_post_body("/test", &http_req, b"{key: value}");
1172        assert!(
1173            result.is_err(),
1174            "Invalid JSON with unquoted keys should return error"
1175        );
1176    }
1177
1178    /// Verifies query parameter edge cases: keys without values, empty values, etc.
1179    #[actix_web::test]
1180    async fn test_query_params_with_edge_cases() {
1181        use actix_web::test::TestRequest;
1182
1183        // Test: key without value (flag parameter)
1184        let req = TestRequest::default()
1185            .uri("/test?flag&key=value")
1186            .to_http_request();
1187        let params = extract_query_params(&req);
1188
1189        assert_eq!(
1190            params.get("flag"),
1191            Some(&vec!["".to_string()]),
1192            "Flag parameter without value should have empty string value"
1193        );
1194        assert_eq!(
1195            params.get("key"),
1196            Some(&vec!["value".to_string()]),
1197            "Key with value should be extracted correctly"
1198        );
1199
1200        // Test: empty value vs no value
1201        let req = TestRequest::default()
1202            .uri("/test?empty=&flag")
1203            .to_http_request();
1204        let params = extract_query_params(&req);
1205
1206        assert_eq!(
1207            params.get("empty"),
1208            Some(&vec!["".to_string()]),
1209            "Empty value should be preserved"
1210        );
1211        assert_eq!(
1212            params.get("flag"),
1213            Some(&vec!["".to_string()]),
1214            "Flag without value should also have empty string"
1215        );
1216    }
1217
1218    /// Verifies that header values preserve their original case (keys are lowercased)
1219    #[actix_web::test]
1220    async fn test_extract_headers_preserves_original_case_in_values() {
1221        use actix_web::test::TestRequest;
1222
1223        let req = TestRequest::default()
1224            .insert_header(("X-Mixed-Case", "Value-With-CAPS"))
1225            .insert_header(("X-Lower", "lowercase-value"))
1226            .insert_header(("X-Upper", "UPPERCASE-VALUE"))
1227            .to_http_request();
1228
1229        let headers = extract_headers(&req);
1230
1231        // Keys are normalized to lowercase, but values should preserve case
1232        let mixed_case_values = headers.get("x-mixed-case").unwrap();
1233        assert_eq!(
1234            mixed_case_values[0], "Value-With-CAPS",
1235            "Header values should preserve original case"
1236        );
1237
1238        let lower_values = headers.get("x-lower").unwrap();
1239        assert_eq!(lower_values[0], "lowercase-value");
1240
1241        let upper_values = headers.get("x-upper").unwrap();
1242        assert_eq!(upper_values[0], "UPPERCASE-VALUE");
1243    }
1244
1245    /// Verifies handling of very long query strings
1246    #[actix_web::test]
1247    async fn test_very_long_query_string() {
1248        use actix_web::test::TestRequest;
1249
1250        // Test with a reasonably long query string (1000 chars)
1251        let long_value = "a".repeat(1000);
1252        let uri = format!("/test?data={long_value}");
1253
1254        let req = TestRequest::default().uri(&uri).to_http_request();
1255
1256        let params = extract_query_params(&req);
1257        assert_eq!(
1258            params.get("data").unwrap()[0].len(),
1259            1000,
1260            "Long query parameter value should be handled correctly"
1261        );
1262        assert_eq!(
1263            params.get("data").unwrap()[0],
1264            long_value,
1265            "Long query parameter value should match"
1266        );
1267    }
1268
1269    /// Verifies that complex nested JSON structures in params are preserved correctly
1270    #[actix_web::test]
1271    async fn test_params_field_with_complex_nested_structure() {
1272        use actix_web::test::TestRequest;
1273
1274        let http_req = TestRequest::default().to_http_request();
1275        let complex_json = serde_json::json!({
1276            "params": {
1277                "user": {
1278                    "name": "alice",
1279                    "metadata": {
1280                        "tags": ["a", "b", "c"],
1281                        "score": 100
1282                    }
1283                },
1284                "action": "verify"
1285            }
1286        });
1287
1288        let body = serde_json::to_vec(&complex_json).unwrap();
1289        let req = build_plugin_call_request_from_post_body("/test", &http_req, &body).unwrap();
1290
1291        // Verify nested structure is preserved
1292        assert_eq!(
1293            req.params.get("user").unwrap().get("name").unwrap(),
1294            "alice",
1295            "Nested user name should be preserved"
1296        );
1297        assert!(
1298            req.params.get("user").unwrap().get("metadata").is_some(),
1299            "Nested metadata should be preserved"
1300        );
1301        let metadata = req.params.get("user").unwrap().get("metadata").unwrap();
1302        assert_eq!(
1303            metadata.get("score").unwrap(),
1304            100,
1305            "Nested score should be preserved"
1306        );
1307        assert!(
1308            metadata.get("tags").is_some(),
1309            "Nested tags array should be preserved"
1310        );
1311    }
1312
1313    /// Integration test: Verifies GET restriction logic by testing the repository behavior
1314    /// that the route handler uses. This test verifies that plugin_call_get correctly checks
1315    /// allow_get_invocation and would return 405 Method Not Allowed when GET is disabled,
1316    /// and 404 when plugin doesn't exist.
1317    #[actix_web::test]
1318    async fn test_get_restriction_logic_through_route_handler() {
1319        use crate::utils::mocks::mockutils::create_mock_app_state;
1320        use std::time::Duration;
1321
1322        // Create plugin with allow_get_invocation = false
1323        let plugin_disabled = PluginModel {
1324            id: "plugin-no-get".to_string(),
1325            path: "test-path".to_string(),
1326            timeout: Duration::from_secs(60),
1327            emit_logs: false,
1328            emit_traces: false,
1329            raw_response: false,
1330            allow_get_invocation: false,
1331            config: None,
1332            forward_logs: false,
1333        };
1334
1335        // Create plugin with allow_get_invocation = true
1336        let plugin_enabled = PluginModel {
1337            id: "plugin-with-get".to_string(),
1338            path: "test-path".to_string(),
1339            timeout: Duration::from_secs(60),
1340            emit_logs: false,
1341            emit_traces: false,
1342            raw_response: false,
1343            allow_get_invocation: true,
1344            config: None,
1345            forward_logs: false,
1346        };
1347
1348        let app_state = create_mock_app_state(
1349            None,
1350            None,
1351            None,
1352            None,
1353            Some(vec![plugin_disabled.clone(), plugin_enabled.clone()]),
1354            None,
1355        )
1356        .await;
1357
1358        // Test 1: GET request to non-existent plugin should return 404
1359        // We verify the repository logic that the handler uses
1360        let plugin_repo = app_state.plugin_repository.clone();
1361        let found_plugin = plugin_repo
1362            .get_by_id("non-existent")
1363            .await
1364            .expect("Repository call should succeed");
1365        assert!(
1366            found_plugin.is_none(),
1367            "Non-existent plugin should return None (would trigger 404 in route handler)"
1368        );
1369
1370        // Test 2: GET request to plugin with allow_get_invocation=false should be rejected
1371        let found_plugin = plugin_repo
1372            .get_by_id("plugin-no-get")
1373            .await
1374            .expect("Repository call should succeed");
1375        assert!(found_plugin.is_some(), "Plugin should exist in repository");
1376        let plugin = found_plugin.unwrap();
1377        assert!(
1378            !plugin.allow_get_invocation,
1379            "Plugin should have allow_get_invocation=false (would trigger 405 in route handler)"
1380        );
1381
1382        // Test 3: GET request to plugin with allow_get_invocation=true should be allowed
1383        let found_plugin = plugin_repo
1384            .get_by_id("plugin-with-get")
1385            .await
1386            .expect("Repository call should succeed");
1387        assert!(found_plugin.is_some(), "Plugin should exist in repository");
1388        assert!(
1389            found_plugin.unwrap().allow_get_invocation,
1390            "Plugin should have allow_get_invocation=true (would proceed in route handler)"
1391        );
1392    }
1393
1394    /// Verifies that error responses contain appropriate status codes and messages
1395    #[actix_web::test]
1396    async fn test_error_responses_contain_appropriate_messages() {
1397        use crate::models::ApiResponse;
1398        use actix_web::test::TestRequest;
1399
1400        let http_req = TestRequest::default().to_http_request();
1401
1402        // Test invalid JSON error response
1403        let result = build_plugin_call_request_from_post_body("/test", &http_req, b"{invalid}");
1404
1405        assert!(result.is_err());
1406        let err_response = result.unwrap_err();
1407        assert_eq!(
1408            err_response.status(),
1409            actix_web::http::StatusCode::BAD_REQUEST,
1410            "Should return 400 Bad Request"
1411        );
1412
1413        // Verify error response structure
1414        let body_bytes = actix_web::body::to_bytes(err_response.into_body())
1415            .await
1416            .unwrap();
1417        let api_response: ApiResponse<()> =
1418            serde_json::from_slice(&body_bytes).expect("Response should be valid JSON");
1419        assert!(
1420            !api_response.success,
1421            "Error response should have success=false"
1422        );
1423        assert!(
1424            api_response.error.is_some(),
1425            "Error response should contain error message"
1426        );
1427        assert!(
1428            api_response.error.unwrap().contains("Invalid JSON"),
1429            "Error message should mention 'Invalid JSON'"
1430        );
1431    }
1432
1433    /// Verifies query parameters with special characters and URL encoding
1434    #[actix_web::test]
1435    async fn test_query_params_with_special_characters_and_encoding() {
1436        use actix_web::test::TestRequest;
1437
1438        // Test various special characters and encoding scenarios
1439        let req = TestRequest::default()
1440            .uri("/test?key=value%20with%20spaces&symbol=%26%3D%3F&unicode=%E4%B8%AD%E6%96%87")
1441            .to_http_request();
1442
1443        let params = extract_query_params(&req);
1444
1445        assert_eq!(
1446            params.get("key"),
1447            Some(&vec!["value with spaces".to_string()]),
1448            "URL-encoded spaces should be decoded"
1449        );
1450        assert_eq!(
1451            params.get("symbol"),
1452            Some(&vec!["&=?".to_string()]),
1453            "URL-encoded special characters should be decoded"
1454        );
1455        assert_eq!(
1456            params.get("unicode"),
1457            Some(&vec!["中文".to_string()]),
1458            "URL-encoded Unicode should be decoded"
1459        );
1460    }
1461
1462    /// Verifies that params without wrapper are correctly wrapped
1463    #[actix_web::test]
1464    async fn test_params_without_wrapper_correctly_wrapped() {
1465        use actix_web::test::TestRequest;
1466
1467        let http_req = TestRequest::default().to_http_request();
1468
1469        // Body without "params" field should be wrapped
1470        let body_without_params = serde_json::json!({
1471            "user": "alice",
1472            "action": "transfer",
1473            "amount": 100
1474        });
1475
1476        let body = serde_json::to_vec(&body_without_params).unwrap();
1477        let req = build_plugin_call_request_from_post_body("/test", &http_req, &body).unwrap();
1478
1479        // The entire body should become the params field
1480        assert_eq!(
1481            req.params.get("user").unwrap(),
1482            "alice",
1483            "User field should be in params"
1484        );
1485        assert_eq!(
1486            req.params.get("action").unwrap(),
1487            "transfer",
1488            "Action field should be in params"
1489        );
1490        assert_eq!(
1491            req.params.get("amount").unwrap(),
1492            100,
1493            "Amount field should be in params"
1494        );
1495    }
1496
1497    /// Verifies that the method field is correctly set to "GET" for GET requests
1498    #[actix_web::test]
1499    async fn test_method_field_set_correctly_for_get() {
1500        let captured = web::Data::new(CapturedRequest::default());
1501        let app = test::init_service(
1502            App::new()
1503                .app_data(captured.clone())
1504                .service(
1505                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1506                        .route(web::get().to(capturing_plugin_call_get_handler)),
1507                )
1508                .configure(init),
1509        )
1510        .await;
1511
1512        let req = test::TestRequest::get()
1513            .uri("/plugins/test/call/verify?token=abc123")
1514            .to_request();
1515
1516        test::call_service(&app, req).await;
1517
1518        let captured_req = captured.get().expect("Request should have been captured");
1519        assert_eq!(
1520            captured_req.method,
1521            Some("GET".to_string()),
1522            "Method should be set to GET for GET requests"
1523        );
1524        assert_eq!(
1525            captured_req.params,
1526            serde_json::json!({}),
1527            "GET requests should have empty params object"
1528        );
1529    }
1530
1531    /// Verifies that invalid JSON returns 400 Bad Request when sent through the capturing handler
1532    /// This tests the error flow through the actual request processing logic
1533    #[actix_web::test]
1534    async fn test_invalid_json_through_route_handler() {
1535        let captured = web::Data::new(CapturedRequest::default());
1536        let app = test::init_service(
1537            App::new()
1538                .app_data(captured.clone())
1539                .service(
1540                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1541                        .route(web::post().to(capturing_plugin_call_handler)),
1542                )
1543                .configure(init),
1544        )
1545        .await;
1546
1547        let req = test::TestRequest::post()
1548            .uri("/plugins/test-plugin/call")
1549            .insert_header(("Content-Type", "application/json"))
1550            .set_payload("{invalid json syntax}")
1551            .to_request();
1552
1553        let resp = test::call_service(&app, req).await;
1554
1555        assert_eq!(
1556            resp.status(),
1557            actix_web::http::StatusCode::BAD_REQUEST,
1558            "Invalid JSON should return 400 Bad Request through route handler"
1559        );
1560
1561        // Verify error response structure
1562        let body = test::read_body(resp).await;
1563        let body_str = std::str::from_utf8(&body).unwrap();
1564        assert!(
1565            body_str.contains("Invalid JSON"),
1566            "Error message should contain 'Invalid JSON', got: {body_str}"
1567        );
1568
1569        // Verify that invalid request was not captured
1570        assert!(
1571            captured.get().is_none(),
1572            "Invalid JSON request should not be captured"
1573        );
1574    }
1575
1576    /// Verifies that PluginCallRequest with params field handles various param types correctly
1577    /// Since PluginCallRequest deserialization is lenient, we test that params can be any JSON type
1578    #[actix_web::test]
1579    async fn test_plugin_call_request_with_various_param_types() {
1580        use actix_web::test::TestRequest;
1581
1582        let http_req = TestRequest::default().to_http_request();
1583
1584        // Test case 1: params as string
1585        let body_with_string_params = serde_json::json!({
1586            "params": "this is a string, not an object"
1587        });
1588        let body = serde_json::to_vec(&body_with_string_params).unwrap();
1589        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1590        assert!(result.is_ok(), "Params as string should be valid");
1591        let req = result.unwrap();
1592        assert_eq!(
1593            req.params,
1594            serde_json::json!("this is a string, not an object"),
1595            "String params should be preserved"
1596        );
1597
1598        // Test case 2: params as array
1599        let body_with_array_params = serde_json::json!({
1600            "params": [1, 2, 3, "four"]
1601        });
1602        let body = serde_json::to_vec(&body_with_array_params).unwrap();
1603        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1604        assert!(result.is_ok(), "Params as array should be valid");
1605        let req = result.unwrap();
1606        assert_eq!(
1607            req.params,
1608            serde_json::json!([1, 2, 3, "four"]),
1609            "Array params should be preserved"
1610        );
1611
1612        // Test case 3: params as number
1613        let body_with_number_params = serde_json::json!({
1614            "params": 42
1615        });
1616        let body = serde_json::to_vec(&body_with_number_params).unwrap();
1617        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1618        assert!(result.is_ok(), "Params as number should be valid");
1619        let req = result.unwrap();
1620        assert_eq!(
1621            req.params,
1622            serde_json::json!(42),
1623            "Number params should be preserved"
1624        );
1625
1626        // Test case 4: params as boolean
1627        let body_with_bool_params = serde_json::json!({
1628            "params": true
1629        });
1630        let body = serde_json::to_vec(&body_with_bool_params).unwrap();
1631        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1632        assert!(result.is_ok(), "Params as boolean should be valid");
1633        let req = result.unwrap();
1634        assert_eq!(
1635            req.params,
1636            serde_json::json!(true),
1637            "Boolean params should be preserved"
1638        );
1639    }
1640
1641    /// Verifies that requests without Content-Type header are handled gracefully
1642    #[actix_web::test]
1643    async fn test_request_without_content_type_header() {
1644        let captured = web::Data::new(CapturedRequest::default());
1645        let app = test::init_service(
1646            App::new()
1647                .app_data(captured.clone())
1648                .service(
1649                    web::resource("/plugins/{plugin_id}/call{route:.*}")
1650                        .route(web::post().to(capturing_plugin_call_handler)),
1651                )
1652                .configure(init),
1653        )
1654        .await;
1655
1656        // POST request without Content-Type header
1657        let req = test::TestRequest::post()
1658            .uri("/plugins/test-plugin/call")
1659            .set_payload("{\"test\": \"data\"}")
1660            .to_request();
1661
1662        let resp = test::call_service(&app, req).await;
1663
1664        // Should still process the request (Content-Type is not strictly required for JSON parsing)
1665        // The request should succeed and be captured
1666        assert!(
1667            resp.status().is_success(),
1668            "Request without Content-Type should be handled gracefully, got status: {}",
1669            resp.status()
1670        );
1671
1672        // Verify the request was captured and processed
1673        let captured_req = captured.get();
1674        assert!(
1675            captured_req.is_some(),
1676            "Request without Content-Type should still be processed"
1677        );
1678    }
1679
1680    /// Verifies handling of very large request bodies (stress test)
1681    #[actix_web::test]
1682    async fn test_very_large_request_body() {
1683        use actix_web::test::TestRequest;
1684
1685        let http_req = TestRequest::default().to_http_request();
1686
1687        // Create a large JSON body (10KB of data)
1688        let large_data = "x".repeat(10000);
1689        let large_json = serde_json::json!({
1690            "params": {
1691                "large_data": large_data,
1692                "count": 10000
1693            }
1694        });
1695
1696        let body = serde_json::to_vec(&large_json).unwrap();
1697        let result = build_plugin_call_request_from_post_body("/test", &http_req, &body);
1698
1699        assert!(result.is_ok(), "Large request body should be handled");
1700        let req = result.unwrap();
1701        assert_eq!(
1702            req.params
1703                .get("large_data")
1704                .unwrap()
1705                .as_str()
1706                .unwrap()
1707                .len(),
1708            10000,
1709            "Large data should be preserved correctly"
1710        );
1711    }
1712
1713    /// Verifies that nested routes with special characters are handled correctly
1714    #[actix_web::test]
1715    async fn test_nested_routes_with_special_characters() {
1716        use actix_web::test::TestRequest;
1717
1718        let http_req = TestRequest::default().to_http_request();
1719        let body = serde_json::to_vec(&serde_json::json!({"test": "data"})).unwrap();
1720
1721        let test_cases = vec![
1722            ("/api/v1/verify", "/api/v1/verify"),
1723            ("/settle/transaction", "/settle/transaction"),
1724            ("/path-with-dashes", "/path-with-dashes"),
1725            ("/path_with_underscores", "/path_with_underscores"),
1726            ("/path.with.dots", "/path.with.dots"),
1727            ("/path%20with%20spaces", "/path%20with%20spaces"), // URL encoded spaces
1728        ];
1729
1730        for (route, expected_route) in test_cases {
1731            let req = build_plugin_call_request_from_post_body(route, &http_req, &body).unwrap();
1732            assert_eq!(
1733                req.route,
1734                Some(expected_route.to_string()),
1735                "Route '{route}' should be preserved as '{expected_route}'"
1736            );
1737        }
1738    }
1739
1740    #[actix_web::test]
1741    async fn test_plugin_call_route_handler_post() {
1742        use crate::utils::mocks::mockutils::create_mock_app_state;
1743        use std::time::Duration;
1744
1745        let plugin = PluginModel {
1746            id: "test-plugin".to_string(),
1747            path: "test-path".to_string(),
1748            timeout: Duration::from_secs(60),
1749            emit_logs: false,
1750            emit_traces: false,
1751            raw_response: false,
1752            allow_get_invocation: false,
1753            config: None,
1754            forward_logs: false,
1755        };
1756
1757        let app_state =
1758            create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
1759
1760        let app = test::init_service(
1761            App::new()
1762                .app_data(web::Data::new(web::ThinData(app_state)))
1763                .configure(init),
1764        )
1765        .await;
1766
1767        let req = test::TestRequest::post()
1768            .uri("/plugins/test-plugin/call")
1769            .insert_header(("Content-Type", "application/json"))
1770            .set_json(serde_json::json!({"params": {"test": "data"}}))
1771            .to_request();
1772
1773        let resp = test::call_service(&app, req).await;
1774
1775        // Plugin execution fails in test environment (no ts-node), but route handler is executed
1776        // Verify the route handler was called (returns 500 due to plugin execution failure)
1777        assert!(
1778            resp.status().is_server_error() || resp.status().is_client_error(),
1779            "Route handler should be executed, got status: {}",
1780            resp.status()
1781        );
1782    }
1783
1784    /// Integration test: Verifies that the actual plugin_call_get route handler processes
1785    /// GET requests when allowed.
1786    #[actix_web::test]
1787    async fn test_plugin_call_get_route_handler_allowed() {
1788        use crate::utils::mocks::mockutils::create_mock_app_state;
1789        use std::time::Duration;
1790
1791        let plugin = PluginModel {
1792            id: "test-plugin-with-get".to_string(),
1793            path: "test-path".to_string(),
1794            timeout: Duration::from_secs(60),
1795            emit_logs: false,
1796            emit_traces: false,
1797            raw_response: false,
1798            allow_get_invocation: true, // GET allowed
1799            config: None,
1800            forward_logs: false,
1801        };
1802
1803        let app_state =
1804            create_mock_app_state(None, None, None, None, Some(vec![plugin]), None).await;
1805
1806        let app = test::init_service(
1807            App::new()
1808                .app_data(web::Data::new(web::ThinData(app_state)))
1809                .configure(init),
1810        )
1811        .await;
1812
1813        let req = test::TestRequest::get()
1814            .uri("/plugins/test-plugin-with-get/call?token=abc123")
1815            .to_request();
1816
1817        let resp = test::call_service(&app, req).await;
1818
1819        // Plugin execution fails in test environment (no ts-node), but route handler is executed
1820        // Verify the route handler was called (returns 500 due to plugin execution failure)
1821        assert!(
1822            resp.status().is_server_error() || resp.status().is_client_error(),
1823            "Route handler should be executed, got status: {}",
1824            resp.status()
1825        );
1826    }
1827
1828    // ============================================================================
1829    // GET PLUGIN ROUTE TESTS
1830    // ============================================================================
1831
1832    /// Mock handler for get plugin that returns a plugin by ID
1833    async fn mock_get_plugin(path: web::Path<String>) -> impl Responder {
1834        let plugin_id = path.into_inner();
1835
1836        if plugin_id == "not-found" {
1837            return HttpResponse::NotFound().json(ApiResponse::<()>::error(format!(
1838                "Plugin with id {plugin_id} not found"
1839            )));
1840        }
1841
1842        let plugin = PluginModel {
1843            id: plugin_id,
1844            path: "test-path.ts".to_string(),
1845            timeout: Duration::from_secs(30),
1846            emit_logs: true,
1847            emit_traces: false,
1848            raw_response: false,
1849            allow_get_invocation: true,
1850            config: None,
1851            forward_logs: true,
1852        };
1853
1854        HttpResponse::Ok().json(ApiResponse::success(plugin))
1855    }
1856
1857    #[actix_web::test]
1858    async fn test_get_plugin_route_success() {
1859        let app = test::init_service(
1860            App::new()
1861                .service(
1862                    web::resource("/plugins/{plugin_id}").route(web::get().to(mock_get_plugin)),
1863                )
1864                .configure(init),
1865        )
1866        .await;
1867
1868        let req = test::TestRequest::get()
1869            .uri("/plugins/my-plugin")
1870            .to_request();
1871
1872        let resp = test::call_service(&app, req).await;
1873        assert!(resp.status().is_success());
1874
1875        let body = test::read_body(resp).await;
1876        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
1877        assert!(response.success);
1878        assert_eq!(response.data.as_ref().unwrap().id, "my-plugin");
1879        assert!(response.data.as_ref().unwrap().emit_logs);
1880        assert!(response.data.as_ref().unwrap().forward_logs);
1881    }
1882
1883    #[actix_web::test]
1884    async fn test_get_plugin_route_not_found() {
1885        let app = test::init_service(
1886            App::new()
1887                .service(
1888                    web::resource("/plugins/{plugin_id}").route(web::get().to(mock_get_plugin)),
1889                )
1890                .configure(init),
1891        )
1892        .await;
1893
1894        let req = test::TestRequest::get()
1895            .uri("/plugins/not-found")
1896            .to_request();
1897
1898        let resp = test::call_service(&app, req).await;
1899        assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND);
1900    }
1901
1902    // ============================================================================
1903    // UPDATE PLUGIN ROUTE TESTS
1904    // ============================================================================
1905
1906    /// Mock handler for update plugin that returns the updated plugin
1907    async fn mock_update_plugin(
1908        path: web::Path<String>,
1909        body: web::Json<UpdatePluginRequest>,
1910    ) -> impl Responder {
1911        let plugin_id = path.into_inner();
1912        let update = body.into_inner();
1913
1914        // Simulate successful update
1915        let updated_plugin = PluginModel {
1916            id: plugin_id,
1917            path: "test-path".to_string(),
1918            timeout: Duration::from_secs(update.timeout.unwrap_or(30)),
1919            emit_logs: update.emit_logs.unwrap_or(false),
1920            emit_traces: update.emit_traces.unwrap_or(false),
1921            raw_response: update.raw_response.unwrap_or(false),
1922            allow_get_invocation: update.allow_get_invocation.unwrap_or(false),
1923            config: update.config.flatten(),
1924            forward_logs: update.forward_logs.unwrap_or(false),
1925        };
1926
1927        HttpResponse::Ok().json(ApiResponse::success(updated_plugin))
1928    }
1929
1930    #[actix_web::test]
1931    async fn test_update_plugin_route_success() {
1932        let app = test::init_service(
1933            App::new()
1934                .service(
1935                    web::resource("/plugins/{plugin_id}")
1936                        .route(web::patch().to(mock_update_plugin)),
1937                )
1938                .configure(init),
1939        )
1940        .await;
1941
1942        let req = test::TestRequest::patch()
1943            .uri("/plugins/test-plugin")
1944            .insert_header(("Content-Type", "application/json"))
1945            .set_json(serde_json::json!({
1946                "timeout": 60,
1947                "emit_logs": true,
1948                "forward_logs": true
1949            }))
1950            .to_request();
1951
1952        let resp = test::call_service(&app, req).await;
1953        assert!(resp.status().is_success());
1954
1955        let body = test::read_body(resp).await;
1956        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
1957        assert!(response.success);
1958        assert_eq!(response.data.as_ref().unwrap().id, "test-plugin");
1959        assert_eq!(
1960            response.data.as_ref().unwrap().timeout,
1961            Duration::from_secs(60)
1962        );
1963        assert!(response.data.as_ref().unwrap().emit_logs);
1964        assert!(response.data.as_ref().unwrap().forward_logs);
1965    }
1966
1967    #[actix_web::test]
1968    async fn test_update_plugin_route_with_config() {
1969        let app = test::init_service(
1970            App::new()
1971                .service(
1972                    web::resource("/plugins/{plugin_id}")
1973                        .route(web::patch().to(mock_update_plugin)),
1974                )
1975                .configure(init),
1976        )
1977        .await;
1978
1979        let req = test::TestRequest::patch()
1980            .uri("/plugins/my-plugin")
1981            .insert_header(("Content-Type", "application/json"))
1982            .set_json(serde_json::json!({
1983                "config": {
1984                    "feature_enabled": true,
1985                    "api_key": "secret123"
1986                }
1987            }))
1988            .to_request();
1989
1990        let resp = test::call_service(&app, req).await;
1991        assert!(resp.status().is_success());
1992
1993        let body = test::read_body(resp).await;
1994        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
1995        assert!(response.success);
1996        assert!(response.data.as_ref().unwrap().config.is_some());
1997        let config = response.data.as_ref().unwrap().config.as_ref().unwrap();
1998        assert_eq!(
1999            config.get("feature_enabled"),
2000            Some(&serde_json::json!(true))
2001        );
2002        assert_eq!(config.get("api_key"), Some(&serde_json::json!("secret123")));
2003    }
2004
2005    #[actix_web::test]
2006    async fn test_update_plugin_route_clear_config() {
2007        let app = test::init_service(
2008            App::new()
2009                .service(
2010                    web::resource("/plugins/{plugin_id}")
2011                        .route(web::patch().to(mock_update_plugin)),
2012                )
2013                .configure(init),
2014        )
2015        .await;
2016
2017        let req = test::TestRequest::patch()
2018            .uri("/plugins/test-plugin")
2019            .insert_header(("Content-Type", "application/json"))
2020            .set_json(serde_json::json!({
2021                "config": null
2022            }))
2023            .to_request();
2024
2025        let resp = test::call_service(&app, req).await;
2026        assert!(resp.status().is_success());
2027
2028        let body = test::read_body(resp).await;
2029        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
2030        assert!(response.success);
2031        assert!(response.data.as_ref().unwrap().config.is_none());
2032    }
2033
2034    #[actix_web::test]
2035    async fn test_update_plugin_route_empty_body() {
2036        let app = test::init_service(
2037            App::new()
2038                .service(
2039                    web::resource("/plugins/{plugin_id}")
2040                        .route(web::patch().to(mock_update_plugin)),
2041                )
2042                .configure(init),
2043        )
2044        .await;
2045
2046        // Empty JSON object - no fields to update
2047        let req = test::TestRequest::patch()
2048            .uri("/plugins/test-plugin")
2049            .insert_header(("Content-Type", "application/json"))
2050            .set_json(serde_json::json!({}))
2051            .to_request();
2052
2053        let resp = test::call_service(&app, req).await;
2054        assert!(resp.status().is_success());
2055    }
2056
2057    #[actix_web::test]
2058    async fn test_update_plugin_route_all_fields() {
2059        let app = test::init_service(
2060            App::new()
2061                .service(
2062                    web::resource("/plugins/{plugin_id}")
2063                        .route(web::patch().to(mock_update_plugin)),
2064                )
2065                .configure(init),
2066        )
2067        .await;
2068
2069        let req = test::TestRequest::patch()
2070            .uri("/plugins/full-update-plugin")
2071            .insert_header(("Content-Type", "application/json"))
2072            .set_json(serde_json::json!({
2073                "timeout": 120,
2074                "emit_logs": true,
2075                "emit_traces": true,
2076                "raw_response": true,
2077                "allow_get_invocation": true,
2078                "forward_logs": true,
2079                "config": {
2080                    "key": "value"
2081                }
2082            }))
2083            .to_request();
2084
2085        let resp = test::call_service(&app, req).await;
2086        assert!(resp.status().is_success());
2087
2088        let body = test::read_body(resp).await;
2089        let response: ApiResponse<PluginModel> = serde_json::from_slice(&body).unwrap();
2090        let plugin = response.data.unwrap();
2091
2092        assert_eq!(plugin.timeout, Duration::from_secs(120));
2093        assert!(plugin.emit_logs);
2094        assert!(plugin.emit_traces);
2095        assert!(plugin.raw_response);
2096        assert!(plugin.allow_get_invocation);
2097        assert!(plugin.forward_logs);
2098        assert!(plugin.config.is_some());
2099    }
2100
2101    #[actix_web::test]
2102    async fn test_update_plugin_route_invalid_json() {
2103        let app = test::init_service(
2104            App::new()
2105                .service(
2106                    web::resource("/plugins/{plugin_id}")
2107                        .route(web::patch().to(mock_update_plugin)),
2108                )
2109                .configure(init),
2110        )
2111        .await;
2112
2113        let req = test::TestRequest::patch()
2114            .uri("/plugins/test-plugin")
2115            .insert_header(("Content-Type", "application/json"))
2116            .set_payload("{ invalid json }")
2117            .to_request();
2118
2119        let resp = test::call_service(&app, req).await;
2120        assert!(resp.status().is_client_error());
2121    }
2122
2123    #[actix_web::test]
2124    async fn test_update_plugin_route_unknown_field_rejected() {
2125        let app = test::init_service(
2126            App::new()
2127                .service(
2128                    web::resource("/plugins/{plugin_id}")
2129                        .route(web::patch().to(mock_update_plugin)),
2130                )
2131                .configure(init),
2132        )
2133        .await;
2134
2135        // UpdatePluginRequest has deny_unknown_fields, so this should fail
2136        let req = test::TestRequest::patch()
2137            .uri("/plugins/test-plugin")
2138            .insert_header(("Content-Type", "application/json"))
2139            .set_json(serde_json::json!({
2140                "timeout": 60,
2141                "unknown_field": "should_fail"
2142            }))
2143            .to_request();
2144
2145        let resp = test::call_service(&app, req).await;
2146        assert!(resp.status().is_client_error());
2147    }
2148}