package logx import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) func TestNormalizeConfigRejectsBadLevel(t *testing.T) { _, err := NormalizeConfig(Config{Level: "verbose"}) if !errors.Is(err, ErrUnsupportedLevel) { t.Fatalf("NormalizeConfig error=%v, want ErrUnsupportedLevel", err) } } func TestSafePayloadRedactsSensitiveFieldsAndOmitsLargePayload(t *testing.T) { payload := safePayload([]byte(`{"username":"tom","password":"pw","nested":{"user_sig":"sig"}}`), 2048, false) values, ok := payload.(map[string]any) if !ok { t.Fatalf("payload type=%T, want map", payload) } if values["password"] != redactedValue { t.Fatalf("password was not redacted: %+v", values) } nested := values["nested"].(map[string]any) if nested["user_sig"] != redactedValue { t.Fatalf("nested user_sig was not redacted: %+v", nested) } large := safePayload([]byte(`{"token":"secret-token","data":"abcdef"}`), 8, true) largeMap, ok := large.(map[string]any) if !ok || largeMap["truncated"] != true { t.Fatalf("large payload=%+v, want truncated marker", large) } if encoded := toJSON(t, large); bytes.Contains(encoded, []byte("secret-token")) { t.Fatalf("truncated payload leaked sensitive value: %s", encoded) } } func TestHTTPMiddlewareUsesConfiguredBodyLoggingAndWarnLevel(t *testing.T) { var output bytes.Buffer if err := Init(Config{Service: "gateway-service", Env: "test", Level: "debug", Format: "json", IncludeRequestBody: true, IncludeResponseBody: true, MaxPayloadBytes: 2048, Output: &output}); err != nil { t.Fatalf("Init failed: %v", err) } handler := HTTPMiddleware("gateway-service", func(context.Context) string { return "req-http-1" }, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusBadRequest) _, _ = writer.Write([]byte(`{"code":"INVALID_ARGUMENT","token":"server-token"}`)) })) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login?debug=1", bytes.NewBufferString(`{"username":"tom","password":"client-password"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1") handler.ServeHTTP(httptest.NewRecorder(), req) entry := decodeOne(t, output.Bytes()) if entry["level"] != "WARN" || entry["msg"] != "http_access" || entry["service"] != "gateway-service" { t.Fatalf("unexpected http log entry: %+v", entry) } if entry["request_id"] != "req-http-1" || entry["path"] != "/api/v1/auth/login" || entry["client_ip"] != "203.0.113.10" { t.Fatalf("missing access fields: %+v", entry) } request := entry["request"].(map[string]any) if request["password"] != redactedValue { t.Fatalf("request body was not redacted: %+v", request) } response := entry["response"].(map[string]any) if response["token"] != redactedValue { t.Fatalf("response body was not redacted: %+v", response) } } func TestHTTPMiddlewareOmitsBodiesByDefault(t *testing.T) { var output bytes.Buffer if err := Init(Config{Service: "gateway-service", Env: "test", Format: "json", Output: &output}); err != nil { t.Fatalf("Init failed: %v", err) } handler := HTTPMiddleware("gateway-service", func(context.Context) string { return "req-http-2" }, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { _, _ = writer.Write([]byte(`{"ok":true}`)) })) handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/v1/ping", bytes.NewBufferString(`{"password":"client-password"}`))) entry := decodeOne(t, output.Bytes()) if _, exists := entry["request"]; exists { t.Fatalf("request body should be omitted by default: %+v", entry) } if _, exists := entry["response"]; exists { t.Fatalf("response body should be omitted by default: %+v", entry) } } func TestUnaryServerInterceptorLevelsAndRequestMeta(t *testing.T) { var output bytes.Buffer if err := Init(Config{Service: "user-service", Env: "test", Format: "json", IncludeRequestBody: true, Output: &output}); err != nil { t.Fatalf("Init failed: %v", err) } interceptor := UnaryServerInterceptor("user-service") request := &userv1.GetUserRequest{Meta: &userv1.RequestMeta{RequestId: "req-grpc-1", AppCode: "lalu"}, UserId: 1001} _, err := interceptor(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/hyapp.user.v1.UserService/GetUser"}, func(ctx context.Context, req any) (any, error) { return nil, status.Error(codes.InvalidArgument, "bad user id") }) if err == nil { t.Fatal("interceptor should return handler error") } entry := decodeOne(t, output.Bytes()) if entry["level"] != "WARN" || entry["msg"] != "grpc_access" || entry["request_id"] != "req-grpc-1" || entry["app_code"] != "lalu" { t.Fatalf("unexpected grpc log entry: %+v", entry) } if entry["grpc_code"] != "InvalidArgument" { t.Fatalf("grpc_code=%v, want InvalidArgument", entry["grpc_code"]) } if entry["reason"] != "INVALID_ARGUMENT" || entry["error_message"] != "bad user id" { t.Fatalf("grpc error fields=%+v, want stable reason and service message", entry) } } func TestUnaryServerInterceptorExtractsTopLevelTraceFieldsWithoutBody(t *testing.T) { var output bytes.Buffer if err := Init(Config{Service: "wallet-service", Env: "test", Format: "json", Output: &output}); err != nil { t.Fatalf("Init failed: %v", err) } interceptor := UnaryServerInterceptor("wallet-service") request := &walletv1.AdminCreditAssetRequest{ CommandId: "cmd-credit-1", TargetUserId: 42001, AssetType: "COIN_SELLER_COIN", Amount: 80000, AppCode: "lalu", } _, err := interceptor(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/hyapp.wallet.v1.WalletService/AdminCreditAsset"}, func(ctx context.Context, req any) (any, error) { return &walletv1.AdminCreditAssetResponse{}, nil }) if err != nil { t.Fatalf("interceptor returned error: %v", err) } entry := decodeOne(t, output.Bytes()) if entry["app_code"] != "lalu" || entry["command_id"] != "cmd-credit-1" || entry["asset_type"] != "COIN_SELLER_COIN" { t.Fatalf("missing top-level trace fields: %+v", entry) } if _, exists := entry["request"]; exists { t.Fatalf("request body should remain omitted: %+v", entry) } } func decodeOne(t *testing.T, raw []byte) map[string]any { t.Helper() lines := bytes.Split(bytes.TrimSpace(raw), []byte("\n")) if len(lines) != 1 || len(lines[0]) == 0 { t.Fatalf("log lines=%q, want exactly one", raw) } var entry map[string]any if err := json.Unmarshal(lines[0], &entry); err != nil { t.Fatalf("decode log JSON failed: %v raw=%s", err, lines[0]) } return entry } func toJSON(t *testing.T, value any) []byte { t.Helper() encoded, err := json.Marshal(value) if err != nil { t.Fatalf("json marshal failed: %v", err) } return encoded }