@@ -165,11 +165,17 @@ func mustMarshalJSON(t *testing.T, v any) []byte {
165165 return b
166166}
167167
168+ // testCallbackToken is the bearer secret every callback test installs into the
169+ // Server.cfg so handleCallback's mandatory auth check passes. Real deployments
170+ // supply a high-entropy value via config.callback_token; tests just need a
171+ // stable non-empty string.
172+ const testCallbackToken = "test-callback-token"
173+
168174func setupServerWithStore (broker * kafka.RecordingBroker , ms * mockStore ) (* Server , * gin.Engine ) {
169175 gin .SetMode (gin .TestMode )
170176 producer := kafka .NewProducer (broker )
171177 srv := & Server {
172- cfg : & config.Config {},
178+ cfg : & config.Config {CallbackToken : testCallbackToken },
173179 logger : zap .NewNop (),
174180 producer : producer ,
175181 store : ms ,
@@ -183,7 +189,7 @@ func setupServer(broker *kafka.RecordingBroker) (*Server, *gin.Engine) {
183189 gin .SetMode (gin .TestMode )
184190 producer := kafka .NewProducer (broker )
185191 srv := & Server {
186- cfg : & config.Config {},
192+ cfg : & config.Config {CallbackToken : testCallbackToken },
187193 logger : zap .NewNop (),
188194 producer : producer ,
189195 }
@@ -192,6 +198,17 @@ func setupServer(broker *kafka.RecordingBroker) (*Server, *gin.Engine) {
192198 return srv , router
193199}
194200
201+ // authedCallbackRequest builds a callback POST with the canonical bearer
202+ // header so the mandatory auth check inside handleCallback accepts it. Tests
203+ // that exercise the auth check itself construct their own requests instead.
204+ func authedCallbackRequest (t * testing.T , body []byte ) * http.Request {
205+ t .Helper ()
206+ req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
207+ req .Header .Set ("Content-Type" , "application/json" )
208+ req .Header .Set ("Authorization" , "Bearer " + testCallbackToken )
209+ return req
210+ }
211+
195212// totalMessages returns the combined count of single-message Sends and
196213// batched entries — matching the old Sarama mock's flat-message semantics.
197214func totalMessages (broker * kafka.RecordingBroker ) int {
@@ -319,8 +336,7 @@ func TestHandleCallback_SeenMultipleNodes_UpdatesStatus(t *testing.T) {
319336 }
320337 body := mustMarshalJSON (t , payload )
321338
322- req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
323- req .Header .Set ("Content-Type" , "application/json" )
339+ req := authedCallbackRequest (t , body )
324340 w := httptest .NewRecorder ()
325341
326342 router .ServeHTTP (w , req )
@@ -360,8 +376,7 @@ func TestHandleCallback_Stump_StorageError_Returns500(t *testing.T) {
360376 }
361377 body := mustMarshalJSON (t , payload )
362378
363- req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
364- req .Header .Set ("Content-Type" , "application/json" )
379+ req := authedCallbackRequest (t , body )
365380 w := httptest .NewRecorder ()
366381
367382 router .ServeHTTP (w , req )
@@ -384,8 +399,7 @@ func TestHandleCallback_SeenMultipleNodes_EmptyTxIDs(t *testing.T) {
384399 }
385400 body := mustMarshalJSON (t , payload )
386401
387- req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
388- req .Header .Set ("Content-Type" , "application/json" )
402+ req := authedCallbackRequest (t , body )
389403 w := httptest .NewRecorder ()
390404
391405 router .ServeHTTP (w , req )
@@ -463,8 +477,7 @@ func TestHandleCallback_FullBlockFlow_20Subtrees(t *testing.T) {
463477 errCh <- fmt .Errorf ("marshal subtree %d: %w" , i , err )
464478 return
465479 }
466- req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
467- req .Header .Set ("Content-Type" , "application/json" )
480+ req := authedCallbackRequest (t , body )
468481 // Match merkle-service's delivery headers exactly.
469482 req .Header .Set ("X-Idempotency-Key" , fmt .Sprintf ("%s:%d:STUMP" , blockHash , i ))
470483 w := httptest .NewRecorder ()
@@ -527,8 +540,7 @@ func TestHandleCallback_FullBlockFlow_20Subtrees(t *testing.T) {
527540 if err != nil {
528541 t .Fatalf ("marshal BLOCK_PROCESSED: %v" , err )
529542 }
530- req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
531- req .Header .Set ("Content-Type" , "application/json" )
543+ req := authedCallbackRequest (t , body )
532544 req .Header .Set ("X-Idempotency-Key" , blockHash + ":BLOCK_PROCESSED" )
533545 w := httptest .NewRecorder ()
534546 router .ServeHTTP (w , req )
@@ -577,8 +589,7 @@ func TestHandleCallback_FullBlockFlow_20Subtrees(t *testing.T) {
577589 Stump : stumpPayloads [0 ],
578590 }
579591 retryBody := mustMarshalJSON (t , retryPayload )
580- retryReq := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (retryBody ))
581- retryReq .Header .Set ("Content-Type" , "application/json" )
592+ retryReq := authedCallbackRequest (t , retryBody )
582593 retryW := httptest .NewRecorder ()
583594 router .ServeHTTP (retryW , retryReq )
584595 if retryW .Code != http .StatusOK {
@@ -662,8 +673,7 @@ func TestHandleCallback_FullBlockFlow_PartialStumpFailure(t *testing.T) {
662673 Stump : stumpPayloads [i ],
663674 }
664675 body := mustMarshalJSON (t , payload )
665- req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
666- req .Header .Set ("Content-Type" , "application/json" )
676+ req := authedCallbackRequest (t , body )
667677 w := httptest .NewRecorder ()
668678 router .ServeHTTP (w , req )
669679 resCh <- result {idx : i , status : w .Code }
@@ -711,8 +721,7 @@ func TestHandleCallback_FullBlockFlow_PartialStumpFailure(t *testing.T) {
711721 BlockHash : blockHash ,
712722 }
713723 body := mustMarshalJSON (t , blockMsg )
714- req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
715- req .Header .Set ("Content-Type" , "application/json" )
724+ req := authedCallbackRequest (t , body )
716725 w := httptest .NewRecorder ()
717726 router .ServeHTTP (w , req )
718727 if w .Code != http .StatusOK {
@@ -867,3 +876,117 @@ func TestHandleSubmitTransactions_TxID_IsCanonical(t *testing.T) {
867876 t .Errorf ("submissions: want %v, got %v" , want , got )
868877 }
869878}
879+
880+ // TestHandleCallback_RejectsUnauthenticated locks down the F-018 fix: the
881+ // /api/v1/merkle-service/callback receiver MUST refuse any request missing
882+ // or presenting the wrong bearer token, and MUST refuse all requests when
883+ // the configured token is empty (fail-closed). Pre-fix, the handler skipped
884+ // the entire bearer check when CallbackToken == "", letting any unauthenticated
885+ // caller submit forged Merkle status updates.
886+ //
887+ // This test exercises the runtime check directly. The "config rejects empty
888+ // token when Merkle is enabled" half of the fix is covered in
889+ // config/config_test.go (TestValidate_RequiresCallbackTokenWhenMerkleEnabled).
890+ func TestHandleCallback_RejectsUnauthenticated (t * testing.T ) {
891+ payload := mustMarshalJSON (t , models.CallbackMessage {
892+ Type : models .CallbackSeenMultipleNodes ,
893+ TxIDs : []string {"tx1" },
894+ })
895+
896+ cases := []struct {
897+ name string
898+ token string // configured CallbackToken on the Server.
899+ header string // Authorization header sent on the request, "" = none.
900+ wantOK bool
901+ wantErr int
902+ }{
903+ {
904+ name : "no auth header is rejected" ,
905+ token : testCallbackToken ,
906+ header : "" ,
907+ wantOK : false ,
908+ wantErr : http .StatusUnauthorized ,
909+ },
910+ {
911+ name : "wrong bearer is rejected" ,
912+ token : testCallbackToken ,
913+ header : "Bearer not-the-real-token" ,
914+ wantOK : false ,
915+ wantErr : http .StatusUnauthorized ,
916+ },
917+ {
918+ name : "non-bearer scheme is rejected" ,
919+ token : testCallbackToken ,
920+ header : "Basic " + testCallbackToken ,
921+ wantOK : false ,
922+ wantErr : http .StatusUnauthorized ,
923+ },
924+ {
925+ // Defense-in-depth. Config validation now refuses to start with an
926+ // empty CallbackToken when Merkle is enabled, but if a misconfigured
927+ // process somehow reaches the handler with cfg.CallbackToken == ""
928+ // every request — including one presenting an empty bearer — must
929+ // still be refused.
930+ name : "empty configured token rejects all callers" ,
931+ token : "" ,
932+ header : "Bearer " ,
933+ wantOK : false ,
934+ wantErr : http .StatusUnauthorized ,
935+ },
936+ {
937+ // Same scenario as above with no auth header — must also be 401.
938+ name : "empty configured token rejects unauthenticated caller" ,
939+ token : "" ,
940+ header : "" ,
941+ wantOK : false ,
942+ wantErr : http .StatusUnauthorized ,
943+ },
944+ {
945+ name : "correct bearer is accepted" ,
946+ token : testCallbackToken ,
947+ header : "Bearer " + testCallbackToken ,
948+ wantOK : true ,
949+ },
950+ }
951+
952+ for _ , tc := range cases {
953+ t .Run (tc .name , func (t * testing.T ) {
954+ ms := & mockStore {}
955+ gin .SetMode (gin .TestMode )
956+ producer := kafka .NewProducer (& kafka.RecordingBroker {})
957+ srv := & Server {
958+ cfg : & config.Config {CallbackToken : tc .token },
959+ logger : zap .NewNop (),
960+ producer : producer ,
961+ store : ms ,
962+ }
963+ router := gin .New ()
964+ srv .registerRoutes (router )
965+
966+ req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (payload ))
967+ req .Header .Set ("Content-Type" , "application/json" )
968+ if tc .header != "" {
969+ req .Header .Set ("Authorization" , tc .header )
970+ }
971+ w := httptest .NewRecorder ()
972+ router .ServeHTTP (w , req )
973+
974+ if tc .wantOK {
975+ if w .Code != http .StatusOK {
976+ t .Fatalf ("expected 200, got %d: %s" , w .Code , w .Body .String ())
977+ }
978+ if len (ms .updateStatusCalls ) != 1 {
979+ t .Errorf ("expected store update on accepted callback, got %d" , len (ms .updateStatusCalls ))
980+ }
981+ return
982+ }
983+ if w .Code != tc .wantErr {
984+ t .Fatalf ("expected %d, got %d: %s" , tc .wantErr , w .Code , w .Body .String ())
985+ }
986+ // Rejected requests must not reach the dispatch path.
987+ if len (ms .updateStatusCalls ) != 0 {
988+ t .Errorf ("rejected callback must not write to the store, got %d UpdateStatus calls" , len (ms .updateStatusCalls ))
989+ }
990+ })
991+ }
992+ }
0 commit comments