Skip to main content

openentropy_server/
lib.rs

1//! HTTP entropy server with an ANU-style random endpoint.
2//!
3//! Serves random bytes via HTTP with an ANU-style JSON shape for easy integration with QRNG
4//! clients while preserving OpenEntropy's explicit byte-length semantics.
5
6use 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
23/// Shared server state.
24///
25/// `EntropyPool` uses interior mutability (`Mutex<Vec<u8>>`, `Mutex<[u8;32]>`, etc.)
26/// so all its methods take `&self`. No outer mutex needed — concurrent HTTP requests
27/// can access the pool simultaneously without serializing.
28struct 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    /// If true, return raw unconditioned entropy (no SHA-256/DRBG).
39    raw: Option<bool>,
40    /// Conditioning mode: raw, vonneumann, sha256 (overrides `raw` flag).
41    conditioning: Option<String>,
42    /// Request entropy from a specific source by name.
43    source: Option<String>,
44}
45
46#[derive(Serialize)]
47struct RandomResponse {
48    #[serde(rename = "type")]
49    data_type: String,
50    /// Number of output bytes represented by `data`.
51    length: usize,
52    /// Number of values in the `data` array after encoding for `type`.
53    value_count: usize,
54    data: serde_json::Value,
55    success: bool,
56    /// Whether this output was conditioned (SHA-256) or raw.
57    conditioned: bool,
58    /// Which source was queried (null if mixed pool).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    source: Option<String>,
61    /// Error message if request failed.
62    #[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    // Determine conditioning mode: ?conditioning= takes priority, then ?raw=true
161    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(&params).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(&params).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
489/// Build the axum router.
490fn 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
502/// Run the HTTP entropy server.
503///
504/// Returns an error if the address cannot be bound or the server encounters
505/// a fatal I/O error.
506pub 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}