1use 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
37pub 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 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
77const ALLOWED_HOSTS: &[&str] = &["localhost", "127.0.0.1", "[::1]"];
88
89fn is_localhost_host(host: &str) -> bool {
96 let hostname = if host.starts_with('[') {
98 host.find(']').map_or(host, |end| &host[..=end])
100 } else {
101 host.split(':').next().unwrap_or(host)
103 };
104 ALLOWED_HOSTS
105 .iter()
106 .any(|h| hostname.eq_ignore_ascii_case(h))
107}
108
109pub 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 ApiError::forbidden("forbidden: dev API only accessible via localhost").into_response()
125 }
126 }
127}
128
129pub async fn security_headers_middleware(req: Request<Body>, next: Next) -> impl IntoResponse {
143 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#[derive(Debug, Clone, Serialize)]
176pub struct HealthResponse {
177 pub uptime_seconds: u64,
179 pub relay_connections: u64,
181 pub storage_status: String,
183}
184
185#[derive(Debug, Clone, Serialize)]
191pub struct IdentityResponse {
192 pub did: String,
194 pub document: serde_json::Value,
196}
197
198#[derive(Debug, Clone, Serialize)]
205pub struct RelayStatusResponse {
206 pub bound_addr: String,
208 pub active_connections: u64,
210 pub blob_count: u64,
212}
213
214#[derive(Debug, Clone, Serialize)]
222pub struct ContextResponse {
223 pub id: String,
225 pub name: Option<String>,
227 pub mode: String,
229 pub subscriber_count: u64,
231}
232
233#[derive(Debug, Clone, Deserialize)]
240pub struct CreateContextRequest {
241 pub id: String,
243 pub name: Option<String>,
245}
246
247pub 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
274pub 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
293pub 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
319async 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
337pub 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
367pub 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
404const MAX_CONTEXT_ID_LEN: usize = 64;
406const MAX_CONTEXT_NAME_LEN: usize = 256;
408
409pub async fn create_context_handler(
422 State(state): State<Arc<NodeState>>,
423 body: Result<Json<CreateContextRequest>, JsonRejection>,
424) -> impl IntoResponse {
425 let Ok(Json(body)) = body else {
427 return ApiError::bad_request("invalid JSON body").into_response();
428 };
429
430 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 let id = body.id.to_ascii_lowercase();
444
445 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 if contexts.contains_key(&id) {
464 return ApiError::conflict(format!("context {id} already exists")).into_response();
465 }
466
467 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 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 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
500pub 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
521const 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 .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#[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 const TEST_TOKEN: &str = "scp_local_token_abcdef1234567890abcdef1234567890";
599
600 fn localhost_request() -> axum::http::request::Builder {
604 Request::builder().header(header::HOST, "localhost")
605 }
606
607 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 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 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 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 #[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 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 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 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 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 #[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 #[tokio::test]
1203 async fn bearer_scheme_case_insensitive() {
1204 let token = TEST_TOKEN;
1205 let state = test_state(token);
1206
1207 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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[tokio::test]
1450 async fn context_id_normalized_to_lowercase() {
1451 let token = TEST_TOKEN;
1452 let state = test_state(token);
1453
1454 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 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 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 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 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 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 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 #[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 #[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 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 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 #[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 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}