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());
+ }
+}