diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/legacy/WatchedFolderPipelineImport.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/legacy/WatchedFolderPipelineImport.java index 8cbf15f56d..256321ec90 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/legacy/WatchedFolderPipelineImport.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/legacy/WatchedFolderPipelineImport.java @@ -68,6 +68,7 @@ public class WatchedFolderPipelineImport implements MigratedWatchedFolders { private final PolicyTriggerManager policyTriggerManager; private final TeamRepository teamRepository; private final RuntimePathConfig runtimePathConfig; + private final FolderAccessGuard folderAccessGuard; @Override public boolean isMigrated(Path directory) { @@ -82,6 +83,18 @@ public class WatchedFolderPipelineImport implements MigratedWatchedFolders { @Order(3) @EventListener(ApplicationReadyEvent.class) public void importWatchedFolders() { + try { + convertWatchedFolders(); + } catch (Exception e) { + // A ready-event listener that throws aborts SpringApplication.run; a cosmetic + // migration must never be able to stop the app booting. + log.error( + "Watched-folder conversion failed; leaving the folders to the legacy scanner", + e); + } + } + + private void convertWatchedFolders() { Long teamId = defaultTeamId(); int imported = 0; for (String root : runtimePathConfig.getPipelineWatchedFoldersPaths()) { @@ -123,6 +136,21 @@ public class WatchedFolderPipelineImport implements MigratedWatchedFolders { return false; } + // The sink checks this on every delivery; converting a folder it would refuse just swaps a + // working legacy automation for a pipeline that fails every run. + Path outputDirectory = converter.resolveOutputDirectory(config, directory); + try { + folderAccessGuard.requirePermitted(outputDirectory); + } catch (IllegalArgumentException notPermitted) { + log.warn( + "Watched folder {} writes to {}, which folder access does not permit ({});" + + " leaving it to the legacy scanner", + directory, + outputDirectory, + notPermitted.getMessage()); + return false; + } + Source input = inputSourceFor(directory, teamId); Source destination = destinationSourceFor(config, directory, teamId); Policy policy = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 22713b95b5..b98d2205d5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -106,7 +106,8 @@ public record Policy( /** A copy under a different owner (e.g. moving a seed off a placeholder name). */ public Policy withOwner(String newOwner) { - return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId); + return new Policy( + id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId, origin); } /** A copy referencing the given saved output destinations. */ diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/legacy/WatchedFolderPipelineImportTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/legacy/WatchedFolderPipelineImportTest.java index 65fa9d2f18..31193db6bd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/legacy/WatchedFolderPipelineImportTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/legacy/WatchedFolderPipelineImportTest.java @@ -1,9 +1,12 @@ package stirling.software.proprietary.policy.legacy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -16,8 +19,11 @@ import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.springframework.dao.DataAccessResourceFailureException; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.proprietary.policy.config.FolderAccessDeniedException; +import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.source.InProcessSourceStore; @@ -41,6 +47,7 @@ class WatchedFolderPipelineImportTest { private final RuntimePathConfig runtimePathConfig = mock(RuntimePathConfig.class); private final TeamRepository teamRepository = mock(TeamRepository.class); private final PolicyTriggerManager policyTriggerManager = mock(PolicyTriggerManager.class); + private final FolderAccessGuard folderAccessGuard = mock(FolderAccessGuard.class); private WatchedFolderPipelineImport importer; private String finishedFolders; @@ -62,7 +69,8 @@ class WatchedFolderPipelineImportTest { importedPipelines, policyTriggerManager, teamRepository, - runtimePathConfig); + runtimePathConfig, + folderAccessGuard); } @Test @@ -176,8 +184,41 @@ class WatchedFolderPipelineImportTest { assertFalse(importer.isMigrated(watchedRoot.resolve("empty"))); } + @Test + void leavesAFolderAloneWhenItsOutputDirectoryIsNotPermitted() throws IOException { + Path folder = watchedFolderWritingTo("invoices", "/data/outbox"); + doThrow(new FolderAccessDeniedException("denied")) + .when(folderAccessGuard) + .requirePermitted(any()); + + importer.importWatchedFolders(); + + // The sink would refuse every delivery, so the legacy scanner has to keep the folder. + assertTrue(policyStore.all().isEmpty()); + assertTrue(sourceStore.all().isEmpty()); + assertFalse(importer.isMigrated(folder)); + assertTrue( + Files.exists(folder.resolve("config.json")), + "the legacy scanner still needs its config"); + } + + @Test + void neverFailsBootWhenTheTeamLookupBlowsUp() throws IOException { + watchedFolder("invoices"); + when(teamRepository.findByName(org.mockito.ArgumentMatchers.anyString())) + .thenThrow(new DataAccessResourceFailureException("db down")); + + // A ready-event listener that throws aborts SpringApplication.run. + assertDoesNotThrow(() -> importer.importWatchedFolders()); + assertTrue(policyStore.all().isEmpty()); + } + /** A legacy watched folder: a pipeline JSON beside the files it processes. */ private Path watchedFolder(String relative) throws IOException { + return watchedFolderWritingTo(relative, "{outputFolder}"); + } + + private Path watchedFolderWritingTo(String relative, String outputDir) throws IOException { Path folder = watchedRoot.resolve(relative); Files.createDirectories(folder); Files.writeString( @@ -191,10 +232,11 @@ class WatchedFolderPipelineImportTest { "parameters": {"optimizeLevel": 3, "fileInput": "automated"} } ], - "outputDir": "{outputFolder}", + "outputDir": "%s", "outputFileName": "compressed_{filename}" } - """); + """ + .formatted(outputDir)); return folder; } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java index f6e82bd011..2977f0a9e2 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -95,6 +95,21 @@ class DefaultClassificationPolicySeederTest { assertThat(saved.getValue().id()).isEqualTo("p1"); } + @Test + void keepsTheProvenanceMarkerWhenClearingThePlaceholderOwner() { + when(policyStore.findByTeam(7L)) + .thenReturn( + List.of( + classificationPolicy(7L, "system") + .withOrigin(Policy.ORIGIN_MIGRATED))); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Policy.class); + verify(policyStore).save(saved.capture()); + assertThat(saved.getValue().origin()).isEqualTo(Policy.ORIGIN_MIGRATED); + } + @Test void leavesADeliberatelyChosenOwnerAlone() { when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "alice"))); diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java index 7853c56856..91739e7783 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java @@ -97,6 +97,7 @@ public final class SaasSchemaOwnership { "policies", "policy_assets", "policy_completed_migrations", + "policy_imported_pipelines", "policy_processed_files", "policy_source_doc_counts", "policy_source_doc_totals",