1use std::sync::Arc;
38use std::time::Duration;
39use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
40use tokio::net::TcpListener;
41use tokio::sync::Semaphore;
42use tropel_metrics::collector::{MetricsCollector, MetricsSnapshot, SeriesSnapshot};
43use tropel_scheduler::VUScheduler;
44use tropel_sdk::{Result, TropelError};
45
46const MAX_BODY_SIZE: usize = 64 * 1024;
51const MAX_HEADER_LINE_LEN: usize = 8 * 1024;
55const CONN_TIMEOUT: Duration = Duration::from_secs(10);
58const MAX_CONNS: usize = 8;
61const ACCEPT_BACKOFF: Duration = Duration::from_millis(100);
65
66pub struct ControlApiState {
69 pub scheduler: Arc<VUScheduler>,
70 pub metrics: Arc<MetricsCollector>,
72 pub setup_data: Arc<std::sync::Mutex<Option<Vec<u8>>>>,
75 pub scenario_name: String,
78}
79
80pub async fn serve_control_api(port: u16, state: ControlApiState) -> Result<()> {
83 let addr = format!("127.0.0.1:{}", port);
84 let listener = match TcpListener::bind(&addr).await {
88 Ok(l) => l,
89 Err(e) => {
90 tracing::error!("control API: failed to bind {}: {}", addr, e);
91 return Err(tropel_sdk::TropelError::Config(format!(
92 "control API: failed to bind {}: {}",
93 addr, e
94 )));
95 }
96 };
97 tracing::info!("Control API listening on http://{addr}");
98
99 let conn_permits = Arc::new(Semaphore::new(MAX_CONNS));
102 let state = Arc::new(state);
103
104 loop {
105 let (stream, _peer) = match listener.accept().await {
106 Ok(x) => x,
107 Err(e) => {
108 tracing::debug!("control API: accept error: {}; backing off", e);
112 tokio::time::sleep(ACCEPT_BACKOFF).await;
113 continue;
114 }
115 };
116 let permit = match conn_permits.clone().try_acquire_owned() {
119 Ok(p) => p,
120 Err(_) => {
121 let mut out = stream;
124 let _ = out
125 .write_all(
126 b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
127 )
128 .await;
129 continue;
130 }
131 };
132 let state = state.clone();
133 tokio::spawn(async move {
134 let _permit = permit;
137 if let Err(e) = handle_conn(stream, &state).await {
138 tracing::debug!("control API: connection error: {}", e);
139 }
140 });
141 }
142}
143
144async fn handle_conn<S>(stream: S, state: &Arc<ControlApiState>) -> Result<()>
147where
148 S: AsyncRead + AsyncWrite + Unpin,
149{
150 match tokio::time::timeout(CONN_TIMEOUT, serve_request(stream, state)).await {
154 Ok(r) => r,
155 Err(_elapsed) => {
156 tracing::debug!("control API: connection timed out");
157 Ok(())
158 }
159 }
160}
161
162async fn serve_request<S>(stream: S, state: &Arc<ControlApiState>) -> Result<()>
164where
165 S: AsyncRead + AsyncWrite + Unpin,
166{
167 let mut reader = BufReader::new(stream);
168 let mut request_line = String::new();
169 let mut limited = (&mut reader).take(MAX_HEADER_LINE_LEN as u64 + 1);
173 if limited.read_line(&mut request_line).await? == 0 {
174 return Ok(());
175 }
176 if request_line.len() > MAX_HEADER_LINE_LEN {
177 return Err(TropelError::Http(format!(
178 "control API: request line too long ({} > {})",
179 request_line.len(),
180 MAX_HEADER_LINE_LEN
181 )));
182 }
183 let request_line = request_line.trim_end().to_string();
184 let mut parts = request_line.split_whitespace();
185 let method = parts.next().unwrap_or("").to_string();
186 let path = parts.next().unwrap_or("").to_string();
187
188 let mut content_length: usize = 0;
190 loop {
191 let mut line = String::new();
192 let mut limited = (&mut reader).take(MAX_HEADER_LINE_LEN as u64 + 1);
193 if limited.read_line(&mut line).await? == 0 {
194 break;
195 }
196 if line.len() > MAX_HEADER_LINE_LEN {
198 return Err(TropelError::Http(format!(
199 "control API: header line too long ({} > {})",
200 line.len(),
201 MAX_HEADER_LINE_LEN
202 )));
203 }
204 let line = line.trim_end();
205 if line.is_empty() {
206 break;
207 }
208 if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
209 content_length = v.trim().parse().unwrap_or(0);
210 }
211 }
212
213 if content_length > MAX_BODY_SIZE {
218 let mut out = reader.into_inner();
219 out.write_all(
220 b"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
221 )
222 .await?;
223 out.flush().await?;
224 return Ok(());
225 }
226
227 let mut body = Vec::new();
229 if content_length > 0 {
230 body.resize(content_length, 0);
231 reader.read_exact(&mut body).await?;
232 }
233
234 let (status, response_body) = route(&method, &path, &body, state).await;
235
236 let response = format!(
237 "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
238 status,
239 response_body.len(),
240 response_body
241 );
242 let mut out = reader.into_inner();
243 out.write_all(response.as_bytes()).await?;
244 out.flush().await?;
245 Ok(())
246}
247
248async fn route(
250 method: &str,
251 path: &str,
252 body: &[u8],
253 state: &Arc<ControlApiState>,
254) -> (String, String) {
255 let sched = &state.scheduler;
256 match (method, path) {
257 ("GET", "/v1/status") => ("200 OK".to_string(), status_json(sched)),
258 ("PATCH", "/v1/status") => match parse_status_body(body) {
259 Some(patch) => {
260 if patch.vus.is_some() || patch.max.is_some() {
264 let vus = patch.vus.unwrap_or_else(|| sched.control_target());
265 let max = patch.max.unwrap_or_else(|| sched.control_max());
266 sched.set_control_target(vus, max);
267 tracing::info!("Control API: set VUs target={} max={}", vus, max);
268 }
269 if let Some(paused) = patch.paused {
270 sched.set_paused(paused);
271 tracing::info!("Control API: paused={}", paused);
272 }
273 if patch.stopped == Some(true) {
278 sched.request_stop();
279 tracing::info!("Control API: stop requested");
280 }
281 ("200 OK".to_string(), status_json(sched))
282 }
283 None => (
284 "400 Bad Request".to_string(),
285 "{\"error\":\"expected {\\\"vus\\\":N,\\\"max\\\":M,\\\"paused\\\":bool}\"}"
286 .to_string(),
287 ),
288 },
289 ("PATCH", "/v1/stop") => {
292 match parse_status_body(body) {
293 Some(patch) if patch.stopped == Some(true) || patch.stopped.is_none() => {
294 if patch.stopped == Some(true) {
295 sched.request_stop();
296 tracing::info!("Control API: stop requested (PATCH /v1/stop)");
297 }
298 ("200 OK".to_string(), status_json(sched))
299 }
300 _ => (
301 "400 Bad Request".to_string(),
302 "{\"error\":\"expected {\\\"data\\\":{\\\"attributes\\\":{\\\"stopped\\\":true}}}\"}"
303 .to_string(),
304 ),
305 }
306 }
307 ("POST", "/v1/stop") => {
308 sched.request_stop();
309 ("200 OK".to_string(), status_json(sched))
310 }
311 ("GET", "/v1/metrics") => {
312 let snap = state.metrics.snapshot().await;
313 ("200 OK".to_string(), metrics_json(&snap))
314 }
315 ("GET", "/v1/groups") => {
316 let snap = state.metrics.snapshot().await;
317 ("200 OK".to_string(), groups_json(&snap, &state.scenario_name))
318 }
319 ("GET", "/v1/setup") => {
320 let data = state.setup_data.lock().unwrap().clone();
321 ("200 OK".to_string(), setup_json(data.as_deref()))
322 }
323 ("PUT", "/v1/setup") => {
324 let parsed: Option<serde_json::Value> = if body.is_empty() {
327 None
328 } else {
329 match serde_json::from_slice(body) {
330 Ok(v) => Some(v),
331 Err(e) => {
332 return (
333 "400 Bad Request".to_string(),
334 format!(r#"{{"error":"invalid setup data: {e}"}}"#),
335 )
336 }
337 }
338 };
339 *state.setup_data.lock().unwrap() = parsed.map(|v| v.to_string().into_bytes());
340 let data = state.setup_data.lock().unwrap().clone();
341 ("200 OK".to_string(), setup_json(data.as_deref()))
342 }
343 ("POST", "/v1/setup") => {
344 (
348 "405 Method Not Allowed".to_string(),
349 r#"{"error":"setup() runs once at engine start; POST re-run not supported"}"#
350 .to_string(),
351 )
352 }
353 ("POST", "/v1/teardown") => {
354 (
355 "405 Method Not Allowed".to_string(),
356 r#"{"error":"teardown() runs once at engine stop; POST re-run not supported"}"#
357 .to_string(),
358 )
359 }
360 _ => (
361 "404 Not Found".to_string(),
362 r#"{"error":"not found"}"#.to_string(),
363 ),
364 }
365}
366
367fn status_json(sched: &Arc<VUScheduler>) -> String {
371 let vus = sched.control_target();
372 let max = sched.control_max();
373 let paused = sched.is_paused();
374 let stopped = sched.is_stop_requested();
375 let running = !stopped;
376 let tainted = sched.is_tainted();
377 format!(
378 r#"{{"data":{{"type":"status","id":"default","attributes":{{"vus":{},"vus-max":{},"max":{},"paused":{},"running":{},"stopped":{},"tainted":{}}}}}}}"#,
379 vus, max, max, paused, running, stopped, tainted
380 )
381}
382
383fn metrics_json(snap: &MetricsSnapshot) -> String {
388 let mut entries: Vec<serde_json::Value> = Vec::new();
389 let mut by_metric: std::collections::BTreeMap<&str, &SeriesSnapshot> = Default::default();
394 for s in &snap.series {
395 by_metric.insert(&s.metric, s);
396 }
397 for (name, s) in by_metric {
398 let sample = serde_json::json!({
401 "value": s.last,
402 });
403 entries.push(serde_json::json!({
404 "type": "metrics",
405 "id": name,
406 "attributes": {
407 "type": metric_type_name(s.metric_type),
408 "contains": "default",
409 "tainted": false,
410 "sample": sample,
411 },
412 }));
413 }
414 serde_json::json!({ "data": entries }).to_string()
416}
417
418fn metric_type_name(t: tropel_metrics::collector::MetricType) -> &'static str {
420 use tropel_metrics::collector::MetricType;
421 match t {
422 MetricType::Counter => "counter",
423 MetricType::Gauge => "gauge",
424 MetricType::Rate => "rate",
425 MetricType::Trend => "trend",
426 }
427}
428
429fn groups_json(_snap: &MetricsSnapshot, scenario_name: &str) -> String {
435 serde_json::json!({
436 "data": [{
437 "type": "groups",
438 "id": "0",
439 "attributes": {
440 "path": "",
441 "name": scenario_name,
442 "checks": [],
443 },
444 "relationships": {
445 "groups": { "data": [] },
446 "parent": { "data": null },
447 },
448 }]
449 })
450 .to_string()
451}
452
453fn setup_json(data: Option<&[u8]>) -> String {
456 let value = match data {
457 Some(bytes) => serde_json::from_slice(bytes).unwrap_or(serde_json::Value::Null),
458 None => serde_json::Value::Null,
459 };
460 serde_json::json!({ "data": { "data": value } }).to_string()
461}
462
463#[derive(Debug, Clone, Copy, PartialEq)]
466struct StatusPatch {
467 vus: Option<u32>,
468 max: Option<u32>,
469 paused: Option<bool>,
470 stopped: Option<bool>,
471}
472
473fn parse_status_body(body: &[u8]) -> Option<StatusPatch> {
481 let text = std::str::from_utf8(body).ok()?;
482 let json: serde_json::Value = serde_json::from_str(text).ok()?;
483
484 let attrs = json
486 .get("data")
487 .and_then(|d| d.get("attributes"))
488 .or(Some(&json))?;
489
490 let vus = attrs.get("vus").and_then(|v| v.as_u64()).map(|v| v as u32);
491 let max = attrs
492 .get("vus-max")
493 .and_then(|v| v.as_u64())
494 .map(|v| v as u32)
495 .or_else(|| attrs.get("max").and_then(|v| v.as_u64()).map(|v| v as u32));
496 let paused = attrs.get("paused").and_then(|v| v.as_bool());
497 let stopped = attrs.get("stopped").and_then(|v| v.as_bool());
500
501 if vus.is_none() && max.is_none() && paused.is_none() && stopped.is_none() {
502 return None;
503 }
504 Some(StatusPatch {
505 vus,
506 max,
507 paused,
508 stopped,
509 })
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use tropel_metrics::collector::MetricsCollector;
516
517 fn test_state(sched: Arc<VUScheduler>) -> Arc<ControlApiState> {
520 Arc::new(ControlApiState {
521 scheduler: sched,
522 metrics: Arc::new(MetricsCollector::new()),
523 setup_data: Arc::new(std::sync::Mutex::new(None)),
524 scenario_name: "s".to_string(),
525 })
526 }
527
528 #[test]
529 fn parses_flat_body() {
530 assert_eq!(
531 parse_status_body(br#"{"vus":5,"max":20}"#),
532 Some(StatusPatch {
533 vus: Some(5),
534 max: Some(20),
535 paused: None,
536 stopped: None,
537 })
538 );
539 }
540
541 #[test]
542 fn parses_k6_envelope() {
543 assert_eq!(
544 parse_status_body(br#"{"data":{"attributes":{"vus":3,"max":9}}}"#),
545 Some(StatusPatch {
546 vus: Some(3),
547 max: Some(9),
548 paused: None,
549 stopped: None,
550 })
551 );
552 }
553
554 #[test]
555 fn parses_paused_only() {
556 assert_eq!(
557 parse_status_body(br#"{"paused":true}"#),
558 Some(StatusPatch {
559 vus: None,
560 max: None,
561 paused: Some(true),
562 stopped: None,
563 })
564 );
565 }
566
567 #[test]
568 fn partial_patch_with_only_vus_is_valid() {
569 assert_eq!(
570 parse_status_body(br#"{"vus":5}"#),
571 Some(StatusPatch {
572 vus: Some(5),
573 max: None,
574 paused: None,
575 stopped: None,
576 })
577 );
578 }
579
580 #[test]
581 fn rejects_garbage_and_unknown_only() {
582 assert_eq!(parse_status_body(br#"{"foo":1}"#), None); assert_eq!(parse_status_body(b"garbage"), None);
584 assert_eq!(parse_status_body(b"{}"), None);
585 }
586
587 #[test]
591 fn parses_stopped_only() {
592 assert_eq!(
593 parse_status_body(br#"{"stopped":true}"#),
594 Some(StatusPatch {
595 vus: None,
596 max: None,
597 paused: None,
598 stopped: Some(true),
599 })
600 );
601 }
602
603 #[test]
607 fn route_stopped_true_requests_stop() {
608 let sched = Arc::new(VUScheduler::new(
609 &tropel_core::config::ExecutionConfig::ExternallyControlled {
610 vus: 2,
611 max_vus: 10,
612 duration: None,
613 graceful_stop: None,
614 think_time: Default::default(),
615 },
616 ));
617 let state = test_state(sched.clone());
618 assert!(!sched.is_stop_requested());
619 let rt = tokio::runtime::Runtime::new().unwrap();
620 let (status, body) =
621 rt.block_on(route("PATCH", "/v1/status", br#"{"stopped":true}"#, &state));
622 assert_eq!(status, "200 OK");
623 assert!(
624 sched.is_stop_requested(),
625 "PATCH stopped:true must request a stop"
626 );
627 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
629 assert_eq!(v["data"]["attributes"]["stopped"], true);
630 }
631
632 #[test]
634 fn route_stopped_false_is_noop() {
635 let sched = Arc::new(VUScheduler::new(
636 &tropel_core::config::ExecutionConfig::ExternallyControlled {
637 vus: 2,
638 max_vus: 10,
639 duration: None,
640 graceful_stop: None,
641 think_time: Default::default(),
642 },
643 ));
644 let state = test_state(sched.clone());
645 let rt = tokio::runtime::Runtime::new().unwrap();
646 let (status, _) = rt.block_on(route(
647 "PATCH",
648 "/v1/status",
649 br#"{"stopped":false}"#,
650 &state,
651 ));
652 assert_eq!(status, "200 OK");
653 assert!(!sched.is_stop_requested());
654 }
655
656 #[tokio::test]
660 async fn oversized_body_rejected_before_alloc() {
661 let sched = Arc::new(VUScheduler::new(
662 &tropel_core::config::ExecutionConfig::ExternallyControlled {
663 vus: 2,
664 max_vus: 10,
665 duration: None,
666 graceful_stop: None,
667 think_time: Default::default(),
668 },
669 ));
670 let (mut client, server) = tokio::io::duplex(4096);
671 let state = test_state(sched);
672 let server_task = tokio::spawn(async move { serve_request(server, &state).await });
673
674 client
677 .write_all(b"PATCH /v1/status HTTP/1.1\r\nContent-Length: 68719476736\r\n\r\n")
678 .await
679 .unwrap();
680 let mut resp = Vec::new();
681 client.read_to_end(&mut resp).await.unwrap();
682 let text = String::from_utf8_lossy(&resp);
683 assert!(
684 text.contains("413 Payload Too Large"),
685 "expected 413, got: {}",
686 text
687 );
688 server_task.await.unwrap().unwrap();
689 }
690
691 #[tokio::test(start_paused = true)]
697 async fn stalled_client_is_timed_out() {
698 let sched = Arc::new(VUScheduler::new(
699 &tropel_core::config::ExecutionConfig::ExternallyControlled {
700 vus: 2,
701 max_vus: 10,
702 duration: None,
703 graceful_stop: None,
704 think_time: Default::default(),
705 },
706 ));
707 let (mut client, server) = tokio::io::duplex(4096);
708 client.write_all(b"PATCH /v1/status HTT").await.unwrap();
711 let state = test_state(sched);
712 let server_task = tokio::spawn(async move { handle_conn(server, &state).await });
713 tokio::time::advance(Duration::from_secs(11)).await;
716 let result = server_task.await.unwrap();
717 assert!(
718 result.is_ok(),
719 "stalled client must be cut off by the read timeout"
720 );
721 }
722
723 #[test]
724 fn status_json_is_k6_shape() {
725 let sched = VUScheduler::new(
726 &tropel_core::config::ExecutionConfig::ExternallyControlled {
727 vus: 2,
728 max_vus: 10,
729 duration: None,
730 graceful_stop: None,
731 think_time: Default::default(),
732 },
733 );
734 let sched = Arc::new(sched);
735 sched.set_control_target(4, 10);
736 let body = status_json(&sched);
737 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
738 let attrs = &v["data"]["attributes"];
739 assert_eq!(attrs["type"], serde_json::Value::Null); assert_eq!(v["data"]["type"], "status");
741 assert_eq!(v["data"]["id"], "default");
742 assert_eq!(attrs["vus"], 4);
743 assert_eq!(attrs["vus-max"], 10);
746 assert_eq!(attrs["max"], 10);
747 assert_eq!(attrs["paused"], false);
748 assert_eq!(attrs["running"], true);
749 assert_eq!(attrs["stopped"], false);
750 assert_eq!(attrs["tainted"], false);
751
752 sched.set_tainted();
754 let body = status_json(&sched);
755 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
756 assert_eq!(v["data"]["attributes"]["tainted"], true);
757 }
758
759 #[test]
762 fn patch_stop_accepts_k6_envelope() {
763 let sched = Arc::new(VUScheduler::new(
764 &tropel_core::config::ExecutionConfig::ExternallyControlled {
765 vus: 2,
766 max_vus: 10,
767 duration: None,
768 graceful_stop: None,
769 think_time: Default::default(),
770 },
771 ));
772 let state = test_state(sched.clone());
773 assert!(!sched.is_stop_requested());
774 let rt = tokio::runtime::Runtime::new().unwrap();
775 let (status, body) = rt.block_on(route(
776 "PATCH",
777 "/v1/stop",
778 br#"{"data":{"attributes":{"stopped":true}}}"#,
779 &state,
780 ));
781 assert_eq!(status, "200 OK");
782 assert!(
783 sched.is_stop_requested(),
784 "PATCH /v1/stop with the k6 envelope must stop the run"
785 );
786 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
787 assert_eq!(v["data"]["attributes"]["stopped"], true);
788 }
789
790 #[tokio::test]
793 async fn get_metrics_returns_k6_envelope() {
794 let sched = Arc::new(VUScheduler::new(
795 &tropel_core::config::ExecutionConfig::ExternallyControlled {
796 vus: 2,
797 max_vus: 10,
798 duration: None,
799 graceful_stop: None,
800 think_time: Default::default(),
801 },
802 ));
803 let state = test_state(sched);
804 let (status, body) = route("GET", "/v1/metrics", b"", &state).await;
805 assert_eq!(status, "200 OK");
806 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
807 assert_eq!(
808 v["data"],
809 serde_json::Value::Array(vec![]),
810 "no metrics yet"
811 );
812 }
813
814 #[tokio::test]
817 async fn get_groups_returns_k6_envelope() {
818 let sched = Arc::new(VUScheduler::new(
819 &tropel_core::config::ExecutionConfig::ExternallyControlled {
820 vus: 2,
821 max_vus: 10,
822 duration: None,
823 graceful_stop: None,
824 think_time: Default::default(),
825 },
826 ));
827 let state = test_state(sched);
828 let (status, body) = route("GET", "/v1/groups", b"", &state).await;
829 assert_eq!(status, "200 OK");
830 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
831 let group = &v["data"][0];
832 assert_eq!(group["type"], "groups");
833 assert_eq!(group["id"], "0");
834 assert_eq!(group["attributes"]["path"], "");
835 assert_eq!(group["attributes"]["name"], "s");
836 assert_eq!(
837 group["relationships"]["groups"]["data"],
838 serde_json::Value::Array(vec![])
839 );
840 assert_eq!(
841 group["relationships"]["parent"]["data"],
842 serde_json::Value::Null
843 );
844 }
845
846 #[tokio::test]
849 async fn setup_get_put_roundtrip() {
850 let sched = Arc::new(VUScheduler::new(
851 &tropel_core::config::ExecutionConfig::ExternallyControlled {
852 vus: 2,
853 max_vus: 10,
854 duration: None,
855 graceful_stop: None,
856 think_time: Default::default(),
857 },
858 ));
859 let state = test_state(sched);
860
861 let (status, body) = route("GET", "/v1/setup", b"", &state).await;
862 assert_eq!(status, "200 OK");
863 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
864 assert_eq!(v["data"]["data"], serde_json::Value::Null);
865
866 let (status, body) = route("PUT", "/v1/setup", br#"{"token":"abc"}"#, &state).await;
867 assert_eq!(status, "200 OK");
868 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
869 assert_eq!(v["data"]["data"]["token"], "abc");
870
871 let (status, body) = route("GET", "/v1/setup", b"", &state).await;
872 assert_eq!(status, "200 OK");
873 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
874 assert_eq!(v["data"]["data"]["token"], "abc");
875 }
876
877 #[tokio::test]
881 async fn setup_teardown_post_reexecution_rejected() {
882 let sched = Arc::new(VUScheduler::new(
883 &tropel_core::config::ExecutionConfig::ExternallyControlled {
884 vus: 2,
885 max_vus: 10,
886 duration: None,
887 graceful_stop: None,
888 think_time: Default::default(),
889 },
890 ));
891 let state = test_state(sched);
892 let (status, body) = route("POST", "/v1/setup", b"", &state).await;
893 assert_eq!(status, "405 Method Not Allowed");
894 assert!(body.contains("setup() runs once"));
895 let (status, body) = route("POST", "/v1/teardown", b"", &state).await;
896 assert_eq!(status, "405 Method Not Allowed");
897 assert!(body.contains("teardown() runs once"));
898 }
899}