1use std::sync::Arc;
7
8use axum::{
9 Router,
10 extract::{Query, State, rejection::QueryRejection},
11 http::StatusCode,
12 response::Json,
13 routing::get,
14};
15use openentropy_core::conditioning::ConditioningMode;
16use openentropy_core::pool::EntropyPool;
17use openentropy_core::pool::HealthReport;
18use openentropy_core::telemetry::{
19 TelemetryWindowReport, collect_telemetry_snapshot, collect_telemetry_window,
20};
21use serde::{Deserialize, Serialize};
22
23struct AppState {
29 pool: EntropyPool,
30 allow_raw: bool,
31}
32
33#[derive(Deserialize)]
34struct RandomParams {
35 length: Option<usize>,
36 #[serde(rename = "type")]
37 data_type: Option<String>,
38 raw: Option<bool>,
40 conditioning: Option<String>,
42 source: Option<String>,
44}
45
46#[derive(Serialize)]
47struct RandomResponse {
48 #[serde(rename = "type")]
49 data_type: String,
50 length: usize,
52 value_count: usize,
54 data: serde_json::Value,
55 success: bool,
56 conditioned: bool,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 source: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 error: Option<String>,
64}
65
66#[derive(Serialize)]
67struct HealthResponse {
68 status: String,
69 sources_healthy: usize,
70 sources_total: usize,
71 raw_bytes: u64,
72 output_bytes: u64,
73}
74
75#[derive(Serialize)]
76struct ApiErrorResponse {
77 success: bool,
78 error: String,
79}
80
81#[derive(Serialize)]
82struct SourcesResponse {
83 sources: Vec<SourceEntry>,
84 total: usize,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 telemetry_v1: Option<TelemetryWindowReport>,
87}
88
89#[derive(Serialize)]
90struct PoolStatusResponse {
91 sources_healthy: usize,
92 total: usize,
93 raw_bytes: u64,
94 output_bytes: u64,
95 buffer_size: usize,
96 sources: Vec<SourceEntry>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 telemetry_v1: Option<TelemetryWindowReport>,
99}
100
101#[derive(Serialize)]
102struct SourceEntry {
103 name: String,
104 healthy: bool,
105 bytes: u64,
106 entropy: f64,
107 min_entropy: f64,
108 autocorrelation: f64,
109 time: f64,
110 failures: u64,
111}
112
113#[derive(Deserialize, Default)]
114struct DiagnosticsParams {
115 telemetry: Option<bool>,
116}
117
118fn include_telemetry(params: &DiagnosticsParams) -> bool {
119 params.telemetry.unwrap_or(false)
120}
121
122async fn handle_random(
123 State(state): State<Arc<AppState>>,
124 params: Result<Query<RandomParams>, QueryRejection>,
125) -> (StatusCode, Json<RandomResponse>) {
126 let params = match params {
127 Ok(Query(params)) => params,
128 Err(err) => return random_query_error_response(err),
129 };
130
131 let length = params.length.unwrap_or(1024);
132 let data_type = params.data_type.unwrap_or_else(|| "hex16".to_string());
133 if !matches!(data_type.as_str(), "hex16" | "uint8" | "uint16") {
134 return Json(RandomResponse {
135 data_type,
136 length: 0,
137 value_count: 0,
138 data: serde_json::Value::Array(vec![]),
139 success: false,
140 conditioned: false,
141 source: params.source.clone(),
142 error: Some("Invalid type. Expected one of: hex16, uint8, uint16.".to_string()),
143 })
144 .with_status(StatusCode::BAD_REQUEST);
145 }
146 if let Err(error) = validate_request_length(length, &data_type) {
147 return Json(RandomResponse {
148 data_type,
149 length: 0,
150 value_count: 0,
151 data: serde_json::Value::Array(vec![]),
152 success: false,
153 conditioned: false,
154 source: params.source.clone(),
155 error: Some(error),
156 })
157 .with_status(StatusCode::BAD_REQUEST);
158 }
159
160 let mode = if let Some(ref c) = params.conditioning {
162 match c.as_str() {
163 "raw" if state.allow_raw => ConditioningMode::Raw,
164 "raw" => {
165 return Json(RandomResponse {
166 data_type,
167 length: 0,
168 value_count: 0,
169 data: serde_json::Value::Array(vec![]),
170 success: false,
171 conditioned: false,
172 source: params.source.clone(),
173 error: Some("Raw conditioning is not enabled. Start the server with --allow-raw to permit unconditioned output.".to_string()),
174 })
175 .with_status(StatusCode::FORBIDDEN);
176 }
177 "vonneumann" | "von_neumann" | "vn" => ConditioningMode::VonNeumann,
178 "sha256" => ConditioningMode::Sha256,
179 other => {
180 return Json(RandomResponse {
181 data_type,
182 length: 0,
183 value_count: 0,
184 data: serde_json::Value::Array(vec![]),
185 success: false,
186 conditioned: false,
187 source: params.source.clone(),
188 error: Some(format!(
189 "Invalid conditioning mode '{other}'. Expected one of: sha256, vonneumann|von_neumann|vn, raw."
190 )),
191 })
192 .with_status(StatusCode::BAD_REQUEST);
193 }
194 }
195 } else if params.raw.unwrap_or(false) {
196 if state.allow_raw {
197 ConditioningMode::Raw
198 } else {
199 return Json(RandomResponse {
200 data_type,
201 length: 0,
202 value_count: 0,
203 data: serde_json::Value::Array(vec![]),
204 success: false,
205 conditioned: false,
206 source: params.source.clone(),
207 error: Some("Raw output is not enabled. Start the server with --allow-raw to permit unconditioned output.".to_string()),
208 })
209 .with_status(StatusCode::FORBIDDEN);
210 }
211 } else {
212 ConditioningMode::Sha256
213 };
214
215 let raw = if let Some(ref source_name) = params.source {
216 match state.pool.get_source_bytes(source_name, length, mode) {
217 Some(bytes) => bytes,
218 None => {
219 let err_msg = format!(
220 "Unknown source: {source_name}. Use /sources to list available sources."
221 );
222 return Json(RandomResponse {
223 data_type,
224 length: 0,
225 value_count: 0,
226 data: serde_json::Value::Array(vec![]),
227 success: false,
228 conditioned: mode != ConditioningMode::Raw,
229 source: Some(source_name.clone()),
230 error: Some(err_msg),
231 })
232 .with_status(StatusCode::BAD_REQUEST);
233 }
234 }
235 } else {
236 state.pool.get_bytes(length, mode)
237 };
238 let use_raw = mode == ConditioningMode::Raw;
239
240 let (data, value_count) = serialize_random_data(&raw, &data_type);
241
242 (
243 StatusCode::OK,
244 Json(RandomResponse {
245 data_type,
246 length: raw.len(),
247 value_count,
248 data,
249 success: true,
250 conditioned: !use_raw,
251 source: params.source,
252 error: None,
253 }),
254 )
255}
256
257fn validate_request_length(length: usize, data_type: &str) -> Result<(), String> {
258 if !(1..=65536).contains(&length) {
259 return Err(format!(
260 "Invalid length {length}. Expected a byte count in the range 1..=65536."
261 ));
262 }
263 if matches!(data_type, "hex16" | "uint16") && !length.is_multiple_of(2) {
264 return Err(format!(
265 "type={data_type} requires an even byte length because values are encoded as 16-bit words."
266 ));
267 }
268 Ok(())
269}
270
271fn serialize_random_data(raw: &[u8], data_type: &str) -> (serde_json::Value, usize) {
272 match data_type {
273 "hex16" => {
274 let hex_pairs: Vec<String> = raw
275 .chunks_exact(2)
276 .map(|c| format!("{:02x}{:02x}", c[0], c[1]))
277 .collect();
278 let value_count = hex_pairs.len();
279 (
280 serde_json::Value::Array(
281 hex_pairs
282 .into_iter()
283 .map(serde_json::Value::String)
284 .collect(),
285 ),
286 value_count,
287 )
288 }
289 "uint8" => (
290 serde_json::Value::Array(raw.iter().map(|&b| serde_json::Value::from(b)).collect()),
291 raw.len(),
292 ),
293 "uint16" => {
294 let vals: Vec<u16> = raw
295 .chunks_exact(2)
296 .map(|c| u16::from_le_bytes([c[0], c[1]]))
297 .collect();
298 let value_count = vals.len();
299 (
300 serde_json::Value::Array(vals.into_iter().map(serde_json::Value::from).collect()),
301 value_count,
302 )
303 }
304 _ => unreachable!("validated above"),
305 }
306}
307
308trait JsonWithStatus<T> {
309 fn with_status(self, status: StatusCode) -> (StatusCode, Json<T>);
310}
311
312impl<T> JsonWithStatus<T> for Json<T> {
313 fn with_status(self, status: StatusCode) -> (StatusCode, Json<T>) {
314 (status, self)
315 }
316}
317
318fn query_error_message(err: QueryRejection) -> String {
319 format!("Invalid query parameters: {err}")
320}
321
322fn random_query_error_response(err: QueryRejection) -> (StatusCode, Json<RandomResponse>) {
323 Json(RandomResponse {
324 data_type: "hex16".to_string(),
325 length: 0,
326 value_count: 0,
327 data: serde_json::Value::Array(vec![]),
328 success: false,
329 conditioned: false,
330 source: None,
331 error: Some(query_error_message(err)),
332 })
333 .with_status(StatusCode::BAD_REQUEST)
334}
335
336fn api_query_error_response(err: QueryRejection) -> (StatusCode, Json<ApiErrorResponse>) {
337 Json(ApiErrorResponse {
338 success: false,
339 error: query_error_message(err),
340 })
341 .with_status(StatusCode::BAD_REQUEST)
342}
343
344fn source_entries(report: &HealthReport) -> Vec<SourceEntry> {
345 report
346 .sources
347 .iter()
348 .map(|s| SourceEntry {
349 name: s.name.clone(),
350 healthy: s.healthy,
351 bytes: s.bytes,
352 entropy: s.entropy,
353 min_entropy: s.min_entropy,
354 autocorrelation: s.autocorrelation,
355 time: s.time,
356 failures: s.failures,
357 })
358 .collect()
359}
360
361async fn handle_health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
362 let report = state.pool.health_report();
363 Json(HealthResponse {
364 status: if report.healthy > 0 {
365 "healthy".to_string()
366 } else {
367 "degraded".to_string()
368 },
369 sources_healthy: report.healthy,
370 sources_total: report.total,
371 raw_bytes: report.raw_bytes,
372 output_bytes: report.output_bytes,
373 })
374}
375
376async fn handle_sources(
377 State(state): State<Arc<AppState>>,
378 params: Result<Query<DiagnosticsParams>, QueryRejection>,
379) -> Result<Json<SourcesResponse>, (StatusCode, Json<ApiErrorResponse>)> {
380 let params = match params {
381 Ok(Query(params)) => params,
382 Err(err) => return Err(api_query_error_response(err)),
383 };
384 let telemetry_start = include_telemetry(¶ms).then(collect_telemetry_snapshot);
385 let report = state.pool.health_report();
386 let telemetry_v1 = telemetry_start.map(collect_telemetry_window);
387 let sources = source_entries(&report);
388 let total = sources.len();
389 Ok(Json(SourcesResponse {
390 sources,
391 total,
392 telemetry_v1,
393 }))
394}
395
396async fn handle_pool_status(
397 State(state): State<Arc<AppState>>,
398 params: Result<Query<DiagnosticsParams>, QueryRejection>,
399) -> Result<Json<PoolStatusResponse>, (StatusCode, Json<ApiErrorResponse>)> {
400 let params = match params {
401 Ok(Query(params)) => params,
402 Err(err) => return Err(api_query_error_response(err)),
403 };
404 let telemetry_start = include_telemetry(¶ms).then(collect_telemetry_snapshot);
405 let report = state.pool.health_report();
406 Ok(Json(PoolStatusResponse {
407 sources_healthy: report.healthy,
408 total: report.total,
409 raw_bytes: report.raw_bytes,
410 output_bytes: report.output_bytes,
411 buffer_size: report.buffer_size,
412 sources: source_entries(&report),
413 telemetry_v1: telemetry_start.map(collect_telemetry_window),
414 }))
415}
416
417async fn handle_index(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
418 let source_names = state.pool.source_names();
419
420 Json(serde_json::json!({
421 "name": "OpenEntropy Server",
422 "version": openentropy_core::VERSION,
423 "sources": source_names.len(),
424 "endpoints": {
425 "/": "This API index",
426 "/api/v1/random": {
427 "method": "GET",
428 "description": "Get random entropy bytes",
429 "params": {
430 "length": "Number of output bytes (1-65536, default: 1024). type=hex16 and type=uint16 require an even byte length.",
431 "type": "Output encoding: hex16, uint8, uint16 (default: hex16). hex16/uint16 pack 2 bytes per array value.",
432 "source": format!("Request from a specific source by name. Available: {}", source_names.join(", ")),
433 "conditioning": "Conditioning mode: sha256 (default), vonneumann, raw",
434 },
435 "response_fields": {
436 "length": "Returned byte count represented by data",
437 "value_count": "Number of encoded values in the data array",
438 "data": "Entropy payload encoded according to type"
439 }
440 },
441 "/sources": {
442 "description": "List all active entropy sources with health metrics",
443 "params": {
444 "telemetry": "Include telemetry_v1 start/end report (true/false, default false)"
445 },
446 "response_fields": {
447 "total": "Total number of source entries in the response",
448 "sources": "Per-source health rows with name, healthy(boolean), bytes, entropy, min_entropy, autocorrelation, time, failures",
449 "telemetry_v1": "Optional telemetry window when telemetry=true"
450 }
451 },
452 "/pool/status": {
453 "description": "Detailed pool status",
454 "params": {
455 "telemetry": "Include telemetry_v1 start/end report (true/false, default false)"
456 },
457 "response_fields": {
458 "sources_healthy": "Number of currently healthy sources in the pool",
459 "total": "Total number of registered sources",
460 "raw_bytes": "Total raw bytes collected across sources",
461 "output_bytes": "Total conditioned output bytes produced",
462 "buffer_size": "Current pool buffer size in bytes",
463 "sources": "Per-source health rows with name, healthy(boolean), bytes, entropy, min_entropy, autocorrelation, time, failures",
464 "telemetry_v1": "Optional telemetry window when telemetry=true"
465 }
466 },
467 "/health": {
468 "description": "Health check",
469 "response_fields": {
470 "status": "healthy when one or more sources are healthy, degraded otherwise",
471 "sources_healthy": "Number of currently healthy sources",
472 "sources_total": "Total number of registered sources",
473 "raw_bytes": "Total raw bytes collected across sources",
474 "output_bytes": "Total conditioned output bytes produced"
475 }
476 },
477 },
478 "error_contract": "Invalid query parameters return JSON 400 responses",
479 "examples": {
480 "mixed_pool": "/api/v1/random?length=32&type=uint8",
481 "single_source": format!("/api/v1/random?length=32&source={}", source_names.first().map(|s| s.as_str()).unwrap_or("clock_jitter")),
482 "raw_output": "/api/v1/random?length=32&conditioning=raw",
483 "sources_with_telemetry": "/sources?telemetry=true",
484 "pool_with_telemetry": "/pool/status?telemetry=true",
485 }
486 }))
487}
488
489fn build_router(pool: EntropyPool, allow_raw: bool) -> Router {
491 let state = Arc::new(AppState { pool, allow_raw });
492
493 Router::new()
494 .route("/", get(handle_index))
495 .route("/api/v1/random", get(handle_random))
496 .route("/health", get(handle_health))
497 .route("/sources", get(handle_sources))
498 .route("/pool/status", get(handle_pool_status))
499 .with_state(state)
500}
501
502pub async fn run_server(
507 pool: EntropyPool,
508 host: &str,
509 port: u16,
510 allow_raw: bool,
511) -> std::io::Result<()> {
512 let app = build_router(pool, allow_raw);
513 let addr = format!("{host}:{port}");
514 let listener = tokio::net::TcpListener::bind(&addr).await?;
515 axum::serve(listener, app).await?;
516 Ok(())
517}
518
519#[cfg(test)]
520mod tests {
521 use std::collections::BTreeSet;
522 use std::sync::Arc;
523
524 use super::{
525 AppState, DiagnosticsParams, RandomParams, build_router, handle_random, include_telemetry,
526 };
527 use axum::{
528 body::{Body, to_bytes},
529 extract::{Query, State},
530 http::{Request, StatusCode},
531 };
532 use openentropy_core::pool::EntropyPool;
533 use openentropy_core::source::{
534 EntropySource, Platform, Requirement, SourceCategory, SourceInfo,
535 };
536 use serde_json::Value;
537 use tower::util::ServiceExt;
538
539 struct TestSource {
540 info: SourceInfo,
541 }
542
543 impl TestSource {
544 fn new() -> Self {
545 Self {
546 info: SourceInfo {
547 name: "test_source",
548 description: "test source",
549 physics: "deterministic test bytes",
550 category: SourceCategory::System,
551 platform: Platform::Any,
552 requirements: &[] as &[Requirement],
553 entropy_rate_estimate: 1.0,
554 composite: false,
555 is_fast: true,
556 },
557 }
558 }
559 }
560
561 impl EntropySource for TestSource {
562 fn info(&self) -> &SourceInfo {
563 &self.info
564 }
565
566 fn is_available(&self) -> bool {
567 true
568 }
569
570 fn collect(&self, n_samples: usize) -> Vec<u8> {
571 vec![0xAA; n_samples]
572 }
573 }
574
575 fn test_state() -> Arc<AppState> {
576 Arc::new(AppState {
577 pool: EntropyPool::new(None),
578 allow_raw: false,
579 })
580 }
581
582 fn test_router() -> axum::Router {
583 let mut pool = EntropyPool::new(Some(b"server-test"));
584 pool.add_source(Box::new(TestSource::new()));
585 build_router(pool, false)
586 }
587
588 async fn response_json(response: axum::response::Response) -> Value {
589 let bytes = to_bytes(response.into_body(), usize::MAX)
590 .await
591 .expect("response body bytes");
592 serde_json::from_slice(&bytes).expect("valid json body")
593 }
594
595 fn assert_source_entry_schema(source: &Value) {
596 let obj = source.as_object().expect("source row object");
597 let keys: BTreeSet<_> = obj.keys().map(String::as_str).collect();
598 let expected = BTreeSet::from([
599 "autocorrelation",
600 "bytes",
601 "entropy",
602 "failures",
603 "healthy",
604 "min_entropy",
605 "name",
606 "time",
607 ]);
608
609 assert_eq!(keys, expected);
610 assert!(source["name"].is_string());
611 assert!(source["healthy"].is_boolean());
612 assert!(source["bytes"].is_u64());
613 assert!(source["entropy"].is_number());
614 assert!(source["min_entropy"].is_number());
615 assert!(source["autocorrelation"].is_number());
616 assert!(source["time"].is_number());
617 assert!(source["failures"].is_u64());
618 }
619
620 #[test]
621 fn telemetry_flag_defaults_to_false() {
622 let default = DiagnosticsParams::default();
623 assert!(!include_telemetry(&default));
624 assert!(include_telemetry(&DiagnosticsParams {
625 telemetry: Some(true),
626 }));
627 }
628
629 #[tokio::test]
630 async fn invalid_conditioning_returns_bad_request() {
631 let state = test_state();
632
633 let (status, body) = handle_random(
634 State(state),
635 Ok(Query(RandomParams {
636 length: Some(32),
637 data_type: Some("uint8".to_string()),
638 raw: None,
639 conditioning: Some("bogus".to_string()),
640 source: None,
641 })),
642 )
643 .await;
644
645 assert_eq!(status, StatusCode::BAD_REQUEST);
646 assert!(!body.0.success);
647 assert!(
648 body.0
649 .error
650 .as_deref()
651 .is_some_and(|msg| msg.contains("Invalid conditioning mode"))
652 );
653 }
654
655 #[tokio::test]
656 async fn invalid_type_returns_bad_request() {
657 let state = test_state();
658
659 let (status, body) = handle_random(
660 State(state),
661 Ok(Query(RandomParams {
662 length: Some(32),
663 data_type: Some("hex".to_string()),
664 raw: None,
665 conditioning: None,
666 source: None,
667 })),
668 )
669 .await;
670
671 assert_eq!(status, StatusCode::BAD_REQUEST);
672 assert!(!body.0.success);
673 assert!(
674 body.0
675 .error
676 .as_deref()
677 .is_some_and(|msg| msg.contains("Invalid type"))
678 );
679 }
680
681 #[tokio::test]
682 async fn unknown_source_returns_bad_request() {
683 let state = test_state();
684
685 let (status, body) = handle_random(
686 State(state),
687 Ok(Query(RandomParams {
688 length: Some(32),
689 data_type: Some("uint8".to_string()),
690 raw: None,
691 conditioning: None,
692 source: Some("definitely_not_a_source".to_string()),
693 })),
694 )
695 .await;
696
697 assert_eq!(status, StatusCode::BAD_REQUEST);
698 assert!(!body.0.success);
699 assert_eq!(body.0.source.as_deref(), Some("definitely_not_a_source"));
700 assert!(
701 body.0
702 .error
703 .as_deref()
704 .is_some_and(|msg| msg.contains("Unknown source"))
705 );
706 }
707
708 #[tokio::test]
709 async fn raw_conditioning_requires_allow_raw() {
710 let state = test_state();
711
712 let (status, body) = handle_random(
713 State(state),
714 Ok(Query(RandomParams {
715 length: Some(32),
716 data_type: Some("uint8".to_string()),
717 raw: None,
718 conditioning: Some("raw".to_string()),
719 source: None,
720 })),
721 )
722 .await;
723
724 assert_eq!(status, StatusCode::FORBIDDEN);
725 assert!(!body.0.success);
726 assert!(
727 body.0
728 .error
729 .as_deref()
730 .is_some_and(|msg| msg.contains("--allow-raw"))
731 );
732 }
733
734 #[tokio::test]
735 async fn uint8_length_reports_bytes_and_value_count() {
736 let state = test_state();
737
738 let (status, body) = handle_random(
739 State(state),
740 Ok(Query(RandomParams {
741 length: Some(32),
742 data_type: Some("uint8".to_string()),
743 raw: None,
744 conditioning: None,
745 source: None,
746 })),
747 )
748 .await;
749
750 assert_eq!(status, StatusCode::OK);
751 assert!(body.0.success);
752 assert_eq!(body.0.length, 32);
753 assert_eq!(body.0.value_count, 32);
754 assert_eq!(body.0.data.as_array().map(Vec::len), Some(32));
755 }
756
757 #[tokio::test]
758 async fn uint16_length_reports_bytes_and_word_count() {
759 let state = test_state();
760
761 let (status, body) = handle_random(
762 State(state),
763 Ok(Query(RandomParams {
764 length: Some(32),
765 data_type: Some("uint16".to_string()),
766 raw: None,
767 conditioning: None,
768 source: None,
769 })),
770 )
771 .await;
772
773 assert_eq!(status, StatusCode::OK);
774 assert!(body.0.success);
775 assert_eq!(body.0.length, 32);
776 assert_eq!(body.0.value_count, 16);
777 assert_eq!(body.0.data.as_array().map(Vec::len), Some(16));
778 }
779
780 #[tokio::test]
781 async fn hex16_length_reports_bytes_and_word_count() {
782 let state = test_state();
783
784 let (status, body) = handle_random(
785 State(state),
786 Ok(Query(RandomParams {
787 length: Some(32),
788 data_type: Some("hex16".to_string()),
789 raw: None,
790 conditioning: None,
791 source: None,
792 })),
793 )
794 .await;
795
796 assert_eq!(status, StatusCode::OK);
797 assert!(body.0.success);
798 assert_eq!(body.0.length, 32);
799 assert_eq!(body.0.value_count, 16);
800 assert_eq!(body.0.data.as_array().map(Vec::len), Some(16));
801 assert!(body.0.data.as_array().is_some_and(|items| {
802 items
803 .iter()
804 .all(|value| value.as_str().is_some_and(|s| s.len() == 4))
805 }));
806 }
807
808 #[tokio::test]
809 async fn uint16_rejects_odd_byte_lengths() {
810 let state = test_state();
811
812 let (status, body) = handle_random(
813 State(state),
814 Ok(Query(RandomParams {
815 length: Some(31),
816 data_type: Some("uint16".to_string()),
817 raw: None,
818 conditioning: None,
819 source: None,
820 })),
821 )
822 .await;
823
824 assert_eq!(status, StatusCode::BAD_REQUEST);
825 assert!(!body.0.success);
826 assert!(
827 body.0
828 .error
829 .as_deref()
830 .is_some_and(|msg| msg.contains("even byte length"))
831 );
832 }
833
834 #[tokio::test]
835 async fn hex16_rejects_odd_byte_lengths() {
836 let state = test_state();
837
838 let (status, body) = handle_random(
839 State(state),
840 Ok(Query(RandomParams {
841 length: Some(31),
842 data_type: Some("hex16".to_string()),
843 raw: None,
844 conditioning: None,
845 source: None,
846 })),
847 )
848 .await;
849
850 assert_eq!(status, StatusCode::BAD_REQUEST);
851 assert!(!body.0.success);
852 assert!(
853 body.0
854 .error
855 .as_deref()
856 .is_some_and(|msg| msg.contains("even byte length"))
857 );
858 }
859
860 #[tokio::test]
861 async fn length_zero_returns_bad_request() {
862 let state = test_state();
863
864 let (status, body) = handle_random(
865 State(state),
866 Ok(Query(RandomParams {
867 length: Some(0),
868 data_type: Some("uint8".to_string()),
869 raw: None,
870 conditioning: None,
871 source: None,
872 })),
873 )
874 .await;
875
876 assert_eq!(status, StatusCode::BAD_REQUEST);
877 assert!(!body.0.success);
878 assert!(
879 body.0
880 .error
881 .as_deref()
882 .is_some_and(|msg| msg.contains("range 1..=65536"))
883 );
884 }
885
886 #[tokio::test]
887 async fn length_above_max_returns_bad_request() {
888 let state = test_state();
889
890 let (status, body) = handle_random(
891 State(state),
892 Ok(Query(RandomParams {
893 length: Some(65_537),
894 data_type: Some("uint8".to_string()),
895 raw: None,
896 conditioning: None,
897 source: None,
898 })),
899 )
900 .await;
901
902 assert_eq!(status, StatusCode::BAD_REQUEST);
903 assert!(!body.0.success);
904 assert!(
905 body.0
906 .error
907 .as_deref()
908 .is_some_and(|msg| msg.contains("range 1..=65536"))
909 );
910 }
911
912 #[tokio::test]
913 async fn random_route_invalid_query_returns_json_bad_request() {
914 let response = test_router()
915 .oneshot(
916 Request::builder()
917 .uri("/api/v1/random?length=nope")
918 .body(Body::empty())
919 .expect("request"),
920 )
921 .await
922 .expect("router response");
923
924 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
925 let content_type = response
926 .headers()
927 .get(axum::http::header::CONTENT_TYPE)
928 .and_then(|value| value.to_str().ok());
929 assert_eq!(content_type, Some("application/json"));
930
931 let body = response_json(response).await;
932 assert_eq!(body["success"], Value::Bool(false));
933 assert_eq!(body["length"], Value::from(0));
934 assert_eq!(body["value_count"], Value::from(0));
935 assert!(
936 body["error"]
937 .as_str()
938 .is_some_and(|msg| msg.contains("Invalid query parameters"))
939 );
940 }
941
942 #[tokio::test]
943 async fn sources_route_invalid_query_returns_json_bad_request() {
944 let response = test_router()
945 .oneshot(
946 Request::builder()
947 .uri("/sources?telemetry=nope")
948 .body(Body::empty())
949 .expect("request"),
950 )
951 .await
952 .expect("router response");
953
954 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
955 let content_type = response
956 .headers()
957 .get(axum::http::header::CONTENT_TYPE)
958 .and_then(|value| value.to_str().ok());
959 assert_eq!(content_type, Some("application/json"));
960
961 let body = response_json(response).await;
962 assert_eq!(body["success"], Value::Bool(false));
963 assert!(
964 body["error"]
965 .as_str()
966 .is_some_and(|msg| msg.contains("Invalid query parameters"))
967 );
968 }
969
970 #[tokio::test]
971 async fn sources_route_returns_expected_schema_with_telemetry() {
972 let response = test_router()
973 .oneshot(
974 Request::builder()
975 .uri("/sources?telemetry=true")
976 .body(Body::empty())
977 .expect("request"),
978 )
979 .await
980 .expect("router response");
981
982 assert_eq!(response.status(), StatusCode::OK);
983 let body = response_json(response).await;
984 let sources = body["sources"].as_array().expect("sources array");
985 assert_eq!(body["total"].as_u64(), Some(sources.len() as u64));
986 if let Some(source) = sources.first() {
987 assert_source_entry_schema(source);
988 }
989 assert!(body.get("telemetry_v1").is_some());
990 }
991
992 #[tokio::test]
993 async fn pool_status_route_invalid_query_returns_json_bad_request() {
994 let response = test_router()
995 .oneshot(
996 Request::builder()
997 .uri("/pool/status?telemetry=nope")
998 .body(Body::empty())
999 .expect("request"),
1000 )
1001 .await
1002 .expect("router response");
1003
1004 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1005 let content_type = response
1006 .headers()
1007 .get(axum::http::header::CONTENT_TYPE)
1008 .and_then(|value| value.to_str().ok());
1009 assert_eq!(content_type, Some("application/json"));
1010
1011 let body = response_json(response).await;
1012 assert_eq!(body["success"], Value::Bool(false));
1013 assert!(
1014 body["error"]
1015 .as_str()
1016 .is_some_and(|msg| msg.contains("Invalid query parameters"))
1017 );
1018 }
1019
1020 #[tokio::test]
1021 async fn pool_status_route_uses_sources_healthy_and_includes_telemetry() {
1022 let response = test_router()
1023 .oneshot(
1024 Request::builder()
1025 .uri("/pool/status?telemetry=true")
1026 .body(Body::empty())
1027 .expect("request"),
1028 )
1029 .await
1030 .expect("router response");
1031
1032 assert_eq!(response.status(), StatusCode::OK);
1033 let body = response_json(response).await;
1034 let sources = body["sources"].as_array().expect("sources array");
1035 let healthy_count = sources
1036 .iter()
1037 .filter(|source| source["healthy"].as_bool() == Some(true))
1038 .count() as u64;
1039
1040 assert_eq!(body["sources_healthy"].as_u64(), Some(healthy_count));
1041 assert_eq!(body["total"].as_u64(), Some(sources.len() as u64));
1042 if let Some(source) = sources.first() {
1043 assert_source_entry_schema(source);
1044 }
1045 assert!(body.get("healthy").is_none());
1046 assert!(body.get("telemetry_v1").is_some());
1047 }
1048}