@@ -632,7 +632,6 @@ async fn handle_reload(
632632) -> Result < impl IntoResponse , SignerModuleError > {
633633 debug ! ( event = "reload" , "New request" ) ;
634634
635- // Regenerate the config
636635 let config = match StartSignerConfig :: load_from_env ( ) {
637636 Ok ( config) => config,
638637 Err ( err) => {
@@ -641,7 +640,6 @@ async fn handle_reload(
641640 }
642641 } ;
643642
644- // Start a new manager with the updated config
645643 let new_manager = match start_manager ( config) . await {
646644 Ok ( manager) => manager,
647645 Err ( err) => {
@@ -650,17 +648,24 @@ async fn handle_reload(
650648 }
651649 } ;
652650
653- // Update the JWT configs if provided in the request
651+ apply_reload ( state, request, new_manager) . await
652+ }
653+
654+ /// Applies a reload request to the signing state. Separated from
655+ /// `handle_reload` so the business logic can be tested without requiring a
656+ /// live environment (config file, env vars, keystore on disk).
657+ async fn apply_reload (
658+ state : SigningState ,
659+ request : ReloadRequest ,
660+ new_manager : SigningManager ,
661+ ) -> Result < StatusCode , SignerModuleError > {
662+ // Update the JWT configs if provided in the request. Only the provided
663+ // modules are updated; omitted modules keep their existing secrets.
654664 if let Some ( jwt_secrets) = request. jwt_secrets {
655665 let mut jwt_configs = state. jwts . write ( ) ;
656- let mut new_configs = HashMap :: new ( ) ;
657666 for ( module_id, jwt_secret) in jwt_secrets {
658- if let Some ( signing_id) = jwt_configs. get ( & module_id) . map ( |cfg| cfg. signing_id ) {
659- new_configs. insert ( module_id. clone ( ) , ModuleSigningConfig {
660- module_name : module_id,
661- jwt_secret,
662- signing_id,
663- } ) ;
667+ if let Some ( cfg) = jwt_configs. get_mut ( & module_id) {
668+ cfg. jwt_secret = jwt_secret;
664669 } else {
665670 let error_message = format ! (
666671 "Module {module_id} signing ID not found in commit-boost config, cannot reload"
@@ -669,10 +674,8 @@ async fn handle_reload(
669674 return Err ( SignerModuleError :: RequestError ( error_message) ) ;
670675 }
671676 }
672- * jwt_configs = new_configs;
673677 }
674678
675- // Update the rest of the state once everything has passed
676679 if let Some ( admin_secret) = request. admin_secret {
677680 * state. admin_secret . write ( ) = admin_secret;
678681 }
@@ -722,3 +725,156 @@ async fn start_manager(config: StartSignerConfig) -> eyre::Result<SigningManager
722725 }
723726 }
724727}
728+
729+ #[ cfg( test) ]
730+ mod tests {
731+ use alloy:: primitives:: b256;
732+ use parking_lot:: RwLock as ParkingRwLock ;
733+
734+ use super :: * ;
735+ use crate :: manager:: local:: LocalSigningManager ;
736+
737+ fn make_signing_config (
738+ module_name : & str ,
739+ secret : & str ,
740+ signing_id : B256 ,
741+ ) -> ModuleSigningConfig {
742+ ModuleSigningConfig {
743+ module_name : ModuleId ( module_name. to_string ( ) ) ,
744+ jwt_secret : secret. to_string ( ) ,
745+ signing_id,
746+ }
747+ }
748+
749+ fn make_state ( jwts : HashMap < ModuleId , ModuleSigningConfig > ) -> SigningState {
750+ SigningState {
751+ manager : Arc :: new ( RwLock :: new ( SigningManager :: Local (
752+ LocalSigningManager :: new ( Chain :: Holesky , None ) . unwrap ( ) ,
753+ ) ) ) ,
754+ jwts : Arc :: new ( ParkingRwLock :: new ( jwts) ) ,
755+ admin_secret : Arc :: new ( ParkingRwLock :: new ( "admin" . to_string ( ) ) ) ,
756+ jwt_auth_failures : Arc :: new ( ParkingRwLock :: new ( HashMap :: new ( ) ) ) ,
757+ jwt_auth_fail_limit : 3 ,
758+ jwt_auth_fail_timeout : Duration :: from_secs ( 60 ) ,
759+ reverse_proxy : ReverseProxyHeaderSetup :: None ,
760+ }
761+ }
762+
763+ fn empty_manager ( ) -> SigningManager {
764+ SigningManager :: Local ( LocalSigningManager :: new ( Chain :: Holesky , None ) . unwrap ( ) )
765+ }
766+
767+ /// Partial reload must update only the provided modules and leave omitted
768+ /// modules with their existing secrets.
769+ #[ tokio:: test]
770+ async fn test_partial_reload_preserves_omitted_modules ( ) {
771+ let module_a = ModuleId ( "module-a" . to_string ( ) ) ;
772+ let module_b = ModuleId ( "module-b" . to_string ( ) ) ;
773+ let signing_id_a =
774+ b256 ! ( "0101010101010101010101010101010101010101010101010101010101010101" ) ;
775+ let signing_id_b =
776+ b256 ! ( "0202020202020202020202020202020202020202020202020202020202020202" ) ;
777+
778+ let state = make_state ( HashMap :: from ( [
779+ ( module_a. clone ( ) , make_signing_config ( "module-a" , "secret-a" , signing_id_a) ) ,
780+ ( module_b. clone ( ) , make_signing_config ( "module-b" , "secret-b" , signing_id_b) ) ,
781+ ] ) ) ;
782+
783+ let request = ReloadRequest {
784+ jwt_secrets : Some ( HashMap :: from ( [ ( module_a. clone ( ) , "rotated-secret-a" . to_string ( ) ) ] ) ) ,
785+ admin_secret : None ,
786+ } ;
787+
788+ let result = apply_reload ( state. clone ( ) , request, empty_manager ( ) ) . await ;
789+ assert ! ( result. is_ok( ) , "apply_reload should succeed" ) ;
790+
791+ let jwts = state. jwts . read ( ) ;
792+ assert_eq ! (
793+ jwts[ & module_a] . jwt_secret, "rotated-secret-a" ,
794+ "module_a secret should be updated"
795+ ) ;
796+ assert_eq ! (
797+ jwts[ & module_b] . jwt_secret, "secret-b" ,
798+ "module_b secret must be preserved when omitted"
799+ ) ;
800+ }
801+
802+ /// A full reload (all modules provided) should update every module.
803+ #[ tokio:: test]
804+ async fn test_full_reload_updates_all_modules ( ) {
805+ let module_a = ModuleId ( "module-a" . to_string ( ) ) ;
806+ let module_b = ModuleId ( "module-b" . to_string ( ) ) ;
807+ let signing_id_a =
808+ b256 ! ( "0101010101010101010101010101010101010101010101010101010101010101" ) ;
809+ let signing_id_b =
810+ b256 ! ( "0202020202020202020202020202020202020202020202020202020202020202" ) ;
811+
812+ let state = make_state ( HashMap :: from ( [
813+ ( module_a. clone ( ) , make_signing_config ( "module-a" , "secret-a" , signing_id_a) ) ,
814+ ( module_b. clone ( ) , make_signing_config ( "module-b" , "secret-b" , signing_id_b) ) ,
815+ ] ) ) ;
816+
817+ let request = ReloadRequest {
818+ jwt_secrets : Some ( HashMap :: from ( [
819+ ( module_a. clone ( ) , "new-secret-a" . to_string ( ) ) ,
820+ ( module_b. clone ( ) , "new-secret-b" . to_string ( ) ) ,
821+ ] ) ) ,
822+ admin_secret : None ,
823+ } ;
824+
825+ apply_reload ( state. clone ( ) , request, empty_manager ( ) ) . await . unwrap ( ) ;
826+
827+ let jwts = state. jwts . read ( ) ;
828+ assert_eq ! ( jwts[ & module_a] . jwt_secret, "new-secret-a" ) ;
829+ assert_eq ! ( jwts[ & module_b] . jwt_secret, "new-secret-b" ) ;
830+ }
831+
832+ /// Reload with an unknown module ID in jwt_secrets should return an error
833+ /// and leave the existing state unchanged.
834+ #[ tokio:: test]
835+ async fn test_reload_unknown_module_returns_error ( ) {
836+ let module_a = ModuleId ( "module-a" . to_string ( ) ) ;
837+ let signing_id_a =
838+ b256 ! ( "0101010101010101010101010101010101010101010101010101010101010101" ) ;
839+
840+ let state = make_state ( HashMap :: from ( [ (
841+ module_a. clone ( ) ,
842+ make_signing_config ( "module-a" , "secret-a" , signing_id_a) ,
843+ ) ] ) ) ;
844+
845+ let request = ReloadRequest {
846+ jwt_secrets : Some ( HashMap :: from ( [ (
847+ ModuleId ( "unknown-module" . to_string ( ) ) ,
848+ "some-secret" . to_string ( ) ,
849+ ) ] ) ) ,
850+ admin_secret : None ,
851+ } ;
852+
853+ let result = apply_reload ( state. clone ( ) , request, empty_manager ( ) ) . await ;
854+ assert ! ( result. is_err( ) , "unknown module should return an error" ) ;
855+
856+ // Existing module must be untouched
857+ let jwts = state. jwts . read ( ) ;
858+ assert_eq ! ( jwts[ & module_a] . jwt_secret, "secret-a" ) ;
859+ }
860+
861+ /// Reload with no jwt_secrets should leave all module secrets unchanged.
862+ #[ tokio:: test]
863+ async fn test_reload_without_jwt_secrets_preserves_all ( ) {
864+ let module_a = ModuleId ( "module-a" . to_string ( ) ) ;
865+ let signing_id_a =
866+ b256 ! ( "0101010101010101010101010101010101010101010101010101010101010101" ) ;
867+
868+ let state = make_state ( HashMap :: from ( [ (
869+ module_a. clone ( ) ,
870+ make_signing_config ( "module-a" , "secret-a" , signing_id_a) ,
871+ ) ] ) ) ;
872+
873+ let request = ReloadRequest { jwt_secrets : None , admin_secret : None } ;
874+
875+ apply_reload ( state. clone ( ) , request, empty_manager ( ) ) . await . unwrap ( ) ;
876+
877+ let jwts = state. jwts . read ( ) ;
878+ assert_eq ! ( jwts[ & module_a] . jwt_secret, "secret-a" ) ;
879+ }
880+ }
0 commit comments