package rocketmqx import ( "bytes" "context" "encoding/json" "errors" "strings" "testing" "hyapp/pkg/logx" "github.com/apache/rocketmq-client-go/v2/consumer" "github.com/apache/rocketmq-client-go/v2/primitive" ) type fakePushConsumer struct { callback func(context.Context, ...*primitive.MessageExt) (consumer.ConsumeResult, error) } func (f *fakePushConsumer) Start() error { return nil } func (f *fakePushConsumer) Shutdown() error { return nil } func (f *fakePushConsumer) Subscribe(_ string, _ consumer.MessageSelector, callback func(context.Context, ...*primitive.MessageExt) (consumer.ConsumeResult, error)) error { f.callback = callback return nil } func (f *fakePushConsumer) Unsubscribe(string) error { return nil } func (f *fakePushConsumer) Suspend() {} func (f *fakePushConsumer) Resume() {} func (f *fakePushConsumer) GetOffsetDiffMap() map[string]int64 { return nil } func TestSubscribeHandlerErrorReturnsRetryLaterWithoutSDKError(t *testing.T) { var output bytes.Buffer if err := logx.Init(logx.Config{Service: "rocketmqx-test", Env: "test", Format: "json", Output: &output}); err != nil { t.Fatalf("init test logger: %v", err) } client := &fakePushConsumer{} consumerWrapper := &Consumer{client: client, group: "group-safe-log"} handlerErr := errors.New("projection unavailable") if err := consumerWrapper.Subscribe("topic-safe-log", "tag-safe-log", func(context.Context, ConsumedMessage) error { return handlerErr }); err != nil { t.Fatalf("subscribe: %v", err) } if client.callback == nil { t.Fatal("subscribe callback was not captured") } extended := &primitive.MessageExt{MsgId: "msg-safe-log", ReconsumeTimes: 3} // 直接初始化嵌入的 Message,避免复制其 RWMutex;Queue 仍故意保持 nil,确保日志路径 // 一旦把 MessageExt 交给 String 格式化就会在测试中立即暴露。 extended.Topic = "topic-safe-log" extended.Body = []byte("sensitive-body-must-not-be-logged") extended.WithTag("tag-safe-log") extended.WithKeys([]string{"event-safe-log"}) extended.WithProperty("private-property", "secret-value-must-not-be-logged") // Queue 故意保持 nil:MessageExt.String 会解引用 Queue 并 panic。callback 能完成并写日志, // 因而同时锁定包装层只记录复制后的安全标量,绝不把 MessageExt 交给格式化器。 result, callbackErr := client.callback(context.Background(), extended) if result != consumer.ConsumeRetryLater { t.Fatalf("consume result = %v, want ConsumeRetryLater", result) } if callbackErr != nil { t.Fatalf("callback error = %v, want nil so SDK does not format the shared message batch", callbackErr) } rawLog := strings.TrimSpace(output.String()) var record map[string]any if err := json.Unmarshal([]byte(rawLog), &record); err != nil { t.Fatalf("decode safe consume log %q: %v", rawLog, err) } for key, want := range map[string]string{ "msg": "rocketmq_consume_handler_failed", "consumer_group": "group-safe-log", "topic": "topic-safe-log", "tag": "tag-safe-log", "keys": "event-safe-log", "msg_id": "msg-safe-log", "error": handlerErr.Error(), } { if got := record[key]; got != want { t.Errorf("log field %s = %#v, want %q", key, got, want) } } if got := record["reconsume_times"]; got != float64(3) { t.Errorf("log field reconsume_times = %#v, want 3", got) } for _, forbidden := range []string{"sensitive-body-must-not-be-logged", "private-property", "secret-value-must-not-be-logged"} { if strings.Contains(rawLog, forbidden) { t.Errorf("safe consume log leaked forbidden message data %q: %s", forbidden, rawLog) } } }