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 Text

Paragraph

"; + 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); + } +}