This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 5e2abaac1b Fixes #8815 : Lint: resolve every field offered for 
connection, trans… (#8825)
5e2abaac1b is described below

commit 5e2abaac1bbd68cb1e4dfaa1524ec381e1cbf667
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Oct 10 10:34:46 2026 +0200

    Fixes #8815 : Lint: resolve every field offered for connection, trans… 
(#8825)
    
    * Fixes #8815 : Lint: resolve every field offered for connection, transform 
and action rules
    
    * Issue #8815 : Lint: address review on resolved fields: hide SSH 
passphrase, read database plugin settings, skip broken hops, resolve is-getters
---
 .../modules/ROOT/pages/linting/lint-rules.adoc     |  14 +
 .../org/apache/hop/lint/CustomRuleExecutor.java    | 168 +++++++--
 .../java/org/apache/hop/lint/RuleTargetFields.java |  27 +-
 .../org/apache/hop/lint/OfferedFieldsTest.java     | 420 +++++++++++++++++++++
 4 files changed, 585 insertions(+), 44 deletions(-)

diff --git a/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
index 3c7412f9b9..ca65acdb39 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/linting/lint-rules.adoc
@@ -150,8 +150,22 @@ Scope such a rule with `appliesTo`, naming the metadata 
key — the folder name
 
 |`isBlockingTransform`
 |The transform blocks until all rows have arrived
+
+|`errorHandling`
+|A transform sends its error rows to another transform; an action has a hop 
that is followed on failure
+
+|`targetTransforms`, `targetActions`
+|The names of the transforms or actions that the enabled hops lead to
+
+|`databaseType`
+|The type of a database connection, as its plugin id, for example `POSTGRESQL`
+
+|`attributes`
+|The attributes of a database connection, including its extra options. A 
finding lists the attribute names, not their values
 |===
 
+A database connection rule that names a field the connection does not have is 
reported as an error against the rule.
+
 Nested properties are reached with a dotted path:
 
 [source,yaml]
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
index a2bf58c678..94386a913f 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
@@ -20,10 +20,12 @@ import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.TreeSet;
 import java.util.regex.Pattern;
 import org.apache.hop.core.database.DatabaseMeta;
 import org.apache.hop.core.logging.ILogChannel;
@@ -203,8 +205,9 @@ public class CustomRuleExecutor {
         // A rule that named specific plugin types asserted the field exists 
on them, so a
         // missing field is a mistake in the rule and is reported rather than 
passed over.
         // Unscoped rules stay opportunistic: they run across every transform, 
most of which
-        // legitimately do not have the field.
-        if (!rule.getAppliesTo().isEmpty()) {
+        // legitimately do not have the field. Every connection has the same 
fields, so there a
+        // missing one is always a mistake in the rule.
+        if (!rule.getAppliesTo().isEmpty() || hopObject instanceof 
DatabaseMeta) {
           results.add(
               createResult(
                   rule,
@@ -214,7 +217,10 @@ public class CustomRuleExecutor {
                       + rule.getTargetField()
                       + "', which does not exist on "
                       + describe(hopObject)
-                      + ". Check the field name against this transform or 
action.",
+                      + ". Check the field name against "
+                      + (hopObject instanceof DatabaseMeta
+                          ? "the fields offered for database connections."
+                          : "this transform or action."),
                   fileName,
                   hopObject,
                   "ERROR"));
@@ -423,6 +429,8 @@ public class CustomRuleExecutor {
             return dbMeta.getAttributes() != null
                 ? dbMeta.getAttributes().getOrDefault("description", "")
                 : "";
+          case "databaseType":
+            return dbMeta.getPluginId();
           case "hostname":
             return dbMeta.getHostname();
           case "port":
@@ -433,9 +441,17 @@ public class CustomRuleExecutor {
             return dbMeta.getUsername();
           case "password":
             return dbMeta.getPassword();
+          case "servername":
+            return dbMeta.getServername();
+          case "dataTablespace":
+            return dbMeta.getDataTablespace();
+          case "indexTablespace":
+            return dbMeta.getIndexTablespace();
+          case "attributes":
+            // The extra options are attributes too, stored as 
EXTRA_OPTION_<type>.<option>.
+            return dbMeta.getAttributes();
           default:
-            log.logDetailed("Unknown database field: " + fieldName);
-            return null;
+            return connectionField(dbMeta, fieldName);
         }
       } else if (hopObject instanceof PipelineHopMeta) {
         PipelineHopMeta hop = (PipelineHopMeta) hopObject;
@@ -498,6 +514,19 @@ public class CustomRuleExecutor {
     return null;
   }
 
+  /**
+   * Any other connection property, such as manualUrl or Oracle's 
walletPassword. The settings live
+   * on the database plugin's own meta, which is also what the connection's 
file stores, so that is
+   * looked at first; then the getters DatabaseMeta adds on top.
+   */
+  private static Object connectionField(DatabaseMeta dbMeta, String fieldName) 
{
+    Object value =
+        dbMeta.getIDatabase() != null
+            ? extractFieldFromObject(dbMeta.getIDatabase(), fieldName)
+            : FIELD_NOT_FOUND;
+    return value != FIELD_NOT_FOUND ? value : extractFieldFromObject(dbMeta, 
fieldName);
+  }
+
   /**
    * The fields every transform has, whatever its plugin, with their types. 
The linter works them
    * out itself rather than reading them from the plugin, so {@code hop lint 
--list-fields} lists
@@ -509,6 +538,9 @@ public class CustomRuleExecutor {
           "description", "String",
           "pluginId", "String",
           "copies", "int",
+          "distributes", "boolean",
+          "errorHandling", "boolean",
+          "targetTransforms", "List",
           "isDummy", "boolean",
           "hasDefaultName", "boolean",
           "isOrphaned", "boolean",
@@ -522,6 +554,9 @@ public class CustomRuleExecutor {
           "name", "String",
           "description", "String",
           "pluginId", "String",
+          "errorHandling", "boolean",
+          "targetActions", "List",
+          "isStart", "boolean",
           "hasDefaultName", "boolean",
           "isOrphaned", "boolean");
 
@@ -548,6 +583,25 @@ public class CustomRuleExecutor {
         case "copies":
           return transformMeta.getCopies(
               
org.apache.hop.core.variables.Variables.getADefaultVariableSpace());
+        case "distributes":
+          return transformMeta.isDistributes();
+        case "errorHandling":
+          return transformMeta.getTransform() != null && 
transformMeta.isDoingErrorHandling();
+        case "targetTransforms":
+          // Not PipelineMeta.findNextTransforms: a hop whose transform name 
matches nothing loads
+          // with a null end, and that made it throw for every transform in 
the pipeline.
+          if (!(SUBJECT.get() instanceof PipelineMeta pipeline)) {
+            return null;
+          }
+          List<String> targets = new ArrayList<>();
+          for (PipelineHopMeta hop : pipeline.getPipelineHops()) {
+            if (hop.isEnabled()
+                && hop.getFromTransform() == transformMeta
+                && hop.getToTransform() != null) {
+              targets.add(hop.getToTransform().getName());
+            }
+          }
+          return targets;
         case "isDummy":
           return 
"Dummy".equalsIgnoreCase(transformMeta.getTransformPluginId());
         case "hasDefaultName":
@@ -586,6 +640,20 @@ public class CustomRuleExecutor {
           return actionMeta.getDescription();
         case "pluginId":
           return actionMeta.getAction().getPluginId();
+        case "errorHandling":
+          // An action handles its errors with a hop followed on failure.
+          return SUBJECT.get() instanceof WorkflowMeta workflow
+              ? outgoingHops(actionMeta, workflow).stream()
+                  .anyMatch(hop -> !hop.isUnconditional() && 
!hop.isEvaluation())
+              : null;
+        case "targetActions":
+          return SUBJECT.get() instanceof WorkflowMeta workflow
+              ? outgoingHops(actionMeta, workflow).stream()
+                  .map(hop -> hop.getToAction().getName())
+                  .toList()
+              : null;
+        case "isStart":
+          return actionMeta.isStart();
         case "hasDefaultName":
           // Every workflow starts at Start; there is no better name for it.
           return !actionMeta.isStart()
@@ -611,6 +679,22 @@ public class CustomRuleExecutor {
     }
   }
 
+  /**
+   * The enabled hops leaving this action that lead somewhere. A hop whose 
action name matches
+   * nothing loads with a null end, and is skipped rather than failing the 
field for the hops that
+   * are fine. The ends are compared by identity: ActionMeta.equals throws for 
an action without its
+   * inner action.
+   */
+  private static List<WorkflowHopMeta> outgoingHops(ActionMeta actionMeta, 
WorkflowMeta workflow) {
+    List<WorkflowHopMeta> hops = new ArrayList<>();
+    for (WorkflowHopMeta hop : workflow.getWorkflowHops()) {
+      if (hop.isEnabled() && hop.getFromAction() == actionMeta && 
hop.getToAction() != null) {
+        hops.add(hop);
+      }
+    }
+    return hops;
+  }
+
   /**
    * Whether a transform or action still has the name Hop Gui gave it: the 
plugin's name, such as
    * "Table input", followed by a number when that name was taken, as in 
"Dummy (do nothing) 2".
@@ -791,8 +875,8 @@ public class CustomRuleExecutor {
       }
 
       // Then a getter, which covers metas that expose a value they do not 
store directly.
-      String getterName = "get" + fieldName.substring(0, 1).toUpperCase() + 
fieldName.substring(1);
-      for (String candidate : new String[] {getterName, fieldName}) {
+      String capitalised = fieldName.substring(0, 1).toUpperCase() + 
fieldName.substring(1);
+      for (String candidate : new String[] {"get" + capitalised, "is" + 
capitalised, fieldName}) {
         try {
           java.lang.reflect.Method getter = clazz.getMethod(candidate);
           return getter.invoke(obj);
@@ -867,10 +951,21 @@ public class CustomRuleExecutor {
     if (secret) {
       return "hidden";
     }
-    String text = value.toString();
+    String text = shown(value).toString();
     return text.length() > 60 ? text.substring(0, 57) + "..." : text;
   }
 
+  /**
+   * A value as a finding may show it. Of a map only the keys: a connection's 
attributes hold its
+   * extra options, and an option such as a token or a key would otherwise 
land in the report.
+   */
+  private static Object shown(Object value) {
+    if (value instanceof Map<?, ?> map) {
+      return new 
TreeSet<>(map.keySet().stream().map(String::valueOf).toList());
+    }
+    return value;
+  }
+
   /** Human-readable label for a transform or action, used in configuration 
error messages. */
   private static String describe(Object hopObject) {
     if (hopObject instanceof TransformMeta transformMeta) {
@@ -885,6 +980,9 @@ public class CustomRuleExecutor {
           actionMeta.getAction() != null ? 
actionMeta.getAction().getPluginId() : "unknown";
       return "action '" + actionMeta.getName() + "' (" + pluginId + ")";
     }
+    if (hopObject instanceof DatabaseMeta databaseMeta) {
+      return "connection '" + databaseMeta.getName() + "'";
+    }
     return hopObject != null ? hopObject.getClass().getSimpleName() : "null";
   }
 
@@ -1087,6 +1185,9 @@ public class CustomRuleExecutor {
       holder = transformMeta.getTransform();
     } else if (hopObject instanceof ActionMeta actionMeta) {
       holder = actionMeta.getAction();
+    } else if (hopObject instanceof DatabaseMeta databaseMeta) {
+      // A connection's settings, sshTunnelPassphrase among them, live on its 
database plugin.
+      holder = databaseMeta.getIDatabase();
     }
     String name = fieldName;
     int dot = fieldName.lastIndexOf('.');
@@ -1115,6 +1216,7 @@ public class CustomRuleExecutor {
           "apiKey",
           "apikey",
           "secretAccessKey",
+          "passphrase",
           "token",
           "accessToken",
           "authToken");
@@ -1265,34 +1367,20 @@ public class CustomRuleExecutor {
         return !(fieldValue instanceof Boolean) || ((Boolean) fieldValue);
 
       case NOT_EMPTY_COLLECTION:
-        if (fieldValue instanceof List) {
-          return ((List<?>) fieldValue).isEmpty();
-        }
-        return false;
+        return sizeOf(fieldValue) == 0;
 
       case MAX_COLLECTION_SIZE:
-        if (fieldValue instanceof List) {
-          int size = ((List<?>) fieldValue).size();
-          try {
-            int maxSize = Integer.parseInt(conditionValue);
-            return size > maxSize;
-          } catch (NumberFormatException e) {
-            return false;
-          }
-        }
-        return false;
-
       case MIN_COLLECTION_SIZE:
-        if (fieldValue instanceof List) {
-          int size = ((List<?>) fieldValue).size();
-          try {
-            int minSize = Integer.parseInt(conditionValue);
-            return size < minSize;
-          } catch (NumberFormatException e) {
-            return false;
-          }
+        int size = sizeOf(fieldValue);
+        if (size < 0) {
+          return false;
+        }
+        try {
+          int limit = Integer.parseInt(conditionValue);
+          return condition == RuleCondition.MAX_COLLECTION_SIZE ? size > limit 
: size < limit;
+        } catch (NumberFormatException e) {
+          return false;
         }
-        return false;
 
       default:
         // Returning false here would report the rule as passing, which is the 
worst
@@ -1309,6 +1397,20 @@ public class CustomRuleExecutor {
     }
   }
 
+  /**
+   * The number of entries in a list, set or map, or -1 for anything else. A 
connection's {@code
+   * attributes} are a map, which the collection conditions did not count.
+   */
+  private static int sizeOf(Object value) {
+    if (value instanceof Collection<?> collection) {
+      return collection.size();
+    }
+    if (value instanceof Map<?, ?> map) {
+      return map.size();
+    }
+    return -1;
+  }
+
   /** Helper method for numeric condition evaluation */
   private static boolean evaluateNumericCondition(
       Object fieldValue, String conditionValue, NumericComparator comparator) {
@@ -1346,7 +1448,7 @@ public class CustomRuleExecutor {
     message.append(headline);
 
     if (fieldValue != null) {
-      message.append(" (current value: ").append(fieldValue).append(")");
+      message.append(" (current value: 
").append(shown(fieldValue)).append(")");
     }
 
     if (!Utils.isEmpty(rule.getConditionValue())) {
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleTargetFields.java 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleTargetFields.java
index c0faa829d8..bf367db4e6 100644
--- a/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleTargetFields.java
+++ b/plugins/misc/lint/src/main/java/org/apache/hop/lint/RuleTargetFields.java
@@ -89,11 +89,7 @@ public class RuleTargetFields {
             "servername",
             "dataTablespace",
             "indexTablespace",
-            "attributes",
-            "connectionPoolingParameters",
-            "partitioningInformation",
-            "maxConnectionPoolSize",
-            "initialPoolSize"));
+            "attributes"));
 
     // Transform fields
     TARGET_FIELDS.put(
@@ -104,11 +100,8 @@ public class RuleTargetFields {
             "pluginId",
             "copies",
             "distributes",
-            "location",
-            "selected",
             "errorHandling",
             "targetTransforms",
-            "isStart",
             "isDummy",
             "isOrphaned",
             "isBlockingTransform",
@@ -126,8 +119,6 @@ public class RuleTargetFields {
             "name",
             "description",
             "pluginId",
-            "location",
-            "selected",
             "errorHandling",
             "targetActions",
             "isStart",
@@ -182,6 +173,16 @@ public class RuleTargetFields {
         return "Credential field (checks all credential-related fields in 
transforms/actions)";
       case "username":
         return "Database connection username";
+      case "databaseType":
+        return "Database type, the plugin id such as POSTGRESQL";
+      case "attributes":
+        return "Connection attributes, extra options included";
+      case "errorHandling":
+        return "Whether errors go to an error hop (transform) or a failure hop 
(action)";
+      case "targetTransforms":
+        return "Names of the transforms this transform sends rows to";
+      case "targetActions":
+        return "Names of the actions this action leads to";
       case "apiKey":
         return "API key field";
       case "token":
@@ -206,6 +207,7 @@ public class RuleTargetFields {
     } else if (isFlagName(field)
         || field.equals("enabled")
         || field.equals("distributes")
+        || field.equals("errorHandling")
         || field.equals("unconditional")
         || field.equals("evaluation")) {
       // Boolean fields
@@ -214,7 +216,10 @@ public class RuleTargetFields {
         || field.equals("actions")
         || field.equals("hops")
         || field.equals("parameters")
-        || field.equals("variables")) {
+        || field.equals("variables")
+        || field.equals("attributes")
+        || field.equals("targetTransforms")
+        || field.equals("targetActions")) {
       // Collection fields
       return Arrays.asList(
           RuleCondition.NOT_EMPTY_COLLECTION,
diff --git 
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/OfferedFieldsTest.java 
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/OfferedFieldsTest.java
new file mode 100644
index 0000000000..12bfab8ac7
--- /dev/null
+++ b/plugins/misc/lint/src/test/java/org/apache/hop/lint/OfferedFieldsTest.java
@@ -0,0 +1,420 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Set;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.database.NoneDatabaseMeta;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.TransformErrorMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.workflow.WorkflowHopMeta;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.apache.hop.workflow.actions.dummy.ActionDummy;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Every field the rule editor and {@code hop lint --list-fields} offer for a 
connection, transform
+ * or action resolves.
+ *
+ * <p>{@code databaseType}, {@code attributes}, {@code errorHandling} and 
others were offered but
+ * never read, so a rule written against one of them never fired and said 
nothing about why.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/8815";>#8815</a>
+ */
+public class OfferedFieldsTest {
+
+  /**
+   * Offered for transforms and actions, but read from the plugin's own field 
of that name, which
+   * most plugins do not have.
+   */
+  private static final Set<String> PLUGIN_OWN_FIELDS = Set.of("apiKey", 
"token");
+
+  @AfterEach
+  public void clearSubject() {
+    CustomRuleExecutor.setSubject(null);
+  }
+
+  /** Can send its errors to another transform. */
+  public static class ErrorHandlingMeta extends BaseTransformMeta {
+    @Override
+    public boolean supportsErrorHandling() {
+      return true;
+    }
+  }
+
+  private static CustomLintRule rule(RuleTarget target, String field, 
RuleCondition condition) {
+    CustomLintRule rule = new CustomLintRule();
+    rule.setId("TEST-001");
+    rule.setEnabled(true);
+    rule.setSeverity("WARNING");
+    rule.setTarget(target);
+    rule.setTargetField(field);
+    rule.setCondition(condition);
+    rule.setName("Test rule");
+    return rule;
+  }
+
+  private static DatabaseMeta connection() {
+    DatabaseMeta databaseMeta = new DatabaseMeta();
+    databaseMeta.setName("CRM");
+    return databaseMeta;
+  }
+
+  private static List<LintResult> lint(CustomLintRule rule, Object hopObject) {
+    return CustomRuleExecutor.executeRule(rule, hopObject, "/tmp/test");
+  }
+
+  private static void assertResolves(RuleTarget target, Object hopObject, 
String pluginId) {
+    for (String field : RuleTargetFields.getFieldsForTarget(target)) {
+      if (PLUGIN_OWN_FIELDS.contains(field)) {
+        continue;
+      }
+      CustomLintRule rule = rule(target, field, RuleCondition.NOT_NULL);
+      if (pluginId != null) {
+        // Scoped, so that a field which does not resolve is reported rather 
than skipped.
+        rule.setAppliesTo(List.of(pluginId));
+      }
+      for (LintResult result : lint(rule, hopObject)) {
+        assertFalse(
+            result.getMessage().contains("does not exist"),
+            target + "." + field + ": " + result.getMessage());
+      }
+    }
+  }
+
+  // ------------------------------------------------------------------ every 
offered field
+
+  @Test
+  public void everyConnectionFieldResolves() {
+    assertResolves(RuleTarget.DATABASE_CONNECTION, connection(), null);
+  }
+
+  @Test
+  public void everyTransformFieldResolves() {
+    PipelineMeta pipeline = new PipelineMeta();
+    TransformMeta transformMeta = new TransformMeta("Fake", "read", new 
ErrorHandlingMeta());
+    pipeline.addTransform(transformMeta);
+    CustomRuleExecutor.setSubject(pipeline);
+
+    assertResolves(RuleTarget.TRANSFORM, transformMeta, "Fake");
+  }
+
+  @Test
+  public void everyActionFieldResolves() {
+    WorkflowMeta workflow = new WorkflowMeta();
+    ActionMeta actionMeta = new ActionMeta(new ActionDummy("check"));
+    workflow.addAction(actionMeta);
+    CustomRuleExecutor.setSubject(workflow);
+
+    assertResolves(RuleTarget.ACTION, actionMeta, 
actionMeta.getAction().getPluginId());
+  }
+
+  // ------------------------------------------------------------------ 
connections
+
+  /** Every connection has a type, so IS_EMPTY reports each one. It used to 
report none. */
+  @Test
+  public void databaseTypeIsThePluginId() {
+    DatabaseMeta databaseMeta = connection();
+
+    List<LintResult> results =
+        lint(
+            rule(RuleTarget.DATABASE_CONNECTION, "databaseType", 
RuleCondition.IS_EMPTY),
+            databaseMeta);
+
+    assertEquals(1, results.size());
+    assertTrue(
+        results.get(0).getMessage().contains(databaseMeta.getPluginId()),
+        results.get(0).getMessage());
+  }
+
+  @Test
+  public void attributesIncludeTheExtraOptionsButNotTheirValues() {
+    DatabaseMeta databaseMeta = connection();
+    databaseMeta.getAttributes().clear();
+    databaseMeta.addExtraOption("POSTGRESQL", "sslpassword", "hunter2");
+
+    CustomLintRule rule =
+        rule(RuleTarget.DATABASE_CONNECTION, "attributes", 
RuleCondition.MAX_COLLECTION_SIZE);
+    rule.setConditionValue("0");
+    List<LintResult> results = lint(rule, databaseMeta);
+
+    assertEquals(1, results.size());
+    String message = results.get(0).getMessage();
+    assertTrue(message.contains("EXTRA_OPTION_POSTGRESQL.sslpassword"), 
message);
+    assertFalse(message.contains("hunter2"), message);
+  }
+
+  @Test
+  public void noAttributesIsAnEmptyCollection() {
+    DatabaseMeta databaseMeta = connection();
+    databaseMeta.getAttributes().clear();
+
+    assertEquals(
+        1,
+        lint(
+                rule(
+                    RuleTarget.DATABASE_CONNECTION,
+                    "attributes",
+                    RuleCondition.NOT_EMPTY_COLLECTION),
+                databaseMeta)
+            .size());
+  }
+
+  /** A connection property the linter has no case for is read by getter. */
+  @Test
+  public void otherConnectionPropertiesResolve() {
+    DatabaseMeta databaseMeta = connection();
+    databaseMeta.setManualUrl("jdbc:postgresql://db/crm");
+
+    CustomLintRule rule =
+        rule(RuleTarget.DATABASE_CONNECTION, "manualUrl", 
RuleCondition.NOT_CONTAINS);
+    rule.setConditionValue("postgresql");
+
+    assertEquals(1, lint(rule, databaseMeta).size());
+  }
+
+  /** Every connection has the same fields, so a name that matches none of 
them is a typo. */
+  @Test
+  public void anUnknownConnectionFieldIsReported() {
+    List<LintResult> results =
+        lint(
+            rule(RuleTarget.DATABASE_CONNECTION, "hostnmae", 
RuleCondition.NOT_EMPTY),
+            connection());
+
+    assertEquals(1, results.size());
+    assertEquals("ERROR", results.get(0).getSeverity());
+    assertTrue(
+        results.get(0).getMessage().contains("connection 'CRM'"), 
results.get(0).getMessage());
+  }
+
+  /** A database type with settings of its own, as Oracle has walletPassword. 
*/
+  public static class PluginDatabaseMeta extends NoneDatabaseMeta {
+    @HopMetadataProperty private String walletLocation;
+
+    @HopMetadataProperty(password = true)
+    private String walletPin;
+  }
+
+  /**
+   * The SSH passphrase is stored as a password on the database plugin, and 
its name ends in none of
+   * the secret suffixes, so it was printed as the current value.
+   */
+  @Test
+  public void theSshTunnelPassphraseStaysOutOfTheFinding() {
+    DatabaseMeta databaseMeta = connection();
+    databaseMeta.setSshTunnelPassphrase("hunter2");
+
+    List<LintResult> results =
+        lint(
+            rule(RuleTarget.DATABASE_CONNECTION, "sshTunnelPassphrase", 
RuleCondition.NO_HARDCODED),
+            databaseMeta);
+
+    assertEquals(1, results.size());
+    assertEquals("WARNING", results.get(0).getSeverity(), 
results.get(0).getMessage());
+    assertFalse(results.get(0).getMessage().contains("hunter2"), 
results.get(0).getMessage());
+  }
+
+  /** A setting only the database type has is read from it, password marker 
included. */
+  @Test
+  public void aDatabaseTypesOwnSettingsResolve() {
+    DatabaseMeta databaseMeta = connection();
+    PluginDatabaseMeta plugin = new PluginDatabaseMeta();
+    plugin.walletPin = "1234";
+    databaseMeta.setIDatabase(plugin);
+
+    List<LintResult> pin =
+        lint(
+            rule(RuleTarget.DATABASE_CONNECTION, "walletPin", 
RuleCondition.NO_HARDCODED),
+            databaseMeta);
+    assertEquals(1, pin.size());
+    assertEquals("WARNING", pin.get(0).getSeverity(), pin.get(0).getMessage());
+    assertFalse(pin.get(0).getMessage().contains("1234"), 
pin.get(0).getMessage());
+
+    List<LintResult> location =
+        lint(
+            rule(RuleTarget.DATABASE_CONNECTION, "walletLocation", 
RuleCondition.NOT_EMPTY),
+            databaseMeta);
+    assertEquals(1, location.size());
+    assertEquals("WARNING", location.get(0).getSeverity(), 
location.get(0).getMessage());
+  }
+
+  /** Connection flags have an is getter, such as isSshTunnelEnabled. */
+  @Test
+  public void aBooleanConnectionPropertyResolves() {
+    DatabaseMeta databaseMeta = connection();
+    databaseMeta.setSshTunnelEnabled(false);
+
+    List<LintResult> results =
+        lint(
+            rule(RuleTarget.DATABASE_CONNECTION, "sshTunnelEnabled", 
RuleCondition.MUST_BE_TRUE),
+            databaseMeta);
+
+    assertEquals(1, results.size());
+    assertEquals("WARNING", results.get(0).getSeverity(), 
results.get(0).getMessage());
+  }
+
+  // ------------------------------------------------------------------ 
transforms
+
+  private static PipelineMeta pipelineWithErrorHop(boolean 
errorHandlingEnabled) {
+    PipelineMeta pipeline = new PipelineMeta();
+    TransformMeta read = new TransformMeta("Fake", "read", new 
ErrorHandlingMeta());
+    TransformMeta write = new TransformMeta("Dummy", "write", null);
+    TransformMeta errors = new TransformMeta("Dummy", "errors", null);
+    pipeline.addTransform(read);
+    pipeline.addTransform(write);
+    pipeline.addTransform(errors);
+    pipeline.addPipelineHop(new PipelineHopMeta(read, write));
+    pipeline.addPipelineHop(new PipelineHopMeta(read, errors));
+    TransformErrorMeta errorMeta = new TransformErrorMeta(read, errors);
+    errorMeta.setEnabled(errorHandlingEnabled);
+    read.setTransformErrorMeta(errorMeta);
+    CustomRuleExecutor.setSubject(pipeline);
+    return pipeline;
+  }
+
+  @Test
+  public void transformErrorHandling() {
+    CustomLintRule rule = rule(RuleTarget.TRANSFORM, "errorHandling", 
RuleCondition.MUST_BE_TRUE);
+
+    assertTrue(lint(rule, 
pipelineWithErrorHop(true).findTransform("read")).isEmpty());
+    assertEquals(1, lint(rule, 
pipelineWithErrorHop(false).findTransform("read")).size());
+    // A transform that cannot handle errors does not, rather than failing the 
rule.
+    assertEquals(1, lint(rule, 
pipelineWithErrorHop(true).findTransform("write")).size());
+  }
+
+  @Test
+  public void targetTransformsAreTheNextTransforms() {
+    PipelineMeta pipeline = pipelineWithErrorHop(true);
+    CustomLintRule rule =
+        rule(RuleTarget.TRANSFORM, "targetTransforms", 
RuleCondition.MAX_COLLECTION_SIZE);
+    rule.setConditionValue("1");
+
+    List<LintResult> results = lint(rule, pipeline.findTransform("read"));
+    assertEquals(1, results.size());
+    assertTrue(
+        results.get(0).getMessage().contains("[write, errors]"), 
results.get(0).getMessage());
+    assertTrue(lint(rule, pipeline.findTransform("write")).isEmpty());
+  }
+
+  @Test
+  public void distributes() {
+    TransformMeta transformMeta = new TransformMeta("Dummy", "copy", null);
+    transformMeta.setDistributes(false);
+
+    assertEquals(
+        1,
+        lint(rule(RuleTarget.TRANSFORM, "distributes", 
RuleCondition.MUST_BE_TRUE), transformMeta)
+            .size());
+  }
+
+  /**
+   * A hop whose transform name matches nothing loads with a null end. It is 
skipped, and the other
+   * hops still count, for this transform and for the others in the pipeline.
+   */
+  @Test
+  public void aBrokenHopDoesNotBlankTargetTransforms() {
+    PipelineMeta pipeline = pipelineWithErrorHop(true);
+    TransformMeta read = pipeline.findTransform("read");
+    pipeline.addPipelineHop(new PipelineHopMeta(read, null));
+    pipeline.addPipelineHop(new PipelineHopMeta(null, 
pipeline.findTransform("write")));
+    CustomLintRule rule =
+        rule(RuleTarget.TRANSFORM, "targetTransforms", 
RuleCondition.MAX_COLLECTION_SIZE);
+    rule.setConditionValue("0");
+
+    List<LintResult> results = lint(rule, read);
+    assertEquals(1, results.size());
+    assertTrue(
+        results.get(0).getMessage().contains("[write, errors]"), 
results.get(0).getMessage());
+  }
+
+  // ------------------------------------------------------------------ actions
+
+  private static WorkflowMeta workflow(boolean failureHop) {
+    WorkflowMeta workflow = new WorkflowMeta();
+    ActionMeta check = new ActionMeta(new ActionDummy("check"));
+    ActionMeta load = new ActionMeta(new ActionDummy("load"));
+    ActionMeta alert = new ActionMeta(new ActionDummy("alert"));
+    workflow.addAction(check);
+    workflow.addAction(load);
+    workflow.addAction(alert);
+    workflow.addWorkflowHop(new WorkflowHopMeta(check, load));
+    if (failureHop) {
+      WorkflowHopMeta onFailure = new WorkflowHopMeta(check, alert);
+      onFailure.setEvaluation(false);
+      workflow.addWorkflowHop(onFailure);
+    }
+    CustomRuleExecutor.setSubject(workflow);
+    return workflow;
+  }
+
+  /** An action handles its errors with a hop followed on failure. */
+  @Test
+  public void actionErrorHandling() {
+    CustomLintRule rule = rule(RuleTarget.ACTION, "errorHandling", 
RuleCondition.MUST_BE_TRUE);
+
+    assertTrue(lint(rule, workflow(true).findAction("check")).isEmpty());
+    assertEquals(1, lint(rule, workflow(false).findAction("check")).size());
+  }
+
+  @Test
+  public void targetActionsAreTheNextActions() {
+    CustomLintRule rule =
+        rule(RuleTarget.ACTION, "targetActions", 
RuleCondition.MAX_COLLECTION_SIZE);
+    rule.setConditionValue("1");
+
+    List<LintResult> results = lint(rule, workflow(true).findAction("check"));
+    assertEquals(1, results.size());
+    assertTrue(results.get(0).getMessage().contains("[load, alert]"), 
results.get(0).getMessage());
+    assertTrue(lint(rule, workflow(false).findAction("check")).isEmpty());
+  }
+
+  /**
+   * A hop to an action name that matches nothing, and one from an action 
without its inner action,
+   * are skipped. ActionMeta.equals threw on the second.
+   */
+  @Test
+  public void brokenHopsDoNotBlankTheActionFields() {
+    WorkflowMeta workflow = workflow(true);
+    ActionMeta check = workflow.findAction("check");
+    workflow.addWorkflowHop(new WorkflowHopMeta(check, null));
+    WorkflowHopMeta fromEmpty = new WorkflowHopMeta(null, 
workflow.findAction("load"));
+    fromEmpty.setFromAction(new ActionMeta());
+    workflow.addWorkflowHop(fromEmpty);
+
+    CustomLintRule targets =
+        rule(RuleTarget.ACTION, "targetActions", 
RuleCondition.MAX_COLLECTION_SIZE);
+    targets.setConditionValue("0");
+    List<LintResult> results = lint(targets, check);
+    assertEquals(1, results.size());
+    assertTrue(results.get(0).getMessage().contains("[load, alert]"), 
results.get(0).getMessage());
+
+    assertTrue(
+        lint(rule(RuleTarget.ACTION, "errorHandling", 
RuleCondition.MUST_BE_TRUE), check)
+            .isEmpty());
+  }
+}

Reply via email to