diff --git a/.gitignore b/.gitignore index 973d37762..40f1e4fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ step-controller/step-controller-base-plugins/resources/ /step-cli/step-cli-launcher/src/test/resources/samples/ work/ /step-cli/step-cli-core/src/test/resources/testReports/ -**/.claude \ No newline at end of file +dependency-reduced-pom.xml +**/.claude diff --git a/pom.xml b/pom.xml index 66212788a..b0f5e1451 100644 --- a/pom.xml +++ b/pom.xml @@ -46,7 +46,7 @@ 11 0.0.0-MASTER-SNAPSHOT - 0.0.0-MASTER-SNAPSHOT + 0.0.0-SED-4417-SNAPSHOT 5.0.4 diff --git a/step-controller/step-controller-backend/src/test/resources/reporting/layouts/presets/StepMainReportLayout.json b/step-controller/step-controller-backend/src/test/resources/reporting/layouts/presets/StepMainReportLayout.json new file mode 100644 index 000000000..c4ed57d7b --- /dev/null +++ b/step-controller/step-controller-backend/src/test/resources/reporting/layouts/presets/StepMainReportLayout.json @@ -0,0 +1,107 @@ +{ + "id": "69b010aeec94534eb48176db", + "name": "Step Main Layout", + "layout": { + "widgets": [ + { + "widgetType": "errorsWidget", + "position": { + "row": 1, + "column": 1, + "widthInCells": 8, + "heightInCells": 1 + } + }, + { + "widgetType": "testCases", + "position": { + "row": 2, + "column": 1, + "widthInCells": 6, + "heightInCells": 3 + } + }, + { + "widgetType": "testCasesSummary", + "position": { + "row": 2, + "column": 7, + "widthInCells": 2, + "heightInCells": 3 + } + }, + { + "widgetType": "keywordsSummary", + "position": { + "row": 5, + "column": 1, + "widthInCells": 2, + "heightInCells": 3 + } + }, + { + "widgetType": "executionTree", + "position": { + "row": 5, + "column": 3, + "widthInCells": 6, + "heightInCells": 3 + } + }, + { + "widgetType": "keywordsList", + "position": { + "row": 8, + "column": 1, + "widthInCells": 6, + "heightInCells": 3 + } + }, + { + "widgetType": "performanceOverview", + "position": { + "row": 8, + "column": 7, + "widthInCells": 2, + "heightInCells": 3 + } + }, + { + "widgetType": "errors", + "position": { + "row": 11, + "column": 1, + "widthInCells": 8, + "heightInCells": 3 + } + }, + { + "widgetType": "notificationSubscriptionForExecution", + "position": { + "row": 14, + "column": 1, + "widthInCells": 4, + "heightInCells": 3 + } + }, + { + "widgetType": "housekeepingSettingsForExecution", + "position": { + "row": 14, + "column": 5, + "widthInCells": 4, + "heightInCells": 3 + } + }, + { + "widgetType": "currentOperations", + "position": { + "row": 17, + "column": 1, + "widthInCells": 4, + "heightInCells": 3 + } + } + ] + } +} diff --git a/step-controller/step-controller-backend/src/test/resources/step.properties b/step-controller/step-controller-backend/src/test/resources/step.properties index ed348bbe9..f8f7b5870 100644 --- a/step-controller/step-controller-backend/src/test/resources/step.properties +++ b/step-controller/step-controller-backend/src/test/resources/step.properties @@ -94,3 +94,8 @@ plugins.FunctionPackagePlugin.maven.repository.central.url=https://repo1.maven.o #timeseries.collections.week.flush.period=3600000 #timeseries.response.intervals.ideal=100 #timeseries.response.intervals.max=1000 +#----------------------------------------------------- +# Reporting +#----------------------------------------------------- +plugins.reporting.layouts.presets.folder=src/test/resources/reporting/layouts/presets +plugins.reporting.layouts.default.id=69b010aeec94534eb48176db diff --git a/step-controller/step-controller-server/src/main/java/step/controller/services/entities/AbstractEntityServices.java b/step-controller/step-controller-server/src/main/java/step/controller/services/entities/AbstractEntityServices.java index 555474d5e..ebce0ca74 100644 --- a/step-controller/step-controller-server/src/main/java/step/controller/services/entities/AbstractEntityServices.java +++ b/step-controller/step-controller-server/src/main/java/step/controller/services/entities/AbstractEntityServices.java @@ -8,6 +8,7 @@ import step.automation.packages.AutomationPackageEntity; import step.controller.services.async.AsyncTaskStatus; import step.core.GlobalContext; +import step.core.access.User; import step.core.accessors.AbstractIdentifiableObject; import step.core.accessors.AbstractOrganizableObject; import step.core.accessors.AbstractTrackedObject; @@ -152,11 +153,19 @@ private void trackEntityIfApplicable(T entity) { AbstractTrackedObject newTrackedEntity = (AbstractTrackedObject) entity; ObjectId sourceId = entity.getId(); T sourceEntity = (sourceId != null) ? accessor.get(sourceId) : null; - String username = getSession().getUser().getUsername(); + User user = getSession().getUser(); + String username = user.getUsername(); + String userId = user.getId().toHexString(); Date lastModificationDate = new Date(); - if (sourceEntity == null) { + //If source is null or not an instance of AbstractTrackedObject, we set creation metadata + if (!(sourceEntity instanceof AbstractTrackedObject)) { newTrackedEntity.setCreationDate(lastModificationDate); newTrackedEntity.setCreationUser(username); + } else { + //In case of update we make sure we keep the creation metadata from the DB info + AbstractTrackedObject sourceTrackedEntity = (AbstractTrackedObject) sourceEntity; + newTrackedEntity.setCreationDate(sourceTrackedEntity.getCreationDate()); + newTrackedEntity.setCreationUser(sourceTrackedEntity.getCreationUser()); } newTrackedEntity.setLastModificationDate(lastModificationDate); newTrackedEntity.setLastModificationUser(username); diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutAccessor.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutAccessor.java new file mode 100644 index 000000000..009b9fedf --- /dev/null +++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutAccessor.java @@ -0,0 +1,33 @@ +package step.core.reporting; + +import step.core.accessors.AbstractAccessor; +import step.core.accessors.AbstractOrganizableObject; +import step.core.collections.Collection; +import step.core.collections.Filters; +import step.core.collections.SearchOrder; +import step.core.collections.filters.Or; +import step.core.reporting.model.ReportLayout; + +import java.util.List; +import java.util.stream.Collectors; + +import static step.core.collections.Order.ASC; +import static step.core.reporting.model.ReportLayout.FIELD_VISIBILITY; +import static step.core.reporting.model.ReportLayout.ReportLayoutVisibility.Preset; +import static step.core.reporting.model.ReportLayout.ReportLayoutVisibility.Shared; + +public class ReportLayoutAccessor extends AbstractAccessor { + + public ReportLayoutAccessor(Collection collectionDriver) { + super(collectionDriver); + } + + public List getAccessibleReportLayoutsDefinitions(String userName) { + Or ownerOrShared = Filters.or(List.of(Filters.equals(FIELD_VISIBILITY, Preset.name()), Filters.equals(FIELD_VISIBILITY, Shared.name()), Filters.equals("creationUser", userName))); + return this.getCollectionDriver() + .find(ownerOrShared, new SearchOrder(ATTRIBUTES_FIELD_NAME + "." + AbstractOrganizableObject.NAME, ASC.numeric), null, null, 0) + .peek(reportLayout -> reportLayout.layout = null) + .collect(Collectors.toList()); + } + +} diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutPlugin.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutPlugin.java new file mode 100644 index 000000000..ffddf3845 --- /dev/null +++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutPlugin.java @@ -0,0 +1,99 @@ +package step.core.reporting; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.bson.types.ObjectId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import step.core.GlobalContext; +import step.core.accessors.AbstractOrganizableObject; +import step.core.accessors.DefaultJacksonMapperProvider; +import step.core.collections.Collection; +import step.core.collections.Filters; +import step.core.deployment.WebApplicationConfigurationManager; +import step.core.entities.Entity; +import step.core.entities.EntityConstants; +import step.core.plugins.AbstractControllerPlugin; +import step.core.plugins.Plugin; +import step.core.reporting.model.ReportLayout; +import step.core.reporting.model.ReportLayoutJson; +import step.framework.server.tables.Table; +import step.framework.server.tables.TableRegistry; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Plugin +public class ReportLayoutPlugin extends AbstractControllerPlugin { + + private static final Logger logger = LoggerFactory.getLogger(ReportLayoutPlugin.class); + + public static final String DEFAULT_REPORT_LAYOUT = "Default"; + public static final String PRESET_FOLDER_PATH_CONFIG_KEY = "plugins.reporting.layouts.presets.folder"; + public static final String PRESET_FOLDER_PATH_DEFAULT = "../plugins/reporting/layouts"; + public static final String DEFAULT_LAYOUT_ID_CONFIG_KEY = "plugins.reporting.layouts.default.id"; + public static final String DEFAULT_LAYOUT_ID_DEFAULT = "69b010aeec94534eb48176db"; + + private ReportLayoutAccessor reportLayoutAccessor; + private String defaultLayoutId; + + @Override + public void serverStart(GlobalContext context) throws Exception { + super.serverStart(context); + TableRegistry tableRegistry = context.require(TableRegistry.class); + //Create accessor + Collection reportLayoutCollection = context.getCollectionFactory().getCollection(EntityConstants.reportLayouts, ReportLayout.class); + reportLayoutAccessor = new ReportLayoutAccessor(reportLayoutCollection); + context.put(ReportLayoutAccessor.class, reportLayoutAccessor); + //Register entity + context.getEntityManager().register(new Entity<>(EntityConstants.reportLayouts, reportLayoutAccessor, ReportLayout.class)); + //Register Table, table only return layout metadata not the layout itself + tableRegistry.register(EntityConstants.reportLayouts, new Table<>(reportLayoutCollection, ReportLayoutServices.REPORT_LAYOUT_RIGHT + "-read", false).withResultItemTransformer((reportLayout, session) -> { + reportLayout.layout = null; + return reportLayout; + })); + //Register Services + context.getServiceRegistrationCallback().registerService(ReportLayoutServices.class); + + //Add default layout ID to UI configuration + defaultLayoutId = context.getConfiguration().getProperty(DEFAULT_LAYOUT_ID_CONFIG_KEY, DEFAULT_LAYOUT_ID_DEFAULT); + WebApplicationConfigurationManager configurationManager = context.require(WebApplicationConfigurationManager.class); + configurationManager.registerHook(s -> Map.of(DEFAULT_LAYOUT_ID_CONFIG_KEY, defaultLayoutId)); + + } + + @Override + public void initializeData(GlobalContext context) throws Exception { + super.initializeData(context); + // Drop all existing presets - the folder is the source of truth at startup + reportLayoutAccessor.getCollectionDriver().remove( + Filters.equals(ReportLayout.FIELD_VISIBILITY, ReportLayout.ReportLayoutVisibility.Preset.name())); + + // Load presets from the configured folder + File presetsFolder = new File(context.getConfiguration().getProperty(PRESET_FOLDER_PATH_CONFIG_KEY, PRESET_FOLDER_PATH_DEFAULT)); + if (presetsFolder.exists() && presetsFolder.isDirectory()) { + ObjectMapper objectMapper = DefaultJacksonMapperProvider.getObjectMapper(); + File[] jsonFiles = presetsFolder.listFiles((dir, name) -> name.endsWith(".json")); + if (jsonFiles != null) { + for (File jsonFile : jsonFiles) { + try { + ReportLayoutJson layoutJson = objectMapper.readValue(jsonFile, ReportLayoutJson.class); + if (ObjectId.isValid(layoutJson.id)) { + ReportLayout reportLayout = new ReportLayout(layoutJson.layout, ReportLayout.ReportLayoutVisibility.Preset); + reportLayout.addAttribute(AbstractOrganizableObject.NAME, layoutJson.name); + reportLayout.setId(new ObjectId(layoutJson.id)); + reportLayoutAccessor.save(reportLayout); + } else { + logger.error("Invalid json file: {}, the id {} has been tempered with and is not a valid ObjectId", jsonFile.getAbsolutePath(), layoutJson.id); + } + } catch (Exception e) { + logger.error("Failed to load preset layout from file '{}'", jsonFile.getAbsolutePath(), e); + } + } + } + } else { + logger.warn("The configured presets folder '{}' does not exist or is not a directory.", presetsFolder.getAbsolutePath()); + } + } +} diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutServices.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutServices.java new file mode 100644 index 000000000..461bf72f2 --- /dev/null +++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutServices.java @@ -0,0 +1,187 @@ +package step.core.reporting; + + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.PostConstruct; +import jakarta.inject.Singleton; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.apache.poi.ss.formula.functions.T; +import step.controller.services.entities.AbstractEntityServices; +import step.core.accessors.AbstractOrganizableObject; +import step.core.deployment.AuthorizationException; +import step.core.entities.EntityConstants; +import step.core.reporting.model.ReportLayout; +import step.core.reporting.model.ReportLayoutJson; +import step.framework.server.security.Secured; +import step.framework.server.security.SecuredContext; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + + +@Singleton +@Path("/report-layout") +@Tag(name = "ReportLayout") +@Tag(name = "Entity=ReportLayout") +@SecuredContext(key = "entity", value = ReportLayoutServices.REPORT_LAYOUT_RIGHT) +public class ReportLayoutServices extends AbstractEntityServices { + + private static final String SHARED_RIGHT_SUFFIX = "shared"; + public static final String READ_RIGHT = "read"; + public static final String DELETE_RIGHT = "delete"; + public static final String WRITE_RIGHT = "write"; + public static final String REPORT_LAYOUT_RIGHT = "reportLayout"; + + private ReportLayoutAccessor reportLayoutAccessor; + + public ReportLayoutServices() { + super(EntityConstants.reportLayouts); + } + + @PostConstruct + public void init() throws Exception { + super.init(); + reportLayoutAccessor = getContext().require(ReportLayoutAccessor.class); + } + + @Override + public ReportLayout get(String id) { + ReportLayout reportLayout = super.get(id); + checkLayoutRight(reportLayout, READ_RIGHT); + return reportLayout; + } + + @Operation(operationId = "exportLayout", description = "Export the report layout to its Json representation") + @GET + @Path("/{id}/json") + @Produces(MediaType.APPLICATION_JSON) + @Secured(right = "{entity}-read") + public ReportLayoutJson exportLayout(@PathParam("id") String id) { + ReportLayout reportLayout = getEntity(id); + checkLayoutRight(reportLayout, READ_RIGHT); + return new ReportLayoutJson(reportLayout); + } + + @Override + public List findByIds(List ids) { + List byIds = super.findByIds(ids); + byIds.forEach(r -> checkLayoutRight(r, READ_RIGHT)); + return byIds; + } + + @Override + public Map findNamesByIds(List ids) { + return reportLayoutAccessor.findByIds(ids).peek(l -> checkLayoutRight(l, READ_RIGHT)).collect(Collectors.toMap(a -> a.getId().toHexString(), a -> + a.getAttribute(AbstractOrganizableObject.NAME) + )); + } + + @Override + public List findManyByAttributes(Map attributes) { + return super.findManyByAttributes(attributes).stream().filter(this::canReadLayout).collect(Collectors.toList()); + } + + private boolean canReadLayout(ReportLayout r) { + try { + checkLayoutRight(r, READ_RIGHT); + return true; + } catch (AuthorizationException e) { + return false; + } + } + + @Operation(description = "Returns all accessible report layouts.") + @GET + @Path("/list") + @Produces(MediaType.APPLICATION_JSON) + @Secured(right="{entity}-read") + public List getAllReportLayouts() { + return reportLayoutAccessor.getAccessibleReportLayoutsDefinitions(getSession().getUser().getUsername()); + } + + @Override + public ReportLayout save(ReportLayout reportLayout) { + //Only check additional specific rights when updating layout + Optional.ofNullable(super.get(reportLayout.getId().toHexString())).ifPresent(entity -> checkLayoutRight(entity, WRITE_RIGHT)); + return super.save(reportLayout); + } + + private void checkLayoutRight(ReportLayout reportLayout, String right) { + if (ReportLayout.ReportLayoutVisibility.Preset.equals(reportLayout.visibility)) { + //Preset layouts can be read by any user with the base right, modifications are never allowed + if (!READ_RIGHT.equals(right)) { + throw new AuthorizationException("Modifying a preset layout is not allowed."); + } + } else { + //If the current user is the owner, he is always allowed (if he has the base access right role) + if (!reportLayout.getCreationUser().equals(this.getSession().getUser().getUsername())) { + if (ReportLayout.ReportLayoutVisibility.Shared.equals(reportLayout.visibility)) { + //Base read right automatically grant access to reading shared dashboard, write and delete require specific rights + if (!READ_RIGHT.equals(right)) { + //Check specific access right for shared layouts + checkRights(REPORT_LAYOUT_RIGHT + "-" + SHARED_RIGHT_SUFFIX + "-" + right); + } + } else { + //The layout isn't shared, the current user is not the owner, and he doesn't have the "all" right + throw new AuthorizationException("This is a private layout owned by " + reportLayout.getCreationUser() + ", you have no permission to " + right + " it."); + } + } + } + } + + @Override + public void delete(String id) { + ReportLayout reportLayout = getEntity(id); + checkLayoutRight(reportLayout, DELETE_RIGHT); + super.delete(id); + } + + @Override + protected ReportLayout cloneEntity(ReportLayout entity) { + ReportLayout clone = super.cloneEntity(entity); + clone.visibility = ReportLayout.ReportLayoutVisibility.Private; + return clone; + } + + @Override + public ReportLayout restoreVersion(String id, String versionId) { + ReportLayout reportLayout = getEntity(id); + checkLayoutRight(reportLayout, WRITE_RIGHT); + return super.restoreVersion(id, versionId); + } + + @Override + public void setLocked(String id, Boolean locked) { + ReportLayout reportLayout = getEntity(id); + checkLayoutRight(reportLayout, WRITE_RIGHT); + super.setLocked(id, locked); + } + + @Operation(operationId = "shareReportLayout", description = "Share this report layout with other users") + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Secured(right = "{entity}-write") + @Path("{id}/share") + public void shareReportLayout(@PathParam("id") String id) { + changeLayoutVisibility(id, ReportLayout.ReportLayoutVisibility.Shared); + } + + private void changeLayoutVisibility(String id, ReportLayout.ReportLayoutVisibility visibility) { + ReportLayout reportLayout = getEntity(id); + reportLayout.visibility = visibility; + save(reportLayout); + } + + @Operation(operationId = "unshareReportLayout", description = "Unshare this report layout") + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Secured(right = "{entity}-write") + @Path("{id}/unshare") + public void unshareReportLayout(@PathParam("id") String id) { + changeLayoutVisibility(id, ReportLayout.ReportLayoutVisibility.Private); + } +} diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayout.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayout.java new file mode 100644 index 000000000..5f35b59b3 --- /dev/null +++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayout.java @@ -0,0 +1,24 @@ +package step.core.reporting.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.json.JsonObject; +import step.core.accessors.AbstractTrackedObject; + +public class ReportLayout extends AbstractTrackedObject { + + public enum ReportLayoutVisibility { + Preset, Private, Shared + } + + public static final String FIELD_VISIBILITY = "visibility"; + + public JsonObject layout; + public ReportLayoutVisibility visibility; + + @JsonCreator + public ReportLayout(@JsonProperty("layout") JsonObject layout, @JsonProperty(value = FIELD_VISIBILITY, defaultValue = "Private") ReportLayoutVisibility visibility) { + this.layout = layout; + this.visibility = visibility != null ? visibility : ReportLayoutVisibility.Private; + } +} diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayoutJson.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayoutJson.java new file mode 100644 index 000000000..c5af5b7f6 --- /dev/null +++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayoutJson.java @@ -0,0 +1,26 @@ +package step.core.reporting.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.json.JsonObject; +import step.core.accessors.AbstractOrganizableObject; + +public class ReportLayoutJson { + + public final String id; + public final String name; + public final JsonObject layout; + + @JsonCreator + public ReportLayoutJson(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("layout") JsonObject layout) { + this.id = id; + this.name = name; + this.layout = layout; + } + + public ReportLayoutJson(ReportLayout reportLayout) { + this.id = reportLayout.getId().toString(); + this.name = reportLayout.getAttribute(AbstractOrganizableObject.NAME); + this.layout = reportLayout.layout; + } +} diff --git a/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutAccessorTest.java b/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutAccessorTest.java new file mode 100644 index 000000000..fce675d4c --- /dev/null +++ b/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutAccessorTest.java @@ -0,0 +1,107 @@ +package step.core.reporting; + +import org.junit.Before; +import org.junit.Test; +import step.core.accessors.AbstractOrganizableObject; +import step.core.collections.inmemory.InMemoryCollectionFactory; +import step.core.reporting.model.ReportLayout; + +import java.util.List; +import java.util.Properties; + +import static org.junit.Assert.*; + +public class ReportLayoutAccessorTest { + + private ReportLayoutAccessor accessor; + + @Before + public void setUp() { + InMemoryCollectionFactory factory = new InMemoryCollectionFactory(new Properties()); + accessor = new ReportLayoutAccessor(factory.getCollection("reportLayouts", ReportLayout.class)); + } + + private ReportLayout saveLayout(String name, ReportLayout.ReportLayoutVisibility visibility, String user) { + ReportLayout layout = new ReportLayout(null, visibility); + layout.addAttribute(AbstractOrganizableObject.NAME, name); + layout.setCreationUser(user); + return accessor.save(layout); + } + + // --- getAccessibleReportLayoutsDefinitions --- + + @Test + public void getAccessibleReportLayoutsDefinitions_includesPresets() { + saveLayout("GlobalPreset", ReportLayout.ReportLayoutVisibility.Preset, null); + + List layouts = accessor.getAccessibleReportLayoutsDefinitions("anyUser"); + + assertEquals(1, layouts.size()); + assertEquals("GlobalPreset", layouts.get(0).getAttribute(AbstractOrganizableObject.NAME)); + } + + @Test + public void getAccessibleReportLayoutsDefinitions_includesSharedLayouts() { + saveLayout("SharedLayout", ReportLayout.ReportLayoutVisibility.Shared, "alice"); + + List layouts = accessor.getAccessibleReportLayoutsDefinitions("bob"); + + assertEquals(1, layouts.size()); + assertEquals("SharedLayout", layouts.get(0).getAttribute(AbstractOrganizableObject.NAME)); + } + + @Test + public void getAccessibleReportLayoutsDefinitions_includesOwnPrivateLayout() { + saveLayout("AlicePrivate", ReportLayout.ReportLayoutVisibility.Private, "alice"); + + List layouts = accessor.getAccessibleReportLayoutsDefinitions("alice"); + + assertEquals(1, layouts.size()); + assertEquals("AlicePrivate", layouts.get(0).getAttribute(AbstractOrganizableObject.NAME)); + } + + @Test + public void getAccessibleReportLayoutsDefinitions_excludesOtherUsersPrivateLayout() { + saveLayout("AlicePrivate", ReportLayout.ReportLayoutVisibility.Private, "alice"); + + List layouts = accessor.getAccessibleReportLayoutsDefinitions("bob"); + + assertTrue(layouts.isEmpty()); + } + + @Test + public void getAccessibleReportLayoutsDefinitions_combinesAllVisibleTypes() { + saveLayout("Preset", ReportLayout.ReportLayoutVisibility.Preset, null); + saveLayout("Shared", ReportLayout.ReportLayoutVisibility.Shared, "alice"); + saveLayout("BobPrivate", ReportLayout.ReportLayoutVisibility.Private, "bob"); + saveLayout("AlicePrivate", ReportLayout.ReportLayoutVisibility.Private, "alice"); + + List layouts = accessor.getAccessibleReportLayoutsDefinitions("bob"); + + assertEquals(3, layouts.size()); // preset + shared + bob's own private + } + + @Test + public void getAccessibleReportLayoutsDefinitions_stripsLayoutField() { + saveLayout("Preset", ReportLayout.ReportLayoutVisibility.Preset, null); + saveLayout("OwnPrivate", ReportLayout.ReportLayoutVisibility.Private, "alice"); + + List layouts = accessor.getAccessibleReportLayoutsDefinitions("alice"); + + layouts.forEach(l -> assertNull("layout field should be stripped", l.layout)); + } + + @Test + public void getAccessibleReportLayoutsDefinitions_sortedByName() { + saveLayout("Z Layout", ReportLayout.ReportLayoutVisibility.Preset, null); + saveLayout("A Layout", ReportLayout.ReportLayoutVisibility.Preset, null); + saveLayout("M Layout", ReportLayout.ReportLayoutVisibility.Preset, null); + + List layouts = accessor.getAccessibleReportLayoutsDefinitions("anyUser"); + + assertEquals(3, layouts.size()); + assertEquals("A Layout", layouts.get(0).getAttribute(AbstractOrganizableObject.NAME)); + assertEquals("M Layout", layouts.get(1).getAttribute(AbstractOrganizableObject.NAME)); + assertEquals("Z Layout", layouts.get(2).getAttribute(AbstractOrganizableObject.NAME)); + } +} diff --git a/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutPluginTest.java b/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutPluginTest.java new file mode 100644 index 000000000..86d95f848 --- /dev/null +++ b/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutPluginTest.java @@ -0,0 +1,327 @@ +package step.core.reporting; + +import ch.exense.commons.app.Configuration; +import org.bson.types.ObjectId; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mockito; +import step.core.GlobalContext; +import step.core.accessors.AbstractOrganizableObject; +import step.core.collections.inmemory.InMemoryCollectionFactory; +import step.core.deployment.WebApplicationConfigurationManager; +import step.core.entities.EntityManager; +import step.core.reporting.model.ReportLayout; +import step.framework.server.ServiceRegistrationCallback; +import step.framework.server.tables.TableRegistry; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.List; +import java.util.Properties; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +public class ReportLayoutPluginTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private GlobalContext context; + private ReportLayoutPlugin plugin; + private ReportLayoutAccessor accessor; + + @Before + public void setUp() throws Exception { + context = new GlobalContext(); + context.setCollectionFactory(new InMemoryCollectionFactory(new Properties())); + context.put(TableRegistry.class, new TableRegistry()); + context.setEntityManager(new EntityManager()); + context.setServiceRegistrationCallback(Mockito.mock(ServiceRegistrationCallback.class)); + context.setConfiguration(new Configuration()); + context.put(WebApplicationConfigurationManager.class, new WebApplicationConfigurationManager()); + + plugin = new ReportLayoutPlugin(); + plugin.serverStart(context); + accessor = context.require(ReportLayoutAccessor.class); + } + + private void setPresetsFolder(File folder) { + context.getConfiguration().putProperty( + ReportLayoutPlugin.PRESET_FOLDER_PATH_CONFIG_KEY, folder.getAbsolutePath()); + } + + /** Writes a preset JSON file with a fresh ObjectId. Returns the generated id. */ + private String writePresetFile(File folder, String fileName, String name) throws IOException { + String id = new ObjectId().toHexString(); + writePresetFileWithId(folder, fileName, name, id, "{\"type\":\"test\"}"); + return id; + } + + private void writePresetFileWithId(File folder, String fileName, String name, String id, String layoutJson) throws IOException { + File file = new File(folder, fileName); + try (FileWriter writer = new FileWriter(file)) { + writer.write("{\"id\":\"" + id + "\",\"name\":\"" + name + "\",\"layout\":" + layoutJson + "}"); + } + } + + private List getAllPresets() { + return accessor.getCollectionDriver() + .find(step.core.collections.Filters.equals( + ReportLayout.FIELD_VISIBILITY, + ReportLayout.ReportLayoutVisibility.Preset.name()), + null, null, null, 0) + .collect(Collectors.toList()); + } + + // --- initializeData with valid folder --- + + @Test + public void initializeData_singleValidFile_createsPreset() throws Exception { + File folder = tempFolder.newFolder("presets"); + writePresetFile(folder, "layout1.json", "My Preset"); + setPresetsFolder(folder); + + plugin.initializeData(context); + + List presets = getAllPresets(); + assertEquals(1, presets.size()); + assertEquals("My Preset", presets.get(0).getAttribute(AbstractOrganizableObject.NAME)); + assertEquals(ReportLayout.ReportLayoutVisibility.Preset, presets.get(0).visibility); + } + + @Test + public void initializeData_multipleValidFiles_createsAllPresets() throws Exception { + File folder = tempFolder.newFolder("presets"); + writePresetFile(folder, "alpha.json", "Alpha"); + writePresetFile(folder, "beta.json", "Beta"); + writePresetFile(folder, "gamma.json", "Gamma"); + setPresetsFolder(folder); + + plugin.initializeData(context); + + List presets = getAllPresets(); + assertEquals(3, presets.size()); + List names = presets.stream() + .map(p -> p.getAttribute(AbstractOrganizableObject.NAME)) + .collect(Collectors.toList()); + assertTrue(names.contains("Alpha")); + assertTrue(names.contains("Beta")); + assertTrue(names.contains("Gamma")); + } + + @Test + public void initializeData_preservesIdFromJsonFile() throws Exception { + File folder = tempFolder.newFolder("presets"); + String fixedId = new ObjectId().toHexString(); + writePresetFileWithId(folder, "layout.json", "Fixed Id Preset", fixedId, "{\"type\":\"test\"}"); + setPresetsFolder(folder); + + plugin.initializeData(context); + + List presets = getAllPresets(); + assertEquals(1, presets.size()); + assertEquals(fixedId, presets.get(0).getId().toHexString()); + } + + @Test + public void initializeData_nonJsonFilesIgnored() throws Exception { + File folder = tempFolder.newFolder("presets"); + writePresetFile(folder, "layout.json", "Valid Preset"); + new File(folder, "readme.txt").createNewFile(); + new File(folder, "layout.xml").createNewFile(); + setPresetsFolder(folder); + + plugin.initializeData(context); + + List presets = getAllPresets(); + assertEquals(1, presets.size()); + assertEquals("Valid Preset", presets.get(0).getAttribute(AbstractOrganizableObject.NAME)); + } + + // --- initializeData drops and recreates presets on each startup --- + + @Test + public void initializeData_dropsExistingPresetsBeforeLoading() throws Exception { + File folder = tempFolder.newFolder("presets"); + writePresetFile(folder, "initial.json", "Initial Preset"); + setPresetsFolder(folder); + plugin.initializeData(context); + assertEquals(1, getAllPresets().size()); + + // Replace file content and re-run initializeData (simulates restart) + new File(folder, "initial.json").delete(); + writePresetFile(folder, "updated.json", "Updated Preset"); + + plugin.initializeData(context); + + List presets = getAllPresets(); + assertEquals(1, presets.size()); + assertEquals("Updated Preset", presets.get(0).getAttribute(AbstractOrganizableObject.NAME)); + } + + @Test + public void initializeData_idempotent_sameIdEachRestart() throws Exception { + File folder = tempFolder.newFolder("presets"); + String fixedId = new ObjectId().toHexString(); + writePresetFileWithId(folder, "layout.json", "Stable Preset", fixedId, "{\"type\":\"test\"}"); + setPresetsFolder(folder); + + plugin.initializeData(context); + plugin.initializeData(context); + + List presets = getAllPresets(); + assertEquals("Second run should not duplicate the preset", 1, presets.size()); + assertEquals(fixedId, presets.get(0).getId().toHexString()); + } + + @Test + public void initializeData_emptyFolder_dropsAllExistingPresets() throws Exception { + ReportLayout existing = new ReportLayout(null, ReportLayout.ReportLayoutVisibility.Preset); + existing.addAttribute(AbstractOrganizableObject.NAME, "Old Preset"); + accessor.save(existing); + assertEquals(1, getAllPresets().size()); + + File emptyFolder = tempFolder.newFolder("empty-presets"); + setPresetsFolder(emptyFolder); + + plugin.initializeData(context); + + assertTrue("All presets should be dropped when folder is empty", getAllPresets().isEmpty()); + } + + @Test + public void initializeData_doesNotDropNonPresetLayouts() throws Exception { + ReportLayout privateLayout = new ReportLayout(null, ReportLayout.ReportLayoutVisibility.Private); + privateLayout.addAttribute(AbstractOrganizableObject.NAME, "My Private"); + privateLayout.setCreationUser("alice"); + accessor.save(privateLayout); + + ReportLayout sharedLayout = new ReportLayout(null, ReportLayout.ReportLayoutVisibility.Shared); + sharedLayout.addAttribute(AbstractOrganizableObject.NAME, "My Shared"); + sharedLayout.setCreationUser("bob"); + accessor.save(sharedLayout); + + File folder = tempFolder.newFolder("presets"); + writePresetFile(folder, "preset.json", "New Preset"); + setPresetsFolder(folder); + + plugin.initializeData(context); + + assertNotNull(accessor.get(privateLayout.getId())); + assertNotNull(accessor.get(sharedLayout.getId())); + assertEquals(1, getAllPresets().size()); + } + + // --- initializeData with missing / invalid folder --- + + @Test + public void initializeData_missingFolder_createsNoPresets() throws Exception { + context.getConfiguration().putProperty( + ReportLayoutPlugin.PRESET_FOLDER_PATH_CONFIG_KEY, + "/nonexistent/path/to/presets"); + + plugin.initializeData(context); + + assertTrue("No presets should be created when folder does not exist", getAllPresets().isEmpty()); + } + + @Test + public void initializeData_missingFolder_dropsExistingPresets() throws Exception { + ReportLayout existing = new ReportLayout(null, ReportLayout.ReportLayoutVisibility.Preset); + existing.addAttribute(AbstractOrganizableObject.NAME, "Existing Preset"); + accessor.save(existing); + + context.getConfiguration().putProperty( + ReportLayoutPlugin.PRESET_FOLDER_PATH_CONFIG_KEY, + "/nonexistent/path/to/presets"); + + plugin.initializeData(context); + + // The drop step runs unconditionally before the folder existence check + assertTrue("Existing presets are dropped even when folder is missing", getAllPresets().isEmpty()); + } + + // --- id validation --- + + @Test + public void initializeData_fileWithMissingId_isSkipped() throws Exception { + File folder = tempFolder.newFolder("presets"); + try (FileWriter writer = new FileWriter(new File(folder, "no-id.json"))) { + writer.write("{\"name\":\"No Id Preset\",\"layout\":{\"type\":\"test\"}}"); + } + setPresetsFolder(folder); + + plugin.initializeData(context); + + assertTrue("Preset with missing id should be skipped", getAllPresets().isEmpty()); + } + + @Test + public void initializeData_fileWithInvalidId_isSkipped() throws Exception { + File folder = tempFolder.newFolder("presets"); + try (FileWriter writer = new FileWriter(new File(folder, "bad-id.json"))) { + writer.write("{\"id\":\"not-a-valid-objectid\",\"name\":\"Bad Id Preset\",\"layout\":{\"type\":\"test\"}}"); + } + setPresetsFolder(folder); + + plugin.initializeData(context); + + assertTrue("Preset with invalid id should be skipped", getAllPresets().isEmpty()); + } + + @Test + public void initializeData_invalidJsonFile_skipsItAndLoadsOthers() throws Exception { + File folder = tempFolder.newFolder("presets"); + try (FileWriter writer = new FileWriter(new File(folder, "broken.json"))) { + writer.write("{ this is not valid json }"); + } + writePresetFile(folder, "valid.json", "Valid Preset"); + setPresetsFolder(folder); + + plugin.initializeData(context); + + List presets = getAllPresets(); + assertEquals("Valid preset should still be loaded despite broken file", 1, presets.size()); + assertEquals("Valid Preset", presets.get(0).getAttribute(AbstractOrganizableObject.NAME)); + } + + @Test + public void initializeData_allInvalidFiles_createsNoPresets() throws Exception { + File folder = tempFolder.newFolder("presets"); + try (FileWriter writer = new FileWriter(new File(folder, "broken1.json"))) { + writer.write("not json at all"); + } + try (FileWriter writer = new FileWriter(new File(folder, "no-id.json"))) { + writer.write("{\"name\":\"Missing Id\",\"layout\":{}}"); + } + setPresetsFolder(folder); + + plugin.initializeData(context); + + assertTrue(getAllPresets().isEmpty()); + } + + // --- preset layout content --- + + @Test + public void initializeData_presetLayoutHasCorrectContent() throws Exception { + File folder = tempFolder.newFolder("presets"); + String id = new ObjectId().toHexString(); + writePresetFileWithId(folder, "layout.json", "Dashboard", id, "{\"columns\":3,\"rows\":2}"); + setPresetsFolder(folder); + + plugin.initializeData(context); + + List presets = getAllPresets(); + assertEquals(1, presets.size()); + ReportLayout preset = presets.get(0); + assertEquals("Dashboard", preset.getAttribute(AbstractOrganizableObject.NAME)); + assertEquals(ReportLayout.ReportLayoutVisibility.Preset, preset.visibility); + assertEquals(id, preset.getId().toHexString()); + assertNotNull("Layout JSON should be parsed and stored", preset.layout); + } +} diff --git a/step-core-model/src/main/java/step/core/entities/EntityConstants.java b/step-core-model/src/main/java/step/core/entities/EntityConstants.java index 677aa5c85..571fcfd31 100644 --- a/step-core-model/src/main/java/step/core/entities/EntityConstants.java +++ b/step-core-model/src/main/java/step/core/entities/EntityConstants.java @@ -14,4 +14,5 @@ public class EntityConstants { public final static String metricTypes = "metricTypes"; public final static String dashboards = "dashboards"; public final static String bookmarks = "bookmarks"; + public final static String reportLayouts = "reportLayouts"; }