1use std::path::PathBuf;
2use std::sync::Arc;
3
4use crossbeam_channel::Sender;
5use koan_core::audio::viz::VizSnapshot;
6use koan_core::auth::{self, parse_duration_secs};
7use koan_core::config::Config;
8use koan_core::db::pool::Pool;
9use koan_core::player::commands::PlayerCommand;
10use koan_core::player::state::SharedPlayerState;
11
12use super::{KoanSchema, build_schema};
13use crate::auth::AuthUser;
14use crate::auth::middleware::{AuthState, auth_middleware};
15use crate::auth::routes::{AuthRouteState, LoginRateLimiter, auth_router};
16
17pub fn cmd_serve(
22 port: Option<u16>,
23 bind: Option<std::net::IpAddr>,
24 subsonic_port: Option<u16>,
25 playground: bool,
26 mcp_bind: Option<std::net::SocketAddr>,
27) {
28 use koan_core::player::Player;
29
30 let _db = koan_core::db::connection::Database::open_default().expect("failed to open database");
32 let db_path = koan_core::config::db_path();
33 let pool = Arc::new(Pool::new(db_path.clone()));
34
35 let (state, _timeline, _viz, cmd_tx) = Player::spawn();
36
37 let watched = db_path.clone();
40 koan_core::helpers::spawn_library_watch(db_path, move |running| {
41 if !running {
44 crate::clients::fulfil_from(&watched);
45 crate::clients::changed_if_library_moved(&watched);
46 }
47 });
48
49 if let Some(addr) = mcp_bind {
50 match crate::mcp::spawn_http(addr, state.clone(), cmd_tx.clone(), pool.clone()) {
51 Ok(_) => log::info!("MCP over HTTP at http://{addr}/mcp"),
52 Err(e) => {
53 eprintln!("koan: MCP listener on {addr}: {e}");
54 std::process::exit(1);
55 }
56 }
57 }
58
59 if let Err(e) = run_api_blocking(ApiServerOpts {
60 state,
61 cmd_tx,
62 pool,
63 port,
64 bind,
65 subsonic_port,
66 playground,
67 viz: None, }) {
69 eprintln!("koan: {}", e);
70 std::process::exit(1);
71 }
72}
73
74const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
81
82const MAX_INFLIGHT_QUERIES: usize = 64;
86
87fn load_perimeter<S>(router: axum::Router<S>) -> axum::Router<S>
92where
93 S: Clone + Send + Sync + 'static,
94{
95 router
96 .layer(tower_http::catch_panic::CatchPanicLayer::new())
99 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
100 axum::http::StatusCode::REQUEST_TIMEOUT,
101 REQUEST_TIMEOUT,
102 ))
103 .layer(
107 tower::ServiceBuilder::new()
108 .layer(axum::error_handling::HandleErrorLayer::new(
109 |err: tower::BoxError| async move {
110 if err.is::<tower::load_shed::error::Overloaded>() {
111 (
112 axum::http::StatusCode::SERVICE_UNAVAILABLE,
113 "server at capacity",
114 )
115 } else {
116 (
117 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
118 "internal error",
119 )
120 }
121 },
122 ))
123 .load_shed()
124 .concurrency_limit(MAX_INFLIGHT_QUERIES),
125 )
126}
127
128pub struct ApiServerOpts {
130 pub state: Arc<SharedPlayerState>,
131 pub cmd_tx: Sender<PlayerCommand>,
132 pub pool: Arc<Pool>,
135 pub port: Option<u16>,
136 pub bind: Option<std::net::IpAddr>,
137 pub subsonic_port: Option<u16>,
138 pub playground: bool,
139 pub viz: Option<Arc<VizSnapshot>>,
140}
141
142fn run_api_blocking(opts: ApiServerOpts) -> Result<(), String> {
148 let ApiServerOpts {
149 state,
150 cmd_tx,
151 pool,
152 port,
153 bind,
154 subsonic_port,
155 playground,
156 viz,
157 } = opts;
158 use axum::routing::{get, post};
159
160 let cfg = Config::load().unwrap_or_default();
161 let port = port.unwrap_or(cfg.graphql.port);
162 let bind = bind.unwrap_or(cfg.graphql.bind);
163 let subsonic_port = subsonic_port.or(cfg.subsonic.port);
164 let playground_enabled = playground || cfg.graphql.playground;
165 let auth_enabled = cfg.graphql.auth_enabled;
166
167 let (private_pem, public_pem) = if auth_enabled {
173 let kp = auth::load_or_generate_keypair().map_err(|e| {
174 format!(
175 "auth_enabled = true but the keypair could not be loaded or created: {}",
176 e
177 )
178 })?;
179 if kp.0.is_empty() || kp.1.is_empty() {
182 return Err("auth_enabled = true but the keypair files are empty. \
183 Run `koan auth regenerate-keys`."
184 .into());
185 }
186 kp
187 } else {
188 auth::load_or_generate_keypair().unwrap_or_default()
191 };
192
193 let access_ttl = parse_duration_secs(&cfg.graphql.access_token_ttl).unwrap_or(900);
194 let refresh_ttl = parse_duration_secs(&cfg.graphql.refresh_token_ttl).unwrap_or(2_592_000);
195
196 let introspection_key = if playground_enabled && auth_enabled {
200 Some(Arc::new(auth::random_token().map_err(|e| {
201 format!("failed to generate introspection key: {}", e)
202 })?))
203 } else {
204 None
205 };
206
207 let auth_state = AuthState {
208 public_pem: Arc::new(public_pem.clone()),
209 auth_enabled,
210 introspection_key: introspection_key.clone(),
211 };
212
213 let auth_route_state = AuthRouteState {
214 pool: pool.clone(),
215 private_pem: Arc::new(private_pem),
216 public_pem: Arc::new(public_pem),
217 access_ttl_secs: access_ttl,
218 refresh_ttl_secs: refresh_ttl,
219 cookie_secure: cfg.graphql.cookie_secure,
220 login_limiter: Arc::new(LoginRateLimiter::default()),
221 };
222
223 let schema = build_schema(state, cmd_tx, pool.path().to_path_buf(), viz);
224
225 if auth_enabled {
226 log::info!(
227 "Auth enabled (Ed25519 JWT, access TTL {}s, refresh TTL {}s)",
228 access_ttl,
229 refresh_ttl
230 );
231 } else {
232 log::info!("Auth disabled — all requests treated as admin");
233 }
234
235 let browser_policy = Arc::new(BrowserPolicy {
236 origins: cfg.graphql.cors_origins.clone(),
237 hosts: cfg.graphql.allowed_hosts.clone(),
238 });
239
240 if cfg.graphql.cors_origins.is_empty() {
241 log::info!("CORS: no origins configured — browsers get no cross-origin access");
242 }
243
244 let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
245 rt.block_on(async {
246 let query_route = load_perimeter(axum::Router::new().route("/graphql", post(graphql_handler)));
251
252 let gql_app = axum::Router::new()
253 .merge(query_route)
254 .route("/graphql/ws", get(graphql_ws_handler))
255 .layer(axum::middleware::from_fn_with_state(
256 auth_state.clone(),
257 auth_middleware,
258 ))
259 .layer(axum::middleware::from_fn_with_state(
262 browser_policy.clone(),
263 browser_guard,
264 ))
265 .with_state(schema);
266
267 let covers = Arc::new(crate::covers::Covers::in_config_dir());
271 let ui_routes = crate::ui::router(
272 pool.clone(),
273 auth_route_state.clone(),
274 auth_enabled,
275 covers.clone(),
276 );
277
278 let auth_app = auth_router(auth_route_state);
280
281 let origins: Vec<axum::http::HeaderValue> = cfg
285 .graphql
286 .cors_origins
287 .iter()
288 .filter_map(|o| o.parse().ok())
289 .collect();
290 let cors = tower_http::cors::CorsLayer::new()
291 .allow_origin(origins)
292 .allow_methods([
293 axum::http::Method::GET,
294 axum::http::Method::POST,
295 axum::http::Method::OPTIONS,
296 ])
297 .allow_headers([
298 axum::http::header::AUTHORIZATION,
299 axum::http::header::CONTENT_TYPE,
300 axum::http::HeaderName::from_static("x-introspection-key"),
301 ])
302 .allow_credentials(true);
303
304 let share_routes = crate::share::router(
313 pool.clone(),
314 cfg.sharing.public_url.clone(),
315 covers,
316 );
317 let subsonic_merged = crate::subsonic::subsonic_router(pool);
318 let subsonic_on_main = subsonic_merged.is_some();
319 let subsonic_dedicated = subsonic_merged.clone();
320
321 let mut app = auth_app.merge(gql_app).merge(share_routes).merge(ui_routes);
322 if let Some(sub) = subsonic_merged {
323 app = app.merge(sub);
324 }
325 if playground_enabled {
326 app = app.route(
327 "/graphql",
328 get(graphql_playground).with_state(introspection_key.clone()),
329 );
330 }
331 let app = app.layer(cors).layer(axum::middleware::from_fn_with_state(
335 browser_policy.clone(),
336 host_guard,
337 ));
338
339 let playground_url = if playground_enabled {
341 if let Some(ref key) = introspection_key {
342 format!("http://{}:{}/graphql?introspection-key={}", bind, port, key)
343 } else {
344 format!("http://{}:{}/graphql", bind, port)
345 }
346 } else {
347 format!("http://{}:{}/graphql", bind, port)
348 };
349
350 let gql_addr = std::net::SocketAddr::new(bind, port);
351
352 let gql_listener = match tokio::net::TcpListener::bind(gql_addr).await {
353 Ok(l) => {
354 log::info!("GraphQL API on http://{}:{}/graphql", bind, port);
355 if subsonic_on_main {
356 log::info!("Subsonic REST on http://{}:{}/rest/", bind, port);
357 }
358 if playground_enabled {
359 log::info!("GraphiQL: {}", playground_url);
360 #[cfg(target_os = "macos")]
362 let _ = std::process::Command::new("open").arg(&playground_url).spawn();
363 #[cfg(target_os = "linux")]
364 let _ = std::process::Command::new("xdg-open").arg(&playground_url).spawn();
365 }
366 l
367 }
368 Err(e) => {
369 return Err(format!(
370 "failed to bind GraphQL port {port} — {e} (another instance running?)"
371 ));
372 }
373 };
374 let gql_server = axum::serve(
375 gql_listener,
376 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
377 )
378 .with_graceful_shutdown(shutdown_signal());
379
380 let extra_sub_port = subsonic_port.filter(|p| *p != port);
384 if let Some(sub_port) = extra_sub_port
385 && let Some(sub_app) = subsonic_dedicated
386 {
387 let sub_addr = std::net::SocketAddr::new(bind, sub_port);
388 match tokio::net::TcpListener::bind(sub_addr).await {
389 Ok(sub_listener) => {
390 log::info!(
391 "Subsonic REST also on http://{}:{}/rest/ (dedicated port)",
392 bind,
393 sub_port,
394 );
395 let sub_server = axum::serve(sub_listener, sub_app)
396 .with_graceful_shutdown(shutdown_signal());
397
398 tokio::select! {
399 r = gql_server => { if let Err(e) = r { log::error!("GraphQL server error: {e}"); } },
400 r = sub_server => { if let Err(e) = r { log::error!("Subsonic server error: {e}"); } },
401 }
402 return Ok(());
403 }
404 Err(e) => {
405 log::warn!(
406 "Dedicated Subsonic port {} unavailable — {}. Mounted on GraphQL port only.",
407 sub_port,
408 e,
409 );
410 }
411 }
412 }
413
414 if let Err(e) = gql_server.await {
415 log::error!("GraphQL server error: {e}");
416 }
417 Ok(())
418 })
419}
420
421pub fn start_api_background(
427 state: Arc<SharedPlayerState>,
428 cmd_tx: Sender<PlayerCommand>,
429 db_path: PathBuf,
430 port: Option<u16>,
431 bind: Option<std::net::IpAddr>,
432 subsonic_port: Option<u16>,
433 playground: bool,
434) {
435 if let Err(e) = run_api_blocking(ApiServerOpts {
438 state,
439 cmd_tx,
440 pool: Arc::new(Pool::new(db_path)),
441 port,
442 bind,
443 subsonic_port,
444 playground,
445 viz: None,
446 }) {
447 log::error!("API server not started: {}", e);
448 }
449}
450
451pub(crate) struct BrowserPolicy {
460 origins: Vec<String>,
461 hosts: Vec<String>,
462}
463
464impl BrowserPolicy {
465 fn host_allowed(&self, host: &str) -> bool {
466 if self.hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) {
467 return true;
468 }
469 let bare = strip_port(host);
470 if self.hosts.iter().any(|h| h.eq_ignore_ascii_case(bare)) {
471 return true;
472 }
473 bare.eq_ignore_ascii_case("localhost") || bare.parse::<std::net::IpAddr>().is_ok()
476 }
477
478 fn origin_allowed(&self, origin: &str, host: Option<&str>) -> bool {
481 if self.origins.iter().any(|o| o == origin) {
482 return true;
483 }
484 match (origin.split_once("://"), host) {
485 (Some((_, authority)), Some(host)) => authority.eq_ignore_ascii_case(host),
486 _ => false,
487 }
488 }
489}
490
491fn strip_port(host: &str) -> &str {
493 if let Some(rest) = host.strip_prefix('[') {
494 return rest.split(']').next().unwrap_or(rest);
495 }
496 match host.rsplit_once(':') {
497 Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
498 _ => host,
499 }
500}
501
502fn header_str(request: &axum::extract::Request, name: axum::http::HeaderName) -> Option<&str> {
503 request.headers().get(name).and_then(|v| v.to_str().ok())
504}
505
506async fn host_guard(
508 axum::extract::State(policy): axum::extract::State<Arc<BrowserPolicy>>,
509 request: axum::extract::Request,
510 next: axum::middleware::Next,
511) -> axum::response::Response {
512 use axum::response::IntoResponse;
513
514 let host = header_str(&request, axum::http::header::HOST)
517 .map(str::to_owned)
518 .or_else(|| request.uri().host().map(str::to_owned));
519
520 if let Some(ref host) = host
521 && !policy.host_allowed(host)
522 {
523 log::warn!("rejected request for unrecognised Host: {}", host);
524 return (axum::http::StatusCode::FORBIDDEN, "host not allowed").into_response();
525 }
526
527 next.run(request).await
528}
529
530async fn browser_guard(
539 axum::extract::State(policy): axum::extract::State<Arc<BrowserPolicy>>,
540 request: axum::extract::Request,
541 next: axum::middleware::Next,
542) -> axum::response::Response {
543 use axum::response::IntoResponse;
544
545 let host = header_str(&request, axum::http::header::HOST).map(str::to_owned);
546 if let Some(origin) = header_str(&request, axum::http::header::ORIGIN)
548 && !policy.origin_allowed(origin, host.as_deref())
549 {
550 log::warn!(
551 "rejected GraphQL request from disallowed Origin: {}",
552 origin
553 );
554 return (axum::http::StatusCode::FORBIDDEN, "origin not allowed").into_response();
555 }
556
557 if request.method() == axum::http::Method::POST && !is_graphql_content_type(&request) {
558 return (
559 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
560 "content type must be application/json or application/graphql",
561 )
562 .into_response();
563 }
564
565 next.run(request).await
566}
567
568fn is_graphql_content_type(request: &axum::extract::Request) -> bool {
569 header_str(request, axum::http::header::CONTENT_TYPE).is_some_and(|ct| {
570 let ct = ct.trim().to_ascii_lowercase();
571 ct.starts_with("application/json") || ct.starts_with("application/graphql")
572 })
573}
574
575async fn shutdown_signal() {
576 tokio::signal::ctrl_c()
577 .await
578 .expect("failed to listen for ctrl+c");
579}
580
581async fn graphql_handler(
582 axum::Extension(user): axum::Extension<AuthUser>,
583 axum::extract::State(schema): axum::extract::State<KoanSchema>,
584 req: async_graphql_axum::GraphQLRequest,
585) -> async_graphql_axum::GraphQLResponse {
586 let mut request = req.into_inner();
587 request = request.data(user);
590 schema.execute(request).await.into()
591}
592
593async fn graphql_ws_handler(
594 axum::Extension(user): axum::Extension<AuthUser>,
595 axum::extract::State(schema): axum::extract::State<KoanSchema>,
596 protocol: async_graphql_axum::GraphQLProtocol,
597 websocket: axum::extract::WebSocketUpgrade,
598) -> axum::response::Response {
599 websocket
600 .protocols(async_graphql::http::ALL_WEBSOCKET_PROTOCOLS)
601 .on_upgrade(move |stream| {
602 let stream = async_graphql_axum::GraphQLWebSocket::new(stream, schema, protocol)
603 .on_connection_init(move |_| async move {
604 let mut data = async_graphql::Data::default();
605 data.insert(user);
606 Ok(data)
607 });
608 async move {
609 stream.serve().await;
610 }
611 })
612}
613
614async fn graphql_playground(
615 axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
616 axum::extract::State(key): axum::extract::State<Option<Arc<String>>>,
617) -> axum::response::Response {
618 use axum::response::IntoResponse;
619
620 if let Some(ref expected) = key {
622 let provided = params.get("introspection-key");
623 if provided.map(|k| k.as_str()) != Some(expected.as_str()) {
624 return (
625 axum::http::StatusCode::FORBIDDEN,
626 "invalid or missing introspection-key",
627 )
628 .into_response();
629 }
630 }
631
632 let mut source = async_graphql::http::GraphiQLSource::build().endpoint("/graphql");
635 if let Some(ref k) = key {
636 source = source.header("X-Introspection-Key", k.as_str());
637 }
638
639 axum::response::Html(source.finish()).into_response()
640}
641
642pub fn cmd_serve_daemon(
644 port: Option<u16>,
645 bind: Option<std::net::IpAddr>,
646 subsonic_port: Option<u16>,
647 playground: bool,
648 mcp_bind: Option<std::net::SocketAddr>,
649) {
650 use std::fs;
651 use std::process::Command;
652
653 let cfg = Config::load().unwrap_or_default();
654 let port_val = port.unwrap_or(cfg.graphql.port);
655 let bind_val = bind.unwrap_or(cfg.graphql.bind);
656
657 let exe = std::env::current_exe().expect("failed to get current exe path");
658 let mut cmd = Command::new(exe);
659 cmd.arg("--headless");
661 cmd.arg("--port").arg(port_val.to_string());
662 cmd.arg("--bind").arg(bind_val.to_string());
663 if let Some(sp) = subsonic_port {
664 cmd.arg("--subsonic").arg(sp.to_string());
665 }
666 if let Some(addr) = mcp_bind {
667 cmd.arg("--mcp-bind").arg(addr.to_string());
668 }
669 if playground || cfg.graphql.playground {
670 cmd.arg("--playground");
671 }
672
673 cmd.stdin(std::process::Stdio::null());
674 cmd.stdout(std::process::Stdio::null());
675 cmd.stderr(std::process::Stdio::null());
676
677 let mut child = cmd.spawn().expect("failed to spawn daemon process");
678 let pid = child.id();
679
680 let pid_path = koan_core::config::config_dir().join("koan-serve.pid");
681 fs::write(&pid_path, pid.to_string()).ok();
682
683 std::thread::spawn(move || {
684 let _ = child.wait();
685 });
686
687 eprintln!("kōan daemon started (pid {}) on port {}", pid, port_val);
688 if let Some(sp) = subsonic_port {
689 eprintln!(" Subsonic REST on port {}", sp);
690 }
691 eprintln!(" PID file: {}", pid_path.display());
692}
693
694pub async fn execute_in_process(
703 schema: &KoanSchema,
704 query: &str,
705 variables: Option<serde_json::Value>,
706 role: koan_core::auth::Role,
707) -> serde_json::Value {
708 let mut request = async_graphql::Request::new(query);
709 request = request.data(AuthUser {
710 role,
711 ..AuthUser::anonymous_admin()
712 });
713 if let Some(serde_json::Value::Object(map)) = variables {
714 let mut gql_vars = async_graphql::Variables::default();
715 for (k, v) in map {
716 gql_vars.insert(
717 async_graphql::Name::new(&k),
718 async_graphql::Value::from_json(v).unwrap_or(async_graphql::Value::Null),
719 );
720 }
721 request = request.variables(gql_vars);
722 }
723 let response = schema.execute(request).await;
724 serde_json::to_value(&response).unwrap_or(serde_json::Value::Null)
725}
726
727#[cfg(test)]
732mod tests {
733 use super::*;
734 use axum::body::Body;
735 use axum::http::{Request as HttpRequest, StatusCode};
736 use axum::routing::{get, post};
737 use tower::ServiceExt as _;
738
739 fn policy() -> Arc<BrowserPolicy> {
740 Arc::new(BrowserPolicy {
741 origins: vec!["https://music.example.com".into()],
742 hosts: vec!["koan.local".into()],
743 })
744 }
745
746 async fn ok() -> &'static str {
747 "ok"
748 }
749
750 fn routes() -> axum::Router<Arc<BrowserPolicy>> {
751 axum::Router::new()
752 .route("/graphql", post(ok).get(ok))
753 .route("/graphql/ws", get(ok))
754 }
755
756 async fn run_host(req: HttpRequest<Body>) -> StatusCode {
757 let app = routes()
758 .layer(axum::middleware::from_fn_with_state(policy(), host_guard))
759 .with_state(policy());
760 app.oneshot(req).await.unwrap().status()
761 }
762
763 async fn run_browser(req: HttpRequest<Body>) -> StatusCode {
764 let app = routes()
765 .layer(axum::middleware::from_fn_with_state(
766 policy(),
767 browser_guard,
768 ))
769 .with_state(policy());
770 app.oneshot(req).await.unwrap().status()
771 }
772
773 fn json_post(uri: &str) -> axum::http::request::Builder {
774 HttpRequest::post(uri).header(axum::http::header::CONTENT_TYPE, "application/json")
775 }
776
777 #[test]
780 fn host_policy_accepts_loopback_literals_and_configured_names() {
781 let p = policy();
782 assert!(p.host_allowed("localhost:4000"));
783 assert!(p.host_allowed("127.0.0.1:4000"));
784 assert!(p.host_allowed("192.168.1.20:4000"));
785 assert!(p.host_allowed("[::1]:4000"));
786 assert!(p.host_allowed("koan.local"));
787 assert!(p.host_allowed("koan.local:4000"));
788 }
789
790 #[test]
791 fn host_policy_rejects_attacker_controlled_names() {
792 let p = policy();
793 assert!(!p.host_allowed("evil.com"));
794 assert!(!p.host_allowed("rebind.evil.com:4000"));
795 assert!(!p.host_allowed("koan.local.evil.com"));
796 }
797
798 #[tokio::test]
799 async fn host_guard_rejects_foreign_host() {
800 let req = json_post("/graphql")
801 .header(axum::http::header::HOST, "rebind.evil.com")
802 .body(Body::empty())
803 .unwrap();
804 assert_eq!(run_host(req).await, StatusCode::FORBIDDEN);
805 }
806
807 #[tokio::test]
808 async fn host_guard_allows_known_host_and_missing_host() {
809 let req = json_post("/graphql")
810 .header(axum::http::header::HOST, "127.0.0.1:4000")
811 .body(Body::empty())
812 .unwrap();
813 assert_eq!(run_host(req).await, StatusCode::OK);
814
815 let req = json_post("/graphql").body(Body::empty()).unwrap();
816 assert_eq!(run_host(req).await, StatusCode::OK);
817 }
818
819 #[tokio::test]
822 async fn ws_upgrade_from_foreign_origin_is_rejected() {
823 let req = HttpRequest::get("/graphql/ws")
824 .header(axum::http::header::HOST, "127.0.0.1:4000")
825 .header(axum::http::header::ORIGIN, "https://evil.com")
826 .body(Body::empty())
827 .unwrap();
828 assert_eq!(run_browser(req).await, StatusCode::FORBIDDEN);
829 }
830
831 #[tokio::test]
832 async fn ws_upgrade_without_origin_is_allowed() {
833 let req = HttpRequest::get("/graphql/ws")
834 .header(axum::http::header::HOST, "127.0.0.1:4000")
835 .body(Body::empty())
836 .unwrap();
837 assert_eq!(run_browser(req).await, StatusCode::OK);
838 }
839
840 #[tokio::test]
841 async fn configured_and_same_origin_are_allowed() {
842 let req = HttpRequest::get("/graphql/ws")
843 .header(axum::http::header::HOST, "127.0.0.1:4000")
844 .header(axum::http::header::ORIGIN, "https://music.example.com")
845 .body(Body::empty())
846 .unwrap();
847 assert_eq!(run_browser(req).await, StatusCode::OK);
848
849 let req = json_post("/graphql")
851 .header(axum::http::header::HOST, "127.0.0.1:4000")
852 .header(axum::http::header::ORIGIN, "http://127.0.0.1:4000")
853 .body(Body::empty())
854 .unwrap();
855 assert_eq!(run_browser(req).await, StatusCode::OK);
856 }
857
858 #[tokio::test]
861 async fn text_plain_post_is_rejected() {
862 let req = HttpRequest::post("/graphql")
863 .header(axum::http::header::CONTENT_TYPE, "text/plain")
864 .body(Body::from(r#"{"query":"mutation{clearQueue{ok}}"}"#))
865 .unwrap();
866 assert_eq!(run_browser(req).await, StatusCode::UNSUPPORTED_MEDIA_TYPE);
867 }
868
869 #[tokio::test]
870 async fn post_without_content_type_is_rejected() {
871 let req = HttpRequest::post("/graphql").body(Body::empty()).unwrap();
872 assert_eq!(run_browser(req).await, StatusCode::UNSUPPORTED_MEDIA_TYPE);
873 }
874
875 #[tokio::test]
878 async fn load_perimeter_passes_requests_and_turns_panics_into_500s() {
879 async fn boom() -> &'static str {
880 panic!("resolver exploded");
881 }
882
883 let app = load_perimeter(
884 axum::Router::new()
885 .route("/graphql", post(ok))
886 .route("/boom", post(boom)),
887 );
888
889 let req = json_post("/graphql").body(Body::empty()).unwrap();
890 assert_eq!(
891 app.clone().oneshot(req).await.unwrap().status(),
892 StatusCode::OK
893 );
894
895 let req = json_post("/boom").body(Body::empty()).unwrap();
897 assert_eq!(
898 app.oneshot(req).await.unwrap().status(),
899 StatusCode::INTERNAL_SERVER_ERROR
900 );
901 }
902
903 #[tokio::test]
904 async fn json_post_is_accepted() {
905 let req = json_post("/graphql").body(Body::empty()).unwrap();
906 assert_eq!(run_browser(req).await, StatusCode::OK);
907
908 let req = HttpRequest::post("/graphql")
909 .header(
910 axum::http::header::CONTENT_TYPE,
911 "application/json; charset=utf-8",
912 )
913 .body(Body::empty())
914 .unwrap();
915 assert_eq!(run_browser(req).await, StatusCode::OK);
916 }
917}