diff --git a/src/prerna/reactor/automation/definition/AutomationDefinitionService.java b/src/prerna/reactor/automation/definition/AutomationDefinitionService.java index 051798daac..94f8677697 100644 --- a/src/prerna/reactor/automation/definition/AutomationDefinitionService.java +++ b/src/prerna/reactor/automation/definition/AutomationDefinitionService.java @@ -516,7 +516,14 @@ private static Path nodeSourcePath(Path folder, Map node) { return result; } - static String safeNodeFileName(Map node) { + /** + * 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()) { 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 3f84cc4ad0..210cc1c411 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/AutomationProjectService.java b/src/prerna/reactor/automation/project/AutomationProjectService.java index 521ebdc437..42919fee34 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 0000000000..fe8bab823e --- /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 new file mode 100644 index 0000000000..cc61ce931b --- /dev/null +++ b/src/prerna/reactor/automation/project/ListAutomationVersionsReactor.java @@ -0,0 +1,113 @@ +/******************************************************************************* + * 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.ReactorKeysEnum; +import prerna.sablecc2.om.execptions.SemossPixelException; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Returns the git commit history for an automation project's workflow definition. + * + *

+ * 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=[""])} + */ +public class ListAutomationVersionsReactor extends AbstractReactor { + + private static final Logger classLogger = LogManager.getLogger(ListAutomationVersionsReactor.class); + + private static final int DEFAULT_LIMIT = 25; + private static final int MAXIMUM_LIMIT = 100; + + public ListAutomationVersionsReactor() { + this.keysToGet = new String[] { ReactorKeysEnum.PROJECT.getKey(), ReactorKeysEnum.LIMIT.getKey() }; + 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; + } + + IProject project = AutomationProjectService.getViewableAutomationProject(user, + this.keyValue.get(ReactorKeysEnum.PROJECT.getKey())); + String projectId = project.getProjectId(); + + 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); + + 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); + } + + @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 (ReactorKeysEnum.PROJECT.getKey().equals(key)) { + return "ID or alias of the automation project whose version history should be listed."; + } + 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 new file mode 100644 index 0000000000..fa00aa5409 --- /dev/null +++ b/src/prerna/reactor/automation/project/RollbackAutomationReactor.java @@ -0,0 +1,136 @@ +/******************************************************************************* + * 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 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.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.ReactorKeysEnum; +import prerna.sablecc2.om.execptions.SemossPixelException; +import prerna.sablecc2.om.nounmeta.NounMetadata; +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. + * + *

+ * Pixel: + * {@code RollbackAutomation(project=[""], revision=[""], expectedRevision=[""])} + */ +public class RollbackAutomationReactor extends AbstractReactor { + + private static final Logger classLogger = LogManager.getLogger(RollbackAutomationReactor.class); + + private static final String REVISION_KEY = "revision"; + + public RollbackAutomationReactor() { + this.keysToGet = new String[] { ReactorKeysEnum.PROJECT.getKey(), REVISION_KEY, + AutomationConstants.EXPECTED_REVISION_KEY }; + this.keyRequired = new int[] { 1, 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(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 + IProject project = AutomationProjectService.getEditableAutomationProject(user, projectIdOrAlias); + String projectId = project.getProjectId(); + AutomationDefinitionService.DefinitionFiles files = AutomationVersionService.restore(project, revision, + expectedRevision, user); + + 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, 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."; + } + + @Override + protected String getDescriptionForKey(String key) { + if (ReactorKeysEnum.PROJECT.getKey().equals(key)) { + return "ID or alias of the automation project."; + } + if (REVISION_KEY.equals(key)) { + 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 a23f82bed5..feb3853bd7 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 0000000000..d99ac2dd06 --- /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 new file mode 100644 index 0000000000..6484eb883d --- /dev/null +++ b/test/prerna/reactor/automation/project/ListAutomationVersionsReactorUnitTests.java @@ -0,0 +1,151 @@ +/******************************************************************************* + * 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.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.sablecc2.om.execptions.SemossPixelException; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** Covers the ACL and input boundary for Automation version listing. */ +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.getViewableAutomationProject(user, "project-1")) + .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 zeroLimitThrows() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + reactor.getNounStore().makeNoun(LIMIT_KEY).addLiteral("0"); + IProject project = project(); + + try (var projectService = mockStatic(AutomationProjectService.class)) { + projectService.when(() -> AutomationProjectService.getViewableAutomationProject(user, "project-1")) + .thenReturn(project); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, reactor::execute); + assertEquals("limit must be a positive integer.", error.getMessage()); + } + } + + @Test + void returnsVersionsFromAuthorizedProject() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + 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 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(); + + assertEquals(versions, result.getValue()); + } + } + + @Test + void capsRequestedLimitAt100BeforeReadingHistory() { + reactor.keyValue.put(PROJECT_KEY, "project-1"); + reactor.getNounStore().makeNoun(LIMIT_KEY).addLiteral("999"); + IProject project = project(); + + try (var projectService = mockStatic(AutomationProjectService.class); + var versionService = mockStatic(AutomationVersionService.class)) { + projectService.when(() -> AutomationProjectService.getViewableAutomationProject(user, "project-1")) + .thenReturn(project); + versionService.when(() -> AutomationVersionService.listVersions(project, 100)).thenReturn(List.of()); + + assertEquals(List.of(), reactor.execute().getValue()); + versionService.verify(() -> AutomationVersionService.listVersions(project, 100)); + } + } + + private static IProject project() { + IProject project = mock(IProject.class); + when(project.getProjectId()).thenReturn("project-1"); + when(project.getProjectName()).thenReturn("My Project"); + return project; + } +} diff --git a/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java b/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java new file mode 100644 index 0000000000..2903278bd1 --- /dev/null +++ b/test/prerna/reactor/automation/project/RollbackAutomationReactorUnitTests.java @@ -0,0 +1,150 @@ +/******************************************************************************* + * 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.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; + } +}