Skip to main content

scp_node/
dev_api.rs

1//! Dev API handlers for local development and diagnostics.
2//!
3//! Provides the `/scp/dev/v1` endpoint family: health, identity, relay
4//! status, and context management. All requests require bearer token
5//! authentication (spec section 18.10.2). The token is validated using
6//! constant-time comparison to prevent timing side-channel attacks.
7//!
8//! ## Endpoints
9//!
10//! | Method | Path | Handler |
11//! |--------|------|---------|
12//! | GET | `/scp/dev/v1/health` | [`health_handler`] |
13//! | GET | `/scp/dev/v1/identity` | [`identity_handler`] |
14//! | GET | `/scp/dev/v1/relay/status` | [`relay_status_handler`] |
15//! | GET | `/scp/dev/v1/contexts` | [`list_contexts_handler`] |
16//! | GET | `/scp/dev/v1/contexts/{id}` | [`get_context_handler`] |
17//! | POST | `/scp/dev/v1/contexts` | [`create_context_handler`] |
18//! | DELETE | `/scp/dev/v1/contexts/{id}` | [`delete_context_handler`] |
19//!
20//! See spec section 18.10 for the full dev API specification.
21
22use std::sync::Arc;
23
24use axum::Json;
25use axum::body::Body;
26use axum::extract::rejection::JsonRejection;
27use axum::extract::{Path, State};
28use axum::http::{Request, StatusCode, header};
29use axum::middleware::Next;
30use axum::response::IntoResponse;
31use serde::{Deserialize, Serialize};
32use subtle::ConstantTimeEq;
33
34use crate::error::ApiError;
35use crate::http::NodeState;
36
37// ---------------------------------------------------------------------------
38// Bearer auth middleware
39// ---------------------------------------------------------------------------
40
41/// Axum middleware that validates bearer token authentication.
42///
43/// Extracts the `Authorization: Bearer <token>` header from incoming requests
44/// and validates it against `expected_token` using constant-time comparison
45/// (via [`subtle::ConstantTimeEq`]) to prevent timing side-channel attacks.
46///
47/// Returns HTTP 401 with a JSON error body if:
48/// - The `Authorization` header is missing
49/// - The header value is not in `Bearer <token>` format
50/// - The provided token does not match the expected token
51///
52/// See spec section 18.10.2.
53pub async fn bearer_auth_middleware(
54    req: Request<Body>,
55    next: Next,
56    expected_token: String,
57) -> impl IntoResponse {
58    let auth_header = req
59        .headers()
60        .get(header::AUTHORIZATION)
61        .and_then(|v| v.to_str().ok());
62
63    match auth_header {
64        // RFC 7235 §2.1: auth-scheme tokens are case-insensitive.
65        Some(value) if value.len() > 7 && value[..7].eq_ignore_ascii_case("bearer ") => {
66            let provided = &value[7..];
67            if bool::from(provided.as_bytes().ct_eq(expected_token.as_bytes())) {
68                next.run(req).await.into_response()
69            } else {
70                ApiError::unauthorized().into_response()
71            }
72        }
73        _ => ApiError::unauthorized().into_response(),
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Host header validation (DNS rebinding protection)
79// ---------------------------------------------------------------------------
80
81/// Allowed Host header values for the dev API.
82///
83/// The dev API is intended for localhost access only (spec section 18.10.1).
84/// This middleware rejects requests whose `Host` header does not match a
85/// localhost pattern, preventing DNS rebinding attacks where a malicious
86/// website resolves to 127.0.0.1 and accesses the dev API through the browser.
87const ALLOWED_HOSTS: &[&str] = &["localhost", "127.0.0.1", "[::1]"];
88
89/// Returns true if the `Host` header value is a localhost address,
90/// optionally followed by a port (e.g., `localhost:8080`, `127.0.0.1:3000`,
91/// `[::1]:8080`).
92///
93/// Handles RFC 3986 bracket notation for IPv6 addresses — `[::1]:8080`
94/// is correctly parsed as hostname `[::1]`, not split on internal colons.
95fn is_localhost_host(host: &str) -> bool {
96    // IPv6 bracket notation: [::1] or [::1]:port
97    let hostname = if host.starts_with('[') {
98        // Find the closing bracket; everything up to and including it is the host.
99        host.find(']').map_or(host, |end| &host[..=end])
100    } else {
101        // IPv4 or hostname: split on first ':' to strip port.
102        host.split(':').next().unwrap_or(host)
103    };
104    ALLOWED_HOSTS
105        .iter()
106        .any(|h| hostname.eq_ignore_ascii_case(h))
107}
108
109/// Axum middleware that rejects requests with non-localhost Host headers.
110///
111/// Prevents DNS rebinding attacks against the dev API (spec section 18.10.1).
112/// Returns HTTP 403 if the Host header is missing or does not match a
113/// localhost address.
114pub async fn localhost_host_middleware(req: Request<Body>, next: Next) -> impl IntoResponse {
115    let host = req
116        .headers()
117        .get(header::HOST)
118        .and_then(|v| v.to_str().ok());
119
120    match host {
121        Some(h) if is_localhost_host(h) => next.run(req).await.into_response(),
122        _ => {
123            // No Host header or non-localhost Host — reject.
124            ApiError::forbidden("forbidden: dev API only accessible via localhost").into_response()
125        }
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Security response headers
131// ---------------------------------------------------------------------------
132
133/// Axum middleware that sets security response headers on every dev API response.
134///
135/// Headers applied:
136/// - `X-Content-Type-Options: nosniff` — prevents MIME sniffing
137/// - `Cache-Control: no-store` — prevents caching of sensitive diagnostics
138/// - `X-Frame-Options: DENY` — prevents clickjacking via iframe embedding
139///
140/// Also rejects CORS preflight (OPTIONS) requests with 403 Forbidden.
141/// The dev API is localhost-only and must not be accessible cross-origin.
142pub async fn security_headers_middleware(req: Request<Body>, next: Next) -> impl IntoResponse {
143    // Reject CORS preflight requests explicitly.
144    if req.method() == axum::http::Method::OPTIONS {
145        return ApiError::forbidden("forbidden: CORS requests not allowed on dev API")
146            .into_response();
147    }
148
149    let mut response = next.run(req).await;
150    let headers = response.headers_mut();
151    headers.insert(
152        axum::http::header::X_CONTENT_TYPE_OPTIONS,
153        axum::http::HeaderValue::from_static("nosniff"),
154    );
155    headers.insert(
156        axum::http::header::CACHE_CONTROL,
157        axum::http::HeaderValue::from_static("no-store"),
158    );
159    headers.insert(
160        axum::http::header::X_FRAME_OPTIONS,
161        axum::http::HeaderValue::from_static("DENY"),
162    );
163    response
164}
165
166// ---------------------------------------------------------------------------
167// Response types
168// ---------------------------------------------------------------------------
169
170/// Response body for `GET /scp/dev/v1/health`.
171///
172/// Reports basic health metrics for the running node.
173///
174/// See spec section 18.10.3.
175#[derive(Debug, Clone, Serialize)]
176pub struct HealthResponse {
177    /// Seconds since the node was started.
178    pub uptime_seconds: u64,
179    /// Total number of active relay connections across all IPs.
180    pub relay_connections: u64,
181    /// Storage subsystem status. Currently always `"ok"`.
182    pub storage_status: String,
183}
184
185/// Response body for `GET /scp/dev/v1/identity`.
186///
187/// Returns the node operator's DID string and full DID document.
188///
189/// See spec section 18.10.3.
190#[derive(Debug, Clone, Serialize)]
191pub struct IdentityResponse {
192    /// The operator's DID string (e.g., `did:dht:...`).
193    pub did: String,
194    /// The operator's full DID document, serialized as a JSON object.
195    pub document: serde_json::Value,
196}
197
198/// Response body for `GET /scp/dev/v1/relay/status`.
199///
200/// Reports relay server status with real-time metrics from the
201/// connection tracker and blob storage backend.
202///
203/// See spec section 18.10.3.
204#[derive(Debug, Clone, Serialize)]
205pub struct RelayStatusResponse {
206    /// The address the relay server is bound to (e.g., `127.0.0.1:9000`).
207    pub bound_addr: String,
208    /// Total number of active connections across all IPs.
209    pub active_connections: u64,
210    /// Number of blobs currently stored in the blob storage backend.
211    pub blob_count: u64,
212}
213
214/// Response body for context endpoints (`GET /scp/dev/v1/contexts` and
215/// `GET /scp/dev/v1/contexts/{id}`).
216///
217/// Represents a registered broadcast context with its metadata. The `mode`
218/// field is always `"broadcast"` in the current implementation.
219///
220/// See spec section 18.10.3.
221#[derive(Debug, Clone, Serialize)]
222pub struct ContextResponse {
223    /// Context ID (hex-encoded).
224    pub id: String,
225    /// Human-readable context name (advisory, may be absent).
226    pub name: Option<String>,
227    /// Context mode. Currently always `"broadcast"`.
228    pub mode: String,
229    /// Number of active subscribers for this context's routing ID.
230    pub subscriber_count: u64,
231}
232
233/// Request body for `POST /scp/dev/v1/contexts`.
234///
235/// Registers a new broadcast context. The `id` field is required; `name` is
236/// an optional human-readable label.
237///
238/// See spec section 18.10.3.
239#[derive(Debug, Clone, Deserialize)]
240pub struct CreateContextRequest {
241    /// Context ID (hex-encoded).
242    pub id: String,
243    /// Human-readable context name (advisory).
244    pub name: Option<String>,
245}
246
247// ---------------------------------------------------------------------------
248// Handlers
249// ---------------------------------------------------------------------------
250
251/// Handler for `GET /scp/dev/v1/health`.
252///
253/// Returns a [`HealthResponse`] with the node's uptime (computed from
254/// [`NodeState::start_time`]), relay connection count, and storage status.
255///
256/// See spec section 18.10.3.
257pub async fn health_handler(State(state): State<Arc<NodeState>>) -> impl IntoResponse {
258    let uptime = state.start_time.elapsed().as_secs();
259    let relay_connections = {
260        let tracker = state.connection_tracker.read().await;
261        tracker.values().sum::<usize>() as u64
262    };
263
264    (
265        StatusCode::OK,
266        Json(HealthResponse {
267            uptime_seconds: uptime,
268            relay_connections,
269            storage_status: "ok".to_owned(),
270        }),
271    )
272}
273
274/// Handler for `GET /scp/dev/v1/identity`.
275///
276/// Returns an [`IdentityResponse`] with the node operator's DID string and
277/// full DID document.
278///
279/// See spec section 18.10.3.
280pub async fn identity_handler(State(state): State<Arc<NodeState>>) -> impl IntoResponse {
281    let document = serde_json::to_value(&state.did_document)
282        .unwrap_or_else(|_| serde_json::Value::String(state.did.clone()));
283
284    (
285        StatusCode::OK,
286        Json(IdentityResponse {
287            did: state.did.clone(),
288            document,
289        }),
290    )
291}
292
293/// Handler for `GET /scp/dev/v1/relay/status`.
294///
295/// Returns a [`RelayStatusResponse`] with the relay's bound address, active
296/// connection count from the shared connection tracker, and blob count
297/// from the blob storage backend.
298///
299/// See spec section 18.10.3.
300pub async fn relay_status_handler(State(state): State<Arc<NodeState>>) -> impl IntoResponse {
301    use scp_transport::native::storage::BlobStorage as _;
302
303    let active_connections = {
304        let tracker = state.connection_tracker.read().await;
305        tracker.values().sum::<usize>() as u64
306    };
307    let blob_count = state.blob_storage.count().await.unwrap_or(0) as u64;
308
309    (
310        StatusCode::OK,
311        Json(RelayStatusResponse {
312            bound_addr: state.relay_addr.to_string(),
313            active_connections,
314            blob_count,
315        }),
316    )
317}
318
319/// Returns the subscriber count for a hex-encoded context/routing ID.
320///
321/// Parses the hex string into a `[u8; 32]` routing ID and looks up the
322/// number of subscriber entries in the subscription registry. Returns 0
323/// if the hex is invalid or the routing ID has no subscribers.
324async fn subscriber_count_for_context(state: &NodeState, hex_id: &str) -> u64 {
325    let Ok(bytes) = hex::decode(hex_id) else {
326        return 0;
327    };
328    let Ok(routing_id) = <[u8; 32]>::try_from(bytes) else {
329        return 0;
330    };
331    let registry = state.subscription_registry.read().await;
332    registry
333        .get(&routing_id)
334        .map_or(0, |entries| entries.len() as u64)
335}
336
337/// Handler for `GET /scp/dev/v1/contexts`.
338///
339/// Returns a JSON array of all registered broadcast contexts as
340/// [`ContextResponse`] values. Returns an empty array when no contexts
341/// are registered.
342///
343/// See spec section 18.10.3.
344pub async fn list_contexts_handler(State(state): State<Arc<NodeState>>) -> impl IntoResponse {
345    let snapshot: Vec<(String, Option<String>)> = {
346        let contexts = state.broadcast_contexts.read().await;
347        contexts
348            .values()
349            .map(|ctx| (ctx.id.clone(), ctx.name.clone()))
350            .collect()
351    };
352
353    let mut responses = Vec::with_capacity(snapshot.len());
354    for (id, name) in snapshot {
355        let subscriber_count = subscriber_count_for_context(&state, &id).await;
356        responses.push(ContextResponse {
357            id,
358            name,
359            mode: "broadcast".to_owned(),
360            subscriber_count,
361        });
362    }
363
364    (StatusCode::OK, Json(responses))
365}
366
367/// Handler for `GET /scp/dev/v1/contexts/{id}`.
368///
369/// Returns the [`ContextResponse`] for the context matching the given `id`
370/// path parameter. Returns HTTP 404 if no context with that ID is
371/// registered.
372///
373/// See spec section 18.10.3.
374pub async fn get_context_handler(
375    State(state): State<Arc<NodeState>>,
376    Path(id): Path<String>,
377) -> impl IntoResponse {
378    let id = id.to_ascii_lowercase();
379    let ctx_data = {
380        let contexts = state.broadcast_contexts.read().await;
381        contexts
382            .get(&id)
383            .map(|ctx| (ctx.id.clone(), ctx.name.clone()))
384    };
385
386    match ctx_data {
387        None => ApiError::not_found(format!("context {id} not found")).into_response(),
388        Some((ctx_id, ctx_name)) => {
389            let subscriber_count = subscriber_count_for_context(&state, &ctx_id).await;
390            (
391                StatusCode::OK,
392                Json(ContextResponse {
393                    id: ctx_id,
394                    name: ctx_name,
395                    mode: "broadcast".to_owned(),
396                    subscriber_count,
397                }),
398            )
399                .into_response()
400        }
401    }
402}
403
404/// Maximum allowed length for a context ID (hex-encoded, so 64 chars for 32 bytes).
405const MAX_CONTEXT_ID_LEN: usize = 64;
406/// Maximum allowed length for a context name.
407const MAX_CONTEXT_NAME_LEN: usize = 256;
408
409/// Handler for `POST /scp/dev/v1/contexts`.
410///
411/// Parses a [`CreateContextRequest`] JSON body and registers a new
412/// broadcast context. Returns HTTP 201 Created with the newly created
413/// [`ContextResponse`].
414///
415/// Validates:
416/// - `id` is non-empty, ASCII hex only, max 64 chars (32 bytes hex-encoded)
417/// - `name` (if present) is max 256 chars, no control characters
418/// - No duplicate context ID already registered
419///
420/// See spec section 18.10.3.
421pub async fn create_context_handler(
422    State(state): State<Arc<NodeState>>,
423    body: Result<Json<CreateContextRequest>, JsonRejection>,
424) -> impl IntoResponse {
425    // Unwrap JSON body, mapping extraction failures to ApiError (spec §18.10.4).
426    let Ok(Json(body)) = body else {
427        return ApiError::bad_request("invalid JSON body").into_response();
428    };
429
430    // Validate context ID: non-empty, hex-only, bounded length.
431    if body.id.is_empty() || body.id.len() > MAX_CONTEXT_ID_LEN {
432        return ApiError::bad_request(format!(
433            "context id must be 1-{MAX_CONTEXT_ID_LEN} characters"
434        ))
435        .into_response();
436    }
437    if !body.id.bytes().all(|b| b.is_ascii_hexdigit()) {
438        return ApiError::bad_request("context id must contain only hex characters")
439            .into_response();
440    }
441
442    // Normalize to lowercase so mixed-case hex values are not treated as distinct.
443    let id = body.id.to_ascii_lowercase();
444
445    // Validate context name if present: bounded length, no control chars.
446    // Use chars().count() for correct Unicode character counting.
447    if let Some(ref name) = body.name {
448        if name.chars().count() > MAX_CONTEXT_NAME_LEN {
449            return ApiError::bad_request(format!(
450                "context name must be at most {MAX_CONTEXT_NAME_LEN} characters"
451            ))
452            .into_response();
453        }
454        if name.chars().any(char::is_control) {
455            return ApiError::bad_request("context name must not contain control characters")
456                .into_response();
457        }
458    }
459
460    let mut contexts = state.broadcast_contexts.write().await;
461
462    // Reject duplicate context IDs (compared against normalized lowercase).
463    if contexts.contains_key(&id) {
464        return ApiError::conflict(format!("context {id} already exists")).into_response();
465    }
466
467    // Enforce broadcast context limit (mirrors MAX_PROJECTED_CONTEXTS).
468    if contexts.len() >= crate::MAX_BROADCAST_CONTEXTS {
469        return ApiError::bad_request(format!(
470            "broadcast context limit ({}) reached",
471            crate::MAX_BROADCAST_CONTEXTS
472        ))
473        .into_response();
474    }
475
476    let ctx = crate::http::BroadcastContext {
477        id: id.clone(),
478        name: body.name,
479    };
480    // Newly created context starts with 0 subscribers.
481    let response = ContextResponse {
482        id: ctx.id.clone(),
483        name: ctx.name.clone(),
484        mode: "broadcast".to_owned(),
485        subscriber_count: 0,
486    };
487    contexts.insert(id.clone(), ctx);
488    drop(contexts);
489
490    // Include Location header per HTTP semantics for 201 Created.
491    let location = format!("/scp/dev/v1/contexts/{id}");
492    let mut headers = axum::http::HeaderMap::new();
493    if let Ok(val) = axum::http::HeaderValue::from_str(&location) {
494        headers.insert(axum::http::header::LOCATION, val);
495    }
496
497    (StatusCode::CREATED, headers, Json(response)).into_response()
498}
499
500/// Handler for `DELETE /scp/dev/v1/contexts/{id}`.
501///
502/// Removes the broadcast context matching the given `id` path parameter.
503/// Returns HTTP 204 No Content on success, or HTTP 404 if no context with
504/// that ID is registered.
505///
506/// See spec section 18.10.3.
507pub async fn delete_context_handler(
508    State(state): State<Arc<NodeState>>,
509    Path(id): Path<String>,
510) -> impl IntoResponse {
511    let id = id.to_ascii_lowercase();
512    let mut contexts = state.broadcast_contexts.write().await;
513
514    if contexts.remove(&id).is_some() {
515        StatusCode::NO_CONTENT.into_response()
516    } else {
517        ApiError::not_found(format!("context {id} not found")).into_response()
518    }
519}
520
521// ---------------------------------------------------------------------------
522// Router constructor
523// ---------------------------------------------------------------------------
524
525/// Returns an axum [`Router`] serving the dev API endpoints under
526/// `/scp/dev/v1`.
527///
528/// All routes are protected by bearer token authentication. The `token`
529/// parameter is the expected bearer token (format:
530/// `scp_local_token_<32 hex chars>`). Requests without a valid
531/// `Authorization: Bearer <token>` header receive HTTP 401.
532///
533/// See spec section 18.10.2.
534/// Maximum request body size for the dev API (64 KiB).
535/// Prevents unbounded memory allocation from oversized POST bodies.
536const DEV_API_MAX_BODY_SIZE: usize = 64 * 1024;
537
538pub fn dev_router(state: Arc<NodeState>, token: String) -> axum::Router {
539    use axum::middleware;
540    use axum::routing::get;
541
542    let expected = token;
543    axum::Router::new()
544        .route("/scp/dev/v1/health", get(health_handler))
545        .route("/scp/dev/v1/identity", get(identity_handler))
546        .route("/scp/dev/v1/relay/status", get(relay_status_handler))
547        .route(
548            "/scp/dev/v1/contexts",
549            get(list_contexts_handler).post(create_context_handler),
550        )
551        .route(
552            "/scp/dev/v1/contexts/{id}",
553            get(get_context_handler).delete(delete_context_handler),
554        )
555        .layer(axum::extract::DefaultBodyLimit::max(DEV_API_MAX_BODY_SIZE))
556        // Axum layers are LIFO: last added = outermost = runs first.
557        //
558        // Execution order (outermost → innermost):
559        //   1. Security headers — sets X-Content-Type-Options, Cache-Control,
560        //      X-Frame-Options on ALL responses (including auth/host rejections),
561        //      and rejects CORS preflight requests.
562        //   2. Host check — cheapest rejection, prevents DNS rebinding.
563        //   3. Bearer auth — validates token, rejects unauthorized requests.
564        //   4. Body limit — enforces 64 KiB max on POST bodies.
565        //   5. Route handlers.
566        .layer(middleware::from_fn(move |req, next| {
567            bearer_auth_middleware(req, next, expected.clone())
568        }))
569        .layer(middleware::from_fn(localhost_host_middleware))
570        .layer(middleware::from_fn(security_headers_middleware))
571        .with_state(state)
572}
573
574// ---------------------------------------------------------------------------
575// Tests
576// ---------------------------------------------------------------------------
577
578#[cfg(test)]
579#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
580mod tests {
581    use std::collections::HashMap;
582    use std::net::SocketAddr;
583    use std::sync::Arc;
584    use std::time::Instant;
585
586    use axum::body::Body;
587    use axum::http::{Request, StatusCode, header};
588    use http_body_util::BodyExt;
589    use scp_transport::native::storage::BlobStorageBackend;
590    use tokio::sync::RwLock;
591    use tower::ServiceExt;
592
593    use crate::http::NodeState;
594
595    use super::*;
596
597    /// Valid bearer token used across all dev API tests.
598    const TEST_TOKEN: &str = "scp_local_token_abcdef1234567890abcdef1234567890";
599
600    /// Helper: creates an HTTP request builder with `Host: localhost` pre-set.
601    /// All dev API test requests must include a localhost Host header for the
602    /// DNS-rebinding protection middleware.
603    fn localhost_request() -> axum::http::request::Builder {
604        Request::builder().header(header::HOST, "localhost")
605    }
606
607    /// Creates a test `NodeState` with the given dev token.
608    fn test_state(token: &str) -> Arc<NodeState> {
609        Arc::new(NodeState {
610            did: "did:dht:test123".to_owned(),
611            relay_url: "wss://localhost/scp/v1".to_owned(),
612            broadcast_contexts: RwLock::new(HashMap::new()),
613            relay_addr: "127.0.0.1:9000".parse::<SocketAddr>().unwrap(),
614            bridge_secret: zeroize::Zeroizing::new([0u8; 32]),
615            dev_token: Some(token.to_owned()),
616            dev_bind_addr: Some("127.0.0.1:9100".parse::<SocketAddr>().unwrap()),
617            projected_contexts: RwLock::new(HashMap::new()),
618            blob_storage: Arc::new(BlobStorageBackend::default()),
619            relay_config: scp_transport::native::server::RelayConfig::default(),
620            start_time: Instant::now(),
621            http_bind_addr: SocketAddr::from(([0, 0, 0, 0], 8443)),
622            shutdown_token: tokio_util::sync::CancellationToken::new(),
623            cors_origins: None,
624            projection_rate_limiter: scp_transport::relay::rate_limit::PublishRateLimiter::new(
625                1000,
626            ),
627            tls_config: None,
628            cert_resolver: None,
629            did_document: scp_identity::document::DidDocument {
630                context: vec!["https://www.w3.org/ns/did/v1".to_owned()],
631                id: "did:dht:test123".to_owned(),
632                verification_method: vec![],
633                authentication: vec![],
634                assertion_method: vec![],
635                also_known_as: vec![],
636                service: vec![],
637            },
638            connection_tracker: scp_transport::relay::rate_limit::new_connection_tracker(),
639            subscription_registry: scp_transport::relay::subscription::new_registry(),
640            acme_challenges: None,
641            bridge_state: Arc::new(crate::bridge_handlers::BridgeState::new()),
642        })
643    }
644
645    #[tokio::test]
646    async fn valid_token_passes_middleware() {
647        let token = TEST_TOKEN;
648        let state = test_state(token);
649        let router = dev_router(state, token.to_owned());
650
651        let req = localhost_request()
652            .uri("/scp/dev/v1/health")
653            .header(header::AUTHORIZATION, format!("Bearer {token}"))
654            .body(Body::empty())
655            .unwrap();
656
657        let resp = router.oneshot(req).await.unwrap();
658        assert_eq!(resp.status(), StatusCode::OK);
659
660        let body = resp.into_body().collect().await.unwrap().to_bytes();
661        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
662        assert!(json.get("uptime_seconds").is_some());
663        assert!(json.get("relay_connections").is_some());
664        assert!(json.get("storage_status").is_some());
665    }
666
667    #[tokio::test]
668    async fn invalid_token_returns_401() {
669        let token = TEST_TOKEN;
670        let state = test_state(token);
671        let router = dev_router(state, token.to_owned());
672
673        let req = localhost_request()
674            .uri("/scp/dev/v1/health")
675            .header(header::AUTHORIZATION, "Bearer wrong_token")
676            .body(Body::empty())
677            .unwrap();
678
679        let resp = router.oneshot(req).await.unwrap();
680        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
681
682        let body = resp.into_body().collect().await.unwrap().to_bytes();
683        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
684        assert_eq!(json["error"], "unauthorized");
685        assert_eq!(json["code"], "UNAUTHORIZED");
686    }
687
688    #[tokio::test]
689    async fn non_localhost_host_returns_403() {
690        let token = TEST_TOKEN;
691        let state = test_state(token);
692        let router = dev_router(state, token.to_owned());
693
694        // DNS rebinding: request with external Host header should be rejected.
695        let req = Request::builder()
696            .uri("/scp/dev/v1/health")
697            .header(header::HOST, "evil.example.com")
698            .header(header::AUTHORIZATION, format!("Bearer {token}"))
699            .body(Body::empty())
700            .unwrap();
701
702        let resp = router.oneshot(req).await.unwrap();
703        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
704
705        let body = resp.into_body().collect().await.unwrap().to_bytes();
706        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
707        assert_eq!(json["code"], "FORBIDDEN");
708    }
709
710    #[tokio::test]
711    async fn ipv6_localhost_host_accepted() {
712        let token = TEST_TOKEN;
713        let state = test_state(token);
714        let router = dev_router(state, token.to_owned());
715
716        // [::1]:8080 is valid localhost — must not be rejected.
717        let req = Request::builder()
718            .uri("/scp/dev/v1/health")
719            .header(header::HOST, "[::1]:8080")
720            .header(header::AUTHORIZATION, format!("Bearer {token}"))
721            .body(Body::empty())
722            .unwrap();
723
724        let resp = router.oneshot(req).await.unwrap();
725        assert_eq!(resp.status(), StatusCode::OK);
726    }
727
728    #[tokio::test]
729    async fn ipv6_localhost_host_without_port_accepted() {
730        let token = TEST_TOKEN;
731        let state = test_state(token);
732        let router = dev_router(state, token.to_owned());
733
734        // [::1] without port is also valid localhost.
735        let req = Request::builder()
736            .uri("/scp/dev/v1/health")
737            .header(header::HOST, "[::1]")
738            .header(header::AUTHORIZATION, format!("Bearer {token}"))
739            .body(Body::empty())
740            .unwrap();
741
742        let resp = router.oneshot(req).await.unwrap();
743        assert_eq!(resp.status(), StatusCode::OK);
744    }
745
746    #[tokio::test]
747    async fn missing_header_returns_401() {
748        let token = TEST_TOKEN;
749        let state = test_state(token);
750        let router = dev_router(state, token.to_owned());
751
752        let req = localhost_request()
753            .uri("/scp/dev/v1/health")
754            .body(Body::empty())
755            .unwrap();
756
757        let resp = router.oneshot(req).await.unwrap();
758        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
759
760        let body = resp.into_body().collect().await.unwrap().to_bytes();
761        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
762        assert_eq!(json["error"], "unauthorized");
763        assert_eq!(json["code"], "UNAUTHORIZED");
764    }
765
766    #[tokio::test]
767    async fn identity_handler_returns_did() {
768        let token = TEST_TOKEN;
769        let state = test_state(token);
770        let router = dev_router(state, token.to_owned());
771
772        let req = localhost_request()
773            .uri("/scp/dev/v1/identity")
774            .header(header::AUTHORIZATION, format!("Bearer {token}"))
775            .body(Body::empty())
776            .unwrap();
777
778        let resp = router.oneshot(req).await.unwrap();
779        assert_eq!(resp.status(), StatusCode::OK);
780
781        let body = resp.into_body().collect().await.unwrap().to_bytes();
782        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
783        assert_eq!(json["did"], "did:dht:test123");
784        assert!(json.get("document").is_some());
785    }
786
787    #[tokio::test]
788    async fn relay_status_handler_returns_addr() {
789        let token = TEST_TOKEN;
790        let state = test_state(token);
791        let router = dev_router(state, token.to_owned());
792
793        let req = localhost_request()
794            .uri("/scp/dev/v1/relay/status")
795            .header(header::AUTHORIZATION, format!("Bearer {token}"))
796            .body(Body::empty())
797            .unwrap();
798
799        let resp = router.oneshot(req).await.unwrap();
800        assert_eq!(resp.status(), StatusCode::OK);
801
802        let body = resp.into_body().collect().await.unwrap().to_bytes();
803        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
804        assert_eq!(json["bound_addr"], "127.0.0.1:9000");
805        assert_eq!(json["active_connections"], 0);
806        assert_eq!(json["blob_count"], 0);
807    }
808
809    #[tokio::test]
810    async fn all_responses_are_json_content_type() {
811        let token = TEST_TOKEN;
812        let state = test_state(token);
813
814        let paths = [
815            "/scp/dev/v1/health",
816            "/scp/dev/v1/identity",
817            "/scp/dev/v1/relay/status",
818        ];
819
820        for path in paths {
821            let router = dev_router(Arc::clone(&state), token.to_owned());
822            let req = localhost_request()
823                .uri(path)
824                .header(header::AUTHORIZATION, format!("Bearer {token}"))
825                .body(Body::empty())
826                .unwrap();
827
828            let resp = router.oneshot(req).await.unwrap();
829            assert_eq!(resp.status(), StatusCode::OK, "path: {path}");
830
831            let content_type = resp
832                .headers()
833                .get(header::CONTENT_TYPE)
834                .expect("missing Content-Type header")
835                .to_str()
836                .unwrap();
837            assert!(
838                content_type.contains("application/json"),
839                "path {path} has Content-Type: {content_type}"
840            );
841        }
842    }
843
844    // -- Context management endpoint tests --
845
846    #[tokio::test]
847    async fn list_contexts_returns_empty_array() {
848        let token = TEST_TOKEN;
849        let state = test_state(token);
850        let router = dev_router(state, token.to_owned());
851
852        let req = localhost_request()
853            .uri("/scp/dev/v1/contexts")
854            .header(header::AUTHORIZATION, format!("Bearer {token}"))
855            .body(Body::empty())
856            .unwrap();
857
858        let resp = router.oneshot(req).await.unwrap();
859        assert_eq!(resp.status(), StatusCode::OK);
860
861        let body = resp.into_body().collect().await.unwrap().to_bytes();
862        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
863        assert_eq!(json, serde_json::json!([]));
864    }
865
866    #[tokio::test]
867    async fn list_contexts_returns_registered_contexts() {
868        let token = TEST_TOKEN;
869        let state = test_state(token);
870        state.broadcast_contexts.write().await.insert(
871            "aa11bb22".to_owned(),
872            crate::http::BroadcastContext {
873                id: "aa11bb22".to_owned(),
874                name: Some("Test Context".to_owned()),
875            },
876        );
877        let router = dev_router(state, token.to_owned());
878
879        let req = localhost_request()
880            .uri("/scp/dev/v1/contexts")
881            .header(header::AUTHORIZATION, format!("Bearer {token}"))
882            .body(Body::empty())
883            .unwrap();
884
885        let resp = router.oneshot(req).await.unwrap();
886        assert_eq!(resp.status(), StatusCode::OK);
887
888        let body = resp.into_body().collect().await.unwrap().to_bytes();
889        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
890        let arr = json.as_array().unwrap();
891        assert_eq!(arr.len(), 1);
892        assert_eq!(arr[0]["id"], "aa11bb22");
893        assert_eq!(arr[0]["name"], "Test Context");
894        assert_eq!(arr[0]["mode"], "broadcast");
895        assert_eq!(arr[0]["subscriber_count"], 0);
896    }
897
898    #[tokio::test]
899    async fn get_context_returns_found() {
900        let token = TEST_TOKEN;
901        let state = test_state(token);
902        state.broadcast_contexts.write().await.insert(
903            "abcdef01".to_owned(),
904            crate::http::BroadcastContext {
905                id: "abcdef01".to_owned(),
906                name: Some("My Context".to_owned()),
907            },
908        );
909        let router = dev_router(state, token.to_owned());
910
911        let req = localhost_request()
912            .uri("/scp/dev/v1/contexts/abcdef01")
913            .header(header::AUTHORIZATION, format!("Bearer {token}"))
914            .body(Body::empty())
915            .unwrap();
916
917        let resp = router.oneshot(req).await.unwrap();
918        assert_eq!(resp.status(), StatusCode::OK);
919
920        let body = resp.into_body().collect().await.unwrap().to_bytes();
921        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
922        assert_eq!(json["id"], "abcdef01");
923        assert_eq!(json["name"], "My Context");
924        assert_eq!(json["mode"], "broadcast");
925        assert_eq!(json["subscriber_count"], 0);
926    }
927
928    #[tokio::test]
929    async fn get_context_returns_404_for_unknown() {
930        let token = TEST_TOKEN;
931        let state = test_state(token);
932        let router = dev_router(state, token.to_owned());
933
934        let req = localhost_request()
935            .uri("/scp/dev/v1/contexts/nonexistent")
936            .header(header::AUTHORIZATION, format!("Bearer {token}"))
937            .body(Body::empty())
938            .unwrap();
939
940        let resp = router.oneshot(req).await.unwrap();
941        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
942
943        let body = resp.into_body().collect().await.unwrap().to_bytes();
944        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
945        assert_eq!(json["code"], "NOT_FOUND");
946    }
947
948    #[tokio::test]
949    async fn create_context_returns_201() {
950        let token = TEST_TOKEN;
951        let state = test_state(token);
952        let router = dev_router(Arc::clone(&state), token.to_owned());
953
954        let req = localhost_request()
955            .method("POST")
956            .uri("/scp/dev/v1/contexts")
957            .header(header::AUTHORIZATION, format!("Bearer {token}"))
958            .header(header::CONTENT_TYPE, "application/json")
959            .body(Body::from(
960                serde_json::to_string(&serde_json::json!({
961                    "id": "cc33dd44",
962                    "name": "New Context"
963                }))
964                .unwrap(),
965            ))
966            .unwrap();
967
968        let resp = router.oneshot(req).await.unwrap();
969        assert_eq!(resp.status(), StatusCode::CREATED);
970
971        let body = resp.into_body().collect().await.unwrap().to_bytes();
972        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
973        assert_eq!(json["id"], "cc33dd44");
974        assert_eq!(json["name"], "New Context");
975        assert_eq!(json["mode"], "broadcast");
976        assert_eq!(json["subscriber_count"], 0);
977
978        // Verify context was actually stored
979        let contexts = state.broadcast_contexts.read().await;
980        assert_eq!(contexts.len(), 1);
981        assert!(contexts.contains_key("cc33dd44"));
982        drop(contexts);
983    }
984
985    #[tokio::test]
986    async fn create_context_without_name() {
987        let token = TEST_TOKEN;
988        let state = test_state(token);
989        let router = dev_router(state, token.to_owned());
990
991        let req = localhost_request()
992            .method("POST")
993            .uri("/scp/dev/v1/contexts")
994            .header(header::AUTHORIZATION, format!("Bearer {token}"))
995            .header(header::CONTENT_TYPE, "application/json")
996            .body(Body::from(r#"{"id":"ee55ff66"}"#))
997            .unwrap();
998
999        let resp = router.oneshot(req).await.unwrap();
1000        assert_eq!(resp.status(), StatusCode::CREATED);
1001
1002        let body = resp.into_body().collect().await.unwrap().to_bytes();
1003        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1004        assert_eq!(json["id"], "ee55ff66");
1005        assert!(json["name"].is_null());
1006    }
1007
1008    #[tokio::test]
1009    async fn delete_context_returns_204() {
1010        let token = TEST_TOKEN;
1011        let state = test_state(token);
1012        state.broadcast_contexts.write().await.insert(
1013            "d00aed".to_owned(),
1014            crate::http::BroadcastContext {
1015                id: "d00aed".to_owned(),
1016                name: None,
1017            },
1018        );
1019        let router = dev_router(Arc::clone(&state), token.to_owned());
1020
1021        let req = localhost_request()
1022            .method("DELETE")
1023            .uri("/scp/dev/v1/contexts/d00aed")
1024            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1025            .body(Body::empty())
1026            .unwrap();
1027
1028        let resp = router.oneshot(req).await.unwrap();
1029        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1030
1031        // Verify context was removed
1032        assert!(state.broadcast_contexts.read().await.is_empty());
1033    }
1034
1035    #[tokio::test]
1036    async fn delete_context_returns_404_for_unknown() {
1037        let token = TEST_TOKEN;
1038        let state = test_state(token);
1039        let router = dev_router(state, token.to_owned());
1040
1041        let req = localhost_request()
1042            .method("DELETE")
1043            .uri("/scp/dev/v1/contexts/nonexistent")
1044            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1045            .body(Body::empty())
1046            .unwrap();
1047
1048        let resp = router.oneshot(req).await.unwrap();
1049        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1050
1051        let body = resp.into_body().collect().await.unwrap().to_bytes();
1052        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1053        assert_eq!(json["code"], "NOT_FOUND");
1054    }
1055
1056    #[tokio::test]
1057    async fn context_endpoints_require_auth() {
1058        let token = TEST_TOKEN;
1059        let state = test_state(token);
1060
1061        // Test all context endpoints without auth
1062        let uris_and_methods: Vec<(&str, &str)> = vec![
1063            ("GET", "/scp/dev/v1/contexts"),
1064            ("GET", "/scp/dev/v1/contexts/any-id"),
1065            ("DELETE", "/scp/dev/v1/contexts/any-id"),
1066        ];
1067
1068        for (method, uri) in uris_and_methods {
1069            let router = dev_router(Arc::clone(&state), token.to_owned());
1070            let req = localhost_request()
1071                .method(method)
1072                .uri(uri)
1073                .body(Body::empty())
1074                .unwrap();
1075
1076            let resp = router.oneshot(req).await.unwrap();
1077            assert_eq!(
1078                resp.status(),
1079                StatusCode::UNAUTHORIZED,
1080                "{method} {uri} should require auth"
1081            );
1082        }
1083
1084        // POST with body but no auth
1085        let router = dev_router(Arc::clone(&state), token.to_owned());
1086        let req = localhost_request()
1087            .method("POST")
1088            .uri("/scp/dev/v1/contexts")
1089            .header(header::CONTENT_TYPE, "application/json")
1090            .body(Body::from(r#"{"id":"aabb0011"}"#))
1091            .unwrap();
1092
1093        let resp = router.oneshot(req).await.unwrap();
1094        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1095    }
1096
1097    #[tokio::test]
1098    async fn create_context_rejects_non_hex_id() {
1099        let token = TEST_TOKEN;
1100        let state = test_state(token);
1101        let router = dev_router(state, token.to_owned());
1102
1103        let req = localhost_request()
1104            .method("POST")
1105            .uri("/scp/dev/v1/contexts")
1106            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1107            .header(header::CONTENT_TYPE, "application/json")
1108            .body(Body::from(r#"{"id":"not-valid-hex!"}"#))
1109            .unwrap();
1110
1111        let resp = router.oneshot(req).await.unwrap();
1112        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1113
1114        let body = resp.into_body().collect().await.unwrap().to_bytes();
1115        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1116        assert_eq!(json["code"], "BAD_REQUEST");
1117    }
1118
1119    #[tokio::test]
1120    async fn create_context_rejects_empty_id() {
1121        let token = TEST_TOKEN;
1122        let state = test_state(token);
1123        let router = dev_router(state, token.to_owned());
1124
1125        let req = localhost_request()
1126            .method("POST")
1127            .uri("/scp/dev/v1/contexts")
1128            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1129            .header(header::CONTENT_TYPE, "application/json")
1130            .body(Body::from(r#"{"id":""}"#))
1131            .unwrap();
1132
1133        let resp = router.oneshot(req).await.unwrap();
1134        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1135    }
1136
1137    #[tokio::test]
1138    async fn create_context_rejects_duplicate_id() {
1139        let token = TEST_TOKEN;
1140        let state = test_state(token);
1141        state.broadcast_contexts.write().await.insert(
1142            "aabb0011".to_owned(),
1143            crate::http::BroadcastContext {
1144                id: "aabb0011".to_owned(),
1145                name: None,
1146            },
1147        );
1148        let router = dev_router(state, token.to_owned());
1149
1150        let req = localhost_request()
1151            .method("POST")
1152            .uri("/scp/dev/v1/contexts")
1153            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1154            .header(header::CONTENT_TYPE, "application/json")
1155            .body(Body::from(r#"{"id":"aabb0011"}"#))
1156            .unwrap();
1157
1158        let resp = router.oneshot(req).await.unwrap();
1159        assert_eq!(resp.status(), StatusCode::CONFLICT);
1160
1161        let body = resp.into_body().collect().await.unwrap().to_bytes();
1162        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1163        assert_eq!(json["code"], "CONFLICT");
1164    }
1165
1166    // -- Tests A-I: additional coverage for confirmed findings --
1167
1168    /// Test A: Wrong bearer token returns 401 with correct error shape.
1169    #[tokio::test]
1170    async fn wrong_bearer_token_returns_401_with_error_shape() {
1171        let token = TEST_TOKEN;
1172        let state = test_state(token);
1173        let router = dev_router(state, token.to_owned());
1174
1175        let req = localhost_request()
1176            .uri("/scp/dev/v1/health")
1177            .header(header::AUTHORIZATION, "Bearer wrong_token_here")
1178            .body(Body::empty())
1179            .unwrap();
1180
1181        let resp = router.oneshot(req).await.unwrap();
1182        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1183
1184        let content_type = resp
1185            .headers()
1186            .get(header::CONTENT_TYPE)
1187            .expect("missing Content-Type on 401")
1188            .to_str()
1189            .unwrap();
1190        assert!(
1191            content_type.contains("application/json"),
1192            "401 response should be JSON, got: {content_type}"
1193        );
1194
1195        let body = resp.into_body().collect().await.unwrap().to_bytes();
1196        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1197        assert_eq!(json["error"], "unauthorized");
1198        assert_eq!(json["code"], "UNAUTHORIZED");
1199    }
1200
1201    /// Test B: Case-insensitive bearer scheme (RFC 7235 §2.1).
1202    #[tokio::test]
1203    async fn bearer_scheme_case_insensitive() {
1204        let token = TEST_TOKEN;
1205        let state = test_state(token);
1206
1207        // lowercase "bearer"
1208        let router = dev_router(Arc::clone(&state), token.to_owned());
1209        let req = localhost_request()
1210            .uri("/scp/dev/v1/health")
1211            .header(header::AUTHORIZATION, format!("bearer {token}"))
1212            .body(Body::empty())
1213            .unwrap();
1214        let resp = router.oneshot(req).await.unwrap();
1215        assert_eq!(
1216            resp.status(),
1217            StatusCode::OK,
1218            "lowercase 'bearer' should pass"
1219        );
1220
1221        // uppercase "BEARER"
1222        let router = dev_router(Arc::clone(&state), token.to_owned());
1223        let req = localhost_request()
1224            .uri("/scp/dev/v1/health")
1225            .header(header::AUTHORIZATION, format!("BEARER {token}"))
1226            .body(Body::empty())
1227            .unwrap();
1228        let resp = router.oneshot(req).await.unwrap();
1229        assert_eq!(
1230            resp.status(),
1231            StatusCode::OK,
1232            "uppercase 'BEARER' should pass"
1233        );
1234
1235        // mixed case "BeArEr"
1236        let router = dev_router(Arc::clone(&state), token.to_owned());
1237        let req = localhost_request()
1238            .uri("/scp/dev/v1/health")
1239            .header(header::AUTHORIZATION, format!("BeArEr {token}"))
1240            .body(Body::empty())
1241            .unwrap();
1242        let resp = router.oneshot(req).await.unwrap();
1243        assert_eq!(
1244            resp.status(),
1245            StatusCode::OK,
1246            "mixed case 'BeArEr' should pass"
1247        );
1248    }
1249
1250    /// Test C: Non-bearer auth scheme returns 401.
1251    #[tokio::test]
1252    async fn non_bearer_auth_scheme_returns_401() {
1253        let token = TEST_TOKEN;
1254        let state = test_state(token);
1255        let router = dev_router(state, token.to_owned());
1256
1257        let req = localhost_request()
1258            .uri("/scp/dev/v1/health")
1259            .header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz")
1260            .body(Body::empty())
1261            .unwrap();
1262
1263        let resp = router.oneshot(req).await.unwrap();
1264        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1265
1266        let body = resp.into_body().collect().await.unwrap().to_bytes();
1267        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1268        assert_eq!(json["code"], "UNAUTHORIZED");
1269    }
1270
1271    /// Test D: Context ID exceeding `MAX_CONTEXT_ID_LEN` returns 400.
1272    #[tokio::test]
1273    async fn create_context_rejects_oversized_id() {
1274        let token = TEST_TOKEN;
1275        let state = test_state(token);
1276        let router = dev_router(state, token.to_owned());
1277
1278        let oversized_id = "a".repeat(MAX_CONTEXT_ID_LEN + 1);
1279        let body_json = serde_json::json!({ "id": oversized_id });
1280
1281        let req = localhost_request()
1282            .method("POST")
1283            .uri("/scp/dev/v1/contexts")
1284            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1285            .header(header::CONTENT_TYPE, "application/json")
1286            .body(Body::from(serde_json::to_string(&body_json).unwrap()))
1287            .unwrap();
1288
1289        let resp = router.oneshot(req).await.unwrap();
1290        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1291
1292        let body = resp.into_body().collect().await.unwrap().to_bytes();
1293        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1294        assert_eq!(json["code"], "BAD_REQUEST");
1295        assert!(
1296            json["error"]
1297                .as_str()
1298                .unwrap()
1299                .contains(&MAX_CONTEXT_ID_LEN.to_string()),
1300            "error message should mention the max length"
1301        );
1302    }
1303
1304    /// Test E: Context name exceeding `MAX_CONTEXT_NAME_LEN` returns 400.
1305    #[tokio::test]
1306    async fn create_context_rejects_oversized_name() {
1307        let token = TEST_TOKEN;
1308        let state = test_state(token);
1309        let router = dev_router(state, token.to_owned());
1310
1311        let oversized_name = "a".repeat(MAX_CONTEXT_NAME_LEN + 1);
1312        let body_json = serde_json::json!({ "id": "aabb", "name": oversized_name });
1313
1314        let req = localhost_request()
1315            .method("POST")
1316            .uri("/scp/dev/v1/contexts")
1317            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1318            .header(header::CONTENT_TYPE, "application/json")
1319            .body(Body::from(serde_json::to_string(&body_json).unwrap()))
1320            .unwrap();
1321
1322        let resp = router.oneshot(req).await.unwrap();
1323        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1324
1325        let body = resp.into_body().collect().await.unwrap().to_bytes();
1326        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1327        assert_eq!(json["code"], "BAD_REQUEST");
1328        assert!(
1329            json["error"]
1330                .as_str()
1331                .unwrap()
1332                .contains(&MAX_CONTEXT_NAME_LEN.to_string()),
1333            "error message should mention the max length"
1334        );
1335    }
1336
1337    /// Test F: Context name with control characters returns 400.
1338    #[tokio::test]
1339    async fn create_context_rejects_control_chars_in_name() {
1340        let token = TEST_TOKEN;
1341        let state = test_state(token);
1342
1343        let names_with_control = [
1344            "name\x00with_null",
1345            "name\x1fwith_unit_sep",
1346            "\ttabbed",
1347            "new\nline",
1348        ];
1349
1350        for bad_name in names_with_control {
1351            let router = dev_router(Arc::clone(&state), token.to_owned());
1352            let body_json = serde_json::json!({ "id": "aabb", "name": bad_name });
1353
1354            let req = localhost_request()
1355                .method("POST")
1356                .uri("/scp/dev/v1/contexts")
1357                .header(header::AUTHORIZATION, format!("Bearer {token}"))
1358                .header(header::CONTENT_TYPE, "application/json")
1359                .body(Body::from(serde_json::to_string(&body_json).unwrap()))
1360                .unwrap();
1361
1362            let resp = router.oneshot(req).await.unwrap();
1363            assert_eq!(
1364                resp.status(),
1365                StatusCode::BAD_REQUEST,
1366                "name with control char should be rejected: {bad_name:?}"
1367            );
1368
1369            let body = resp.into_body().collect().await.unwrap().to_bytes();
1370            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1371            assert_eq!(json["code"], "BAD_REQUEST");
1372        }
1373    }
1374
1375    /// Test G: Malformed JSON body returns 400 with JSON error (not plain text).
1376    #[tokio::test]
1377    async fn malformed_json_returns_400_with_json_body() {
1378        let token = TEST_TOKEN;
1379        let state = test_state(token);
1380        let router = dev_router(state, token.to_owned());
1381
1382        // Send {"id": 42} -- number instead of string
1383        let req = localhost_request()
1384            .method("POST")
1385            .uri("/scp/dev/v1/contexts")
1386            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1387            .header(header::CONTENT_TYPE, "application/json")
1388            .body(Body::from(r#"{"id": 42}"#))
1389            .unwrap();
1390
1391        let resp = router.oneshot(req).await.unwrap();
1392        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1393
1394        let content_type = resp
1395            .headers()
1396            .get(header::CONTENT_TYPE)
1397            .expect("missing Content-Type on malformed JSON 400")
1398            .to_str()
1399            .unwrap();
1400        assert!(
1401            content_type.contains("application/json"),
1402            "malformed JSON error should be JSON, got: {content_type}"
1403        );
1404
1405        let body = resp.into_body().collect().await.unwrap().to_bytes();
1406        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1407        assert_eq!(json["code"], "BAD_REQUEST");
1408        assert!(
1409            json.get("error").is_some(),
1410            "error response must include 'error' field"
1411        );
1412    }
1413
1414    /// Test G (cont.): Completely invalid JSON returns 400 with JSON body.
1415    #[tokio::test]
1416    async fn invalid_json_syntax_returns_400_with_json_body() {
1417        let token = TEST_TOKEN;
1418        let state = test_state(token);
1419        let router = dev_router(state, token.to_owned());
1420
1421        let req = localhost_request()
1422            .method("POST")
1423            .uri("/scp/dev/v1/contexts")
1424            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1425            .header(header::CONTENT_TYPE, "application/json")
1426            .body(Body::from("not json at all"))
1427            .unwrap();
1428
1429        let resp = router.oneshot(req).await.unwrap();
1430        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1431
1432        let content_type = resp
1433            .headers()
1434            .get(header::CONTENT_TYPE)
1435            .expect("missing Content-Type")
1436            .to_str()
1437            .unwrap();
1438        assert!(
1439            content_type.contains("application/json"),
1440            "invalid JSON syntax error should be JSON, got: {content_type}"
1441        );
1442
1443        let body = resp.into_body().collect().await.unwrap().to_bytes();
1444        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1445        assert_eq!(json["code"], "BAD_REQUEST");
1446    }
1447
1448    /// Test H: Mixed-case hex context IDs are normalized to lowercase.
1449    #[tokio::test]
1450    async fn context_id_normalized_to_lowercase() {
1451        let token = TEST_TOKEN;
1452        let state = test_state(token);
1453
1454        // Create context with uppercase ID.
1455        let router = dev_router(Arc::clone(&state), token.to_owned());
1456        let req = localhost_request()
1457            .method("POST")
1458            .uri("/scp/dev/v1/contexts")
1459            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1460            .header(header::CONTENT_TYPE, "application/json")
1461            .body(Body::from(r#"{"id":"AABB","name":"Upper"}"#))
1462            .unwrap();
1463        let resp = router.oneshot(req).await.unwrap();
1464        assert_eq!(resp.status(), StatusCode::CREATED);
1465
1466        // Response should contain normalized lowercase ID.
1467        let body = resp.into_body().collect().await.unwrap().to_bytes();
1468        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1469        assert_eq!(
1470            json["id"], "aabb",
1471            "created ID should be normalized to lowercase"
1472        );
1473
1474        // GET /contexts/aabb should find it.
1475        let router = dev_router(Arc::clone(&state), token.to_owned());
1476        let req = localhost_request()
1477            .uri("/scp/dev/v1/contexts/aabb")
1478            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1479            .body(Body::empty())
1480            .unwrap();
1481        let resp = router.oneshot(req).await.unwrap();
1482        assert_eq!(
1483            resp.status(),
1484            StatusCode::OK,
1485            "lowercase lookup should find it"
1486        );
1487
1488        // GET /contexts/AABB should also find it (lookup is normalized).
1489        let router = dev_router(Arc::clone(&state), token.to_owned());
1490        let req = localhost_request()
1491            .uri("/scp/dev/v1/contexts/AABB")
1492            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1493            .body(Body::empty())
1494            .unwrap();
1495        let resp = router.oneshot(req).await.unwrap();
1496        assert_eq!(
1497            resp.status(),
1498            StatusCode::OK,
1499            "uppercase lookup should also find it (normalized)"
1500        );
1501
1502        // Creating with "aabb" should be rejected as duplicate.
1503        let router = dev_router(Arc::clone(&state), token.to_owned());
1504        let req = localhost_request()
1505            .method("POST")
1506            .uri("/scp/dev/v1/contexts")
1507            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1508            .header(header::CONTENT_TYPE, "application/json")
1509            .body(Body::from(r#"{"id":"aabb"}"#))
1510            .unwrap();
1511        let resp = router.oneshot(req).await.unwrap();
1512        assert_eq!(
1513            resp.status(),
1514            StatusCode::CONFLICT,
1515            "lowercase duplicate of uppercase should conflict"
1516        );
1517
1518        // DELETE /contexts/AaBb should work (normalized).
1519        let router = dev_router(Arc::clone(&state), token.to_owned());
1520        let req = localhost_request()
1521            .method("DELETE")
1522            .uri("/scp/dev/v1/contexts/AaBb")
1523            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1524            .body(Body::empty())
1525            .unwrap();
1526        let resp = router.oneshot(req).await.unwrap();
1527        assert_eq!(
1528            resp.status(),
1529            StatusCode::NO_CONTENT,
1530            "mixed-case delete should find the normalized ID"
1531        );
1532    }
1533
1534    /// Sends a request and asserts the response has the expected status and
1535    /// `application/json` Content-Type (skipped for 204 No Content).
1536    async fn assert_json_content_type(
1537        state: &Arc<NodeState>,
1538        token: &str,
1539        method: &str,
1540        path: &str,
1541        body: Option<&str>,
1542        expected_status: StatusCode,
1543        desc: &str,
1544    ) {
1545        let router = dev_router(Arc::clone(state), token.to_owned());
1546        let mut builder = localhost_request()
1547            .method(method)
1548            .uri(path)
1549            .header(header::AUTHORIZATION, format!("Bearer {token}"));
1550        if body.is_some() {
1551            builder = builder.header(header::CONTENT_TYPE, "application/json");
1552        }
1553        let req = builder
1554            .body(body.map_or_else(Body::empty, |b| Body::from(b.to_owned())))
1555            .unwrap();
1556
1557        let resp = router.oneshot(req).await.unwrap();
1558        assert_eq!(resp.status(), expected_status, "{desc}: wrong status");
1559
1560        if expected_status != StatusCode::NO_CONTENT {
1561            let content_type = resp
1562                .headers()
1563                .get(header::CONTENT_TYPE)
1564                .unwrap_or_else(|| panic!("{desc}: missing Content-Type header"))
1565                .to_str()
1566                .unwrap();
1567            assert!(
1568                content_type.contains("application/json"),
1569                "{desc}: Content-Type should be JSON, got: {content_type}"
1570            );
1571        }
1572    }
1573
1574    /// Test I (part 1): Success and error endpoints return JSON Content-Type.
1575    #[tokio::test]
1576    async fn success_endpoints_return_json_content_type() {
1577        let token = TEST_TOKEN;
1578        let state = test_state(token);
1579        state.broadcast_contexts.write().await.insert(
1580            "deadbeef".to_owned(),
1581            crate::http::BroadcastContext {
1582                id: "deadbeef".to_owned(),
1583                name: Some("Test".to_owned()),
1584            },
1585        );
1586
1587        let cases: &[(&str, &str, Option<&str>, StatusCode, &str)] = &[
1588            (
1589                "GET",
1590                "/scp/dev/v1/health",
1591                None,
1592                StatusCode::OK,
1593                "health 200",
1594            ),
1595            (
1596                "GET",
1597                "/scp/dev/v1/identity",
1598                None,
1599                StatusCode::OK,
1600                "identity 200",
1601            ),
1602            (
1603                "GET",
1604                "/scp/dev/v1/relay/status",
1605                None,
1606                StatusCode::OK,
1607                "relay status 200",
1608            ),
1609            (
1610                "GET",
1611                "/scp/dev/v1/contexts",
1612                None,
1613                StatusCode::OK,
1614                "list contexts 200",
1615            ),
1616            (
1617                "GET",
1618                "/scp/dev/v1/contexts/deadbeef",
1619                None,
1620                StatusCode::OK,
1621                "get context 200",
1622            ),
1623        ];
1624
1625        for &(method, path, body, expected_status, desc) in cases {
1626            assert_json_content_type(&state, token, method, path, body, expected_status, desc)
1627                .await;
1628        }
1629    }
1630
1631    /// Test I (part 2): Error responses and create/auth return JSON Content-Type.
1632    #[tokio::test]
1633    async fn error_and_create_endpoints_return_json_content_type() {
1634        let token = TEST_TOKEN;
1635        let state = test_state(token);
1636        state.broadcast_contexts.write().await.insert(
1637            "deadbeef".to_owned(),
1638            crate::http::BroadcastContext {
1639                id: "deadbeef".to_owned(),
1640                name: Some("Test".to_owned()),
1641            },
1642        );
1643
1644        let error_cases: &[(&str, &str, Option<&str>, StatusCode, &str)] = &[
1645            (
1646                "GET",
1647                "/scp/dev/v1/contexts/nonexistent",
1648                None,
1649                StatusCode::NOT_FOUND,
1650                "get 404",
1651            ),
1652            (
1653                "DELETE",
1654                "/scp/dev/v1/contexts/nonexistent",
1655                None,
1656                StatusCode::NOT_FOUND,
1657                "del 404",
1658            ),
1659            (
1660                "POST",
1661                "/scp/dev/v1/contexts",
1662                Some(r#"{"id":""}"#),
1663                StatusCode::BAD_REQUEST,
1664                "empty 400",
1665            ),
1666            (
1667                "POST",
1668                "/scp/dev/v1/contexts",
1669                Some(r#"{"id":"deadbeef"}"#),
1670                StatusCode::CONFLICT,
1671                "dup 409",
1672            ),
1673        ];
1674
1675        for &(method, path, body, expected_status, desc) in error_cases {
1676            assert_json_content_type(&state, token, method, path, body, expected_status, desc)
1677                .await;
1678        }
1679
1680        // POST 201 (create) -- separate because it changes state.
1681        assert_json_content_type(
1682            &state,
1683            token,
1684            "POST",
1685            "/scp/dev/v1/contexts",
1686            Some(r#"{"id":"cafe0001","name":"Test I"}"#),
1687            StatusCode::CREATED,
1688            "create 201",
1689        )
1690        .await;
1691
1692        // Unauthenticated 401.
1693        let router = dev_router(Arc::clone(&state), token.to_owned());
1694        let req = localhost_request()
1695            .uri("/scp/dev/v1/health")
1696            .body(Body::empty())
1697            .unwrap();
1698        let resp = router.oneshot(req).await.unwrap();
1699        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "unauth 401");
1700        let content_type = resp
1701            .headers()
1702            .get(header::CONTENT_TYPE)
1703            .expect("unauth 401: missing Content-Type")
1704            .to_str()
1705            .unwrap();
1706        assert!(
1707            content_type.contains("application/json"),
1708            "unauth 401: Content-Type should be JSON, got: {content_type}"
1709        );
1710    }
1711
1712    // -----------------------------------------------------------------------
1713    // Security headers
1714    // -----------------------------------------------------------------------
1715
1716    #[tokio::test]
1717    async fn responses_include_security_headers() {
1718        let token = TEST_TOKEN;
1719        let state = test_state(token);
1720        let router = dev_router(state, token.to_owned());
1721
1722        let req = localhost_request()
1723            .uri("/scp/dev/v1/health")
1724            .header(header::AUTHORIZATION, format!("Bearer {token}"))
1725            .body(Body::empty())
1726            .unwrap();
1727
1728        let resp = router.oneshot(req).await.unwrap();
1729        assert_eq!(resp.status(), StatusCode::OK);
1730
1731        assert_eq!(
1732            resp.headers().get(header::X_CONTENT_TYPE_OPTIONS).unwrap(),
1733            "nosniff"
1734        );
1735        assert_eq!(
1736            resp.headers().get(header::CACHE_CONTROL).unwrap(),
1737            "no-store"
1738        );
1739        assert_eq!(resp.headers().get(header::X_FRAME_OPTIONS).unwrap(), "DENY");
1740    }
1741
1742    #[tokio::test]
1743    async fn security_headers_on_error_responses() {
1744        let token = TEST_TOKEN;
1745        let state = test_state(token);
1746        let router = dev_router(state, token.to_owned());
1747
1748        // 401 response should also have security headers.
1749        let req = localhost_request()
1750            .uri("/scp/dev/v1/health")
1751            .body(Body::empty())
1752            .unwrap();
1753
1754        let resp = router.oneshot(req).await.unwrap();
1755        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1756        assert_eq!(
1757            resp.headers().get(header::X_CONTENT_TYPE_OPTIONS).unwrap(),
1758            "nosniff"
1759        );
1760    }
1761
1762    #[tokio::test]
1763    async fn cors_preflight_rejected() {
1764        let token = TEST_TOKEN;
1765        let state = test_state(token);
1766        let router = dev_router(state, token.to_owned());
1767
1768        let req = Request::builder()
1769            .method(axum::http::Method::OPTIONS)
1770            .uri("/scp/dev/v1/health")
1771            .header(header::HOST, "localhost")
1772            .body(Body::empty())
1773            .unwrap();
1774
1775        let resp = router.oneshot(req).await.unwrap();
1776        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1777
1778        let body = resp.into_body().collect().await.unwrap().to_bytes();
1779        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1780        assert_eq!(json["code"], "FORBIDDEN");
1781    }
1782}