fix(policy): guard watched-folder migration and declare its schema owner

This commit is contained in:
Anthony Stirling
2026-08-30 10:47:08 +01:00
parent 3a80641a9d
commit 44808739ef
5 changed files with 91 additions and 4 deletions
@@ -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 =
@@ -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. */
@@ -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;
}
}
@@ -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<Policy> 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")));
@@ -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",