@@ -10,6 +10,7 @@ import (
1010 "net/http"
1111 "net/http/httptest"
1212 "reflect"
13+ "strings"
1314 "sync"
1415 "testing"
1516 "time"
@@ -398,8 +399,7 @@ func TestHandleCallback_UnknownTxid_NoPhantomRow(t *testing.T) {
398399 }
399400 body := mustMarshalJSON (t , payload )
400401
401- req := httptest .NewRequestWithContext (t .Context (), http .MethodPost , "/api/v1/merkle-service/callback" , bytes .NewReader (body ))
402- req .Header .Set ("Content-Type" , "application/json" )
402+ req := authedCallbackRequest (t , body )
403403 w := httptest .NewRecorder ()
404404
405405 router .ServeHTTP (w , req )
@@ -1048,3 +1048,159 @@ func TestHandleCallback_RejectsUnauthenticated(t *testing.T) {
10481048 })
10491049 }
10501050}
1051+
1052+ // setupServerWithCallbackLimit returns a Server / router pair with the
1053+ // callback body cap explicitly configured. Used by the F-019 body-cap tests
1054+ // so they can run against a small, predictable limit instead of the 16 MiB
1055+ // production default.
1056+ func setupServerWithCallbackLimit (broker * kafka.RecordingBroker , ms * mockStore , maxBytes int64 ) (* Server , * gin.Engine ) {
1057+ gin .SetMode (gin .TestMode )
1058+ producer := kafka .NewProducer (broker )
1059+ srv := & Server {
1060+ cfg : & config.Config {
1061+ CallbackToken : testCallbackToken ,
1062+ Callback : config.CallbackConfig {MaxBodyBytes : maxBytes },
1063+ },
1064+ logger : zap .NewNop (),
1065+ producer : producer ,
1066+ store : ms ,
1067+ }
1068+ router := gin .New ()
1069+ srv .registerRoutes (router )
1070+ return srv , router
1071+ }
1072+
1073+ // TestHandleCallback_BodySizeLimit_UnderLimitSucceeds verifies that a
1074+ // callback whose serialized JSON sits comfortably under the configured cap
1075+ // is processed normally — guarding against an overly aggressive limit
1076+ // rejecting legitimate STUMP deliveries. Locks down half of the F-019 fix.
1077+ func TestHandleCallback_BodySizeLimit_UnderLimitSucceeds (t * testing.T ) {
1078+ const limit = 64 * 1024 // 64 KiB
1079+ ms := & mockStore {}
1080+ _ , router := setupServerWithCallbackLimit (& kafka.RecordingBroker {}, ms , limit )
1081+
1082+ // Build a STUMP payload sized so that the resulting JSON body — which
1083+ // hex-encodes the bytes via models.HexBytes — is just under the cap. Hex
1084+ // roughly doubles the byte count, so half the limit (less envelope
1085+ // overhead) leaves headroom for the JSON wrapper.
1086+ stumpBytes := make ([]byte , limit / 2 - 1024 )
1087+ for i := range stumpBytes {
1088+ stumpBytes [i ] = byte (i & 0xFF )
1089+ }
1090+ payload := models.CallbackMessage {
1091+ Type : models .CallbackStump ,
1092+ BlockHash : "0000000000000000000000000000000000000000000000000000000000000001" ,
1093+ Stump : stumpBytes ,
1094+ }
1095+ body := mustMarshalJSON (t , payload )
1096+ if int64 (len (body )) >= limit {
1097+ t .Fatalf ("test setup: body %d bytes is not under limit %d" , len (body ), limit )
1098+ }
1099+
1100+ req := authedCallbackRequest (t , body )
1101+ w := httptest .NewRecorder ()
1102+ router .ServeHTTP (w , req )
1103+
1104+ if w .Code != http .StatusOK {
1105+ t .Fatalf ("expected 200, got %d: %s" , w .Code , w .Body .String ())
1106+ }
1107+
1108+ ms .mu .Lock ()
1109+ stored := len (ms .stumps )
1110+ ms .mu .Unlock ()
1111+ if stored != 1 {
1112+ t .Errorf ("expected 1 stump stored on under-limit body, got %d" , stored )
1113+ }
1114+ }
1115+
1116+ // TestHandleCallback_BodySizeLimit_OverLimitReturns413 verifies the F-019
1117+ // fix: an oversize callback POST is rejected with 413 Payload Too Large
1118+ // (not 400) before any decoding allocates the full body. Locks down the
1119+ // other half of the fix.
1120+ func TestHandleCallback_BodySizeLimit_OverLimitReturns413 (t * testing.T ) {
1121+ const limit = 64 * 1024 // 64 KiB
1122+ ms := & mockStore {}
1123+ _ , router := setupServerWithCallbackLimit (& kafka.RecordingBroker {}, ms , limit )
1124+
1125+ // Construct a body whose raw byte length exceeds the cap. We embed it in
1126+ // a CallbackSeenOnNetwork payload (oversize TxIDs list) so the structure
1127+ // is still valid JSON — what we're testing is the size check, not parse
1128+ // behavior on garbage input.
1129+ huge := strings .Repeat ("a" , int (limit )+ 1024 )
1130+ body := mustMarshalJSON (t , models.CallbackMessage {
1131+ Type : models .CallbackSeenOnNetwork ,
1132+ TxIDs : []string {huge },
1133+ })
1134+ if int64 (len (body )) <= limit {
1135+ t .Fatalf ("test setup: body %d bytes is not over limit %d" , len (body ), limit )
1136+ }
1137+
1138+ req := authedCallbackRequest (t , body )
1139+ w := httptest .NewRecorder ()
1140+ router .ServeHTTP (w , req )
1141+
1142+ if w .Code != http .StatusRequestEntityTooLarge {
1143+ t .Fatalf ("expected 413, got %d: %s" , w .Code , w .Body .String ())
1144+ }
1145+
1146+ // Oversize bodies must short-circuit before the dispatch path runs.
1147+ if len (ms .updateStatusCalls ) != 0 {
1148+ t .Errorf ("oversize callback must not write to the store, got %d UpdateStatus calls" , len (ms .updateStatusCalls ))
1149+ }
1150+ }
1151+
1152+ // TestHandleCallback_BodySizeLimit_HugeStumpRejected covers the original
1153+ // F-019 scenario directly: a STUMP callback whose embedded payload is much
1154+ // larger than the configured cap returns 413 instead of allocating the
1155+ // entire body into memory.
1156+ func TestHandleCallback_BodySizeLimit_HugeStumpRejected (t * testing.T ) {
1157+ const limit = 32 * 1024 // 32 KiB
1158+ ms := & mockStore {}
1159+ _ , router := setupServerWithCallbackLimit (& kafka.RecordingBroker {}, ms , limit )
1160+
1161+ // 4 MiB STUMP — vastly larger than the cap, mimicking the unbounded
1162+ // payload that motivated F-019.
1163+ stumpBytes := make ([]byte , 4 * 1024 * 1024 )
1164+ for i := range stumpBytes {
1165+ stumpBytes [i ] = byte (i & 0xFF )
1166+ }
1167+ body := mustMarshalJSON (t , models.CallbackMessage {
1168+ Type : models .CallbackStump ,
1169+ BlockHash : "0000000000000000000000000000000000000000000000000000000000000002" ,
1170+ Stump : stumpBytes ,
1171+ })
1172+
1173+ req := authedCallbackRequest (t , body )
1174+ w := httptest .NewRecorder ()
1175+ router .ServeHTTP (w , req )
1176+
1177+ if w .Code != http .StatusRequestEntityTooLarge {
1178+ t .Fatalf ("expected 413, got %d: %s" , w .Code , w .Body .String ())
1179+ }
1180+ ms .mu .Lock ()
1181+ stored := len (ms .stumps )
1182+ ms .mu .Unlock ()
1183+ if stored != 0 {
1184+ t .Errorf ("oversize STUMP must not be persisted, got %d stored" , stored )
1185+ }
1186+ }
1187+
1188+ // TestHandleCallback_BodySizeLimit_DefaultsToConfigConstant confirms that
1189+ // leaving Callback.MaxBodyBytes unset (or zero/negative) falls back to the
1190+ // package default, so a misconfiguration can never disable the cap entirely.
1191+ func TestHandleCallback_BodySizeLimit_DefaultsToConfigConstant (t * testing.T ) {
1192+ srv := & Server {cfg : & config.Config {}}
1193+ if got := srv .callbackMaxBodyBytes (); got != config .DefaultCallbackMaxBodyBytes {
1194+ t .Errorf ("zero value: got %d, want %d" , got , config .DefaultCallbackMaxBodyBytes )
1195+ }
1196+
1197+ srv .cfg .Callback .MaxBodyBytes = - 1
1198+ if got := srv .callbackMaxBodyBytes (); got != config .DefaultCallbackMaxBodyBytes {
1199+ t .Errorf ("negative value: got %d, want %d" , got , config .DefaultCallbackMaxBodyBytes )
1200+ }
1201+
1202+ srv .cfg .Callback .MaxBodyBytes = 1 << 10
1203+ if got := srv .callbackMaxBodyBytes (); got != 1 << 10 {
1204+ t .Errorf ("explicit value: got %d, want %d" , got , 1 << 10 )
1205+ }
1206+ }
0 commit comments