From b1210d3df021583b634c1eb326d7b029cc2cc4bb Mon Sep 17 00:00:00 2001 From: Ashwin Date: Fri, 23 Oct 2026 17:06:04 -0400 Subject: [PATCH 1/2] feat(automation): add version listing and rollback support --- .../AutomationDefinitionService.java | 2 +- .../definition/AutomationScopeCatalog.java | 4 +- .../ListAutomationVersionsReactor.java | 124 +++++++++ .../project/RollbackAutomationReactor.java | 158 +++++++++++ ...istAutomationVersionsReactorUnitTests.java | 261 ++++++++++++++++++ .../RollbackAutomationReactorUnitTests.java | 27 ++ 6 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java create mode 100644 src/prerna/reactor/automation/project/RollbackAutomationReactor.java create mode 100644 test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java create mode 100644 test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java diff --git a/src/prerna/reactor/automation/definition/AutomationDefinitionService.java b/src/prerna/reactor/automation/definition/AutomationDefinitionService.java index 051798daaca..4bf1a12f5f7 100644 --- a/src/prerna/reactor/automation/definition/AutomationDefinitionService.java +++ b/src/prerna/reactor/automation/definition/AutomationDefinitionService.java @@ -516,7 +516,7 @@ private static Path nodeSourcePath(Path folder, Map node) { return result; } - static String safeNodeFileName(Map node) { + public static String safeNodeFileName(Map node) { String nodeId = (String) node.get(AutomationConstants.NODE_FIELD_ID); if (nodeId == null || nodeId.isBlank()) { throw new IllegalArgumentException("Automation node id must be nonblank for source persistence."); diff --git a/src/prerna/reactor/automation/definition/AutomationScopeCatalog.java b/src/prerna/reactor/automation/definition/AutomationScopeCatalog.java index 3f84cc4ad0c..210cc1c4116 100644 --- a/src/prerna/reactor/automation/definition/AutomationScopeCatalog.java +++ b/src/prerna/reactor/automation/definition/AutomationScopeCatalog.java @@ -48,7 +48,7 @@ * semantics. At a branch merge, outputs produced on only one branch are marked * conditional and use a safe {@code scope.get(...)} insertion expression. */ -final class AutomationScopeCatalog { +public final class AutomationScopeCatalog { private AutomationScopeCatalog() { } @@ -59,7 +59,7 @@ private AutomationScopeCatalog() { * @param definition validated Automation graph * @return ordered variables available or conditionally available at each node */ - static Map>> variablesByNode( + public static Map>> variablesByNode( AutomationDefinitionValidator.ValidatedDefinition definition) { List> orderedNodes = AutomationRuntime.nodesForRun(definition); Map> predecessors = predecessors(definition); diff --git a/src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java b/src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java new file mode 100644 index 00000000000..99cd34533cf --- /dev/null +++ b/src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java @@ -0,0 +1,124 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed 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. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.automation.project; + +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import prerna.auth.User; +import prerna.project.api.IProject; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.execptions.SemossPixelException; +import prerna.sablecc2.om.nounmeta.NounMetadata; +import prerna.util.AssetUtility; +import prerna.util.Utility; +import prerna.util.git.GitRepoUtils; + +/** + * Returns the git commit history for an automation project's workflow definition. + * + *

Each entry contains the commit author, timestamp, short revision hash, and message + * recorded when {@code SaveAutomation} was called. + * + *

Pixel: {@code ListAutomationVersions(project=[""])} + */ +public class ListAutomationVersionsReactor extends AbstractReactor { + + private static final Logger classLogger = LogManager.getLogger(ListAutomationVersionsReactor.class); + + private static final String PROJECT_KEY = "project"; + private static final String LIMIT_KEY = "limit"; + private static final int DEFAULT_LIMIT = 25; + private static final int MAXIMUM_LIMIT = 100; + + public ListAutomationVersionsReactor() { + this.keysToGet = new String[] { PROJECT_KEY, LIMIT_KEY }; + this.keyRequired = new int[] { 1, 0 }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + if (user == null) { + NounMetadata noun = new NounMetadata( + "User must be signed into an account in order to list automation versions", + PixelDataType.CONST_STRING, PixelOperationType.ERROR, PixelOperationType.LOGGIN_REQUIRED_ERROR); + SemossPixelException error = new SemossPixelException(noun); + error.setContinueThreadOfExecution(false); + throw error; + } + + String projectIdOrAlias = this.keyValue.get(PROJECT_KEY); + // resolveRunScope enforces view-level ACL and rejects inline run scopes + String projectId = AutomationProjectService.resolveRunScope(user, projectIdOrAlias, false); + + IProject project = Utility.getProject(projectId); + if (project == null) { + throw new IllegalArgumentException("Project not found: " + projectId); + } + + int limit = getInt(LIMIT_KEY, DEFAULT_LIMIT); + if (limit <= 0) { + throw new IllegalArgumentException("limit must be a positive integer."); + } + limit = Math.min(limit, MAXIMUM_LIMIT); + + String versionFolder = AssetUtility.getProjectVersionFolder(project.getProjectName(), projectId); + // getCommits returns newest-first (git log order); subList enforces the bound + List> commits = GitRepoUtils.getCommits(versionFolder, null); + if (commits.size() > limit) { + commits = commits.subList(0, limit); + } + + classLogger.info("Returning {} version entries for automation project {}", commits.size(), projectId); + return new NounMetadata(commits, PixelDataType.CUSTOM_DATA_STRUCTURE, PixelOperationType.OPERATION); + } + + @Override + public String getReactorDescription() { + return "Returns the git commit history (author, time, revision hash, message) for each time " + + "SaveAutomation was called on the given automation project."; + } + + @Override + protected String getDescriptionForKey(String key) { + if (PROJECT_KEY.equals(key)) { + return "ID or alias of the automation project whose version history should be listed."; + } + if (LIMIT_KEY.equals(key)) { + return "Maximum number of versions to return. Defaults to " + DEFAULT_LIMIT + ", capped at " + MAXIMUM_LIMIT + "."; + } + return super.getDescriptionForKey(key); + } +} diff --git a/src/prerna/reactor/automation/project/RollbackAutomationReactor.java b/src/prerna/reactor/automation/project/RollbackAutomationReactor.java new file mode 100644 index 00000000000..65011bd1722 --- /dev/null +++ b/src/prerna/reactor/automation/project/RollbackAutomationReactor.java @@ -0,0 +1,158 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed 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. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.automation.project; + +import java.util.LinkedHashMap; +import java.util.Map; + +import prerna.auth.User; +import prerna.project.api.IProject; +import prerna.reactor.AbstractReactor; +import prerna.reactor.automation.AutomationConstants; +import prerna.reactor.automation.AutomationRuntime; +import prerna.reactor.automation.definition.AutomationDefinitionService; +import prerna.reactor.automation.definition.AutomationDefinitionValidator; +import prerna.reactor.automation.definition.AutomationScopeCatalog; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.execptions.SemossPixelException; +import prerna.sablecc2.om.nounmeta.NounMetadata; +import prerna.util.AssetUtility; +import prerna.util.Utility; +import prerna.util.git.GitRepoUtils; + +/** + * Restores an automation project's definition to the state captured at a + * specific git commit, + * then saves it as a new version. + * + *

+ * Pixel: + * {@code RollbackAutomation(project=[""], revision=[""])} + */ +public class RollbackAutomationReactor extends AbstractReactor { + + private static final String PROJECT_KEY = "project"; + private static final String REVISION_KEY = "revision"; + + private static final String WORKFLOW_GIT_PATH = "assets/" + + AutomationConstants.AUTOMATION_PYTHON_DEFINITION_FILE_NAME; + private static final String NODES_GIT_PREFIX = "assets/" + AutomationConstants.AUTOMATION_NODE_SOURCES_FOLDER_NAME + + "/"; + + public RollbackAutomationReactor() { + this.keysToGet = new String[] { PROJECT_KEY, REVISION_KEY }; + this.keyRequired = new int[] { 1, 1 }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + if (user == null) { + NounMetadata noun = new NounMetadata( + "User must be signed into an account in order to restore an automation version", + PixelDataType.CONST_STRING, PixelOperationType.ERROR, PixelOperationType.LOGGIN_REQUIRED_ERROR); + SemossPixelException error = new SemossPixelException(noun); + error.setContinueThreadOfExecution(false); + throw error; + } + + String projectIdOrAlias = this.keyValue.get(PROJECT_KEY); + String revision = this.keyValue.get(REVISION_KEY); + if (revision == null || revision.isBlank()) { + throw new IllegalArgumentException("revision must be provided."); + } + + // Edit-level ACL — restoring writes a new commit, so the user needs edit access + String projectId = AutomationProjectService.getEditableAutomationProject(user, projectIdOrAlias).getProjectId(); + IProject project = Utility.getProject(projectId); + if (project == null) { + throw new IllegalArgumentException("Project not found: " + projectId); + } + + String versionFolder = AssetUtility.getProjectVersionFolder(project.getProjectName(), projectId); + + String definitionJson = GitRepoUtils.getFile(revision, WORKFLOW_GIT_PATH, versionFolder); + if (definitionJson == null || definitionJson.isBlank()) { + throw new IllegalArgumentException("No automation definition found at revision: " + revision); + } + + // Reassemble node sources from the same commit + AutomationDefinitionValidator.ValidatedDefinition validated = AutomationDefinitionValidator + .parseAndValidateForAuthoring(definitionJson); + + Map nodeSources = new LinkedHashMap<>(); + for (Map node : AutomationRuntime.allNodes(validated)) { + if (!AutomationConstants.NODE_DEVELOPER_PYTHON.equals(node.get(AutomationConstants.NODE_FIELD_TYPE))) { + continue; + } + String nodeId = (String) node.get(AutomationConstants.NODE_FIELD_ID); + String gitPath = NODES_GIT_PREFIX + AutomationDefinitionService.safeNodeFileName(node) + ".py"; + String source = GitRepoUtils.getFile(revision, gitPath, versionFolder); + if (source != null) { + nodeSources.put(nodeId, source); + } + } + + // Save as a new commit — null expectedRevision skips optimistic concurrency + // check + AutomationDefinitionService.DefinitionFiles files = AutomationProjectService.saveDefinition( + projectId, definitionJson, nodeSources, null, user); + + // Return the same shape as RollbackAutomation so the frontend can reuse the + // same handler + Map result = new LinkedHashMap<>(); + result.put("saved", true); + result.put(AutomationConstants.DOC_NODE_SOURCES, files.nodeSources()); + result.put(AutomationConstants.RESULT_REVISION, + AutomationDefinitionService.calculateRevision(files.definition(), files.nodeSources())); + AutomationDefinitionValidator.ValidatedDefinition savedValidated = AutomationDefinitionValidator + .parseAndValidateForAuthoring(files.definition()); + result.put(AutomationConstants.DOC_GLOBALS, AutomationRuntime.declaredGlobals(savedValidated)); + result.put(AutomationConstants.RESULT_SCOPE_VARIABLES, AutomationScopeCatalog.variablesByNode(savedValidated)); + + return new NounMetadata(result, PixelDataType.MAP, PixelOperationType.OPERATION); + } + + @Override + public String getReactorDescription() { + return "Restores an automation project's definition to the state at the given git revision and saves it as a new version."; + } + + @Override + protected String getDescriptionForKey(String key) { + if (PROJECT_KEY.equals(key)) { + return "ID or alias of the automation project."; + } + if (REVISION_KEY.equals(key)) { + return "Git commit id (full or short hash) returned by ListAutomationVersions."; + } + return super.getDescriptionForKey(key); + } +} diff --git a/test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java b/test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java new file mode 100644 index 00000000000..4111a3a8e78 --- /dev/null +++ b/test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java @@ -0,0 +1,261 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed 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. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.automation.project; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import prerna.auth.User; +import prerna.om.Insight; +import prerna.project.api.IProject; +import prerna.reactor.automation.AutomationConstants; +import prerna.sablecc2.om.execptions.SemossPixelException; +import prerna.sablecc2.om.nounmeta.NounMetadata; +import prerna.util.AssetUtility; +import prerna.util.Utility; +import prerna.util.git.GitRepoUtils; + +class ListAutomationVersionsReactorUnitTests { + + private static final String PROJECT_KEY = "project"; + private static final String LIMIT_KEY = "limit"; + + private ListAutomationVersionsReactor reactor; + private Insight insight; + private User user; + + @BeforeEach + void setup() { + reactor = new ListAutomationVersionsReactor(); + insight = mock(Insight.class); + user = mock(User.class); + reactor.setInsight(insight); + when(insight.getUser()).thenReturn(user); + } + + @Test + void nullUserThrowsPixelException() { + when(insight.getUser()).thenReturn(null); + reactor.keyValue.put(PROJECT_KEY, "project-1"); + + assertThrows(SemossPixelException.class, reactor::execute); + } + + @Test + void missingProjectThrows() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertEquals("Required input(s) missing: project", error.getMessage()); + } + + @Test + void accessDeniedPropagatesWhenUserLacksViewPermission() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + + try (var projectService = mockStatic(AutomationProjectService.class)) { + projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) + .thenThrow(new IllegalArgumentException("Project does not exist or user does not have access.")); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertEquals("Project does not exist or user does not have access.", error.getMessage()); + } + } + + @Test + void inlineRunScopeIsRejected() { + String inlineScope = AutomationConstants.INLINE_RUN_SCOPE_PREFIX + "other-user"; + reactor.keyValue.put(PROJECT_KEY, inlineScope); + + try (var projectService = mockStatic(AutomationProjectService.class)) { + projectService.when(() -> AutomationProjectService.resolveRunScope(user, inlineScope, false)) + .thenThrow(new IllegalArgumentException("Run does not exist or user does not have access.")); + + assertThrows(IllegalArgumentException.class, reactor::execute); + } + } + + @Test + void zeroLimitThrows() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + reactor.getNounStore().makeNoun(LIMIT_KEY).addLiteral("0"); + + IProject project = mock(IProject.class); + when(project.getProjectName()).thenReturn("My Project"); + + try (var projectService = mockStatic(AutomationProjectService.class)) { + projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) + .thenReturn("project-1"); + + try (var utility = mockStatic(Utility.class)) { + utility.when(() -> Utility.getProject("project-1")).thenReturn(project); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertEquals("limit must be a positive integer.", error.getMessage()); + } + } + } + + @Test + void returnsCommitsFromGit() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + + IProject project = mock(IProject.class); + when(project.getProjectName()).thenReturn("My Project"); + + List> fakeCommits = List.of( + Map.of("user", "Alice", "date", "2024-01-02", "message", "second save", "id", "abc123"), + Map.of("user", "Bob", "date", "2024-01-01", "message", "first save", "id", "def456")); + + try (var projectService = mockStatic(AutomationProjectService.class); + var utility = mockStatic(Utility.class); + var assetUtility = mockStatic(AssetUtility.class); + var gitRepo = mockStatic(GitRepoUtils.class)) { + + projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) + .thenReturn("project-1"); + utility.when(() -> Utility.getProject("project-1")).thenReturn(project); + assetUtility.when(() -> AssetUtility.getProjectVersionFolder("My Project", "project-1")) + .thenReturn("/fake/version/folder"); + gitRepo.when(() -> GitRepoUtils.getCommits("/fake/version/folder", null)) + .thenReturn(new ArrayList<>(fakeCommits)); + + NounMetadata result = reactor.execute(); + + @SuppressWarnings("unchecked") + List> commits = (List>) result.getValue(); + assertEquals(2, commits.size()); + assertEquals("Alice", commits.get(0).get("user")); + assertEquals("Bob", commits.get(1).get("user")); + } + } + + @Test + void limitCapsBeyondMaximumTo100() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + reactor.getNounStore().makeNoun(LIMIT_KEY).addLiteral("999"); + + IProject project = mock(IProject.class); + when(project.getProjectName()).thenReturn("My Project"); + + // Build 150 fake commits to confirm only 100 are returned + List> fakeCommits = new ArrayList<>(); + for (int i = 0; i < 150; i++) { + fakeCommits.add(Map.of("user", "user-" + i, "date", "2024-01-01", "message", "save " + i, "id", "id" + i)); + } + + try (var projectService = mockStatic(AutomationProjectService.class); + var utility = mockStatic(Utility.class); + var assetUtility = mockStatic(AssetUtility.class); + var gitRepo = mockStatic(GitRepoUtils.class)) { + + projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) + .thenReturn("project-1"); + utility.when(() -> Utility.getProject("project-1")).thenReturn(project); + assetUtility.when(() -> AssetUtility.getProjectVersionFolder("My Project", "project-1")) + .thenReturn("/fake/version/folder"); + gitRepo.when(() -> GitRepoUtils.getCommits("/fake/version/folder", null)) + .thenReturn(fakeCommits); + + NounMetadata result = reactor.execute(); + + @SuppressWarnings("unchecked") + List> commits = (List>) result.getValue(); + assertEquals(100, commits.size()); + } + } + + @Test + void defaultLimitIs25WhenNotProvided() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + + IProject project = mock(IProject.class); + when(project.getProjectName()).thenReturn("My Project"); + + List> fakeCommits = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + fakeCommits.add(Map.of("user", "user-" + i, "date", "2024-01-01", "message", "save " + i, "id", "id" + i)); + } + + try (var projectService = mockStatic(AutomationProjectService.class); + var utility = mockStatic(Utility.class); + var assetUtility = mockStatic(AssetUtility.class); + var gitRepo = mockStatic(GitRepoUtils.class)) { + + projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) + .thenReturn("project-1"); + utility.when(() -> Utility.getProject("project-1")).thenReturn(project); + assetUtility.when(() -> AssetUtility.getProjectVersionFolder("My Project", "project-1")) + .thenReturn("/fake/version/folder"); + gitRepo.when(() -> GitRepoUtils.getCommits("/fake/version/folder", null)) + .thenReturn(fakeCommits); + + NounMetadata result = reactor.execute(); + + @SuppressWarnings("unchecked") + List> commits = (List>) result.getValue(); + assertEquals(25, commits.size()); + } + } + + @Test + void emptyGitHistoryReturnsEmptyList() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + + IProject project = mock(IProject.class); + when(project.getProjectName()).thenReturn("My Project"); + + try (var projectService = mockStatic(AutomationProjectService.class); + var utility = mockStatic(Utility.class); + var assetUtility = mockStatic(AssetUtility.class); + var gitRepo = mockStatic(GitRepoUtils.class)) { + + projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) + .thenReturn("project-1"); + utility.when(() -> Utility.getProject("project-1")).thenReturn(project); + assetUtility.when(() -> AssetUtility.getProjectVersionFolder("My Project", "project-1")) + .thenReturn("/fake/version/folder"); + gitRepo.when(() -> GitRepoUtils.getCommits("/fake/version/folder", null)) + .thenReturn(new ArrayList<>()); + + NounMetadata result = reactor.execute(); + + @SuppressWarnings("unchecked") + List> commits = (List>) result.getValue(); + assertEquals(0, commits.size()); + } + } +} diff --git a/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java b/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java new file mode 100644 index 00000000000..b7f6501a947 --- /dev/null +++ b/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java @@ -0,0 +1,27 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed 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. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ From 30ca549c8ad77385e95d40bdccc0ef08282aa112 Mon Sep 17 00:00:00 2001 From: "Patel, Parth" Date: Mon, 26 Oct 2026 10:51:55 -0400 Subject: [PATCH 2/2] feat: add automation version history --- .../AutomationDefinitionService.java | 7 + .../project/AutomationProjectService.java | 120 +++++++- .../project/AutomationVersionService.java | 264 ++++++++++++++++++ .../ListAutomationVersionsReactor.java | 41 +-- .../project/RollbackAutomationReactor.java | 90 +++--- .../AutomationProjectServiceUnitTests.java | 50 ++++ .../AutomationVersionServiceUnitTests.java | 213 ++++++++++++++ ...istAutomationVersionsReactorUnitTests.java | 164 ++--------- .../RollbackAutomationReactorUnitTests.java | 123 ++++++++ 9 files changed, 844 insertions(+), 228 deletions(-) create mode 100644 src/prerna/reactor/automation/project/AutomationVersionService.java create mode 100644 test/prerna/reactor/automation/project/AutomationVersionServiceUnitTests.java diff --git a/src/prerna/reactor/automation/definition/AutomationDefinitionService.java b/src/prerna/reactor/automation/definition/AutomationDefinitionService.java index 4bf1a12f5f7..94f86776970 100644 --- a/src/prerna/reactor/automation/definition/AutomationDefinitionService.java +++ b/src/prerna/reactor/automation/definition/AutomationDefinitionService.java @@ -516,6 +516,13 @@ private static Path nodeSourcePath(Path folder, Map node) { return result; } + /** + * Returns the stable file-system-safe base name used to persist a developer + * Python node's source. + * + * @param node validated Automation node + * @return stable source file base name without the {@code .py} suffix + */ public static String safeNodeFileName(Map node) { String nodeId = (String) node.get(AutomationConstants.NODE_FIELD_ID); if (nodeId == null || nodeId.isBlank()) { diff --git a/src/prerna/reactor/automation/project/AutomationProjectService.java b/src/prerna/reactor/automation/project/AutomationProjectService.java index 521ebdc4370..42919fee34d 100644 --- a/src/prerna/reactor/automation/project/AutomationProjectService.java +++ b/src/prerna/reactor/automation/project/AutomationProjectService.java @@ -27,18 +27,29 @@ *******************************************************************************/ package prerna.reactor.automation.project; +import java.io.File; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.eclipse.jgit.api.AddCommand; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.errors.NoWorkTreeException; import org.javatuples.Pair; +import prerna.auth.AccessToken; import prerna.auth.User; import prerna.auth.utils.SecurityEngineUtils; import prerna.auth.utils.SecurityProjectUtils; @@ -54,7 +65,6 @@ import prerna.util.AssetUtility; import prerna.util.ProjectSyncUtility; import prerna.util.Utility; -import prerna.util.git.GitRepoUtils; /** * Coordinates project-level access, persistence, and derived assets for @@ -73,6 +83,7 @@ public final class AutomationProjectService { private static final Logger classLogger = LogManager.getLogger(AutomationProjectService.class); + private static final String UPDATE_COMMIT_MESSAGE = "Update automation definition"; private AutomationProjectService() { } @@ -226,6 +237,28 @@ public static T withLockedDefinition(String projectId, */ public static AutomationDefinitionService.DefinitionFiles saveDefinition(String projectId, String definitionJson, Map nodeSources, String expectedRevision, User user) { + return saveDefinition(projectId, definitionJson, nodeSources, expectedRevision, user, UPDATE_COMMIT_MESSAGE); + } + + /** + * Saves an automation definition with an operation-specific project Git commit + * message. Package-owned operations such as version restore use this boundary so + * they retain the same locking, validation, and persistence behavior as an + * ordinary save. + * + * @param projectId project ID + * @param definitionJson canonical graph JSON + * @param nodeSources source by non-start node ID + * @param expectedRevision revision returned by the prior read + * @param user user performing the save + * @param commitMessage project Git commit message + * @return persisted graph and node sources + */ + static AutomationDefinitionService.DefinitionFiles saveDefinition(String projectId, String definitionJson, + Map nodeSources, String expectedRevision, User user, String commitMessage) { + if (commitMessage == null || commitMessage.isBlank()) { + throw new IllegalArgumentException("Automation version commit message must be provided."); + } ReentrantLock projectLock = ProjectSyncUtility.getProjectLock(projectId); projectLock.lock(); try { @@ -243,7 +276,7 @@ public static AutomationDefinitionService.DefinitionFiles saveDefinition(String AutomationDefinitionService.DefinitionFiles files = AutomationDefinitionService.save(projectId, definitionJson, nodeSources); AutomationMcpSync.sync(projectId, files.definition(), user); - syncDefinitionAssets(projectId, user, "Update automation definition"); + syncDefinitionAssets(projectId, user, commitMessage); // Return the same normalized aggregate a subsequent GetAutomation call will // read. This keeps the optimistic-concurrency token stable after Save. return AutomationDefinitionService.load(projectId); @@ -416,9 +449,8 @@ private static void syncDefinitionAssets(String projectId, User user, String com private static void syncDefinitionAssets(IProject project, String projectId, User user, String commitMessage) { if (project == null) { - classLogger.warn("Project {} not found in registry; skipping automation asset synchronization", projectId); - SecurityProjectUtils.updateProjectLastEditedDate(projectId); - return; + classLogger.error("Unable to version automation definition because project {} is not available", projectId); + throw new IllegalArgumentException("Automation project is unavailable for version history: " + projectId); } List files = new ArrayList<>(); @@ -432,10 +464,10 @@ private static void syncDefinitionAssets(IProject project, String projectId, Use String versionFolder = AssetUtility.getProjectVersionFolder(project.getProjectName(), projectId); try { - GitRepoUtils.addSpecificFiles(versionFolder, files); - GitRepoUtils.commitAddedFiles(versionFolder, commitMessage, user); - } catch (Exception e) { - classLogger.warn("Git commit failed for automation project {}", projectId, e); + commitDefinitionAssets(versionFolder, files, commitMessage, user); + } catch (RuntimeException e) { + classLogger.error("Failed to version automation definition for project {}", projectId, e); + throw e; } if (ClusterUtil.IS_CLUSTER) { try { @@ -446,4 +478,74 @@ private static void syncDefinitionAssets(IProject project, String projectId, Use } SecurityProjectUtils.updateProjectLastEditedDate(projectId); } + + /** + * Stages and commits Automation-owned project assets through the project's + * standard Git repository. This operation uses JGit directly because the shared + * convenience helpers intentionally log and suppress add/commit API failures, + * while version history must report a failed save to its caller. + * + * @param versionFolder project Git working directory + * @param files Automation-owned absolute artifact paths + * @param commitMessage project Git commit message + * @param user commit author + */ + static void commitDefinitionAssets(String versionFolder, List files, String commitMessage, User user) { + Set ownedPaths = repositoryRelativePaths(versionFolder, files); + try (Git git = Git.open(new File(versionFolder))) { + AddCommand add = git.add(); + ownedPaths.forEach(add::addFilepattern); + add.call(); + + Status status = git.status().call(); + if (hasOwnedChanges(status.getModified(), ownedPaths) || hasOwnedChanges(status.getMissing(), ownedPaths) + || hasOwnedChanges(status.getUntracked(), ownedPaths) + || hasOwnedChanges(status.getConflicting(), ownedPaths)) { + throw new IllegalArgumentException("Unable to stage Automation definition assets for version history."); + } + boolean hasStagedChanges = hasOwnedChanges(status.getAdded(), ownedPaths) + || hasOwnedChanges(status.getChanged(), ownedPaths) + || hasOwnedChanges(status.getRemoved(), ownedPaths); + if (!hasStagedChanges) { + return; + } + + AccessToken accessToken = user.getAccessToken(user.getPrimaryLogin()); + String author = accessToken == null ? null : accessToken.getResolvedUsername(); + String email = accessToken == null ? null : accessToken.getEmail(); + git.commit().setMessage(commitMessage).setAuthor(defaultIfBlank(author, "SEMOSS"), + defaultIfBlank(email, "semoss@semoss.org")).call(); + } catch (IOException | GitAPIException | NoWorkTreeException e) { + throw new IllegalArgumentException("Unable to commit Automation definition assets to version history.", e); + } + } + + private static Set repositoryRelativePaths(String versionFolder, List files) { + Path repository = Path.of(versionFolder).toAbsolutePath().normalize(); + Set relativePaths = new HashSet<>(); + for (String file : files) { + Path artifact = Path.of(file).toAbsolutePath().normalize(); + if (!artifact.startsWith(repository)) { + throw new IllegalArgumentException("Automation definition asset is outside the project version folder."); + } + relativePaths.add(repository.relativize(artifact).toString().replace(File.separatorChar, '/')); + } + return relativePaths; + } + + private static boolean hasOwnedChanges(Collection changedPaths, Set ownedPaths) { + for (String changedPath : changedPaths) { + String normalizedChangedPath = changedPath.replace('\\', '/'); + for (String ownedPath : ownedPaths) { + if (ownedPath.equals(normalizedChangedPath) || ownedPath.startsWith(normalizedChangedPath + "/")) { + return true; + } + } + } + return false; + } + + private static String defaultIfBlank(String value, String defaultValue) { + return value == null || value.isBlank() ? defaultValue : value; + } } diff --git a/src/prerna/reactor/automation/project/AutomationVersionService.java b/src/prerna/reactor/automation/project/AutomationVersionService.java new file mode 100644 index 00000000000..fe8bab823e4 --- /dev/null +++ b/src/prerna/reactor/automation/project/AutomationVersionService.java @@ -0,0 +1,264 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed 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. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.automation.project; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.AbstractTreeIterator; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.eclipse.jgit.treewalk.EmptyTreeIterator; +import org.eclipse.jgit.treewalk.TreeWalk; + +import prerna.auth.User; +import prerna.project.api.IProject; +import prerna.reactor.automation.AutomationConstants; +import prerna.reactor.automation.AutomationRuntime; +import prerna.reactor.automation.definition.AutomationDefinitionService; +import prerna.reactor.automation.definition.AutomationDefinitionValidator; +import prerna.util.AssetUtility; +import prerna.util.git.GitAssetUtils; + +/** + * Reads and restores saved Automation definitions from the Automation project's + * standard Git repository. + * + *

+ * Version identifiers are full commit SHAs reachable from the repository's + * current {@code HEAD}. A restore never rewinds repository history; it persists + * the historical aggregate through {@link AutomationProjectService}, producing + * a new forward commit after optimistic-concurrency and reference validation. + */ +final class AutomationVersionService { + + private static final Logger classLogger = LogManager.getLogger(AutomationVersionService.class); + + private static final String WORKFLOW_GIT_PATH = "assets/" + + AutomationConstants.AUTOMATION_PYTHON_DEFINITION_FILE_NAME; + private static final String NODES_GIT_PREFIX = "assets/" + + AutomationConstants.AUTOMATION_NODE_SOURCES_FOLDER_NAME + "/"; + private static final String RESTORE_COMMIT_PREFIX = "Restore automation definition from "; + + private AutomationVersionService() { + } + + /** + * Lists newest-first commits that changed the Automation graph or its owned + * developer-node sources. + * + * @param project Automation project + * @param limit maximum number of matching versions to return + * @return bounded commit metadata with full SHA identifiers + */ + static List> listVersions(IProject project, int limit) { + String versionFolder = AssetUtility.getProjectVersionFolder(project.getProjectName(), project.getProjectId()); + try (Git git = Git.open(new File(versionFolder))) { + Repository repository = git.getRepository(); + if (repository.resolve(Constants.HEAD) == null) { + return List.of(); + } + Map matchingCommits = new LinkedHashMap<>(); + for (String path : List.of(WORKFLOW_GIT_PATH, + NODES_GIT_PREFIX.substring(0, NODES_GIT_PREFIX.length() - 1))) { + for (RevCommit commit : git.log().addPath(path).setMaxCount(limit).call()) { + matchingCommits.putIfAbsent(commit.getId().name(), commit); + } + } + List orderedCommits = new ArrayList<>(matchingCommits.values()); + orderedCommits.sort(Comparator.comparingInt(RevCommit::getCommitTime).reversed() + .thenComparing(commit -> commit.getId().name())); + + List> versions = new ArrayList<>(); + for (int index = 0; index < Math.min(limit, orderedCommits.size()); index++) { + RevCommit commit = orderedCommits.get(index); + Map version = new LinkedHashMap<>(); + version.put("date", GitAssetUtils.getDate(commit.getCommitTime() * 1_000L)); + version.put("user", commit.getAuthorIdent().getName()); + version.put("message", commit.getFullMessage()); + version.put("id", commit.getId().name()); + versions.add(version); + } + return versions; + } catch (Exception e) { + classLogger.error("Failed to list versions for automation project {}", project.getProjectId(), e); + throw new IllegalArgumentException("Unable to read automation version history.", e); + } + } + + /** + * Restores one historical aggregate using the normal project save path. + * + * @param project editable Automation project + * @param revision full SHA returned by {@link #listVersions(IProject, int)} + * @param expectedRevision current aggregate revision returned by GetAutomation + * @param user user performing the restore + * @return newly persisted aggregate + */ + static AutomationDefinitionService.DefinitionFiles restore(IProject project, String revision, + String expectedRevision, User user) { + if (revision == null || !revision.matches("[0-9a-fA-F]{40}")) { + throw new IllegalArgumentException("revision must be a full commit SHA returned by ListAutomationVersions."); + } + if (expectedRevision == null || expectedRevision.isBlank()) { + throw new IllegalArgumentException("expectedRevision must be provided."); + } + + String projectId = project.getProjectId(); + String versionFolder = AssetUtility.getProjectVersionFolder(project.getProjectName(), projectId); + String definitionJson; + Map nodeSources = new LinkedHashMap<>(); + try (Git git = Git.open(new File(versionFolder))) { + Repository repository = git.getRepository(); + RevCommit target = requireReachableCommit(repository, revision); + if (!isAutomationVersion(repository, target)) { + throw new IllegalArgumentException("Revision is not an Automation definition version."); + } + definitionJson = readFile(repository, target, WORKFLOW_GIT_PATH); + if (definitionJson == null || definitionJson.isBlank()) { + throw new IllegalArgumentException("No automation definition found at revision: " + revision); + } + + AutomationDefinitionValidator.ValidatedDefinition definition = AutomationDefinitionValidator + .parseAndValidateForAuthoring(definitionJson); + for (Map node : AutomationRuntime.allNodes(definition)) { + if (!AutomationConstants.NODE_DEVELOPER_PYTHON + .equals(node.get(AutomationConstants.NODE_FIELD_TYPE))) { + continue; + } + String nodeId = (String) node.get(AutomationConstants.NODE_FIELD_ID); + String path = NODES_GIT_PREFIX + AutomationDefinitionService.safeNodeFileName(node) + ".py"; + String source = readFile(repository, target, path); + if (source == null) { + throw new IllegalArgumentException( + "Developer source for automation node '" + nodeId + "' is unavailable at revision: " + + revision); + } + nodeSources.put(nodeId, source); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + classLogger.error("Failed to load revision {} for automation project {}", revision, projectId, e); + throw new IllegalArgumentException("Unable to restore the selected automation version.", e); + } + + // saveDefinition rechecks the current aggregate while holding the project lock, + // validates current resource access, and writes a new project Git commit. + return AutomationProjectService.saveDefinition(projectId, definitionJson, nodeSources, expectedRevision, user, + RESTORE_COMMIT_PREFIX + revision); + } + + private static RevCommit requireReachableCommit(Repository repository, String revision) throws IOException { + ObjectId headId = repository.resolve(Constants.HEAD); + if (headId == null) { + throw new IllegalArgumentException("Automation project has no saved versions."); + } + ObjectId targetId; + try { + targetId = ObjectId.fromString(revision); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid automation revision.", e); + } + try (RevWalk walk = new RevWalk(repository)) { + RevCommit head = walk.parseCommit(headId); + RevCommit target; + try { + target = walk.parseCommit(targetId); + } catch (IOException e) { + throw new IllegalArgumentException("Automation revision does not exist.", e); + } + if (!walk.isMergedInto(target, head)) { + throw new IllegalArgumentException("Automation revision is not part of the current project history."); + } + return target; + } + } + + private static boolean changesAutomationDefinition(RevWalk walk, ObjectReader reader, DiffFormatter formatter, + RevCommit commit) throws IOException { + AbstractTreeIterator previous = new EmptyTreeIterator(); + if (commit.getParentCount() > 0) { + RevCommit parent = walk.parseCommit(commit.getParent(0).getId()); + previous = treeIterator(reader, parent); + } + for (DiffEntry entry : formatter.scan(previous, treeIterator(reader, commit))) { + if (isAutomationPath(entry.getOldPath()) || isAutomationPath(entry.getNewPath())) { + return true; + } + } + return false; + } + + private static boolean isAutomationVersion(Repository repository, RevCommit commit) throws IOException { + try (RevWalk walk = new RevWalk(repository); + ObjectReader reader = repository.newObjectReader(); + DiffFormatter formatter = new DiffFormatter(new ByteArrayOutputStream())) { + formatter.setRepository(repository); + return changesAutomationDefinition(walk, reader, formatter, commit); + } + } + + private static CanonicalTreeParser treeIterator(ObjectReader reader, RevCommit commit) throws IOException { + CanonicalTreeParser parser = new CanonicalTreeParser(); + parser.reset(reader, commit.getTree().getId()); + return parser; + } + + private static boolean isAutomationPath(String path) { + return WORKFLOW_GIT_PATH.equals(path) || path.startsWith(NODES_GIT_PREFIX); + } + + private static String readFile(Repository repository, RevCommit commit, String path) throws IOException { + try (TreeWalk tree = TreeWalk.forPath(repository, path, commit.getTree())) { + if (tree == null) { + return null; + } + ObjectLoader loader = repository.open(tree.getObjectId(0)); + return new String(loader.getBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java b/src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java index 99cd34533cf..cc61ce931b8 100644 --- a/src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java +++ b/src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java @@ -38,17 +38,17 @@ import prerna.reactor.AbstractReactor; import prerna.sablecc2.om.PixelDataType; import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.ReactorKeysEnum; import prerna.sablecc2.om.execptions.SemossPixelException; import prerna.sablecc2.om.nounmeta.NounMetadata; -import prerna.util.AssetUtility; -import prerna.util.Utility; -import prerna.util.git.GitRepoUtils; /** * Returns the git commit history for an automation project's workflow definition. * - *

Each entry contains the commit author, timestamp, short revision hash, and message - * recorded when {@code SaveAutomation} was called. + *

+ * Each entry contains the commit author, timestamp, full revision SHA, and + * message recorded when the Automation graph or an owned developer-node source + * was saved. * *

Pixel: {@code ListAutomationVersions(project=[""])} */ @@ -56,13 +56,11 @@ public class ListAutomationVersionsReactor extends AbstractReactor { private static final Logger classLogger = LogManager.getLogger(ListAutomationVersionsReactor.class); - private static final String PROJECT_KEY = "project"; - private static final String LIMIT_KEY = "limit"; private static final int DEFAULT_LIMIT = 25; private static final int MAXIMUM_LIMIT = 100; public ListAutomationVersionsReactor() { - this.keysToGet = new String[] { PROJECT_KEY, LIMIT_KEY }; + this.keysToGet = new String[] { ReactorKeysEnum.PROJECT.getKey(), ReactorKeysEnum.LIMIT.getKey() }; this.keyRequired = new int[] { 1, 0 }; } @@ -79,27 +77,17 @@ public NounMetadata execute() { throw error; } - String projectIdOrAlias = this.keyValue.get(PROJECT_KEY); - // resolveRunScope enforces view-level ACL and rejects inline run scopes - String projectId = AutomationProjectService.resolveRunScope(user, projectIdOrAlias, false); + IProject project = AutomationProjectService.getViewableAutomationProject(user, + this.keyValue.get(ReactorKeysEnum.PROJECT.getKey())); + String projectId = project.getProjectId(); - IProject project = Utility.getProject(projectId); - if (project == null) { - throw new IllegalArgumentException("Project not found: " + projectId); - } - - int limit = getInt(LIMIT_KEY, DEFAULT_LIMIT); + int limit = getInt(ReactorKeysEnum.LIMIT.getKey(), DEFAULT_LIMIT); if (limit <= 0) { throw new IllegalArgumentException("limit must be a positive integer."); } limit = Math.min(limit, MAXIMUM_LIMIT); - String versionFolder = AssetUtility.getProjectVersionFolder(project.getProjectName(), projectId); - // getCommits returns newest-first (git log order); subList enforces the bound - List> commits = GitRepoUtils.getCommits(versionFolder, null); - if (commits.size() > limit) { - commits = commits.subList(0, limit); - } + List> commits = AutomationVersionService.listVersions(project, limit); classLogger.info("Returning {} version entries for automation project {}", commits.size(), projectId); return new NounMetadata(commits, PixelDataType.CUSTOM_DATA_STRUCTURE, PixelOperationType.OPERATION); @@ -113,11 +101,12 @@ public String getReactorDescription() { @Override protected String getDescriptionForKey(String key) { - if (PROJECT_KEY.equals(key)) { + if (ReactorKeysEnum.PROJECT.getKey().equals(key)) { return "ID or alias of the automation project whose version history should be listed."; } - if (LIMIT_KEY.equals(key)) { - return "Maximum number of versions to return. Defaults to " + DEFAULT_LIMIT + ", capped at " + MAXIMUM_LIMIT + "."; + if (ReactorKeysEnum.LIMIT.getKey().equals(key)) { + return "Maximum number of versions to return. Defaults to " + DEFAULT_LIMIT + ", capped at " + + MAXIMUM_LIMIT + "."; } return super.getDescriptionForKey(key); } diff --git a/src/prerna/reactor/automation/project/RollbackAutomationReactor.java b/src/prerna/reactor/automation/project/RollbackAutomationReactor.java index 65011bd1722..fa00aa54095 100644 --- a/src/prerna/reactor/automation/project/RollbackAutomationReactor.java +++ b/src/prerna/reactor/automation/project/RollbackAutomationReactor.java @@ -30,6 +30,9 @@ import java.util.LinkedHashMap; import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import prerna.auth.User; import prerna.project.api.IProject; import prerna.reactor.AbstractReactor; @@ -40,34 +43,30 @@ import prerna.reactor.automation.definition.AutomationScopeCatalog; import prerna.sablecc2.om.PixelDataType; import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.ReactorKeysEnum; import prerna.sablecc2.om.execptions.SemossPixelException; import prerna.sablecc2.om.nounmeta.NounMetadata; -import prerna.util.AssetUtility; -import prerna.util.Utility; -import prerna.util.git.GitRepoUtils; +import prerna.usertracking.AuditEventType; +import prerna.usertracking.UserAuditTrailUtils; /** * Restores an automation project's definition to the state captured at a - * specific git commit, - * then saves it as a new version. + * specific Git commit, then saves it as a new version. * *

* Pixel: - * {@code RollbackAutomation(project=[""], revision=[""])} + * {@code RollbackAutomation(project=[""], revision=[""], expectedRevision=[""])} */ public class RollbackAutomationReactor extends AbstractReactor { - private static final String PROJECT_KEY = "project"; - private static final String REVISION_KEY = "revision"; + private static final Logger classLogger = LogManager.getLogger(RollbackAutomationReactor.class); - private static final String WORKFLOW_GIT_PATH = "assets/" - + AutomationConstants.AUTOMATION_PYTHON_DEFINITION_FILE_NAME; - private static final String NODES_GIT_PREFIX = "assets/" + AutomationConstants.AUTOMATION_NODE_SOURCES_FOLDER_NAME - + "/"; + private static final String REVISION_KEY = "revision"; public RollbackAutomationReactor() { - this.keysToGet = new String[] { PROJECT_KEY, REVISION_KEY }; - this.keyRequired = new int[] { 1, 1 }; + this.keysToGet = new String[] { ReactorKeysEnum.PROJECT.getKey(), REVISION_KEY, + AutomationConstants.EXPECTED_REVISION_KEY }; + this.keyRequired = new int[] { 1, 1, 1 }; } @Override @@ -83,75 +82,54 @@ public NounMetadata execute() { throw error; } - String projectIdOrAlias = this.keyValue.get(PROJECT_KEY); + String projectIdOrAlias = this.keyValue.get(ReactorKeysEnum.PROJECT.getKey()); String revision = this.keyValue.get(REVISION_KEY); + String expectedRevision = this.keyValue.get(AutomationConstants.EXPECTED_REVISION_KEY); if (revision == null || revision.isBlank()) { throw new IllegalArgumentException("revision must be provided."); } // Edit-level ACL — restoring writes a new commit, so the user needs edit access - String projectId = AutomationProjectService.getEditableAutomationProject(user, projectIdOrAlias).getProjectId(); - IProject project = Utility.getProject(projectId); - if (project == null) { - throw new IllegalArgumentException("Project not found: " + projectId); - } - - String versionFolder = AssetUtility.getProjectVersionFolder(project.getProjectName(), projectId); - - String definitionJson = GitRepoUtils.getFile(revision, WORKFLOW_GIT_PATH, versionFolder); - if (definitionJson == null || definitionJson.isBlank()) { - throw new IllegalArgumentException("No automation definition found at revision: " + revision); - } + IProject project = AutomationProjectService.getEditableAutomationProject(user, projectIdOrAlias); + String projectId = project.getProjectId(); + AutomationDefinitionService.DefinitionFiles files = AutomationVersionService.restore(project, revision, + expectedRevision, user); - // Reassemble node sources from the same commit - AutomationDefinitionValidator.ValidatedDefinition validated = AutomationDefinitionValidator - .parseAndValidateForAuthoring(definitionJson); - - Map nodeSources = new LinkedHashMap<>(); - for (Map node : AutomationRuntime.allNodes(validated)) { - if (!AutomationConstants.NODE_DEVELOPER_PYTHON.equals(node.get(AutomationConstants.NODE_FIELD_TYPE))) { - continue; - } - String nodeId = (String) node.get(AutomationConstants.NODE_FIELD_ID); - String gitPath = NODES_GIT_PREFIX + AutomationDefinitionService.safeNodeFileName(node) + ".py"; - String source = GitRepoUtils.getFile(revision, gitPath, versionFolder); - if (source != null) { - nodeSources.put(nodeId, source); - } - } - - // Save as a new commit — null expectedRevision skips optimistic concurrency - // check - AutomationDefinitionService.DefinitionFiles files = AutomationProjectService.saveDefinition( - projectId, definitionJson, nodeSources, null, user); - - // Return the same shape as RollbackAutomation so the frontend can reuse the - // same handler + String savedRevision = AutomationDefinitionService.calculateRevision(files.definition(), files.nodeSources()); Map result = new LinkedHashMap<>(); result.put("saved", true); result.put(AutomationConstants.DOC_NODE_SOURCES, files.nodeSources()); - result.put(AutomationConstants.RESULT_REVISION, - AutomationDefinitionService.calculateRevision(files.definition(), files.nodeSources())); + result.put(AutomationConstants.RESULT_REVISION, savedRevision); AutomationDefinitionValidator.ValidatedDefinition savedValidated = AutomationDefinitionValidator .parseAndValidateForAuthoring(files.definition()); result.put(AutomationConstants.DOC_GLOBALS, AutomationRuntime.declaredGlobals(savedValidated)); result.put(AutomationConstants.RESULT_SCOPE_VARIABLES, AutomationScopeCatalog.variablesByNode(savedValidated)); + UserAuditTrailUtils.recordAutomationEvent(user, AuditEventType.AUTOMATION_UPDATE, projectId, null, null, + Map.of("revision", savedRevision, "restoredFrom", revision, "nodeSourceCount", + files.nodeSources() == null ? 0 : files.nodeSources().size())); + classLogger.info("Restored automation project {} from revision {} as revision {}", projectId, revision, + savedRevision); + return new NounMetadata(result, PixelDataType.MAP, PixelOperationType.OPERATION); } @Override public String getReactorDescription() { - return "Restores an automation project's definition to the state at the given git revision and saves it as a new version."; + return "Restores an automation project's definition to the state at the given Git revision and saves it as a new version."; } @Override protected String getDescriptionForKey(String key) { - if (PROJECT_KEY.equals(key)) { + if (ReactorKeysEnum.PROJECT.getKey().equals(key)) { return "ID or alias of the automation project."; } if (REVISION_KEY.equals(key)) { - return "Git commit id (full or short hash) returned by ListAutomationVersions."; + return "Full Git commit SHA returned by ListAutomationVersions."; + } + if (AutomationConstants.EXPECTED_REVISION_KEY.equals(key)) { + return "Current definition revision returned by GetAutomation. The restore is rejected if the automation " + + "changed."; } return super.getDescriptionForKey(key); } diff --git a/test/prerna/reactor/automation/project/AutomationProjectServiceUnitTests.java b/test/prerna/reactor/automation/project/AutomationProjectServiceUnitTests.java index a23f82bed58..feb3853bd78 100644 --- a/test/prerna/reactor/automation/project/AutomationProjectServiceUnitTests.java +++ b/test/prerna/reactor/automation/project/AutomationProjectServiceUnitTests.java @@ -37,11 +37,15 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.eclipse.jgit.api.Git; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.MockedStatic; import prerna.auth.AccessToken; @@ -57,6 +61,9 @@ /** Covers the owner-scoped run scope used by runs of inline definition JSON. */ public class AutomationProjectServiceUnitTests { + @TempDir + Path repositoryFolder; + private static User user(String id) { AccessToken token = new AccessToken(); token.setId(id); @@ -140,6 +147,49 @@ void namedProjectStillUsesTheProjectAcl() { } } + @Test + void commitsChangedDefinitionAssetsWithTheRequestedMessage() throws Exception { + Path versionFolder = repositoryFolder.resolve("version"); + Files.createDirectories(versionFolder); + String initialHead; + try (Git git = Git.init().setDirectory(versionFolder.toFile()).call()) { + Path readme = versionFolder.resolve("README.md"); + Files.writeString(readme, "initial"); + git.add().addFilepattern("README.md").call(); + initialHead = git.commit().setMessage("initial").setAuthor("Tester", "tester@example.com") + .setCommitter("Tester", "tester@example.com").call().getId().name(); + } + + Path definition = versionFolder.resolve("assets/automation-workflow.json"); + Files.createDirectories(definition.getParent()); + Files.writeString(definition, "{\"formatVersion\":2}"); + + AutomationProjectService.commitDefinitionAssets(versionFolder.toString(), List.of(definition.toString()), + "Restore automation definition from abc123", user("owner")); + + try (Git git = Git.open(versionFolder.toFile())) { + assertNotEquals(initialHead, git.getRepository().resolve("HEAD").name()); + assertEquals("Restore automation definition from abc123", git.log().setMaxCount(1).call().iterator().next() + .getFullMessage()); + } + } + + @Test + void reportsGitFailureInsteadOfSilentlySavingWithoutHistory() throws Exception { + Path versionFolder = repositoryFolder.resolve("version"); + Files.createDirectories(versionFolder); + Path definition = versionFolder.resolve("assets/automation-workflow.json"); + Git.init().setBare(true).setDirectory(versionFolder.toFile()).call().close(); + Files.createDirectories(definition.getParent()); + Files.writeString(definition, "{\"formatVersion\":2}"); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> AutomationProjectService.commitDefinitionAssets(versionFolder.toString(), + List.of(definition.toString()), "Update automation definition", user("owner"))); + + assertEquals("Unable to commit Automation definition assets to version history.", error.getMessage()); + } + @Test void agentRunWithoutAWorkspaceSkipsOnlyTheWorkspaceCheck() { User owner = user("owner"); diff --git a/test/prerna/reactor/automation/project/AutomationVersionServiceUnitTests.java b/test/prerna/reactor/automation/project/AutomationVersionServiceUnitTests.java new file mode 100644 index 00000000000..d99ac2dd063 --- /dev/null +++ b/test/prerna/reactor/automation/project/AutomationVersionServiceUnitTests.java @@ -0,0 +1,213 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed 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. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.automation.project; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import prerna.auth.User; +import prerna.project.api.IProject; +import prerna.reactor.automation.AutomationConstants; +import prerna.reactor.automation.definition.AutomationDefinitionService; +import prerna.util.AssetUtility; + +/** Covers project-Git filtering and exact historical aggregate restoration. */ +class AutomationVersionServiceUnitTests { + + private static final String START_ONLY = """ + { + "formatVersion": 2, + "triggerBindings": [{ "id": "manual", "type": "manual" }], + "graph": { + "nodes": [{ "id": "start", "type": "trigger.start", "label": "Start", + "codeMode": "generated", "config": {} }], + "edges": [] + } + } + """; + private static final String RESTORE_COMMIT_PREFIX = "Restore automation definition from "; + + @TempDir + Path repositoryFolder; + + @Test + void listsOnlyAutomationCommitsWithFullIdentifiersAndHonorsLimit() throws Exception { + try (Git git = Git.init().setDirectory(repositoryFolder.toFile()).call()) { + commit(git, "README.md", "one", "unrelated"); + RevCommit firstAutomation = commit(git, workflowPath(), START_ONLY, "first automation"); + commit(git, "README.md", "two", "another unrelated"); + RevCommit secondAutomation = commit(git, nodePath(), "def run(scope):\n return 1\n", "source save"); + + try (var assets = mockStatic(AssetUtility.class)) { + IProject project = project(); + assets.when(() -> AssetUtility.getProjectVersionFolder("Project", "project-1")) + .thenReturn(repositoryFolder.toString()); + + List> versions = AutomationVersionService.listVersions(project, 2); + + assertEquals(Set.of(secondAutomation.getId().name(), firstAutomation.getId().name()), + Set.copyOf(versions.stream().map(version -> (String) version.get("id")).toList())); + assertTrue(versions.stream().allMatch(version -> ((String) version.get("id")).length() == 40)); + assertTrue(versions.stream().allMatch(version -> !((String) version.get("date")).isBlank())); + assertFalse(versions.stream().anyMatch(version -> ((String) version.get("date")).contains("1970"))); + } + } + } + + @Test + void restoresGraphAndDeveloperSourceUsingExpectedCurrentRevision() throws Exception { + String developerDefinition = developerDefinition(); + try (Git git = Git.init().setDirectory(repositoryFolder.toFile()).call()) { + commit(git, workflowPath(), developerDefinition, "graph"); + RevCommit target = commit(git, nodePath(), "def run(scope):\n return 'old'\n", "source"); + commit(git, nodePath(), "def run(scope):\n return 'new'\n", "later source"); + + IProject project = project(); + User user = mock(User.class); + AutomationDefinitionService.DefinitionFiles saved = new AutomationDefinitionService.DefinitionFiles( + developerDefinition, Map.of("custom", "def run(scope):\n return 'old'\n")); + try (var assets = mockStatic(AssetUtility.class); + var projects = mockStatic(AutomationProjectService.class)) { + assets.when(() -> AssetUtility.getProjectVersionFolder("Project", "project-1")) + .thenReturn(repositoryFolder.toString()); + projects.when(() -> AutomationProjectService.saveDefinition("project-1", developerDefinition, + Map.of("custom", "def run(scope):\n return 'old'\n"), "current", user, + RESTORE_COMMIT_PREFIX + target.getId().name())).thenReturn(saved); + + assertEquals(saved, AutomationVersionService.restore(project, target.getId().name(), "current", user)); + projects.verify(() -> AutomationProjectService.saveDefinition("project-1", developerDefinition, + Map.of("custom", "def run(scope):\n return 'old'\n"), "current", user, + RESTORE_COMMIT_PREFIX + target.getId().name())); + } + } + } + + @Test + void missingDeveloperSourceFailsBeforeSaving() throws Exception { + try (Git git = Git.init().setDirectory(repositoryFolder.toFile()).call()) { + RevCommit target = commit(git, workflowPath(), developerDefinition(), "missing source"); + IProject project = project(); + User user = mock(User.class); + try (var assets = mockStatic(AssetUtility.class); + var projects = mockStatic(AutomationProjectService.class)) { + assets.when(() -> AssetUtility.getProjectVersionFolder("Project", "project-1")) + .thenReturn(repositoryFolder.toString()); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> AutomationVersionService.restore(project, target.getId().name(), "current", user)); + + assertTrue(error.getMessage().contains("Developer source")); + projects.verifyNoInteractions(); + } + } + } + + @Test + void rejectsReachableCommitThatDidNotChangeAutomation() throws Exception { + try (Git git = Git.init().setDirectory(repositoryFolder.toFile()).call()) { + commit(git, workflowPath(), START_ONLY, "automation"); + RevCommit unrelated = commit(git, "README.md", "notes", "unrelated"); + IProject project = project(); + User user = mock(User.class); + try (var assets = mockStatic(AssetUtility.class); + var projects = mockStatic(AutomationProjectService.class)) { + assets.when(() -> AssetUtility.getProjectVersionFolder("Project", "project-1")) + .thenReturn(repositoryFolder.toString()); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> AutomationVersionService.restore(project, unrelated.getId().name(), "current", user)); + + assertEquals("Revision is not an Automation definition version.", error.getMessage()); + projects.verifyNoInteractions(); + } + } + } + + private RevCommit commit(Git git, String relativePath, String value, String message) throws Exception { + Path file = repositoryFolder.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, value); + git.add().addFilepattern(relativePath).call(); + return git.commit().setMessage(message).setAuthor("Tester", "tester@example.com") + .setCommitter("Tester", "tester@example.com").call(); + } + + private static String workflowPath() { + return "assets/" + AutomationConstants.AUTOMATION_PYTHON_DEFINITION_FILE_NAME; + } + + private static String nodePath() { + Map node = Map.of(AutomationConstants.NODE_FIELD_ID, "custom", + AutomationConstants.NODE_FIELD_LABEL, "Custom", AutomationConstants.NODE_FIELD_TYPE, + AutomationConstants.NODE_DEVELOPER_PYTHON); + return "assets/" + AutomationConstants.AUTOMATION_NODE_SOURCES_FOLDER_NAME + "/" + + AutomationDefinitionService.safeNodeFileName(node) + ".py"; + } + + private static IProject project() { + IProject project = mock(IProject.class); + when(project.getProjectId()).thenReturn("project-1"); + when(project.getProjectName()).thenReturn("Project"); + return project; + } + + private static String developerDefinition() { + return """ + { + "formatVersion": 2, + "triggerBindings": [{ "id": "manual", "type": "manual" }], + "graph": { + "nodes": [ + { "id": "start", "type": "trigger.start", "label": "Start", + "codeMode": "generated", "config": {} }, + { "id": "custom", "type": "developer.python", "label": "Custom", "outputVar": "result", + "codeMode": "custom", "config": {} } + ], + "edges": [{ "id": "e1", "kind": "control", "source": "start", "sourcePort": "out", + "target": "custom", "targetPort": "in" }] + } + } + """; + } +} diff --git a/test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java b/test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java index 4111a3a8e78..6484eb883d1 100644 --- a/test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java +++ b/test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java @@ -33,7 +33,6 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; -import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -43,13 +42,10 @@ import prerna.auth.User; import prerna.om.Insight; import prerna.project.api.IProject; -import prerna.reactor.automation.AutomationConstants; import prerna.sablecc2.om.execptions.SemossPixelException; import prerna.sablecc2.om.nounmeta.NounMetadata; -import prerna.util.AssetUtility; -import prerna.util.Utility; -import prerna.util.git.GitRepoUtils; +/** Covers the ACL and input boundary for Automation version listing. */ class ListAutomationVersionsReactorUnitTests { private static final String PROJECT_KEY = "project"; @@ -87,7 +83,7 @@ void accessDeniedPropagatesWhenUserLacksViewPermission() { reactor.keyValue.put(PROJECT_KEY, "project-1"); try (var projectService = mockStatic(AutomationProjectService.class)) { - projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) + projectService.when(() -> AutomationProjectService.getViewableAutomationProject(user, "project-1")) .thenThrow(new IllegalArgumentException("Project does not exist or user does not have access.")); IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); @@ -95,167 +91,61 @@ void accessDeniedPropagatesWhenUserLacksViewPermission() { } } - @Test - void inlineRunScopeIsRejected() { - String inlineScope = AutomationConstants.INLINE_RUN_SCOPE_PREFIX + "other-user"; - reactor.keyValue.put(PROJECT_KEY, inlineScope); - - try (var projectService = mockStatic(AutomationProjectService.class)) { - projectService.when(() -> AutomationProjectService.resolveRunScope(user, inlineScope, false)) - .thenThrow(new IllegalArgumentException("Run does not exist or user does not have access.")); - - assertThrows(IllegalArgumentException.class, reactor::execute); - } - } - @Test void zeroLimitThrows() { reactor.keyValue.put(PROJECT_KEY, "project-1"); reactor.getNounStore().makeNoun(LIMIT_KEY).addLiteral("0"); - - IProject project = mock(IProject.class); - when(project.getProjectName()).thenReturn("My Project"); + IProject project = project(); try (var projectService = mockStatic(AutomationProjectService.class)) { - projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) - .thenReturn("project-1"); + projectService.when(() -> AutomationProjectService.getViewableAutomationProject(user, "project-1")) + .thenReturn(project); - try (var utility = mockStatic(Utility.class)) { - utility.when(() -> Utility.getProject("project-1")).thenReturn(project); - - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); - assertEquals("limit must be a positive integer.", error.getMessage()); - } + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertEquals("limit must be a positive integer.", error.getMessage()); } } @Test - void returnsCommitsFromGit() { + void returnsVersionsFromAuthorizedProject() { reactor.keyValue.put(PROJECT_KEY, "project-1"); - - IProject project = mock(IProject.class); - when(project.getProjectName()).thenReturn("My Project"); - - List> fakeCommits = List.of( - Map.of("user", "Alice", "date", "2024-01-02", "message", "second save", "id", "abc123"), - Map.of("user", "Bob", "date", "2024-01-01", "message", "first save", "id", "def456")); + IProject project = project(); + List> versions = List.of( + Map.of("user", "Alice", "date", "2024-01-02", "message", "Save", "id", "a".repeat(40))); try (var projectService = mockStatic(AutomationProjectService.class); - var utility = mockStatic(Utility.class); - var assetUtility = mockStatic(AssetUtility.class); - var gitRepo = mockStatic(GitRepoUtils.class)) { - - projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) - .thenReturn("project-1"); - utility.when(() -> Utility.getProject("project-1")).thenReturn(project); - assetUtility.when(() -> AssetUtility.getProjectVersionFolder("My Project", "project-1")) - .thenReturn("/fake/version/folder"); - gitRepo.when(() -> GitRepoUtils.getCommits("/fake/version/folder", null)) - .thenReturn(new ArrayList<>(fakeCommits)); + var versionService = mockStatic(AutomationVersionService.class)) { + projectService.when(() -> AutomationProjectService.getViewableAutomationProject(user, "project-1")) + .thenReturn(project); + versionService.when(() -> AutomationVersionService.listVersions(project, 25)).thenReturn(versions); NounMetadata result = reactor.execute(); - @SuppressWarnings("unchecked") - List> commits = (List>) result.getValue(); - assertEquals(2, commits.size()); - assertEquals("Alice", commits.get(0).get("user")); - assertEquals("Bob", commits.get(1).get("user")); + assertEquals(versions, result.getValue()); } } @Test - void limitCapsBeyondMaximumTo100() { + void capsRequestedLimitAt100BeforeReadingHistory() { reactor.keyValue.put(PROJECT_KEY, "project-1"); reactor.getNounStore().makeNoun(LIMIT_KEY).addLiteral("999"); - - IProject project = mock(IProject.class); - when(project.getProjectName()).thenReturn("My Project"); - - // Build 150 fake commits to confirm only 100 are returned - List> fakeCommits = new ArrayList<>(); - for (int i = 0; i < 150; i++) { - fakeCommits.add(Map.of("user", "user-" + i, "date", "2024-01-01", "message", "save " + i, "id", "id" + i)); - } + IProject project = project(); try (var projectService = mockStatic(AutomationProjectService.class); - var utility = mockStatic(Utility.class); - var assetUtility = mockStatic(AssetUtility.class); - var gitRepo = mockStatic(GitRepoUtils.class)) { - - projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) - .thenReturn("project-1"); - utility.when(() -> Utility.getProject("project-1")).thenReturn(project); - assetUtility.when(() -> AssetUtility.getProjectVersionFolder("My Project", "project-1")) - .thenReturn("/fake/version/folder"); - gitRepo.when(() -> GitRepoUtils.getCommits("/fake/version/folder", null)) - .thenReturn(fakeCommits); + var versionService = mockStatic(AutomationVersionService.class)) { + projectService.when(() -> AutomationProjectService.getViewableAutomationProject(user, "project-1")) + .thenReturn(project); + versionService.when(() -> AutomationVersionService.listVersions(project, 100)).thenReturn(List.of()); - NounMetadata result = reactor.execute(); - - @SuppressWarnings("unchecked") - List> commits = (List>) result.getValue(); - assertEquals(100, commits.size()); + assertEquals(List.of(), reactor.execute().getValue()); + versionService.verify(() -> AutomationVersionService.listVersions(project, 100)); } } - @Test - void defaultLimitIs25WhenNotProvided() { - reactor.keyValue.put(PROJECT_KEY, "project-1"); - + private static IProject project() { IProject project = mock(IProject.class); + when(project.getProjectId()).thenReturn("project-1"); when(project.getProjectName()).thenReturn("My Project"); - - List> fakeCommits = new ArrayList<>(); - for (int i = 0; i < 50; i++) { - fakeCommits.add(Map.of("user", "user-" + i, "date", "2024-01-01", "message", "save " + i, "id", "id" + i)); - } - - try (var projectService = mockStatic(AutomationProjectService.class); - var utility = mockStatic(Utility.class); - var assetUtility = mockStatic(AssetUtility.class); - var gitRepo = mockStatic(GitRepoUtils.class)) { - - projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) - .thenReturn("project-1"); - utility.when(() -> Utility.getProject("project-1")).thenReturn(project); - assetUtility.when(() -> AssetUtility.getProjectVersionFolder("My Project", "project-1")) - .thenReturn("/fake/version/folder"); - gitRepo.when(() -> GitRepoUtils.getCommits("/fake/version/folder", null)) - .thenReturn(fakeCommits); - - NounMetadata result = reactor.execute(); - - @SuppressWarnings("unchecked") - List> commits = (List>) result.getValue(); - assertEquals(25, commits.size()); - } - } - - @Test - void emptyGitHistoryReturnsEmptyList() { - reactor.keyValue.put(PROJECT_KEY, "project-1"); - - IProject project = mock(IProject.class); - when(project.getProjectName()).thenReturn("My Project"); - - try (var projectService = mockStatic(AutomationProjectService.class); - var utility = mockStatic(Utility.class); - var assetUtility = mockStatic(AssetUtility.class); - var gitRepo = mockStatic(GitRepoUtils.class)) { - - projectService.when(() -> AutomationProjectService.resolveRunScope(user, "project-1", false)) - .thenReturn("project-1"); - utility.when(() -> Utility.getProject("project-1")).thenReturn(project); - assetUtility.when(() -> AssetUtility.getProjectVersionFolder("My Project", "project-1")) - .thenReturn("/fake/version/folder"); - gitRepo.when(() -> GitRepoUtils.getCommits("/fake/version/folder", null)) - .thenReturn(new ArrayList<>()); - - NounMetadata result = reactor.execute(); - - @SuppressWarnings("unchecked") - List> commits = (List>) result.getValue(); - assertEquals(0, commits.size()); - } + return project; } } diff --git a/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java b/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java index b7f6501a947..2903278bd1e 100644 --- a/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java +++ b/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java @@ -25,3 +25,126 @@ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. *******************************************************************************/ +package prerna.reactor.automation.project; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import prerna.auth.User; +import prerna.om.Insight; +import prerna.project.api.IProject; +import prerna.reactor.automation.AutomationConstants; +import prerna.reactor.automation.definition.AutomationDefinitionService; +import prerna.sablecc2.om.nounmeta.NounMetadata; +import prerna.usertracking.AuditEventType; +import prerna.usertracking.UserAuditTrailUtils; + +/** Covers rollback authorization, concurrency forwarding, and audit behavior. */ +class RollbackAutomationReactorUnitTests { + + private static final String TARGET_REVISION = "a".repeat(40); + private static final String CURRENT_REVISION = "current-revision"; + private static final String DEFINITION = """ + { + "formatVersion": 2, + "triggerBindings": [{ "id": "manual", "type": "manual" }], + "graph": { + "nodes": [{ "id": "start", "type": "trigger.start", "label": "Start", + "codeMode": "generated", "config": {} }], + "edges": [] + } + } + """; + + private RollbackAutomationReactor reactor; + private Insight insight; + private User user; + + @BeforeEach + void setup() { + reactor = new RollbackAutomationReactor(); + insight = mock(Insight.class); + user = mock(User.class); + reactor.setInsight(insight); + when(insight.getUser()).thenReturn(user); + reactor.keyValue.put("project", "project-1"); + reactor.keyValue.put("revision", TARGET_REVISION); + reactor.keyValue.put(AutomationConstants.EXPECTED_REVISION_KEY, CURRENT_REVISION); + } + + @Test + void requiresEditAccess() { + try (var projects = mockStatic(AutomationProjectService.class)) { + projects.when(() -> AutomationProjectService.getEditableAutomationProject(user, "project-1")) + .thenThrow(new IllegalArgumentException("Project does not exist or user does not have edit access.")); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertEquals("Project does not exist or user does not have edit access.", error.getMessage()); + } + } + + @Test + void requiresCurrentRevision() { + reactor.keyValue.remove(AutomationConstants.EXPECTED_REVISION_KEY); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertEquals("Required input(s) missing: expectedRevision", error.getMessage()); + } + + @Test + void restoresWithConcurrencyTokenAndAuditsAfterSuccess() { + IProject project = project(); + AutomationDefinitionService.DefinitionFiles files = new AutomationDefinitionService.DefinitionFiles(DEFINITION, + Map.of()); + + try (var projects = mockStatic(AutomationProjectService.class); + var versions = mockStatic(AutomationVersionService.class); + var audit = mockStatic(UserAuditTrailUtils.class)) { + projects.when(() -> AutomationProjectService.getEditableAutomationProject(user, "project-1")) + .thenReturn(project); + versions.when(() -> AutomationVersionService.restore(project, TARGET_REVISION, CURRENT_REVISION, user)) + .thenReturn(files); + + NounMetadata result = reactor.execute(); + + @SuppressWarnings("unchecked") + Map output = (Map) result.getValue(); + assertEquals(true, output.get("saved")); + versions.verify(() -> AutomationVersionService.restore(project, TARGET_REVISION, CURRENT_REVISION, user)); + audit.verify(() -> UserAuditTrailUtils.recordAutomationEvent(eq(user), + eq(AuditEventType.AUTOMATION_UPDATE), eq("project-1"), eq(null), eq(null), any(Map.class))); + } + } + + @Test + void failedRestoreDoesNotWriteAuditEvent() { + IProject project = project(); + try (var projects = mockStatic(AutomationProjectService.class); + var versions = mockStatic(AutomationVersionService.class); + var audit = mockStatic(UserAuditTrailUtils.class)) { + projects.when(() -> AutomationProjectService.getEditableAutomationProject(user, "project-1")) + .thenReturn(project); + versions.when(() -> AutomationVersionService.restore(project, TARGET_REVISION, CURRENT_REVISION, user)) + .thenThrow(new IllegalArgumentException("Automation changed since it was loaded.")); + + assertThrows(IllegalArgumentException.class, reactor::execute); + audit.verifyNoInteractions(); + } + } + + private static IProject project() { + IProject project = mock(IProject.class); + when(project.getProjectId()).thenReturn("project-1"); + return project; + } +}