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

jsinovassin pushed a commit to branch UNOMI-973-review-followups
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 174a179df39cabd3f9060bb6ba9899e48c1e1173
Author: jsinovassin <[email protected]>
AuthorDate: Tue Sep 1 18:48:16 2026 +0200

    UNOMI-973: Cover what the follow-ups changed and the existing tests did not
    
    Each of the five behavioural commits carried a test that fails without it.
    These are the parts of what they changed that nothing yet observed.
    
    Resolving a path one component at a time, beyond the case that motivated it:
    a path-bearing option leaving through a link and a parent segment is refused
    the same way the endpoint directory is; a link that stays inside the base
    directory still builds its route; a base directory that is itself a link
    still matches the directory it points at, so naming one directory by two
    paths is one directory; and more parent segments than a path has components
    is a refusal rather than an exception.
    
    Confining localWorkDirectory, beyond the import route: the export direction
    refuses it too, the option is matched on its name rather than on how it is
    spelt, and the REST layer answers 400 for a remote source that stages its
    downloads out of bounds -- three code paths, one rule.
    
    Appending moveFailed: the endpoint the route is built on is now asserted, 
not
    just the fact that a route exists. A source that carries no query opens one,
    a source that carries a query gets another parameter.
    
    Answering 503 while the router is starting: the export endpoint does it too,
    and so does a partly published configuration -- the scheme allow-list there,
    the permitted directories not yet.
    
    71 tests over the three modules.
---
 .../core/route/FileEndpointContainmentTest.java    | 98 ++++++++++++++++++++++
 .../rest/ConfigurationEndpointValidationTest.java  | 36 ++++++++
 2 files changed, 134 insertions(+)

diff --git 
a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
 
b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
index 85e22b368..b47f85e47 100644
--- 
a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
+++ 
b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
@@ -42,6 +42,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
@@ -165,6 +166,23 @@ public class FileEndpointContainmentTest {
                 "a parent segment applies to the target of the link that 
precedes it, not to the link's own parent");
     }
 
+    @Test
+    public void moveFailedIsAppendedAsTheFirstOptionOfASourceThatCarriesNone() 
throws Exception {
+        addImportRoutes(recurrentImport("first-option", 
fileUri(permittedImportDir, "")));
+
+        assertEquals("the option must open the query, or it becomes part of 
the directory name",
+                fileUri(permittedImportDir, "?moveFailed=.error"), 
builtEndpointUri("first-option"));
+    }
+
+    @Test
+    public void moveFailedIsAppendedToASourceThatAlreadyCarriesAQuery() throws 
Exception {
+        addImportRoutes(recurrentImport("next-option", 
fileUri(permittedImportDir, "?fileName=profiles.csv")));
+
+        assertEquals("an existing query takes the option as another parameter",
+                fileUri(permittedImportDir, 
"?fileName=profiles.csv&moveFailed=.error"),
+                builtEndpointUri("next-option"));
+    }
+
     @Test
     public void importRouteIsBuiltWhenSourceCarriesNoOption() throws Exception 
{
         // the router appends moveFailed itself; a source with no query has no 
separator to append to,
@@ -174,6 +192,64 @@ public class FileEndpointContainmentTest {
         assertRouteBuilt("no-option", "a source may name a directory and 
nothing else");
     }
 
+    @Test
+    public void 
importRouteIsBuiltWhenSourceIsASymlinkPointingInsidePermittedBaseDir() throws 
Exception {
+        File target = new File(permittedImportDir, "incoming");
+        assertTrue("could not prepare the test fixture", target.mkdir());
+        File link = new File(permittedImportDir, "shortcut");
+        try {
+            Files.createSymbolicLink(link.toPath(), target.toPath());
+        } catch (IOException | UnsupportedOperationException e) {
+            Assume.assumeNoException("this file system does not support 
symbolic links", e);
+        }
+
+        addImportRoutes(recurrentImport("symlink-inside", fileUri(link, 
"?fileName=profiles.csv")));
+
+        assertRouteBuilt("symlink-inside", "resolving a link must not refuse 
one that stays inside the base directory");
+    }
+
+    @Test
+    public void importRouteIsBuiltWhenThePermittedBaseDirIsItselfASymlink() 
throws Exception {
+        File link = new File(tmp.getRoot(), "permitted-import-link");
+        try {
+            Files.createSymbolicLink(link.toPath(), 
permittedImportDir.toPath());
+        } catch (IOException | UnsupportedOperationException e) {
+            Assume.assumeNoException("this file system does not support 
symbolic links", e);
+        }
+
+        // the deployment names the link, the configuration names the 
directory it points at
+        addImportRoutesInto(link, recurrentImport("base-dir-link", 
fileUri(permittedImportDir, "?fileName=profiles.csv")));
+
+        assertRouteBuilt("base-dir-link",
+                "both sides are resolved, so naming the same directory by two 
paths is the same directory");
+    }
+
+    @Test
+    public void 
importRouteIsRefusedWhenAnOptionLeavesThroughASymlinkAndAParentSegment() throws 
Exception {
+        File outsideChild = new File(arbitraryDir, "child");
+        assertTrue("could not prepare the test fixture", outsideChild.mkdir());
+        File link = new File(permittedImportDir, "option-link");
+        try {
+            Files.createSymbolicLink(link.toPath(), outsideChild.toPath());
+        } catch (IOException | UnsupportedOperationException e) {
+            Assume.assumeNoException("this file system does not support 
symbolic links", e);
+        }
+
+        addImportRoutes(recurrentImport("option-symlink-parent",
+                fileUri(permittedImportDir, 
"?fileName=profiles.csv&move=option-link/..")));
+
+        assertRouteRefused("option-symlink-parent",
+                "a path-bearing option is resolved the same way the endpoint 
directory is");
+    }
+
+    @Test
+    public void importRouteIsRefusedWhenSourceClimbsAboveTheRoot() throws 
Exception {
+        addImportRoutes(recurrentImport("above-root", 
"file:///../../../../../../../../etc?fileName=profiles.csv"));
+
+        assertRouteRefused("above-root",
+                "more parent segments than the path has components is a 
refusal, not an exception");
+    }
+
     @Test
     public void importRouteIsRefusedWhenSourceIsOutsidePermittedBaseDir() 
throws Exception {
         addImportRoutes(recurrentImport("arbitrary-dir", fileUri(arbitraryDir, 
"?fileName=profiles.csv")));
@@ -494,6 +570,22 @@ public class FileEndpointContainmentTest {
         assertRouteBuilt("remote", "ftp is an allowed scheme and carries no 
local path");
     }
 
+    @Test
+    public void 
exportRouteIsRefusedWhenDestinationStagesItsDownloadsOutsidePermittedBaseDir() 
throws Exception {
+        addExportRoutes(recurrentExport("remote-staging", 
"ftp://ftp.example.com/profiles";
+                + "?fileName=profiles.csv&localWorkDirectory=" + 
arbitraryDir.getAbsolutePath()));
+
+        assertRouteRefused("remote-staging", "the option names a local 
directory in either direction");
+    }
+
+    @Test
+    public void importRouteIsRefusedWhenTheStagingOptionIsSpeltInAnotherCase() 
throws Exception {
+        addImportRoutes(recurrentImport("staging-case", 
"ftp://ftp.example.com/profiles";
+                + "?fileName=profiles.csv&LOCALWORKDIRECTORY=" + 
arbitraryDir.getAbsolutePath()));
+
+        assertRouteRefused("staging-case", "the option is matched on its name, 
not on how it is spelt");
+    }
+
     @Test
     public void 
malformedDestinationIsSkippedWithoutPreventingTheOtherRoutesFromBeingBuilt() 
throws Exception {
         addExportRoutes(
@@ -513,6 +605,12 @@ public class FileEndpointContainmentTest {
                 camelContext.getRouteDefinition(routeId));
     }
 
+    /** The endpoint URI the route was actually built on, which is not the one 
that was configured. */
+    private String builtEndpointUri(String routeId) {
+        assertRouteBuilt(routeId, "there is no endpoint to look at otherwise");
+        return 
camelContext.getRouteDefinition(routeId).getInputs().get(0).getUri();
+    }
+
     private void assertRouteRefused(String routeId, String why) {
         assertNull("a route was built for configuration '" + routeId + "', 
although " + why,
                 camelContext.getRouteDefinition(routeId));
diff --git 
a/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/ConfigurationEndpointValidationTest.java
 
b/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/ConfigurationEndpointValidationTest.java
index b5df90d11..a5ea1de0a 100644
--- 
a/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/ConfigurationEndpointValidationTest.java
+++ 
b/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/ConfigurationEndpointValidationTest.java
@@ -113,6 +113,42 @@ public class ConfigurationEndpointValidationTest {
         assertFalse("nothing is stored while the endpoint cannot be judged", 
importConfigurations.contains("in-bounds"));
     }
 
+    @Test
+    public void 
savingARecurrentImportWhoseRemoteSourceStagesDownloadsOutsideThePermittedBaseDirsIsRefused()
 {
+        // ftp is an allowed scheme, but localWorkDirectory names a local 
directory all the same
+        ImportConfiguration configuration = 
recurrentImport("ftp://ftp.example.com/profiles";
+                + "?fileName=profiles.csv&localWorkDirectory=" + 
arbitraryDir.getAbsolutePath());
+
+        assertRefused(() -> importEndpoint.saveConfiguration(configuration));
+        assertFalse("a refused configuration must not be stored", 
importConfigurations.contains("in-bounds"));
+    }
+
+    @Test
+    public void 
savingARecurrentImportBeforeThePermittedDirectoriesArePublishedIsNotRefused() 
throws Exception {
+        // the scheme allow-list is there, the directories are not: still 
nothing to judge against
+        InMemoryConfigSharingService partial = new 
InMemoryConfigSharingService();
+        partial.setProperty(RouterConstants.CONFIG_ALLOWED_ENDPOINTS, 
"file,ftp,sftp,ftps");
+        ImportConfigurationServiceEndPoint starting = new 
ImportConfigurationServiceEndPoint();
+        starting.setImportConfigurationService(importConfigurations);
+        starting.setConfigSharingService(partial);
+
+        ImportConfiguration configuration = 
recurrentImport(fileUri(permittedImportDir, "?fileName=profiles.csv"));
+
+        assertUnavailable(() -> starting.saveConfiguration(configuration));
+    }
+
+    @Test
+    public void 
savingARecurrentExportBeforeTheRouterPublishedItsSettingsIsNotRefused() throws 
Exception {
+        ExportConfigurationServiceEndPoint starting = new 
ExportConfigurationServiceEndPoint();
+        starting.setExportConfigurationService(exportConfigurations);
+        starting.setConfigSharingService(new InMemoryConfigSharingService());
+
+        ExportConfiguration configuration = 
recurrentExport(fileUri(permittedExportDir, "?fileName=profiles.csv"));
+
+        assertUnavailable(() -> starting.saveConfiguration(configuration));
+        assertFalse("nothing is stored while the endpoint cannot be judged", 
exportConfigurations.contains("in-bounds"));
+    }
+
     @Test
     public void 
savingARecurrentImportWhoseSourceIsOutsideThePermittedBaseDirsIsRefused() {
         ImportConfiguration configuration = 
recurrentImport(fileUri(arbitraryDir, "?fileName=profiles.csv"));

Reply via email to