diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/pom.xml b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/pom.xml
index 7de2c8b1820..444673aa24a 100644
--- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/pom.xml
+++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/pom.xml
@@ -85,6 +85,11 @@
javax.servlet-api
provided
+
+ org.mockito
+ mockito-core
+ test
+
diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/ContextMenuManager.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/ContextMenuManager.java
index ed483d614f1..fd676bf98c0 100644
--- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/ContextMenuManager.java
+++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/ContextMenuManager.java
@@ -13,6 +13,8 @@
import java.util.LinkedList;
import org.apache.poi.ss.util.CellRangeAddress;
+import org.jsoup.Jsoup;
+import org.jsoup.safety.Safelist;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -42,6 +44,23 @@ public class ContextMenuManager implements Serializable {
private int contextMenuHeaderIndex = -1;
+ /**
+ * Enum for spreadsheet action types.
+ */
+ enum ActionType {
+ CELL(0), ROW(1), COLUMN(2);
+
+ private final int value;
+
+ ActionType(int value) {
+ this.value = value;
+ }
+
+ public int getValue() {
+ return value;
+ }
+ }
+
/**
* Constructs a new ContextMenuManager and ties it to the given Spreadsheet.
*
@@ -216,19 +235,11 @@ public void onActionOnColumnHeader(String actionKey) {
protected ArrayList createActionsListForSelection() {
ArrayList actions = new ArrayList();
for (Handler handler : actionHandlers) {
- Action[] actions2 = handler.getActions(spreadsheet
+ Action[] handlerActions = handler.getActions(spreadsheet
.getCellSelectionManager().getLatestSelectionEvent(),
spreadsheet);
- if (actions2 != null) {
- for (Action action : actions2) {
- String key = actionMapper.key(action);
- SpreadsheetActionDetails spreadsheetActionDetails = new SpreadsheetActionDetails();
- spreadsheetActionDetails.caption = action.getCaption();
- spreadsheetActionDetails.key = key;
- spreadsheetActionDetails.type = 0;
- actions.add(spreadsheetActionDetails);
- }
- }
+ actions.addAll(
+ createActionDetailsList(handlerActions, ActionType.CELL));
}
return actions;
}
@@ -246,14 +257,9 @@ protected ArrayList createActionsListForColumn(
final CellRangeAddress column = new CellRangeAddress(-1, -1,
columnIndex - 1, columnIndex - 1);
for (Handler handler : actionHandlers) {
- for (Action action : handler.getActions(column, spreadsheet)) {
- String key = actionMapper.key(action);
- SpreadsheetActionDetails spreadsheetActionDetails = new SpreadsheetActionDetails();
- spreadsheetActionDetails.caption = action.getCaption();
- spreadsheetActionDetails.key = key;
- spreadsheetActionDetails.type = 2;
- actions.add(spreadsheetActionDetails);
- }
+ Action[] handlerActions = handler.getActions(column, spreadsheet);
+ actions.addAll(
+ createActionDetailsList(handlerActions, ActionType.COLUMN));
}
return actions;
}
@@ -271,16 +277,40 @@ protected ArrayList createActionsListForRow(
final CellRangeAddress row = new CellRangeAddress(rowIndex - 1,
rowIndex - 1, -1, -1);
for (Handler handler : actionHandlers) {
- for (Action action : handler.getActions(row, spreadsheet)) {
+ Action[] handlerActions = handler.getActions(row, spreadsheet);
+ actions.addAll(
+ createActionDetailsList(handlerActions, ActionType.ROW));
+ }
+ return actions;
+ }
+
+ /**
+ * Helper method to create SpreadsheetActionDetails from actions.
+ *
+ * @param actions
+ * Array of actions to convert
+ * @param actionType
+ * Type of the action (cell, row, or column)
+ * @return List of SpreadsheetActionDetails
+ */
+ private ArrayList createActionDetailsList(
+ Action[] actions, ActionType actionType) {
+ ArrayList actionDetailsList = new ArrayList();
+ if (actions != null) {
+ for (Action action : actions) {
String key = actionMapper.key(action);
SpreadsheetActionDetails spreadsheetActionDetails = new SpreadsheetActionDetails();
- spreadsheetActionDetails.caption = action.getCaption();
+ String caption = action.getCaption();
+ if (caption == null) {
+ caption = "";
+ }
+ spreadsheetActionDetails.caption = Jsoup.clean(caption,
+ Safelist.relaxed());
spreadsheetActionDetails.key = key;
- spreadsheetActionDetails.type = 1;
- actions.add(spreadsheetActionDetails);
+ spreadsheetActionDetails.type = actionType.getValue();
+ actionDetailsList.add(spreadsheetActionDetails);
}
}
- return actions;
+ return actionDetailsList;
}
-
}
diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/client/SpreadsheetActionDetails.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/client/SpreadsheetActionDetails.java
index 124ddad2207..e781e19aa4b 100644
--- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/client/SpreadsheetActionDetails.java
+++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/client/SpreadsheetActionDetails.java
@@ -14,6 +14,9 @@
public class SpreadsheetActionDetails implements Serializable {
public String caption;
public String key;
- /** 0 = cell, 1 = row, 2 = column TODO replace with enum type */
+ /**
+ * 0 = cell, 1 = row, 2 = column - kept as int for client-server
+ * compatibility
+ */
public int type;
}
diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/test/java/com/vaadin/flow/component/spreadsheet/tests/ContextMenuManagerTest.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/test/java/com/vaadin/flow/component/spreadsheet/tests/ContextMenuManagerTest.java
new file mode 100644
index 00000000000..104771418cd
--- /dev/null
+++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/test/java/com/vaadin/flow/component/spreadsheet/tests/ContextMenuManagerTest.java
@@ -0,0 +1,402 @@
+/**
+ * Copyright 2000-2025 Vaadin Ltd.
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See {@literal } for the full
+ * license.
+ */
+package com.vaadin.flow.component.spreadsheet.tests;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.ss.util.CellReference;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import com.vaadin.flow.component.spreadsheet.CellSelectionManager;
+import com.vaadin.flow.component.spreadsheet.ContextMenuManager;
+import com.vaadin.flow.component.spreadsheet.Spreadsheet;
+import com.vaadin.flow.component.spreadsheet.Spreadsheet.SelectionChangeEvent;
+import com.vaadin.flow.component.spreadsheet.client.SpreadsheetActionDetails;
+import com.vaadin.flow.component.spreadsheet.framework.Action;
+import com.vaadin.flow.component.spreadsheet.framework.Action.Handler;
+
+public class ContextMenuManagerTest {
+
+ private ContextMenuManager contextMenuManager;
+
+ @Mock
+ private Spreadsheet mockSpreadsheet;
+
+ @Mock
+ private Handler mockHandler;
+
+ @Mock
+ private CellSelectionManager mockSelectionManager;
+
+ private SelectionChangeEvent mockSelectionEvent;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.openMocks(this);
+ contextMenuManager = new ContextMenuManager(mockSpreadsheet);
+
+ // Create a mock selection event
+ CellReference cellRef = new CellReference("Sheet1", 0, 0, false, false);
+ mockSelectionEvent = new SelectionChangeEvent(mockSpreadsheet, cellRef,
+ Collections.emptyList(), null, Collections.emptyList());
+
+ Mockito.when(mockSpreadsheet.getCellSelectionManager())
+ .thenReturn(mockSelectionManager);
+ Mockito.when(mockSelectionManager.getLatestSelectionEvent())
+ .thenReturn(mockSelectionEvent);
+ }
+
+ @Test
+ public void hasActionHandlers_withNoHandlers_shouldReturnFalse() {
+ assertFalse(contextMenuManager.hasActionHandlers());
+ }
+
+ @Test
+ public void hasActionHandlers_withHandler_shouldReturnTrue() {
+ contextMenuManager.addActionHandler(mockHandler);
+ assertTrue(contextMenuManager.hasActionHandlers());
+ }
+
+ @Test
+ public void addActionHandler_nullHandler_shouldNotAddHandler() {
+ contextMenuManager.addActionHandler(null);
+ assertFalse(contextMenuManager.hasActionHandlers());
+ }
+
+ @Test
+ public void addActionHandler_sameHandlerTwice_shouldAddOnlyOnce() {
+ contextMenuManager.addActionHandler(mockHandler);
+ contextMenuManager.addActionHandler(mockHandler);
+
+ // Verify only one handler is added by checking removal behavior
+ contextMenuManager.removeActionHandler(mockHandler);
+ assertFalse(contextMenuManager.hasActionHandlers());
+ }
+
+ @Test
+ public void removeActionHandler_existingHandler_shouldRemoveHandler() {
+ contextMenuManager.addActionHandler(mockHandler);
+ assertTrue(contextMenuManager.hasActionHandlers());
+
+ contextMenuManager.removeActionHandler(mockHandler);
+ assertFalse(contextMenuManager.hasActionHandlers());
+ }
+
+ @Test
+ public void createActionsListForSelection_withValidCaption_shouldCreateActionDetails()
+ throws Exception {
+ String caption = "Test Action";
+ Action action = new Action(caption);
+ Action[] actions = { action };
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(
+ mockHandler.getActions(Mockito.any(SelectionChangeEvent.class),
+ Mockito.eq(mockSpreadsheet)))
+ .thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForSelection");
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager);
+
+ assertEquals(1, result.size());
+ SpreadsheetActionDetails details = result.get(0);
+ assertEquals(caption, details.caption);
+ assertEquals(0, details.type); // CELL type
+ assertNotNull(details.key);
+ }
+
+ @Test
+ public void createActionsListForSelection_withNullCaption_shouldUseSanitizedEmptyString()
+ throws Exception {
+ Action action = new Action(null);
+ Action[] actions = { action };
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(
+ mockHandler.getActions(Mockito.any(SelectionChangeEvent.class),
+ Mockito.eq(mockSpreadsheet)))
+ .thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForSelection");
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager);
+
+ assertEquals(1, result.size());
+ SpreadsheetActionDetails details = result.get(0);
+ assertEquals("", details.caption);
+ assertEquals(0, details.type); // CELL type
+ }
+
+ @Test
+ public void createActionsListForSelection_withHTMLCaption_shouldSanitizeHTML()
+ throws Exception {
+ String htmlCaption = "Bold TextParagraph
";
+ Action action = new Action(htmlCaption);
+ Action[] actions = { action };
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(
+ mockHandler.getActions(Mockito.any(SelectionChangeEvent.class),
+ Mockito.eq(mockSpreadsheet)))
+ .thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForSelection");
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager);
+
+ assertEquals(1, result.size());
+ SpreadsheetActionDetails details = result.get(0);
+
+ // Normalize whitespace for comparison
+ String normalizedCaption = details.caption.replaceAll("\\s+", " ")
+ .trim();
+ String expectedNormalized = "Bold Text Paragraph
"; // Normalized
+ // expected
+
+ assertEquals(expectedNormalized, normalizedCaption);
+ assertFalse("Should not contain script tags",
+ details.caption.contains("script"));
+ assertEquals(0, details.type); // CELL type
+ }
+
+ @Test
+ public void createActionsListForSelection_withMaliciousHTML_shouldRemoveDangerousContent()
+ throws Exception {
+ String maliciousCaption = ""
+ + "
"
+ + "Link" + "Safe text";
+ Action action = new Action(maliciousCaption);
+ Action[] actions = { action };
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(
+ mockHandler.getActions(Mockito.any(SelectionChangeEvent.class),
+ Mockito.eq(mockSpreadsheet)))
+ .thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForSelection");
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager);
+
+ assertEquals(1, result.size());
+ SpreadsheetActionDetails details = result.get(0);
+ // Should contain only safe content, no scripts or dangerous attributes
+ assertTrue(details.caption.contains("Safe text"));
+ assertFalse(details.caption.contains("script"));
+ assertFalse(details.caption.contains("onerror"));
+ assertFalse(details.caption.contains("javascript:"));
+ }
+
+ @Test
+ public void createActionsListForRow_shouldCreateCorrectActionType()
+ throws Exception {
+ String caption = "Row Action";
+ Action action = new Action(caption);
+ Action[] actions = { action };
+ int rowIndex = 5;
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(mockHandler.getActions(Mockito.any(CellRangeAddress.class),
+ Mockito.eq(mockSpreadsheet))).thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForRow", int.class);
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager, rowIndex);
+
+ assertEquals(1, result.size());
+ SpreadsheetActionDetails details = result.get(0);
+ assertEquals(caption, details.caption);
+ assertEquals(1, details.type); // ROW type
+ }
+
+ @Test
+ public void createActionsListForColumn_shouldCreateCorrectActionType()
+ throws Exception {
+ String caption = "Column Action";
+ Action action = new Action(caption);
+ Action[] actions = { action };
+ int columnIndex = 3;
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(mockHandler.getActions(Mockito.any(CellRangeAddress.class),
+ Mockito.eq(mockSpreadsheet))).thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForColumn", int.class);
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager, columnIndex);
+
+ assertEquals(1, result.size());
+ SpreadsheetActionDetails details = result.get(0);
+ assertEquals(caption, details.caption);
+ assertEquals(2, details.type); // COLUMN type
+ }
+
+ @Test
+ public void createActionsListForColumn_withHTMLCaption_shouldSanitizeHTML()
+ throws Exception {
+ String htmlCaption = "Emphasized";
+ String expectedSanitized = "Emphasized"; // Script removed, em
+ // tag kept
+ Action action = new Action(htmlCaption);
+ Action[] actions = { action };
+ int columnIndex = 1;
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(mockHandler.getActions(Mockito.any(CellRangeAddress.class),
+ Mockito.eq(mockSpreadsheet))).thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForColumn", int.class);
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager, columnIndex);
+
+ assertEquals(1, result.size());
+ SpreadsheetActionDetails details = result.get(0);
+ assertEquals(expectedSanitized, details.caption);
+ assertEquals(2, details.type); // COLUMN type
+ }
+
+ @Test
+ public void createActionsListForRow_withHTMLCaption_shouldSanitizeHTML()
+ throws Exception {
+ String htmlCaption = "Strong";
+ String expectedSanitized = "Strong"; // iframe removed,
+ // strong kept
+ Action action = new Action(htmlCaption);
+ Action[] actions = { action };
+ int rowIndex = 2;
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(mockHandler.getActions(Mockito.any(CellRangeAddress.class),
+ Mockito.eq(mockSpreadsheet))).thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForRow", int.class);
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager, rowIndex);
+
+ assertEquals(1, result.size());
+ SpreadsheetActionDetails details = result.get(0);
+ assertEquals(expectedSanitized, details.caption);
+ assertEquals(1, details.type); // ROW type
+ }
+
+ @Test
+ public void createActionsListForSelection_withNullActions_shouldReturnEmptyList()
+ throws Exception {
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(
+ mockHandler.getActions(Mockito.any(SelectionChangeEvent.class),
+ Mockito.eq(mockSpreadsheet)))
+ .thenReturn(null);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForSelection");
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager);
+
+ assertEquals(0, result.size());
+ }
+
+ @Test
+ public void createActionsListForSelection_withEmptyActions_shouldReturnEmptyList()
+ throws Exception {
+ Action[] emptyActions = {};
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(
+ mockHandler.getActions(Mockito.any(SelectionChangeEvent.class),
+ Mockito.eq(mockSpreadsheet)))
+ .thenReturn(emptyActions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForSelection");
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager);
+
+ assertEquals(0, result.size());
+ }
+
+ @Test
+ public void createActionsListForSelection_withMultipleActions_shouldCreateMultipleDetails()
+ throws Exception {
+ Action action1 = new Action("Action 1");
+ Action action2 = new Action("Action 2");
+ Action[] actions = { action1, action2 };
+
+ contextMenuManager.addActionHandler(mockHandler);
+ Mockito.when(
+ mockHandler.getActions(Mockito.any(SelectionChangeEvent.class),
+ Mockito.eq(mockSpreadsheet)))
+ .thenReturn(actions);
+
+ // Use reflection to access protected method
+ Method method = ContextMenuManager.class
+ .getDeclaredMethod("createActionsListForSelection");
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ ArrayList result = (ArrayList) method
+ .invoke(contextMenuManager);
+
+ assertEquals(2, result.size());
+ assertEquals("Action 1", result.get(0).caption);
+ assertEquals("Action 2", result.get(1).caption);
+ assertEquals(0, result.get(0).type); // Both should be CELL type
+ assertEquals(0, result.get(1).type);
+ }
+}