66//! POST /api/optimize → accepts OptimizerRequest JSON, returns top-3 OptimizationResult[]
77
88use actix_cors:: Cors ;
9- use actix_web:: { App , HttpResponse , HttpServer , Responder , web} ;
9+ use actix_web:: {
10+ App , HttpResponse , HttpServer , Responder ,
11+ http:: { Method , header} ,
12+ web,
13+ } ;
1014use serde:: { Deserialize , Serialize } ;
1115use std:: collections:: { HashMap , HashSet } ;
1216use std:: sync:: Arc ;
@@ -160,6 +164,14 @@ fn validate_optimize_request(req: &OptimizeRequest, nodes: &[ResourceNode]) -> R
160164 Ok ( ( ) )
161165}
162166
167+ fn local_dashboard_cors ( ) -> Cors {
168+ Cors :: default ( )
169+ . allowed_origin ( "http://127.0.0.1:3000" )
170+ . allowed_origin ( "http://localhost:3000" )
171+ . allowed_methods ( [ Method :: GET , Method :: POST ] )
172+ . allowed_header ( header:: CONTENT_TYPE )
173+ }
174+
163175// ---------------------------------------------------------------------------
164176// Phase preset builder (mirrors JS PRESETS)
165177// ---------------------------------------------------------------------------
@@ -288,11 +300,8 @@ pub async fn run_server(port: u16) -> std::io::Result<()> {
288300 let data = Arc :: new ( AppState { nodes } ) ;
289301
290302 HttpServer :: new ( move || {
291- // Allow all origins in dev (Vite runs on 3000, server on 8080)
292- let cors = Cors :: permissive ( ) ;
293-
294303 App :: new ( )
295- . wrap ( cors )
304+ . wrap ( local_dashboard_cors ( ) )
296305 . app_data ( web:: Data :: new ( Arc :: clone ( & data) ) )
297306 . app_data (
298307 web:: JsonConfig :: default ( )
@@ -317,7 +326,11 @@ pub async fn run_server(port: u16) -> std::io::Result<()> {
317326mod tests {
318327 use super :: * ;
319328 use crate :: models:: Purity ;
320- use actix_web:: { App , http:: StatusCode , test as actix_test} ;
329+ use actix_web:: {
330+ App ,
331+ http:: { StatusCode , header} ,
332+ test as actix_test,
333+ } ;
321334
322335 #[ test]
323336 fn presets_include_collectibles_and_late_water_wells ( ) {
@@ -540,4 +553,52 @@ mod tests {
540553
541554 assert_eq ! ( response. status( ) , StatusCode :: OK ) ;
542555 }
556+
557+ #[ actix_web:: test]
558+ async fn cors_allows_local_dashboard_origin ( ) {
559+ let app = actix_test:: init_service (
560+ App :: new ( )
561+ . wrap ( local_dashboard_cors ( ) )
562+ . route ( "/api/health" , web:: get ( ) . to ( get_health) ) ,
563+ )
564+ . await ;
565+
566+ let request = actix_test:: TestRequest :: default ( )
567+ . method ( Method :: OPTIONS )
568+ . uri ( "/api/health" )
569+ . insert_header ( ( header:: ORIGIN , "http://127.0.0.1:3000" ) )
570+ . insert_header ( ( header:: ACCESS_CONTROL_REQUEST_METHOD , "GET" ) )
571+ . to_request ( ) ;
572+ let response = actix_test:: call_service ( & app, request) . await ;
573+
574+ assert_eq ! ( response. status( ) , StatusCode :: OK ) ;
575+ assert_eq ! (
576+ response. headers( ) . get( header:: ACCESS_CONTROL_ALLOW_ORIGIN ) ,
577+ Some ( & header:: HeaderValue :: from_static( "http://127.0.0.1:3000" ) )
578+ ) ;
579+ }
580+
581+ #[ actix_web:: test]
582+ async fn cors_rejects_arbitrary_browser_origin ( ) {
583+ let app = actix_test:: init_service (
584+ App :: new ( )
585+ . wrap ( local_dashboard_cors ( ) )
586+ . route ( "/api/health" , web:: get ( ) . to ( get_health) ) ,
587+ )
588+ . await ;
589+
590+ let request = actix_test:: TestRequest :: default ( )
591+ . method ( Method :: OPTIONS )
592+ . uri ( "/api/health" )
593+ . insert_header ( ( header:: ORIGIN , "https://example.com" ) )
594+ . insert_header ( ( header:: ACCESS_CONTROL_REQUEST_METHOD , "GET" ) )
595+ . to_request ( ) ;
596+ let response = actix_test:: call_service ( & app, request) . await ;
597+
598+ assert_eq ! ( response. status( ) , StatusCode :: BAD_REQUEST ) ;
599+ assert_eq ! (
600+ response. headers( ) . get( header:: ACCESS_CONTROL_ALLOW_ORIGIN ) ,
601+ None
602+ ) ;
603+ }
543604}
0 commit comments