diff --git a/features/config/upgrade/src/main/resources/changelog-cm/36.0.0/changelog-cm.xml b/features/config/upgrade/src/main/resources/changelog-cm/36.0.0/changelog-cm.xml index f9f4f8f12ec6..95a68c57efcb 100644 --- a/features/config/upgrade/src/main/resources/changelog-cm/36.0.0/changelog-cm.xml +++ b/features/config/upgrade/src/main/resources/changelog-cm/36.0.0/changelog-cm.xml @@ -12,10 +12,10 @@ - - - - + + + + \ No newline at end of file diff --git a/opennms-webapp-rest/pom.xml b/opennms-webapp-rest/pom.xml index 1701c18efd12..eb45eb66b6c3 100644 --- a/opennms-webapp-rest/pom.xml +++ b/opennms-webapp-rest/pom.xml @@ -618,5 +618,11 @@ org.opennms.features.notifications.api ${onmsLibScope} + + org.opennms.features.config.dao + org.opennms.features.config.dao.api + ${project.version} + ${onmsLibScope} + diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TrapdRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TrapdRestService.java index e492b6e92b3a..fdea5e9b1328 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TrapdRestService.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TrapdRestService.java @@ -151,11 +151,13 @@ private String validateTrapdConfigRequest(final TrapdConfigDto configDto) { } if (configDto.getSnmpv3User() != null) { + int index = 0; for (Snmpv3UserDto user : configDto.getSnmpv3User()) { String userValidation = validateSnmpv3UserPayload(user); if (userValidation != null) { - return "Invalid SNMPv3 user: " + user.getSecurityName() + ". " + userValidation; + return "Invalid SNMPv3 user at index " + index + ": " + userValidation; } + index++; } } return null; @@ -175,6 +177,10 @@ private String validateTrapdConfigRequest(final TrapdConfigDto configDto) { * @return an error message string, or {@code null} if the user is valid. */ private String validateSnmpv3UserPayload(final Snmpv3UserDto user) { + if (user == null) { + return "entry must not be null."; + } + if (StringUtils.isBlank(user.getSecurityName())) { return "securityName is required."; } diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/TrapdRestApi.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/TrapdRestApi.java index 6c40d277b571..82aa8b39ff8e 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/TrapdRestApi.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/TrapdRestApi.java @@ -22,11 +22,9 @@ package org.opennms.web.rest.v2.api; import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; -import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.PUT; import javax.ws.rs.core.Context; @@ -41,7 +39,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import org.opennms.web.rest.v2.model.Snmpv3UserDto; import org.opennms.web.rest.v2.model.TrapdConfigDto; @Path("trapd") diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json index 2143094c92a5..91cfe4d906db 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json @@ -454,8 +454,8 @@ "roles": ["ROLE_ADMIN", "ROLE_PROVISION"] }, { - "id": "trapdConfiguration", - "name": "Trapd Configuration", + "id": "trapListenerConfiguration", + "name": "Trap Listener Configuration", "url": "ui/index.html#/trapd-config", "locationMatch": "", "roles": null diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json index 2143094c92a5..91cfe4d906db 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json @@ -454,8 +454,8 @@ "roles": ["ROLE_ADMIN", "ROLE_PROVISION"] }, { - "id": "trapdConfiguration", - "name": "Trapd Configuration", + "id": "trapListenerConfiguration", + "name": "Trap Listener Configuration", "url": "ui/index.html#/trapd-config", "locationMatch": "", "roles": null diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TrapdRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TrapdRestServiceIT.java index 6fd1373244d7..1d7e7dcacff2 100644 --- a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TrapdRestServiceIT.java +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TrapdRestServiceIT.java @@ -67,7 +67,6 @@ @JUnitConfigurationEnvironment(systemProperties = "org.opennms.timeseries.strategy=integration") @JUnitTemporaryDatabase public class TrapdRestServiceIT { - private static final String PASSPHRASE_PLACEHOLDER = "********"; @Autowired private TrapdRestService trapdRestService; @@ -203,38 +202,17 @@ public void getShouldReturnServerErrorWhenExceptionThrown() { } } - // --- passphrase masking tests --- - @Test - public void getShouldMaskBothPassphrasesWithPlaceholder() { + public void getShouldReturnSnmpv3UserFieldsUnchanged() { TrapdConfiguration config = buildMinimalConfig(); Snmpv3User user = new Snmpv3User(); - user.setSecurityName("user1"); + user.setSecurityName("engine-user"); + user.setEngineId("0x8000000001020304"); user.setSecurityLevel(3); user.setAuthProtocol("SHA"); - user.setAuthPassphrase(PASSPHRASE_PLACEHOLDER); + user.setAuthPassphrase("authpass"); user.setPrivacyProtocol("AES"); - user.setPrivacyPassphrase(PASSPHRASE_PLACEHOLDER); - config.addSnmpv3User(user); - when(trapdConfigDao.getConfig()).thenReturn(config); - - try (Response response = trapdRestService.getTrapdConfiguration(null)) { - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - TrapdConfigDto returned = (TrapdConfigDto) response.getEntity(); - Snmpv3UserDto returnedUser = returned.getSnmpv3User().get(0); - assertEquals(PASSPHRASE_PLACEHOLDER, returnedUser.getAuthPassphrase()); - assertEquals(PASSPHRASE_PLACEHOLDER, returnedUser.getPrivacyPassphrase()); - } - } - - @Test - public void getShouldMaskAuthPassphraseWhenPrivacyPassphraseIsAbsent() { - TrapdConfiguration config = buildMinimalConfig(); - Snmpv3User user = new Snmpv3User(); - user.setSecurityName("user1"); - user.setSecurityLevel(2); - user.setAuthProtocol("MD5"); - user.setAuthPassphrase(PASSPHRASE_PLACEHOLDER); + user.setPrivacyPassphrase("privpass"); config.addSnmpv3User(user); when(trapdConfigDao.getConfig()).thenReturn(config); @@ -242,13 +220,18 @@ public void getShouldMaskAuthPassphraseWhenPrivacyPassphraseIsAbsent() { assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); TrapdConfigDto returned = (TrapdConfigDto) response.getEntity(); Snmpv3UserDto returnedUser = returned.getSnmpv3User().get(0); - assertEquals(PASSPHRASE_PLACEHOLDER, returnedUser.getAuthPassphrase()); - assertNull(returnedUser.getPrivacyPassphrase()); + assertEquals("engine-user", returnedUser.getSecurityName()); + assertEquals("0x8000000001020304", returnedUser.getEngineId()); + assertEquals("SHA", returnedUser.getAuthProtocol()); + assertEquals("AES", returnedUser.getPrivacyProtocol()); + assertEquals(Integer.valueOf(3), returnedUser.getSecurityLevel()); + assertEquals("authpass", returnedUser.getAuthPassphrase()); + assertEquals("privpass", returnedUser.getPrivacyPassphrase()); } } @Test - public void getShouldNotSetPlaceholderWhenPassphrasesAreNull() { + public void getShouldReturnNullPassphrasesWhenNotSet() { TrapdConfiguration config = buildMinimalConfig(); Snmpv3User user = new Snmpv3User(); user.setSecurityName("user1"); @@ -267,23 +250,15 @@ public void getShouldNotSetPlaceholderWhenPassphrasesAreNull() { } @Test - public void getShouldMaskPassphrasesForEveryUser() { + public void getShouldReturnAllSnmpv3Users() { TrapdConfiguration config = buildMinimalConfig(); Snmpv3User userA = new Snmpv3User(); userA.setSecurityName("user-a"); - userA.setSecurityLevel(3); - userA.setAuthProtocol("SHA"); - userA.setAuthPassphrase(PASSPHRASE_PLACEHOLDER); - userA.setPrivacyProtocol("AES"); - userA.setPrivacyPassphrase(PASSPHRASE_PLACEHOLDER); config.addSnmpv3User(userA); Snmpv3User userB = new Snmpv3User(); userB.setSecurityName("user-b"); - userB.setSecurityLevel(2); - userB.setAuthProtocol("MD5"); - userB.setAuthPassphrase(PASSPHRASE_PLACEHOLDER); config.addSnmpv3User(userB); when(trapdConfigDao.getConfig()).thenReturn(config); @@ -292,62 +267,8 @@ public void getShouldMaskPassphrasesForEveryUser() { assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); TrapdConfigDto returned = (TrapdConfigDto) response.getEntity(); assertEquals(2, returned.getSnmpv3User().size()); - assertEquals(PASSPHRASE_PLACEHOLDER, returned.getSnmpv3User().get(0).getAuthPassphrase()); - assertEquals(PASSPHRASE_PLACEHOLDER, returned.getSnmpv3User().get(0).getPrivacyPassphrase()); - assertEquals(PASSPHRASE_PLACEHOLDER, returned.getSnmpv3User().get(1).getAuthPassphrase()); - assertNull(returned.getSnmpv3User().get(1).getPrivacyPassphrase()); - } - } - - @Test - public void getShouldNotMutateStoredConfigWhenMaskingPassphrases() { - TrapdConfiguration config = buildMinimalConfig(); - Snmpv3User user = new Snmpv3User(); - user.setSecurityName("user1"); - user.setSecurityLevel(3); - user.setAuthProtocol("SHA"); - user.setAuthPassphrase(PASSPHRASE_PLACEHOLDER); - user.setPrivacyProtocol("AES"); - user.setPrivacyPassphrase(PASSPHRASE_PLACEHOLDER); - config.addSnmpv3User(user); - when(trapdConfigDao.getConfig()).thenReturn(config); - - try (Response response = trapdRestService.getTrapdConfiguration(null)) { - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - } - - // The config returned by getConfig should not have been mutated by the service - assertEquals(PASSPHRASE_PLACEHOLDER, config.getSnmpv3User(0).getAuthPassphrase()); - assertEquals(PASSPHRASE_PLACEHOLDER, config.getSnmpv3User(0).getPrivacyPassphrase()); - } - - @Test - public void getShouldReturnConfigWithOtherFieldsIntactAfterMasking() { - TrapdConfiguration config = buildMinimalConfig(); - Snmpv3User user = new Snmpv3User(); - user.setSecurityName("engine-user"); - user.setEngineId("0x8000000001020304"); - user.setSecurityLevel(3); - user.setAuthProtocol("SHA"); - user.setAuthPassphrase(PASSPHRASE_PLACEHOLDER); - user.setPrivacyProtocol("AES"); - user.setPrivacyPassphrase(PASSPHRASE_PLACEHOLDER); - config.addSnmpv3User(user); - when(trapdConfigDao.getConfig()).thenReturn(config); - - try (Response response = trapdRestService.getTrapdConfiguration(null)) { - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - TrapdConfigDto returned = (TrapdConfigDto) response.getEntity(); - Snmpv3UserDto returnedUser = returned.getSnmpv3User().get(0); - // Non-sensitive fields must be preserved - assertEquals("engine-user", returnedUser.getSecurityName()); - assertEquals("0x8000000001020304", returnedUser.getEngineId()); - assertEquals("SHA", returnedUser.getAuthProtocol()); - assertEquals("AES", returnedUser.getPrivacyProtocol()); - assertEquals(Integer.valueOf(3), returnedUser.getSecurityLevel()); - // Sensitive fields must be masked - assertEquals(PASSPHRASE_PLACEHOLDER, returnedUser.getAuthPassphrase()); - assertEquals(PASSPHRASE_PLACEHOLDER, returnedUser.getPrivacyPassphrase()); + assertEquals("user-a", returned.getSnmpv3User().get(0).getSecurityName()); + assertEquals("user-b", returned.getSnmpv3User().get(1).getSecurityName()); } } @@ -892,16 +813,6 @@ public void updateShouldRejectNegativeForOptionalFields() { } } - // --- Security/Authorization Placeholder --- - // Note: SecurityContext is not currently used for access control in TrapdRestService. - // This test is a placeholder for future security/authorization checks. - @Test - public void updateShouldEnforceAuthorizationIfSecurityContextIsUsed() { - // If/when SecurityContext is used for access control, add tests here. - // For now, this is a no-op. - assertTrue(true); - } - private void whenValidationFailsOnUpdate(final String message) { org.mockito.Mockito.doThrow(new ValidationException(message)).when(trapdConfigDao).replaceConfig(org.mockito.Mockito.any(TrapdConfiguration.class)); } @@ -927,51 +838,130 @@ private static String validTrapdConfigXmlWithUseAddressFromVarbind() { } @Test - public void getShouldHandleLargeNumberOfSnmpv3Users() { - TrapdConfiguration config = buildMinimalConfig(); - int userCount = 1000; // Large number for stress test - for (int i = 0; i < userCount; i++) { - Snmpv3User user = new Snmpv3User(); - user.setSecurityName("user-" + i); - user.setSecurityLevel(3); - user.setAuthProtocol("SHA"); - user.setAuthPassphrase(PASSPHRASE_PLACEHOLDER); - user.setPrivacyProtocol("AES"); - user.setPrivacyPassphrase(PASSPHRASE_PLACEHOLDER); - config.addSnmpv3User(user); - } - when(trapdConfigDao.getConfig()).thenReturn(config); + public void uploadShouldNotCallReplaceConfigWhenXmlParsingFails() { + Attachment attachment = mock(Attachment.class); + when(attachment.getObject(InputStream.class)).thenReturn( + new ByteArrayInputStream(" { - try (Response response = trapdRestService.updateTrapdConfiguration(payload, null)) { - assertTrue(response.getStatus() == Response.Status.OK.getStatusCode() || - response.getStatus() == Response.Status.BAD_REQUEST.getStatusCode() || - response.getStatus() == Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); - } - }; - Thread[] threads = new Thread[threadCount]; - for (int i = 0; i < threadCount; i++) { - threads[i] = new Thread(updateTask); - } - for (Thread t : threads) t.start(); - for (Thread t : threads) t.join(); - // If no exceptions, concurrency is handled gracefully + public void updateShouldNotSetSnmpv3UsersOnEntityWhenListIsNull() { + // When snmpv3User is null (omitted from request), toEntity() must not call setSnmpv3User + // so the entity's user count defaults to 0 (fresh TrapdConfiguration). + TrapdConfigDto payload = buildMinimalUpdatePayload(); + // snmpv3User deliberately not set — remains null + + try (Response response = trapdRestService.updateTrapdConfiguration(payload, null)) { + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(TrapdConfiguration.class); + verify(trapdConfigDao).replaceConfig(captor.capture()); + assertEquals(0, captor.getValue().getSnmpv3UserCount()); + } + + @Test + public void updateShouldPersistSnmpv3UsersWhenValidUsersProvided() { + TrapdConfigDto payload = buildMinimalUpdatePayload(); + + Snmpv3UserDto user = new Snmpv3UserDto(); + user.setSecurityName("my-user"); + user.setSecurityLevel(2); + user.setAuthProtocol("SHA"); + user.setAuthPassphrase("authpass123"); + payload.setSnmpv3User(java.util.List.of(user)); + + try (Response response = trapdRestService.updateTrapdConfiguration(payload, null)) { + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(TrapdConfiguration.class); + verify(trapdConfigDao).replaceConfig(captor.capture()); + assertEquals(1, captor.getValue().getSnmpv3UserCount()); + assertEquals("my-user", captor.getValue().getSnmpv3User(0).getSecurityName()); + assertEquals("SHA", captor.getValue().getSnmpv3User(0).getAuthProtocol()); + } + + @Test + public void updateShouldRejectSnmpv3UserWhenLevel1HasPrivacyCredentials() { + // securityLevel 1 (noAuthNoPriv) must not have privacy-only credentials either + TrapdConfigDto payload = buildMinimalUpdatePayload(); + Snmpv3UserDto user = new Snmpv3UserDto(); + user.setSecurityName("user1"); + user.setSecurityLevel(1); + user.setPrivacyProtocol("AES"); + user.setPrivacyPassphrase("privpass"); + payload.setSnmpv3User(java.util.List.of(user)); + + try (Response response = trapdRestService.updateTrapdConfiguration(payload, null)) { + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertTrue(((String) response.getEntity()).contains("securityLevel 1 does not allow auth or privacy credentials.")); + } + } + + @Test + public void updateShouldRejectSnmpv3UserWhenLevel3MissingAuthCredentials() { + // securityLevel 3 with only privacy (no auth) must be rejected + TrapdConfigDto payload = buildMinimalUpdatePayload(); + Snmpv3UserDto user = new Snmpv3UserDto(); + user.setSecurityName("user1"); + user.setSecurityLevel(3); + // auth intentionally omitted + user.setPrivacyProtocol("AES"); + user.setPrivacyPassphrase("privpass"); + payload.setSnmpv3User(java.util.List.of(user)); + + try (Response response = trapdRestService.updateTrapdConfiguration(payload, null)) { + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertTrue(((String) response.getEntity()).contains("securityLevel 3 requires both auth and privacy credentials.")); + } + } + + @Test + public void updateShouldIncludeSecurityNameInValidationErrorMessage() { + // The error message format is: "Invalid SNMPv3 user at index : " + TrapdConfigDto payload = buildMinimalUpdatePayload(); + Snmpv3UserDto user = new Snmpv3UserDto(); + user.setSecurityName("bad-user"); + user.setSecurityLevel(0); // out of range + payload.setSnmpv3User(java.util.List.of(user)); + + try (Response response = trapdRestService.updateTrapdConfiguration(payload, null)) { + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + String entity = (String) response.getEntity(); + assertTrue(entity.contains("at index 0")); + assertTrue(entity.contains("securityLevel must be between 1 and 3.")); + } + } + + @Test + public void updateShouldRejectNullSnmpv3UserEntryWithIndexInMessage() { + TrapdConfigDto payload = buildMinimalUpdatePayload(); + payload.setSnmpv3User(java.util.Collections.singletonList(null)); + + try (Response response = trapdRestService.updateTrapdConfiguration(payload, null)) { + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + String entity = (String) response.getEntity(); + assertTrue(entity.contains("Invalid SNMPv3 user at index 0:")); + assertTrue(entity.contains("entry must not be null.")); + } + + verify(trapdConfigDao, never()).replaceConfig(org.mockito.Mockito.any(TrapdConfiguration.class)); } } diff --git a/opennms-webapp-rest/src/test/resources/applicationContext-rest-test.xml b/opennms-webapp-rest/src/test/resources/applicationContext-rest-test.xml index 29b6be65bb1d..abc09b14d3d3 100644 --- a/opennms-webapp-rest/src/test/resources/applicationContext-rest-test.xml +++ b/opennms-webapp-rest/src/test/resources/applicationContext-rest-test.xml @@ -11,7 +11,9 @@ + + diff --git a/ui/src/components/SCV/ScvInputIcon.vue b/ui/src/components/SCV/ScvInputIcon.vue index 50118a75f98b..fb2156fe1169 100644 --- a/ui/src/components/SCV/ScvInputIcon.vue +++ b/ui/src/components/SCV/ScvInputIcon.vue @@ -1,15 +1,16 @@ - diff --git a/ui/src/containers/TrapdConfiguration.vue b/ui/src/containers/TrapdConfiguration.vue new file mode 100644 index 000000000000..243387962c91 --- /dev/null +++ b/ui/src/containers/TrapdConfiguration.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/ui/src/lib/constants.ts b/ui/src/lib/constants.ts index a3fe19a3babe..b943758d3277 100644 --- a/ui/src/lib/constants.ts +++ b/ui/src/lib/constants.ts @@ -42,3 +42,15 @@ export const DEFAULT_SNMP_V3_AUTH_PASSPHRASE = '0p3nNMSv3' export const DEFAULT_SNMP_V3_AUTH_PROTOCOL = 'MD5' export const DEFAULT_SNMP_V3_PRIVACY_PASSPHRASE = '0p3nNMSv3' export const DEFAULT_SNMP_V3_PRIVACY_PROTOCOL = 'DES' + +// Trapd Defaults +export const DEFAULT_TRAPD_PORT = 10162 +export const DEFAULT_TRAPD_BIND_ADDRESS = '*' +export const DEFAULT_TRAPD_THREADS = 0 +export const DEFAULT_TRAPD_QUEUE_SIZE = 10000 +export const DEFAULT_TRAPD_BATCH_SIZE = 1000 +export const DEFAULT_TRAPD_BATCH_INTERVAL = 500 +export const DEFAULT_TRAPD_USE_ADDRESS_FROM_VARBIND = false +export const DEFAULT_TRAPD_INCLUDE_RAW_MESSAGE = false +export const DEFAULT_TRAPD_NEW_SUSPECT_ON_TRAP = false + diff --git a/ui/src/lib/trapdValidator.ts b/ui/src/lib/trapdValidator.ts new file mode 100644 index 000000000000..c6a5d76d42a5 --- /dev/null +++ b/ui/src/lib/trapdValidator.ts @@ -0,0 +1,375 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// 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. +/// + +import { TrapConfig, XmlValidationError, XmlValidationResult } from '@/types/trapConfig' +import { ISelectItemType } from '@featherds/select' +import { DEFAULT_TRAPD_BIND_ADDRESS } from './constants' + +export const MIN_PORT = 1 +export const MAX_PORT = 65535 +export const TRAPD_XML_NAMESPACE = 'http://xmlns.opennms.org/xsd/config/trapd' + +export enum SecurityLevel { + None = 0, + NoAuthNoPriv = 1, + AuthNoPriv = 2, + AuthPriv = 3 +} + +export enum AuthProtocol { + MD5 = 'MD5', + SHA = 'SHA', + SHA224 = 'SHA224', + SHA256 = 'SHA256', + SHA512 = 'SHA512' +} + +export enum PrivacyProtocol { + DES = 'DES', + AES = 'AES', + AES192 = 'AES192', + AES256 = 'AES256' +} + +const VALID_SECURITY_LEVELS = [SecurityLevel.NoAuthNoPriv, SecurityLevel.AuthNoPriv, SecurityLevel.AuthPriv] + +export const SECURITY_LEVEL_OPTIONS: ISelectItemType[] = [ + { _text: 'No Auth (1)', _value: String(SecurityLevel.NoAuthNoPriv) }, + { _text: 'Auth Only (2)', _value: String(SecurityLevel.AuthNoPriv) }, + { _text: 'Auth and Privacy (3)', _value: String(SecurityLevel.AuthPriv) } +] + +export const AuthProtocols = [ + AuthProtocol.MD5, + AuthProtocol.SHA, + AuthProtocol.SHA224, + AuthProtocol.SHA256, + AuthProtocol.SHA512 +] + +export const PrivacyProtocols = [ + PrivacyProtocol.DES, + PrivacyProtocol.AES, + PrivacyProtocol.AES192, + PrivacyProtocol.AES256 +] + +export const isValidSnmpSecurityLevel = (level: number | undefined): boolean => { + return level !== undefined && VALID_SECURITY_LEVELS.includes(level) +} + +export const isValidIP = (ip: string): boolean => { + const parts = ip.split('.') + if (parts.length !== 4) { + return false + } + return parts.every((part) => { + const num = parseInt(part, 10) + return !isNaN(num) && num >= 0 && num <= 255 + }) +} + +export const isValidPort = (port: number | undefined): boolean => { + return port !== undefined && !isNaN(port) && port >= MIN_PORT && port <= MAX_PORT +} + +export const AUTH_PROTOCOL_OPTIONS: ISelectItemType[] = AuthProtocols.map((protocol) => ({ + _text: protocol, + _value: protocol +})) + +export const PRIVACY_PROTOCOL_OPTIONS: ISelectItemType[] = PrivacyProtocols.map((protocol) => ({ + _text: protocol, + _value: protocol +})) + +export const getDefaultTrapdConfig = (): TrapConfig => ({ + snmpTrapAddress: DEFAULT_TRAPD_BIND_ADDRESS, + snmpTrapPort: 10162, + newSuspectOnTrap: false, + includeRawMessage: false, + threads: 0, + queueSize: 10000, + batchSize: 1000, + batchInterval: 500, + useAddressFromVarbind: false, + snmpv3User: [] +}) + +// XML auth-protocol values may use dashes (e.g. "SHA-256") — normalize before comparing +const normalizeAuthProtocol = (value: string): string => value.replace(/-/g, '') + +// All valid auth protocol values in normalized form +const VALID_AUTH_PROTOCOL_VALUES = new Set(AuthProtocols.map(normalizeAuthProtocol)) + +// All valid privacy protocol values +const VALID_PRIVACY_PROTOCOL_VALUES = new Set(PrivacyProtocols as string[]) + +const addError = (errors: XmlValidationError[], field: string, message: string) => errors.push({ field, message }) + +const validateSnmpV3UserElement = (user: Element, index: number, errors: XmlValidationError[]): void => { + const prefix = `snmpv3-user[${index}]` + + const securityName = user.getAttribute('security-name') + if (!securityName || securityName.trim() === '') { + addError(errors, `${prefix}.security-name`, `${prefix}: security-name is required`) + } + + const securityLevelAttr = user.getAttribute('security-level') + let securityLevel: number | undefined + if (securityLevelAttr !== null && securityLevelAttr.trim() !== '') { + securityLevel = parseInt(securityLevelAttr, 10) + if (!isValidSnmpSecurityLevel(securityLevel)) { + addError( + errors, + `${prefix}.security-level`, + `${prefix}: invalid security-level '${securityLevelAttr}'. Valid values: 1 (NoAuthNoPriv), 2 (AuthNoPriv), 3 (AuthPriv)` + ) + securityLevel = undefined + } + } + + const authProtocol = user.getAttribute('auth-protocol') + const authPassphrase = user.getAttribute('auth-passphrase') + const privacyProtocol = user.getAttribute('privacy-protocol') + const privacyPassphrase = user.getAttribute('privacy-passphrase') + + if (authProtocol !== null) { + if (!VALID_AUTH_PROTOCOL_VALUES.has(normalizeAuthProtocol(authProtocol))) { + addError( + errors, + `${prefix}.auth-protocol`, + `${prefix}: invalid auth-protocol '${authProtocol}'. Valid values: ${AuthProtocols.join(', ')}` + ) + } + if (!authPassphrase || authPassphrase.trim() === '') { + addError(errors, `${prefix}.auth-passphrase`, `${prefix}: auth-passphrase is required when auth-protocol is set`) + } + } + + if (privacyProtocol !== null) { + if (!VALID_PRIVACY_PROTOCOL_VALUES.has(privacyProtocol)) { + addError( + errors, + `${prefix}.privacy-protocol`, + `${prefix}: invalid privacy-protocol '${privacyProtocol}'. Valid values: ${PrivacyProtocols.join(', ')}` + ) + } + if (!privacyPassphrase || privacyPassphrase.trim() === '') { + addError( + errors, + `${prefix}.privacy-passphrase`, + `${prefix}: privacy-passphrase is required when privacy-protocol is set` + ) + } + if (authProtocol === null) { + addError(errors, `${prefix}.auth-protocol`, `${prefix}: auth-protocol is required when privacy-protocol is set`) + } + } + + if (securityLevel === SecurityLevel.NoAuthNoPriv) { + if (authProtocol !== null) { + addError( + errors, + `${prefix}.auth-protocol`, + `${prefix}: auth-protocol must not be set when security-level is 1 (NoAuthNoPriv)` + ) + } + if (authPassphrase !== null) { + addError( + errors, + `${prefix}.auth-passphrase`, + `${prefix}: auth-passphrase must not be set when security-level is 1 (NoAuthNoPriv)` + ) + } + if (privacyProtocol !== null) { + addError( + errors, + `${prefix}.privacy-protocol`, + `${prefix}: privacy-protocol must not be set when security-level is 1 (NoAuthNoPriv)` + ) + } + if (privacyPassphrase !== null) { + addError( + errors, + `${prefix}.privacy-passphrase`, + `${prefix}: privacy-passphrase must not be set when security-level is 1 (NoAuthNoPriv)` + ) + } + } + + if (securityLevel === SecurityLevel.AuthNoPriv) { + if (authProtocol === null) { + addError( + errors, + `${prefix}.auth-protocol`, + `${prefix}: auth-protocol is required when security-level is 2 (AuthNoPriv)` + ) + } + if (!authPassphrase || authPassphrase.trim() === '') { + addError( + errors, + `${prefix}.auth-passphrase`, + `${prefix}: auth-passphrase is required when security-level is 2 (AuthNoPriv)` + ) + } + if (privacyProtocol !== null) { + addError( + errors, + `${prefix}.privacy-protocol`, + `${prefix}: privacy-protocol must not be set when security-level is 2 (AuthNoPriv)` + ) + } + if (privacyPassphrase !== null) { + addError( + errors, + `${prefix}.privacy-passphrase`, + `${prefix}: privacy-passphrase must not be set when security-level is 2 (AuthNoPriv)` + ) + } + } + + if (securityLevel === SecurityLevel.AuthPriv) { + if (authProtocol === null) { + addError( + errors, + `${prefix}.auth-protocol`, + `${prefix}: auth-protocol is required when security-level is 3 (AuthPriv)` + ) + } + if (!authPassphrase || authPassphrase.trim() === '') { + addError( + errors, + `${prefix}.auth-passphrase`, + `${prefix}: auth-passphrase is required when security-level is 3 (AuthPriv)` + ) + } + if (privacyProtocol === null) { + addError( + errors, + `${prefix}.privacy-protocol`, + `${prefix}: privacy-protocol is required when security-level is 3 (AuthPriv)` + ) + } + if (!privacyPassphrase || privacyPassphrase.trim() === '') { + addError( + errors, + `${prefix}.privacy-passphrase`, + `${prefix}: privacy-passphrase is required when security-level is 3 (AuthPriv)` + ) + } + } +} + +/** + * Validates a trapd-configuration XML string. + * + * Expected structure: + * + * + * ... (zero or more snmpv3-user elements) + * + */ +export const validateTrapdXml = (xmlString: string): XmlValidationResult => { + const errors: XmlValidationError[] = [] + + if (!xmlString || xmlString.trim() === '') { + return { valid: false, errors: [{ field: 'xml', message: 'XML content is empty' }] } + } + + let doc: Document + try { + const parser = new DOMParser() + doc = parser.parseFromString(xmlString, 'application/xml') + const parserError = doc.querySelector('parsererror') + if (parserError) { + const detail = parserError.textContent?.trim() ?? 'unknown parse error' + return { valid: false, errors: [{ field: 'xml', message: `XML parse error: ${detail}` }] } + } + } catch { + return { valid: false, errors: [{ field: 'xml', message: 'Failed to parse XML' }] } + } + + const root = doc.documentElement + if (root.localName !== 'trapd-configuration') { + return { + valid: false, + errors: [ + { + field: 'root', + message: `Root element must be 'trapd-configuration', got '${root.localName}'` + } + ] + } + } + + const xmlns = root.namespaceURI ?? root.getAttribute('xmlns') + if (xmlns !== TRAPD_XML_NAMESPACE) { + addError(errors, 'xmlns', `Invalid xmlns '${xmlns ?? ''}': expected '${TRAPD_XML_NAMESPACE}'`) + } + + // snmp-trap-address: required; must be '*' or a valid IPv4 address + const snmpTrapAddress = root.getAttribute('snmp-trap-address') + if (snmpTrapAddress === null) { + addError(errors, 'snmp-trap-address', 'snmp-trap-address attribute is required') + } else if (snmpTrapAddress !== '*' && !isValidIP(snmpTrapAddress)) { + addError( + errors, + 'snmp-trap-address', + `Invalid snmp-trap-address '${snmpTrapAddress}': must be '*' or a valid IPv4 address` + ) + } + + // snmp-trap-port: required; must be an integer in [MIN_PORT, MAX_PORT] + const snmpTrapPortStr = root.getAttribute('snmp-trap-port') + if (snmpTrapPortStr === null) { + addError(errors, 'snmp-trap-port', 'snmp-trap-port attribute is required') + } else { + const snmpTrapPort = parseInt(snmpTrapPortStr, 10) + if (!isValidPort(snmpTrapPort)) { + addError( + errors, + 'snmp-trap-port', + `Invalid snmp-trap-port '${snmpTrapPortStr}': must be an integer between ${MIN_PORT} and ${MAX_PORT}` + ) + } + } + + // new-suspect-on-trap: optional; must be 'true' or 'false' if present + const newSuspectOnTrap = root.getAttribute('new-suspect-on-trap') + if (newSuspectOnTrap !== null && newSuspectOnTrap !== 'true' && newSuspectOnTrap !== 'false') { + addError( + errors, + 'new-suspect-on-trap', + `Invalid new-suspect-on-trap '${newSuspectOnTrap}': must be 'true' or 'false'` + ) + } + + // snmpv3-user: zero or more child elements + const snmpv3Users = root.getElementsByTagName('snmpv3-user') + for (let i = 0; i < snmpv3Users.length; i++) { + validateSnmpV3UserElement(snmpv3Users[i], i + 1, errors) + } + + return { valid: errors.length === 0, errors } +} + diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts index 83f9b8fe5e20..718aa319b1b1 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -303,9 +303,9 @@ const router = createRouter({ component: () => import('@/containers/EventConfigEventCreate.vue') }, { - path: '/trap-config', - name: 'Trap Configuration', - component: () => import('@/containers/TrapConfiguration.vue') + path: '/trapd-config', + name: 'Trapd Configuration', + component: () => import('@/containers/TrapdConfiguration.vue') }, { path: '/:pathMatch(.*)*', // catch other paths and redirect diff --git a/ui/src/mappers/trapdConfig.mapper.ts b/ui/src/mappers/trapdConfig.mapper.ts new file mode 100644 index 000000000000..4e92337b5e9c --- /dev/null +++ b/ui/src/mappers/trapdConfig.mapper.ts @@ -0,0 +1,53 @@ +import { SnmpV3User, TrapConfig } from '@/types/trapConfig' + +export const mapTrapdConfigFromServer = (data: any): TrapConfig => { + return { + snmpTrapPort: data.snmpTrapPort, + snmpTrapAddress: data.snmpTrapAddress, + newSuspectOnTrap: data.newSuspectOnTrap, + includeRawMessage: data.includeRawMessage, + threads: data.threads, + queueSize: data.queueSize, + batchSize: data.batchSize, + batchInterval: data.batchInterval, + useAddressFromVarbind: data.useAddressFromVarbind, + snmpv3User: (data.snmpv3User || []).map((user: any) => ({ + engineId: user.engineId, + securityName: user.securityName, + securityLevel: user.securityLevel, + authProtocol: user.authProtocol, + authPassphrase: user.authPassphrase, + privacyProtocol: user.privacyProtocol, + privacyPassphrase: user.privacyPassphrase + } as SnmpV3User)) + } +} + +export const mapUserToServer = (payload: any): SnmpV3User => { + const user = { + securityName: payload.securityName, + engineId: payload.engineId, + securityLevel: payload.securityLevel + } as SnmpV3User + + if (payload.securityLevel === 1) { + user.authProtocol = null + user.authPassphrase = null + user.privacyProtocol = null + user.privacyPassphrase = null + } + else if (payload.securityLevel === 2) { + user.authProtocol = payload.authProtocol + user.authPassphrase = payload.authPassphrase + user.privacyProtocol = null + user.privacyPassphrase = null + } + else if (payload.securityLevel === 3) { + user.authProtocol = payload.authProtocol + user.authPassphrase = payload.authPassphrase + user.privacyProtocol = payload.privacyProtocol + user.privacyPassphrase = payload.privacyPassphrase + } + + return user +} diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index 1a90af2585b9..7ef95ff416cc 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -72,10 +72,7 @@ import { getUsageStatisticsStatus, setUsageStatisticsStatus } from './usageStatisticsService' -import { - addZenithRegistration, - getZenithRegistrations -} from './zenithConnectService' +import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService' export default { search, @@ -131,7 +128,7 @@ export default { addCredentials, updateCredentials, getUsageStatistics, - getUsageStatisticsMetadata, + getUsageStatisticsMetadata, getUsageStatisticsStatus, setUsageStatisticsStatus, addZenithRegistration, diff --git a/ui/src/services/scvService.ts b/ui/src/services/scvService.ts index d8b8457558ab..aa882fec2298 100644 --- a/ui/src/services/scvService.ts +++ b/ui/src/services/scvService.ts @@ -20,11 +20,11 @@ /// License. /// -import { rest } from './axiosInstances' import useSnackbar from '@/composables/useSnackbar' import useSpinner from '@/composables/useSpinner' import { SCV_GET_ALL_ALIAS } from '@/lib/constants' import { SCVCredentials } from '@/types/scv' +import { rest } from './axiosInstances' const { showSnackBar } = useSnackbar() const { startSpinner, stopSpinner } = useSpinner() @@ -97,10 +97,5 @@ const updateCredentials = async (credentials: SCVCredentials): Promise { + if (axios.isAxiosError(error)) { + const responseData = error.response?.data + + if (typeof responseData === 'string' && responseData.trim().length > 0) { + return responseData + } + + if ( + responseData && + typeof responseData === 'object' && + 'message' in responseData && + typeof responseData.message === 'string' && + responseData.message.trim().length > 0 + ) { + return responseData.message + } + + if (typeof error.message === 'string' && error.message.trim().length > 0) { + return error.message + } + } + + if (error instanceof Error && error.message.trim().length > 0) { + return error.message + } + + return fallbackMessage +} + +const throwTrapdServiceError = (error: unknown, fallbackMessage: string): never => { + console.error(fallbackMessage, error) + throw new Error(getTrapdServiceErrorMessage(error, fallbackMessage)) +} + +export const uploadTrapdConfiguration = async (file: File): Promise => { + const formData = new FormData() + formData.append('upload', file) + + try { + const response = await v2.post(`${endpoint}/upload`, formData) + + if (response.status === 200) { + return + } + + throw new Error(`Unexpected response status: ${response.status}`) + } catch (error) { + return throwTrapdServiceError(error, 'Failed to upload trapd configuration.') + } +} + +export const getTrapdConfiguration = async (): Promise => { + try { + const response = await v2.get(`${endpoint}/config`) + + if (response.status === 200) { + return mapTrapdConfigFromServer(response.data) as TrapConfig + } + + throw new Error(`Unexpected response status: ${response.status}`) + } catch (error) { + return throwTrapdServiceError(error, 'Failed to retrieve trapd configuration.') + } +} + +export const updateTrapdConfiguration = async (payload: TrapConfig): Promise => { + try { + const response = await v2.put(`${endpoint}/config`, payload) + + if (response.status === 200) { + return + } + + throw new Error(`Unexpected response status: ${response.status}`) + } catch (error) { + return throwTrapdServiceError(error, 'Failed to update trapd configuration.') + } +} + diff --git a/ui/src/stores/scvStore.ts b/ui/src/stores/scvStore.ts index 83b50c5a2dd1..585dc74a488f 100644 --- a/ui/src/stores/scvStore.ts +++ b/ui/src/stores/scvStore.ts @@ -20,10 +20,10 @@ /// License. /// -import { defineStore } from 'pinia' -import API from '@/services' import { SCV_GET_ALL_ALIAS } from '@/lib/constants' +import API from '@/services' import { SCVCredentials, ScvSearchItem } from '@/types/scv' +import { defineStore } from 'pinia' export const useScvStore = defineStore('scvStore', () => { const aliases = ref([] as string[]) @@ -211,3 +211,4 @@ export const useScvStore = defineStore('scvStore', () => { updateCredentials } }) + diff --git a/ui/src/stores/trapConfigStore.ts b/ui/src/stores/trapConfigStore.ts deleted file mode 100644 index b968e9cc68a4..000000000000 --- a/ui/src/stores/trapConfigStore.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CreateEditMode } from '@/types' -import { TrapConfigStoreState } from '@/types/trapConfig' -import { defineStore } from 'pinia' - -export const useTrapConfigStore = defineStore('useTrapConfigStore', { - state: (): TrapConfigStoreState => ({ - isLoading: false, - activeTab: 0, - credentialDrawerState: { - visible: false - }, - createUserDrawerState: { - visible: false, - mode: CreateEditMode.None - } - }), - actions: { - openCredentialDrawer() { - this.credentialDrawerState.visible = true - }, - closeCredentialDrawer() { - this.credentialDrawerState.visible = false - }, - openCreateUserDrawer(mode: CreateEditMode) { - this.createUserDrawerState.visible = true - this.createUserDrawerState.mode = mode - }, - closeCreateUserDrawer() { - this.createUserDrawerState.visible = false - this.createUserDrawerState.mode = CreateEditMode.None - } - } -}) - diff --git a/ui/src/stores/trapdConfigStore.ts b/ui/src/stores/trapdConfigStore.ts new file mode 100644 index 000000000000..5b2dbff05cc4 --- /dev/null +++ b/ui/src/stores/trapdConfigStore.ts @@ -0,0 +1,50 @@ +import { getDefaultTrapdConfig } from '@/lib/trapdValidator' +import { getTrapdConfiguration } from '@/services/trapdConfigurationService' +import { CreateEditMode } from '@/types' +import { TrapConfigStoreState } from '@/types/trapConfig' +import { defineStore } from 'pinia' + +export const useTrapdConfigStore = defineStore('useTrapdConfigStore', { + state: (): TrapConfigStoreState => ({ + isLoading: false, + trapdConfig: getDefaultTrapdConfig(), + snmpV3Users: [], + activeTab: 0, + credentialDrawerState: { + visible: false, + key: null + }, + createUserDrawerState: { + visible: false, + mode: CreateEditMode.None, + selectedUserIndex: -1 + } + }), + actions: { + async fetchTrapConfig() { + // Implementation for fetching trap configuration goes here + const response = await getTrapdConfiguration() + this.trapdConfig = response + this.snmpV3Users = response.snmpv3User + }, + openCredentialDrawer(key: string) { + this.credentialDrawerState.visible = true + this.credentialDrawerState.key = key + }, + closeCredentialDrawer() { + this.credentialDrawerState.visible = false + this.credentialDrawerState.key = null + }, + openCreateUserDrawer(mode: CreateEditMode, selectedUserIndex: number) { + this.createUserDrawerState.visible = true + this.createUserDrawerState.mode = mode + this.createUserDrawerState.selectedUserIndex = selectedUserIndex + }, + closeCreateUserDrawer() { + this.createUserDrawerState.visible = false + this.createUserDrawerState.mode = CreateEditMode.None + this.createUserDrawerState.selectedUserIndex = -1 + } + } +}) + diff --git a/ui/src/types/trapConfig.d.ts b/ui/src/types/trapConfig.d.ts index e09be1d468cb..e3c140a331c6 100644 --- a/ui/src/types/trapConfig.d.ts +++ b/ui/src/types/trapConfig.d.ts @@ -1,14 +1,93 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// 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. +/// + import { CreateEditMode } from '.' export interface TrapConfigStoreState { isLoading: boolean + trapdConfig: TrapConfig + snmpV3Users: SnmpV3User[] activeTab: number credentialDrawerState: { - visible: boolean + visible: boolean, + key: string | null } createUserDrawerState: { visible: boolean mode: CreateEditMode + selectedUserIndex: number } } +export interface TrapConfig { + snmpTrapAddress: string + snmpTrapPort: number + newSuspectOnTrap: boolean + includeRawMessage: boolean + threads: number + queueSize: number + batchSize: number + batchInterval: number + useAddressFromVarbind: boolean + snmpv3User: SnmpV3User[] +} + +export interface SnmpV3User { + engineId: string | null + securityName: string + securityLevel: number + authProtocol: string | null + authPassphrase: string | null + privacyProtocol: string | null + privacyPassphrase: string | null +} + +export interface TrapdConfigurationError { + port?: string + bindAddress?: string + threads?: string + queueSize?: string + batchSize?: string + batchInterval?: string + snmpv3User?: string +} + +export interface SnmpV3UserError { + engineId?: string + securityName?: string + securityLevel?: string + authProtocol?: string + authPassphrase?: string + privacyProtocol?: string + privacyPassphrase?: string +} + +export interface XmlValidationError { + field: string + message: string +} + +export interface XmlValidationResult { + valid: boolean + errors: XmlValidationError[] +} + diff --git a/ui/tests/components/TrapdConfiguration/CreateSnmpV3User.test.ts b/ui/tests/components/TrapdConfiguration/CreateSnmpV3User.test.ts new file mode 100644 index 000000000000..139e7993a69a --- /dev/null +++ b/ui/tests/components/TrapdConfiguration/CreateSnmpV3User.test.ts @@ -0,0 +1,622 @@ +import CreateSnmpV3User from '@/components/TrapdConfiguration/CreateSnmpV3User.vue' +import { + DEFAULT_SNMP_V3_AUTH_PROTOCOL, + DEFAULT_SNMP_V3_PRIVACY_PROTOCOL, + DEFAULT_SNMP_V3_SECURITY_NAME +} from '@/lib/constants' +import { + AUTH_PROTOCOL_OPTIONS, + getDefaultTrapdConfig, + PRIVACY_PROTOCOL_OPTIONS, + SECURITY_LEVEL_OPTIONS +} from '@/lib/trapdValidator' +import { mapUserToServer } from '@/mappers/trapdConfig.mapper' +import { updateTrapdConfiguration } from '@/services/trapdConfigurationService' +import { useScvStore } from '@/stores/scvStore' +import { useTrapdConfigStore } from '@/stores/trapdConfigStore' +import { CreateEditMode } from '@/types' +import type { SnmpV3User } from '@/types/trapConfig' +import { createTestingPinia } from '@pinia/testing' +import { flushPromises, mount } from '@vue/test-utils' +import { setActivePinia } from 'pinia' +import { ISelectItemType } from '@featherds/select' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick } from 'vue' + +const createEmptySelectItem = (): ISelectItemType => (undefined as unknown as ISelectItemType) + +const { showSnackBarMock, populateScvMock } = vi.hoisted(() => ({ + showSnackBarMock: vi.fn(), + populateScvMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('@/composables/useSnackbar', () => ({ + default: () => ({ + showSnackBar: showSnackBarMock + }) +})) + +vi.mock('@/mappers/trapdConfig.mapper', () => ({ + mapUserToServer: vi.fn() +})) + +vi.mock('@/services/trapdConfigurationService', () => ({ + updateTrapdConfiguration: vi.fn() +})) + +vi.mock('@/stores/scvStore', () => ({ + useScvStore: vi.fn(() => ({ + populate: populateScvMock + })) +})) + +const FeatherInputStub = defineComponent({ + name: 'FeatherInput', + props: { + modelValue: { + type: String, + default: '' + }, + label: { + type: String, + default: '' + }, + dataTest: { + type: String, + default: '' + } + }, + emits: ['update:modelValue'], + template: + '' +}) + +const ScvSearchDrawerStub = defineComponent({ + name: 'ScvSearchDrawer', + props: { + isOpen: { + type: Boolean, + default: false + } + }, + emits: ['hidden', 'itemSelected'], + template: '
' +}) + +describe('CreateSnmpV3User.vue', () => { + let store: ReturnType + const useScvStoreMock = vi.mocked(useScvStore) + const mapUserToServerMock = vi.mocked(mapUserToServer) + const updateTrapdConfigurationMock = vi.mocked(updateTrapdConfiguration) + + const selectedUser: SnmpV3User = { + engineId: null, + securityName: 'existing-user', + securityLevel: 2, + authProtocol: 'MD5', + authPassphrase: 'masked-auth', + privacyProtocol: null, + privacyPassphrase: null + } + + const mountComponent = () => { + return mount(CreateSnmpV3User, { + global: { + stubs: { + TableCard: { + template: '
' + }, + FeatherIcon: true, + FeatherInput: FeatherInputStub, + 'feather-input': FeatherInputStub, + FeatherSelect: true, + 'feather-select': true, + ScvInputIcon: { + emits: ['click'], + template: '' + }, + 'feather-button': { + props: ['dataTest', 'disabled'], + emits: ['click'], + template: '' + } + } + } + }) + } + + const setInputValue = async (wrapper: ReturnType, dataTest: string, value: string) => { + const input = wrapper.find(`input[data-test="${dataTest}"]`) + expect(input.exists()).toBe(true) + await input.setValue(value) + } + + const setBindingValue = async (wrapper: ReturnType, key: string, value: any) => { + ;(wrapper.vm as any)[key] = value + await nextTick() + } + + const clickButton = async (wrapper: ReturnType, dataTest: string) => { + const button = wrapper.findComponent(`[data-test="${dataTest}"]`) + expect(button.exists()).toBe(true) + await (button as any).vm.$emit('click') + await flushPromises() + } + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia( + createTestingPinia({ + stubActions: false, + createSpy: vi.fn + }) + ) + + store = useTrapdConfigStore() + store.createUserDrawerState.visible = true + store.createUserDrawerState.mode = CreateEditMode.Create + store.createUserDrawerState.selectedUserIndex = -1 + store.snmpV3Users = [selectedUser] + store.trapdConfig = { + ...getDefaultTrapdConfig(), + snmpv3User: [selectedUser] + } + + store.fetchTrapConfig = vi.fn().mockResolvedValue(undefined) + store.closeCreateUserDrawer = vi.fn() + store.openCredentialDrawer = vi.fn() + store.closeCredentialDrawer = vi.fn() + + mapUserToServerMock.mockImplementation((payload) => payload as SnmpV3User) + updateTrapdConfigurationMock.mockResolvedValue(undefined) + }) + + it('calls scvStore.populate on mount', () => { + mountComponent() + + expect(useScvStoreMock).toHaveBeenCalledTimes(1) + expect(populateScvMock).toHaveBeenCalledTimes(1) + }) + + it('does not render when drawer is hidden', () => { + store.createUserDrawerState.visible = false + const wrapper = mountComponent() + + expect(wrapper.find('[data-test="create-snmpv3-user"]').exists()).toBe(false) + }) + + it('renders create mode with heading and action buttons', () => { + const wrapper = mountComponent() + + expect(wrapper.find('h3').text()).toBe('New SNMPv3 User Management') + expect(wrapper.find('[data-test="create-user-button"]').text()).toContain('Create User') + expect(wrapper.find('[data-test="cancel-button"]').exists()).toBe(true) + }) + + it('renders update label and preloads security name in edit mode', async () => { + store.createUserDrawerState.mode = CreateEditMode.Edit + store.createUserDrawerState.selectedUserIndex = 0 + + const wrapper = mountComponent() + await nextTick() + await nextTick() + + expect(wrapper.find('[data-test="create-user-button"]').text()).toContain('Update User') + expect((wrapper.find('input[data-test="security-name-input"]').element as HTMLInputElement).value).toBe( + 'existing-user' + ) + }) + + it('calls closeCreateUserDrawer from back and cancel buttons', async () => { + const wrapper = mountComponent() + + await wrapper.findAll('button')[0].trigger('click') + await wrapper.find('[data-test="cancel-button"]').trigger('click') + + expect(store.closeCreateUserDrawer).toHaveBeenCalledTimes(2) + }) + + it('opens credential drawer from auth passphrase button in edit mode', async () => { + store.createUserDrawerState.mode = CreateEditMode.Edit + store.createUserDrawerState.selectedUserIndex = 0 + + const wrapper = mountComponent() + await nextTick() + + await wrapper.find('[data-test="auth-passphrase-save-button"]').trigger('click') + + expect(store.openCredentialDrawer).toHaveBeenCalledWith('auth') + }) + + it('opens credential drawer from privacy passphrase button when privacy row is visible', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[2]) + await wrapper.find('[data-test="privacy-passphrase-save-button"]').trigger('click') + + expect(store.openCredentialDrawer).toHaveBeenCalledWith('privacy') + }) + + it('toggles auth/privacy rows based on security level', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[0]) + expect(wrapper.find('[data-test="auth-passphrase-input"]').exists()).toBe(false) + expect(wrapper.find('[data-test="privacy-passphrase-input"]').exists()).toBe(false) + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[1]) + expect(wrapper.find('[data-test="auth-passphrase-input"]').exists()).toBe(true) + expect(wrapper.find('[data-test="privacy-passphrase-input"]').exists()).toBe(false) + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[2]) + expect(wrapper.find('[data-test="auth-passphrase-input"]').exists()).toBe(true) + expect(wrapper.find('[data-test="privacy-passphrase-input"]').exists()).toBe(true) + }) + + it('clears dependent values when security level is lowered to noAuthNoPriv', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[2]) + await setBindingValue(wrapper, 'authProtocol', AUTH_PROTOCOL_OPTIONS[0]) + await setBindingValue(wrapper, 'privacyProtocol', PRIVACY_PROTOCOL_OPTIONS[0]) + await setBindingValue(wrapper, 'authPassphrase', 'auth-secret') + await setBindingValue(wrapper, 'privacyPassphrase', 'privacy-secret') + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[0]) + + expect((wrapper.vm as any).authProtocol).toBeUndefined() + expect((wrapper.vm as any).privacyProtocol).toBeUndefined() + expect((wrapper.vm as any).authPassphrase).toBe('') + expect((wrapper.vm as any).privacyPassphrase).toBe('') + }) + + it('fills auth passphrase from SCV selection and closes SCV drawer', async () => { + const wrapper = mountComponent() + store.credentialDrawerState.key = 'auth' + ;(wrapper.vm as any).scvItemSelected({ alias: 'vault', key: 'auth-key' }) + await nextTick() + + expect((wrapper.vm as any).authPassphrase).toBe('${scv:vault:auth-key}') + expect(store.closeCredentialDrawer).toHaveBeenCalledTimes(1) + }) + + it('fills privacy passphrase from SCV selection and closes SCV drawer', async () => { + const wrapper = mountComponent() + store.credentialDrawerState.key = 'privacy' + ;(wrapper.vm as any).scvItemSelected({ alias: 'vault', key: 'privacy-key' }) + await nextTick() + + expect((wrapper.vm as any).privacyPassphrase).toBe('${scv:vault:privacy-key}') + expect(store.closeCredentialDrawer).toHaveBeenCalledTimes(1) + }) + + it('closes SCV drawer when ScvSearchDrawer emits hidden', async () => { + const wrapper = mountComponent() + + await wrapper.findComponent(ScvSearchDrawerStub).vm.$emit('hidden') + + expect(store.closeCredentialDrawer).toHaveBeenCalledTimes(1) + }) + + it('closes SCV drawer without changing passphrases when SCV key is unknown', async () => { + const wrapper = mountComponent() + store.credentialDrawerState.key = 'other' + await setBindingValue(wrapper, 'authPassphrase', 'existing-auth') + await setBindingValue(wrapper, 'privacyPassphrase', 'existing-privacy') + + ;(wrapper.vm as any).scvItemSelected({ alias: 'vault', key: 'some-key' }) + await nextTick() + + expect((wrapper.vm as any).authPassphrase).toBe('existing-auth') + expect((wrapper.vm as any).privacyPassphrase).toBe('existing-privacy') + expect(store.closeCredentialDrawer).toHaveBeenCalledTimes(1) + }) + + it('onSecurityLevelChange sets default auth protocol for AuthNoPriv', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[1]) + await (wrapper.vm as any).onSecurityLevelChange() + await nextTick() + + expect((wrapper.vm as any).authProtocol).toEqual( + AUTH_PROTOCOL_OPTIONS.find(option => option._value === DEFAULT_SNMP_V3_AUTH_PROTOCOL) + ) + expect((wrapper.vm as any).authPassphrase).toBe('') + }) + + it('onSecurityLevelChange sets default auth/privacy protocols for AuthPriv', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[2]) + await (wrapper.vm as any).onSecurityLevelChange() + await nextTick() + + expect((wrapper.vm as any).authProtocol).toEqual( + AUTH_PROTOCOL_OPTIONS.find(option => option._value === DEFAULT_SNMP_V3_AUTH_PROTOCOL) + ) + expect((wrapper.vm as any).privacyProtocol).toEqual( + PRIVACY_PROTOCOL_OPTIONS.find(option => option._value === DEFAULT_SNMP_V3_PRIVACY_PROTOCOL) + ) + expect((wrapper.vm as any).authPassphrase).toBe('') + expect((wrapper.vm as any).privacyPassphrase).toBe('') + }) + + it('loads create mode defaults with NoAuthNoPriv selected', async () => { + const wrapper = mountComponent() + + store.createUserDrawerState.mode = CreateEditMode.Create + store.createUserDrawerState.selectedUserIndex = -1 + await nextTick() + await nextTick() + + expect((wrapper.vm as any).securityLevel).toEqual(SECURITY_LEVEL_OPTIONS[0]) + expect((wrapper.vm as any).securityName).toBe(DEFAULT_SNMP_V3_SECURITY_NAME) + expect((wrapper.vm as any).engineId).toBe('') + }) + + it('creates user successfully in create mode', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'new-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[0]) + await clickButton(wrapper, 'create-user-button') + + expect(mapUserToServerMock).toHaveBeenCalledWith( + expect.objectContaining({ + securityName: 'new-user', + securityLevel: expect.any(Number) + }) + ) + expect(updateTrapdConfigurationMock).toHaveBeenCalledTimes(1) + expect(updateTrapdConfigurationMock).toHaveBeenCalledWith( + expect.objectContaining({ + snmpv3User: expect.arrayContaining([expect.objectContaining({ securityName: 'new-user' })]) + }) + ) + expect(store.fetchTrapConfig).toHaveBeenCalledTimes(1) + expect(store.closeCreateUserDrawer).toHaveBeenCalledTimes(1) + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'SNMPv3 user created successfully.' }) + }) + + it('updates user successfully in edit mode', async () => { + store.createUserDrawerState.mode = CreateEditMode.Edit + store.createUserDrawerState.selectedUserIndex = 0 + + const wrapper = mountComponent() + await nextTick() + + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[1]) + await setBindingValue(wrapper, 'authProtocol', AUTH_PROTOCOL_OPTIONS[0]) + await setBindingValue(wrapper, 'authPassphrase', 'masked-auth') + await clickButton(wrapper, 'create-user-button') + + expect(updateTrapdConfigurationMock).toHaveBeenCalledTimes(1) + expect(updateTrapdConfigurationMock).toHaveBeenCalledWith( + expect.objectContaining({ + snmpv3User: expect.arrayContaining([expect.objectContaining({ securityName: 'existing-user' })]) + }) + ) + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'SNMPv3 user updated successfully.' }) + }) + + it('shows explicit edit error when selected user cannot be found', async () => { + store.snmpV3Users = [] + store.createUserDrawerState.mode = CreateEditMode.Edit + store.createUserDrawerState.selectedUserIndex = 0 + + const wrapper = mountComponent() + await nextTick() + + await setInputValue(wrapper, 'security-name-input', 'replacement-name') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[1]) + await setBindingValue(wrapper, 'authProtocol', AUTH_PROTOCOL_OPTIONS[0]) + await setBindingValue(wrapper, 'authPassphrase', 'masked-auth') + await clickButton(wrapper, 'create-user-button') + + expect(updateTrapdConfigurationMock).not.toHaveBeenCalled() + expect(store.fetchTrapConfig).not.toHaveBeenCalled() + expect(store.closeCreateUserDrawer).not.toHaveBeenCalled() + expect(showSnackBarMock).toHaveBeenCalledWith({ + msg: 'Unable to determine the selected SNMPv3 user to update.', + error: true + }) + }) + + it('does not require security level to match backend optional behaviour', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'new-user') + await setBindingValue(wrapper, 'securityLevel', createEmptySelectItem()) + + expect((wrapper.vm as any).error.securityLevel).toBeUndefined() + }) + + it('shows validation error when level 1 has auth credentials (backend cross-field rule)', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'new-user') + // Manually set auth protocol with NoAuthNoPriv level to simulate dirty state + ;(wrapper.vm as any).securityLevel = SECURITY_LEVEL_OPTIONS[0] + ;(wrapper.vm as any).authProtocol = AUTH_PROTOCOL_OPTIONS[0] + await nextTick() + + expect((wrapper.vm as any).error.securityLevel).toBe( + 'Security level 1 does not allow auth or privacy credentials' + ) + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows validation error when level 1 has privacy credentials (backend cross-field rule)', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'new-user') + ;(wrapper.vm as any).securityLevel = SECURITY_LEVEL_OPTIONS[0] + ;(wrapper.vm as any).privacyProtocol = PRIVACY_PROTOCOL_OPTIONS[0] + await nextTick() + + expect((wrapper.vm as any).error.securityLevel).toBe( + 'Security level 1 does not allow auth or privacy credentials' + ) + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows validation error when level 2 has privacy credentials (backend cross-field rule)', async () => { + const wrapper = mountComponent() + + // Set level 2 and await so the watcher fires and clears privacyProtocol normally + ;(wrapper.vm as any).securityLevel = SECURITY_LEVEL_OPTIONS[1] + await nextTick() + + // Now inject dirty privacy state AFTER the watcher ran, and call validateInputs + // directly before the Vue scheduler has a chance to run watchEffect again + ;(wrapper.vm as any).privacyProtocol = PRIVACY_PROTOCOL_OPTIONS[0] + const errors = (wrapper.vm as any).validateInputs() + + expect(errors.privacyProtocol).toBe('Security level 2 does not allow privacy credentials') + }) + + it('requires auth protocol and auth passphrase for auth-only security level', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'auth-only-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[1]) + await setBindingValue(wrapper, 'authProtocol', createEmptySelectItem()) + await setBindingValue(wrapper, 'authPassphrase', '') + await clickButton(wrapper, 'create-user-button') + + expect(updateTrapdConfigurationMock).not.toHaveBeenCalled() + expect(showSnackBarMock).toHaveBeenCalledWith({ + msg: 'Please fix validation errors before saving.', + error: true + }) + }) + + it('shows auth protocol error with passphrase-specific message when passphrase is set but protocol is cleared', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'auth-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[1]) + await setBindingValue(wrapper, 'authProtocol', createEmptySelectItem()) + await setBindingValue(wrapper, 'authPassphrase', 'some-passphrase') + + expect((wrapper.vm as any).error.authProtocol).toBe('Auth Passphrase requires an Auth Protocol to be selected') + }) + + it('shows generic auth protocol error when passphrase is also missing', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'auth-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[1]) + await setBindingValue(wrapper, 'authProtocol', createEmptySelectItem()) + await setBindingValue(wrapper, 'authPassphrase', '') + + expect((wrapper.vm as any).error.authProtocol).toBe('Auth Protocol is required for selected security level') + }) + + it('requires privacy protocol and privacy passphrase for auth-priv security level', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'auth-priv-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[2]) + await setBindingValue(wrapper, 'authProtocol', AUTH_PROTOCOL_OPTIONS[0]) + await setBindingValue(wrapper, 'authPassphrase', 'auth-secret') + await clickButton(wrapper, 'create-user-button') + + expect(updateTrapdConfigurationMock).not.toHaveBeenCalled() + expect(showSnackBarMock).toHaveBeenCalledWith({ + msg: 'Please fix validation errors before saving.', + error: true + }) + }) + + it('shows privacy protocol error with passphrase-specific message when privacy passphrase is set but protocol is cleared', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'priv-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[2]) + await setBindingValue(wrapper, 'authProtocol', AUTH_PROTOCOL_OPTIONS[0]) + await setBindingValue(wrapper, 'authPassphrase', 'auth-secret') + await setBindingValue(wrapper, 'privacyProtocol', createEmptySelectItem()) + await setBindingValue(wrapper, 'privacyPassphrase', 'privacy-secret') + + expect((wrapper.vm as any).error.privacyProtocol).toBe('Privacy Passphrase requires a Privacy Protocol to be selected') + }) + + it('shows generic privacy protocol error when privacy passphrase is also missing', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'priv-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[2]) + await setBindingValue(wrapper, 'authProtocol', AUTH_PROTOCOL_OPTIONS[0]) + await setBindingValue(wrapper, 'authPassphrase', 'auth-secret') + await setBindingValue(wrapper, 'privacyProtocol', createEmptySelectItem()) + await setBindingValue(wrapper, 'privacyPassphrase', '') + + expect((wrapper.vm as any).error.privacyProtocol).toBe('Privacy Protocol is required for selected security level') + }) + + it('shows service error when updateTrapdConfiguration throws Error', async () => { + updateTrapdConfigurationMock.mockRejectedValue(new Error('save failed')) + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'new-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[0]) + await clickButton(wrapper, 'create-user-button') + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'save failed', error: true }) + }) + + it('shows generic service error when updateTrapdConfiguration throws non-Error', async () => { + updateTrapdConfigurationMock.mockRejectedValue('boom') + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'new-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[0]) + await clickButton(wrapper, 'create-user-button') + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Failed to save SNMPv3 user.', error: true }) + }) + + it('prevents duplicate create requests while saving is in progress', async () => { + let resolveSave: () => void = () => undefined + updateTrapdConfigurationMock.mockImplementation( + () => + new Promise((resolve) => { + resolveSave = resolve + }) + ) + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', 'new-user') + await setBindingValue(wrapper, 'securityLevel', SECURITY_LEVEL_OPTIONS[0]) + + await clickButton(wrapper, 'create-user-button') + await clickButton(wrapper, 'create-user-button') + + expect(updateTrapdConfigurationMock).toHaveBeenCalledTimes(1) + + resolveSave() + await flushPromises() + }) + + it('shows validation message when trying to save with empty security name', async () => { + const wrapper = mountComponent() + + await setInputValue(wrapper, 'security-name-input', '') + + await clickButton(wrapper, 'create-user-button') + + expect(showSnackBarMock).toHaveBeenCalledWith({ + msg: 'Please fix validation errors before saving.', + error: true + }) + expect(updateTrapdConfigurationMock).not.toHaveBeenCalled() + }) +}) diff --git a/ui/tests/components/TrapdConfiguration/GeneralConfiguration.test.ts b/ui/tests/components/TrapdConfiguration/GeneralConfiguration.test.ts new file mode 100644 index 000000000000..9031e9b8a229 --- /dev/null +++ b/ui/tests/components/TrapdConfiguration/GeneralConfiguration.test.ts @@ -0,0 +1,376 @@ +import GeneralConfiguration from '@/components/TrapdConfiguration/GeneralConfiguration.vue' +import { MAX_PORT, MIN_PORT } from '@/lib/trapdValidator' +import { updateTrapdConfiguration } from '@/services/trapdConfigurationService' +import { useTrapdConfigStore } from '@/stores/trapdConfigStore' +import type { TrapConfig } from '@/types/trapConfig' +import { createTestingPinia } from '@pinia/testing' +import { flushPromises, mount } from '@vue/test-utils' +import { cloneDeep } from 'lodash' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +const { showSnackBarMock } = vi.hoisted(() => ({ + showSnackBarMock: vi.fn() +})) + +vi.mock('@/composables/useSnackbar', () => ({ + default: () => ({ + showSnackBar: showSnackBarMock + }) +})) + +vi.mock('@/services/trapdConfigurationService', () => ({ + updateTrapdConfiguration: vi.fn() +})) + +describe('GeneralConfiguration.vue', () => { + let store: ReturnType + const updateTrapdConfigurationMock = vi.mocked(updateTrapdConfiguration) + + const baseTrapConfig: TrapConfig = { + snmpTrapAddress: '192.168.1.10', + snmpTrapPort: 162, + newSuspectOnTrap: true, + includeRawMessage: false, + threads: 4, + queueSize: 5000, + batchSize: 250, + batchInterval: 750, + useAddressFromVarbind: true, + snmpv3User: [] + } + + const mountComponent = () => { + return mount(GeneralConfiguration, { + global: { + stubs: { + TableCard: { + template: '
' + }, + FeatherExpansionPanel: { + props: ['title'], + template: '
' + }, + FeatherInput: true, + 'feather-input': true, + FeatherButton: true, + 'feather-button': true, + SwitchRender: true, + 'switch-render': true + } + } + }) + } + + const setBindingValue = async ( + wrapper: ReturnType, + key: string, + value: string | number | boolean + ) => { + ;(wrapper.vm as any)[key] = value + await nextTick() + } + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia( + createTestingPinia({ + stubActions: false, + createSpy: vi.fn + }) + ) + + store = useTrapdConfigStore() + store.trapdConfig = cloneDeep(baseTrapConfig) + store.fetchTrapConfig = vi.fn().mockResolvedValue(undefined) + + updateTrapdConfigurationMock.mockResolvedValue(undefined) + }) + + it('renders the section labels and loads the current trap configuration values from the store', () => { + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('Trap Listener Settings') + expect(wrapper.text()).toContain('Update Changes') + expect((wrapper.vm as any).port).toBe(162) + expect((wrapper.vm as any).bindAddress).toBe('192.168.1.10') + expect((wrapper.vm as any).status).toBe(true) + expect((wrapper.vm as any).trapMessageStatus).toBe(false) + expect((wrapper.vm as any).trapSourceAddressStatus).toBe(true) + expect((wrapper.vm as any).threads).toBe(4) + expect((wrapper.vm as any).queueSize).toBe(5000) + expect((wrapper.vm as any).batchSize).toBe(250) + expect((wrapper.vm as any).batchInterval).toBe(750) + expect((wrapper.vm as any).trapConfigError).toEqual({}) + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('reloads form state when trapdConfig changes in the store', async () => { + const wrapper = mountComponent() + + store.trapdConfig = { + ...cloneDeep(baseTrapConfig), + snmpTrapPort: 10162, + snmpTrapAddress: '*', + newSuspectOnTrap: false, + includeRawMessage: true, + useAddressFromVarbind: false, + threads: 0, + queueSize: 10000, + batchSize: 1000, + batchInterval: 500 + } + await nextTick() + await nextTick() + + expect((wrapper.vm as any).port).toBe(10162) + expect((wrapper.vm as any).bindAddress).toBe('*') + expect((wrapper.vm as any).status).toBe(false) + expect((wrapper.vm as any).trapMessageStatus).toBe(true) + expect((wrapper.vm as any).trapSourceAddressStatus).toBe(false) + expect((wrapper.vm as any).threads).toBe(0) + expect((wrapper.vm as any).queueSize).toBe(10000) + expect((wrapper.vm as any).batchSize).toBe(1000) + expect((wrapper.vm as any).batchInterval).toBe(500) + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('enables save when a valid field changes and disables it again when reverted', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'port', 163) + expect((wrapper.vm as any).isSaveDisabled).toBe(false) + + await setBindingValue(wrapper, 'port', 162) + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('toggles all switch values through the component handlers', () => { + const wrapper = mountComponent() + + ;(wrapper.vm as any).onChangeStatus() + ;(wrapper.vm as any).onChangeTrapMessageStatus() + ;(wrapper.vm as any).onChangeTrapSourceAddressStatus() + + expect((wrapper.vm as any).status).toBe(false) + expect((wrapper.vm as any).trapMessageStatus).toBe(true) + expect((wrapper.vm as any).trapSourceAddressStatus).toBe(false) + }) + + it('submits the updated payload successfully and refreshes the store', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'port', '10162') + await setBindingValue(wrapper, 'bindAddress', '*') + await setBindingValue(wrapper, 'status', false) + await setBindingValue(wrapper, 'trapMessageStatus', true) + await setBindingValue(wrapper, 'trapSourceAddressStatus', false) + await setBindingValue(wrapper, 'threads', '2') + await setBindingValue(wrapper, 'queueSize', '6000') + await setBindingValue(wrapper, 'batchSize', '300') + await setBindingValue(wrapper, 'batchInterval', '900') + + await (wrapper.vm as any).updateConfig() + await flushPromises() + + expect(updateTrapdConfigurationMock).toHaveBeenCalledWith({ + snmpTrapPort: 10162, + snmpTrapAddress: '*', + newSuspectOnTrap: false, + useAddressFromVarbind: false, + includeRawMessage: true, + threads: 2, + queueSize: 6000, + batchSize: 300, + batchInterval: 900, + snmpv3User: [] + }) + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Trap configuration updated successfully.' }) + expect(store.fetchTrapConfig).toHaveBeenCalledTimes(1) + expect((wrapper.vm as any).isSaving).toBe(false) + }) + + it('shows the service error message when update fails with an Error', async () => { + updateTrapdConfigurationMock.mockRejectedValue(new Error('update failed')) + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'port', 163) + await (wrapper.vm as any).updateConfig() + await flushPromises() + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'update failed', error: true }) + expect(store.fetchTrapConfig).not.toHaveBeenCalled() + expect((wrapper.vm as any).isSaving).toBe(false) + }) + + it('shows a generic error message when update fails with a non-Error value', async () => { + updateTrapdConfigurationMock.mockRejectedValue('boom') + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'port', 163) + await (wrapper.vm as any).updateConfig() + await flushPromises() + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Failed to update trap configuration.', error: true }) + expect(store.fetchTrapConfig).not.toHaveBeenCalled() + expect((wrapper.vm as any).isSaving).toBe(false) + }) + + it('includes store snmpV3Users in the update payload', async () => { + store.snmpV3Users = [{ + securityName: 'sec-user-1', + securityLevel: 1, + authProtocol: null, + authPassphrase: null, + privacyProtocol: null, + privacyPassphrase: null, + engineId: null + }] + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'port', 163) + await (wrapper.vm as any).updateConfig() + await flushPromises() + + expect(updateTrapdConfigurationMock).toHaveBeenCalledWith(expect.objectContaining({ + snmpv3User: [ + expect.objectContaining({ securityName: 'sec-user-1' }) + ] + })) + }) + + it('sets isSaving during an in-flight request and clears it after completion', async () => { + let resolveRequest: (() => void) | undefined + updateTrapdConfigurationMock.mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + }) + ) + + const wrapper = mountComponent() + await setBindingValue(wrapper, 'port', 163) + + const pendingSave = (wrapper.vm as any).updateConfig() + await nextTick() + + expect((wrapper.vm as any).isSaving).toBe(true) + + resolveRequest?.() + await pendingSave + await flushPromises() + + expect((wrapper.vm as any).isSaving).toBe(false) + }) + + it(`shows a validation error when port is less than ${MIN_PORT}`, async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'port', 0) + + expect((wrapper.vm as any).trapConfigError.port).toBe(`Port must be between ${MIN_PORT} and ${MAX_PORT}.`) + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it(`shows a validation error when port is greater than ${MAX_PORT}`, async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'port', 65536) + + expect((wrapper.vm as any).trapConfigError.port).toBe(`Port must be between ${MIN_PORT} and ${MAX_PORT}.`) + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows a validation error when bind address is empty', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'bindAddress', '') + + expect((wrapper.vm as any).trapConfigError.bindAddress).toBe('Bind Address cannot be empty.') + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows a validation error when bind address is not * or a valid IPv4 address', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'bindAddress', 'localhost') + + expect((wrapper.vm as any).trapConfigError.bindAddress).toBe('Bind Address must be * or a valid IP address.') + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('accepts wildcard bind address as a valid value', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'bindAddress', '*') + + expect((wrapper.vm as any).trapConfigError.bindAddress).toBeUndefined() + expect((wrapper.vm as any).isSaveDisabled).toBe(false) + }) + + it('shows a validation error when threads is negative', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'threads', -1) + + expect((wrapper.vm as any).trapConfigError.threads).toBe('Threads cannot be negative.') + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows a validation error when queue size is negative', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'queueSize', -1) + + expect((wrapper.vm as any).trapConfigError.queueSize).toBe('Queue Size must be greater than 0.') + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows a validation error when queue size is zero', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'queueSize', 0) + + expect((wrapper.vm as any).trapConfigError.queueSize).toBe('Queue Size must be greater than 0.') + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows a validation error when batch size is negative', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'batchSize', -1) + + expect((wrapper.vm as any).trapConfigError.batchSize).toBe('Batch Size must be greater than 0.') + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows a validation error when batch size is zero', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'batchSize', 0) + + expect((wrapper.vm as any).trapConfigError.batchSize).toBe('Batch Size must be greater than 0.') + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('shows a validation error when batch interval is negative', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'batchInterval', -1) + + expect((wrapper.vm as any).trapConfigError.batchInterval).toBe('Batch Interval cannot be negative.') + expect((wrapper.vm as any).isSaveDisabled).toBe(true) + }) + + it('does not call the update service when the form is invalid', async () => { + const wrapper = mountComponent() + + await setBindingValue(wrapper, 'bindAddress', '') + await (wrapper.vm as any).updateConfig() + await flushPromises() + + expect(updateTrapdConfigurationMock).not.toHaveBeenCalled() + expect(showSnackBarMock).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/ui/tests/components/TrapdConfiguration/SnmpV3UserManagement.test.ts b/ui/tests/components/TrapdConfiguration/SnmpV3UserManagement.test.ts new file mode 100644 index 000000000000..97c2569a75a1 --- /dev/null +++ b/ui/tests/components/TrapdConfiguration/SnmpV3UserManagement.test.ts @@ -0,0 +1,401 @@ +import SnmpV3UserManagement from '@/components/TrapdConfiguration/SnmpV3UserManagement.vue' +import { updateTrapdConfiguration } from '@/services/trapdConfigurationService' +import { useTrapdConfigStore } from '@/stores/trapdConfigStore' +import { CreateEditMode } from '@/types' +import type { SnmpV3User } from '@/types/trapConfig' +import { FeatherSortHeader, SORT } from '@featherds/table' +import { createTestingPinia } from '@pinia/testing' +import { flushPromises, mount } from '@vue/test-utils' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick } from 'vue' + +const { showSnackBarMock } = vi.hoisted(() => ({ + showSnackBarMock: vi.fn() +})) + +vi.mock('@/composables/useSnackbar', () => ({ + default: () => ({ + showSnackBar: showSnackBarMock + }) +})) + +vi.mock('@/services/trapdConfigurationService', () => ({ + updateTrapdConfiguration: vi.fn() +})) + +const FeatherButtonStub = defineComponent({ + name: 'FeatherButton', + props: { + dataTest: { + type: String, + default: '' + }, + disabled: { + type: Boolean, + default: false + }, + icon: { + type: String, + default: '' + } + }, + emits: ['click'], + template: + '' +}) + +const FeatherSortHeaderStub = defineComponent({ + name: 'FeatherSortHeader', + props: { + property: { + type: String, + default: '' + }, + sort: { + type: String, + default: '' + } + }, + emits: ['sort-changed'], + template: '' +}) + +const DeleteDialogStub = defineComponent({ + name: 'DeleteUserConfirmationDialog', + props: { + visible: { + type: Boolean, + default: false + } + }, + emits: ['close', 'confirm'], + template: ` +
+
{{ String(visible) }}
+ + +
+ ` +}) + +describe('SnmpV3UserManagement.vue', () => { + let store: ReturnType + const updateTrapdConfigurationMock = vi.mocked(updateTrapdConfiguration) + + const users: SnmpV3User[] = [ + { + engineId: null, + securityName: 'user-one', + securityLevel: 1, + authProtocol: null, + authPassphrase: null, + privacyProtocol: null, + privacyPassphrase: null + }, + { + engineId: null, + securityName: 'user-two', + securityLevel: 3, + authProtocol: 'SHA256', + authPassphrase: 'masked-a', + privacyProtocol: 'AES256', + privacyPassphrase: 'masked-b' + } + ] + + const mountComponent = () => { + return mount(SnmpV3UserManagement, { + global: { + stubs: { + TableCard: { + template: '
' + }, + EmptyList: { + props: ['content'], + template: '
{{ content.msg }}
' + }, + DeleteUserConfirmationDialog: DeleteDialogStub, + FeatherButton: FeatherButtonStub, + 'feather-button': FeatherButtonStub, + FeatherSortHeader: FeatherSortHeaderStub, + 'feather-sort-header': FeatherSortHeaderStub, + FeatherIcon: true, + 'feather-icon': true, + TransitionGroup: { + template: '' + } + } + } + }) + } + + const clickByDataTest = async (wrapper: ReturnType, dataTest: string, index = 0) => { + const elements = wrapper.findAll(`[data-test="${dataTest}"]`) + expect(elements[index]?.exists()).toBe(true) + await elements[index].trigger('click') + await flushPromises() + } + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia( + createTestingPinia({ + stubActions: false, + createSpy: vi.fn + }) + ) + + store = useTrapdConfigStore() + store.createUserDrawerState.visible = false + store.snmpV3Users = [...users] + store.trapdConfig = { + snmpTrapAddress: '127.0.0.1', + snmpTrapPort: 162, + newSuspectOnTrap: false, + includeRawMessage: false, + threads: 0, + queueSize: 10000, + batchSize: 1000, + batchInterval: 500, + useAddressFromVarbind: false, + snmpv3User: [...users] + } + store.fetchTrapConfig = vi.fn().mockResolvedValue(undefined) + store.openCreateUserDrawer = vi.fn() + + updateTrapdConfigurationMock.mockResolvedValue(undefined) + }) + + it('does not render when the create user drawer is visible', () => { + store.createUserDrawerState.visible = true + const wrapper = mountComponent() + + expect(wrapper.find('[data-test="snmpv3-user-management"]').exists()).toBe(false) + }) + + it('renders the heading, add button, column headers, and user rows', () => { + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('SNMPv3 User Management') + expect(wrapper.text()).toContain('List SNMPv3 users credentials') + expect(wrapper.find('[data-test="add-user-button"]').exists()).toBe(true) + expect(wrapper.text()).toContain('SnmpV3 Username') + expect(wrapper.text()).toContain('Security Level') + expect(wrapper.text()).toContain('Authentication Protocol') + expect(wrapper.text()).toContain('Privacy Protocol') + expect(wrapper.text()).toContain('Action') + expect(wrapper.text()).toContain('user-one') + expect(wrapper.text()).toContain('user-two') + expect((wrapper.vm as any).tableRecords).toEqual(users) + }) + + it('renders the empty state when there are no users', async () => { + store.snmpV3Users = [] + const wrapper = mountComponent() + await nextTick() + + expect(wrapper.find('[data-test="empty-list"]').text()).toBe('No SNMPv3 users found') + expect(wrapper.findAll('tbody tr')).toHaveLength(0) + }) + + it('opens create user drawer in create mode from the add user button', async () => { + const wrapper = mountComponent() + + await clickByDataTest(wrapper, 'add-user-button') + + expect(store.openCreateUserDrawer).toHaveBeenCalledWith(CreateEditMode.Create, -1) + }) + + it('opens create user drawer in edit mode for the selected row', async () => { + const wrapper = mountComponent() + + await clickByDataTest(wrapper, 'edit-user-button', 1) + + expect(store.openCreateUserDrawer).toHaveBeenCalledWith(CreateEditMode.Edit, 1) + }) + + it('opens the delete dialog with the selected index', async () => { + const wrapper = mountComponent() + + await clickByDataTest(wrapper, 'delete-user-button', 1) + + expect((wrapper.vm as any).deleteUserIndex).toBe(1) + expect((wrapper.vm as any).deleteDialogVisible).toBe(true) + expect(wrapper.find('[data-test="delete-dialog-visible"]').text()).toBe('true') + }) + + it('cancels delete and resets dialog state', async () => { + const wrapper = mountComponent() + ;(wrapper.vm as any).openDeleteUserDialog(0) + await nextTick() + + await clickByDataTest(wrapper, 'close-delete-dialog') + + expect((wrapper.vm as any).deleteUserIndex).toBe(null) + expect((wrapper.vm as any).deleteDialogVisible).toBe(false) + }) + + it('deletes the selected user successfully, refreshes config, and closes the dialog', async () => { + const wrapper = mountComponent() + ;(wrapper.vm as any).openDeleteUserDialog(0) + await nextTick() + + await clickByDataTest(wrapper, 'confirm-delete-dialog') + + expect(updateTrapdConfigurationMock).toHaveBeenCalledTimes(1) + expect(updateTrapdConfigurationMock).toHaveBeenCalledWith(expect.objectContaining({ + snmpv3User: [expect.objectContaining({ securityName: 'user-two' })] + })) + expect(store.fetchTrapConfig).toHaveBeenCalledTimes(1) + expect((wrapper.vm as any).deleteUserIndex).toBe(null) + expect((wrapper.vm as any).deleteDialogVisible).toBe(false) + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'SNMPv3 user deleted successfully.' }) + expect((wrapper.vm as any).isDeleting).toBe(false) + }) + + it('shows the service error when delete fails with an Error', async () => { + updateTrapdConfigurationMock.mockRejectedValue(new Error('delete failed')) + const wrapper = mountComponent() + ;(wrapper.vm as any).openDeleteUserDialog(0) + await nextTick() + + await clickByDataTest(wrapper, 'confirm-delete-dialog') + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'delete failed', error: true }) + expect(store.fetchTrapConfig).not.toHaveBeenCalled() + expect((wrapper.vm as any).deleteUserIndex).toBe(0) + expect((wrapper.vm as any).deleteDialogVisible).toBe(true) + expect((wrapper.vm as any).isDeleting).toBe(false) + }) + + it('shows a generic error when delete fails with a non-Error value', async () => { + updateTrapdConfigurationMock.mockRejectedValue('boom') + const wrapper = mountComponent() + ;(wrapper.vm as any).openDeleteUserDialog(0) + await nextTick() + + await clickByDataTest(wrapper, 'confirm-delete-dialog') + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Failed to delete SNMPv3 user.', error: true }) + }) + + it('does not call delete when no selected index exists', async () => { + const wrapper = mountComponent() + + await (wrapper.vm as any).confirmDeleteUser() + await flushPromises() + + expect(updateTrapdConfigurationMock).not.toHaveBeenCalled() + expect((wrapper.vm as any).deleteUserIndex).toBe(null) + expect((wrapper.vm as any).deleteDialogVisible).toBe(false) + expect(showSnackBarMock).not.toHaveBeenCalled() + }) + + it('does not call delete again while a delete request is already in progress', async () => { + let resolveDelete: (() => void) | undefined + updateTrapdConfigurationMock.mockImplementation( + () => + new Promise((resolve) => { + resolveDelete = resolve + }) + ) + + const wrapper = mountComponent() + ;(wrapper.vm as any).openDeleteUserDialog(0) + await nextTick() + + const pendingDelete = (wrapper.vm as any).confirmDeleteUser() + await nextTick() + await (wrapper.vm as any).confirmDeleteUser() + + expect(updateTrapdConfigurationMock).toHaveBeenCalledTimes(1) + expect((wrapper.vm as any).isDeleting).toBe(true) + + resolveDelete?.() + await pendingDelete + await flushPromises() + + expect((wrapper.vm as any).isDeleting).toBe(false) + }) + + it('updates tableRecords when the store users list changes', async () => { + const wrapper = mountComponent() + const nextUsers: SnmpV3User[] = [ + { + engineId: null, + securityName: 'replacement-user', + securityLevel: 2, + authProtocol: 'MD5', + authPassphrase: 'masked', + privacyProtocol: null, + privacyPassphrase: null + } + ] + + store.snmpV3Users = nextUsers + await nextTick() + await nextTick() + + expect((wrapper.vm as any).tableRecords).toEqual(nextUsers) + expect(wrapper.text()).toContain('replacement-user') + expect(wrapper.text()).not.toContain('user-one') + }) + + it('falls back to an empty records list when store users become undefined', async () => { + const wrapper = mountComponent() + + ;(store as any).snmpV3Users = undefined + await nextTick() + await nextTick() + + expect((wrapper.vm as any).tableRecords).toEqual([]) + }) + + it('shows user-not-found error and closes dialog when selected index is out of range', async () => { + const wrapper = mountComponent() + ;(wrapper.vm as any).openDeleteUserDialog(99) + await nextTick() + + await (wrapper.vm as any).confirmDeleteUser() + await flushPromises() + + expect(updateTrapdConfigurationMock).not.toHaveBeenCalled() + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'SNMPv3 user not found.', error: true }) + expect((wrapper.vm as any).deleteUserIndex).toBe(null) + expect((wrapper.vm as any).deleteDialogVisible).toBe(false) + }) + + it('updates sort state when a sort header emits sort-changed', async () => { + const wrapper = mountComponent() + const sortHeaders = wrapper.findAllComponents(FeatherSortHeader) + + expect(sortHeaders).toHaveLength(4) + await sortHeaders[1].vm.$emit('sort-changed', { + property: 'securityLevel', + value: SORT.ASCENDING + }) + await nextTick() + + expect((wrapper.vm as any).sort.username).toBe(SORT.NONE) + expect((wrapper.vm as any).sort.securityLevel).toBe(SORT.ASCENDING) + expect((wrapper.vm as any).sort.authenticationProtocol).toBe(SORT.NONE) + expect((wrapper.vm as any).sort.privacyProtocol).toBe(SORT.NONE) + }) + + it('sortChanged resets previous sort values before applying the next property', () => { + const wrapper = mountComponent() + + ;(wrapper.vm as any).sort.username = SORT.DESCENDING + ;(wrapper.vm as any).sort.securityLevel = SORT.ASCENDING + ;(wrapper.vm as any).sortChanged({ + property: 'privacyProtocol', + value: SORT.ASCENDING + }) + + expect((wrapper.vm as any).sort.username).toBe(SORT.NONE) + expect((wrapper.vm as any).sort.securityLevel).toBe(SORT.NONE) + expect((wrapper.vm as any).sort.authenticationProtocol).toBe(SORT.NONE) + expect((wrapper.vm as any).sort.privacyProtocol).toBe(SORT.ASCENDING) + }) +}) diff --git a/ui/tests/containers/TrapdConfiguration.test.ts b/ui/tests/containers/TrapdConfiguration.test.ts new file mode 100644 index 000000000000..848385fd893f --- /dev/null +++ b/ui/tests/containers/TrapdConfiguration.test.ts @@ -0,0 +1,252 @@ +import BreadCrumbs from '@/components/Layout/BreadCrumbs.vue' +import TrapdConfiguration from '@/containers/TrapdConfiguration.vue' +import { validateTrapdXml } from '@/lib/trapdValidator' +import { uploadTrapdConfiguration } from '@/services/trapdConfigurationService' +import { useMenuStore } from '@/stores/menuStore' +import { useTrapdConfigStore } from '@/stores/trapdConfigStore' +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { showSnackBarMock } = vi.hoisted(() => ({ + showSnackBarMock: vi.fn() +})) + +vi.mock('@/composables/useSnackbar', () => ({ + default: () => ({ + showSnackBar: showSnackBarMock + }) +})) + +vi.mock('@/lib/trapdValidator', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateTrapdXml: vi.fn() + } +}) + +vi.mock('@/services/trapdConfigurationService', () => ({ + uploadTrapdConfiguration: vi.fn() +})) + +describe('TrapdConfiguration.vue', () => { + let trapStore: ReturnType + let menuStore: ReturnType + + const validateTrapdXmlMock = vi.mocked(validateTrapdXml) + const uploadTrapdConfigurationMock = vi.mocked(uploadTrapdConfiguration) + + const mountComponent = () => { + return mount(TrapdConfiguration, { + global: { + stubs: { + GeneralConfiguration: true, + SnmpV3UserManagement: true, + CreateSnmpV3User: true, + FeatherTabContainer: { + template: '
' + }, + FeatherTab: { + template: '
' + }, + FeatherTabPanel: { + template: '
' + }, + FeatherButton: { + template: '' + }, + BreadCrumbs: true + } + } + }) + } + + const createXmlFile = (name = 'trapd.xml', content = '') => { + const file = new File([content], name, { type: 'text/xml' }) + vi.spyOn(file, 'text').mockResolvedValue(content) + return file + } + + const triggerUpload = async (wrapper: ReturnType, file?: File) => { + const input = wrapper.find('input[type="file"]') + Object.defineProperty(input.element, 'files', { + value: file ? [file] : [], + configurable: true + }) + await input.trigger('change') + } + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createTestingPinia({ stubActions: true })) + trapStore = useTrapdConfigStore() + menuStore = useMenuStore() + menuStore.mainMenu = { homeUrl: '/home' } as any + trapStore.fetchTrapConfig = vi.fn().mockResolvedValue(undefined) + validateTrapdXmlMock.mockReturnValue({ valid: true, errors: [] }) + uploadTrapdConfigurationMock.mockResolvedValue(undefined) + }) + + it('renders heading and child sections', () => { + const wrapper = mountComponent() + + expect(wrapper.find('h1').text()).toBe('Trap Listener Configuration') + expect(wrapper.findComponent(BreadCrumbs).exists()).toBe(true) + expect(wrapper.find('input[type="file"]').exists()).toBe(true) + }) + + it('renders breadcrumbs with home and trap configuration entries', () => { + const wrapper = mountComponent() + const breadcrumbs = wrapper.findComponent(BreadCrumbs) + const items = breadcrumbs.props('items') + + expect(items).toHaveLength(2) + expect(items[0]).toEqual({ label: 'Home', to: '/home', isAbsoluteLink: true }) + expect(items[1]).toEqual({ label: 'Trap Listener Configuration', to: '#', position: 'last' }) + }) + + it('calls fetchTrapConfig on mount', () => { + mountComponent() + expect(trapStore.fetchTrapConfig).toHaveBeenCalledTimes(1) + }) + + it('shows snackbar when initial fetch fails with Error', async () => { + trapStore.fetchTrapConfig = vi.fn().mockRejectedValue(new Error('initial fetch failed')) + + mountComponent() + await Promise.resolve() + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'initial fetch failed', error: true }) + }) + + it('shows snackbar when initial fetch fails with non-Error', async () => { + trapStore.fetchTrapConfig = vi.fn().mockRejectedValue('boom') + + mountComponent() + await Promise.resolve() + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Failed to retrieve trapd configuration.', error: true }) + }) + + it('opens file chooser when upload button is clicked', async () => { + const wrapper = mountComponent() + const input = wrapper.find('input[type="file"]').element as HTMLInputElement + const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => undefined) + + const uploadButton = wrapper.findAll('button').find((button) => button.text().includes('Upload Configuration')) + expect(uploadButton).toBeDefined() + + await uploadButton!.trigger('click') + expect(clickSpy).toHaveBeenCalledTimes(1) + }) + + it('returns early when no file is selected', async () => { + const wrapper = mountComponent() + await triggerUpload(wrapper) + + expect(validateTrapdXmlMock).not.toHaveBeenCalled() + expect(uploadTrapdConfigurationMock).not.toHaveBeenCalled() + }) + + it('rejects non-xml files before validation', async () => { + const wrapper = mountComponent() + const invalidFile = createXmlFile('trapd.txt', 'not xml') + + await triggerUpload(wrapper, invalidFile) + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Only .xml files are supported.', error: true }) + expect(validateTrapdXmlMock).not.toHaveBeenCalled() + expect(uploadTrapdConfigurationMock).not.toHaveBeenCalled() + }) + + it('shows read error when file.text fails', async () => { + const wrapper = mountComponent() + const file = new File(['x'], 'trapd.xml', { type: 'text/xml' }) + vi.spyOn(file, 'text').mockRejectedValue(new Error('read failed')) + + await triggerUpload(wrapper, file) + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Failed to read XML file.', error: true }) + expect(validateTrapdXmlMock).not.toHaveBeenCalled() + expect(uploadTrapdConfigurationMock).not.toHaveBeenCalled() + }) + + it('blocks upload and shows validation errors when XML is invalid (<=3 errors)', async () => { + const wrapper = mountComponent() + const file = createXmlFile('trapd.xml', '') + validateTrapdXmlMock.mockReturnValue({ + valid: false, + errors: [ + { field: 'root', message: 'Root mismatch' }, + { field: 'xmlns', message: 'Namespace invalid' }, + { field: 'snmp-trap-port', message: 'Port invalid' } + ] + }) + + await triggerUpload(wrapper, file) + + expect(validateTrapdXmlMock).toHaveBeenCalledWith('') + expect(showSnackBarMock).toHaveBeenCalledWith({ + msg: 'Invalid trap configuration XML: Root mismatch | Namespace invalid | Port invalid', + error: true + }) + expect(uploadTrapdConfigurationMock).not.toHaveBeenCalled() + }) + + it('shows +N more suffix for validation errors beyond first 3', async () => { + const wrapper = mountComponent() + const file = createXmlFile('trapd.xml', '') + validateTrapdXmlMock.mockReturnValue({ + valid: false, + errors: [ + { field: 'a', message: 'e1' }, + { field: 'b', message: 'e2' }, + { field: 'c', message: 'e3' }, + { field: 'd', message: 'e4' }, + { field: 'e', message: 'e5' } + ] + }) + + await triggerUpload(wrapper, file) + + expect(showSnackBarMock).toHaveBeenCalledWith({ + msg: 'Invalid trap configuration XML: e1 | e2 | e3 (+2 more)', + error: true + }) + expect(uploadTrapdConfigurationMock).not.toHaveBeenCalled() + }) + + it('uploads file and refreshes store when XML is valid', async () => { + const wrapper = mountComponent() + const file = createXmlFile('trapd.xml', '') + + await triggerUpload(wrapper, file) + + expect(validateTrapdXmlMock).toHaveBeenCalledWith('') + expect(uploadTrapdConfigurationMock).toHaveBeenCalledWith(file) + expect(trapStore.fetchTrapConfig).toHaveBeenCalledTimes(2) + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Trap configuration uploaded successfully.' }) + }) + + it('shows upload error message from Error instance', async () => { + const wrapper = mountComponent() + const file = createXmlFile('trapd.xml', '') + uploadTrapdConfigurationMock.mockRejectedValue(new Error('upload failed')) + + await triggerUpload(wrapper, file) + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'upload failed', error: true }) + }) + + it('shows generic upload error message for non-Error throw', async () => { + const wrapper = mountComponent() + const file = createXmlFile('trapd.xml', '') + uploadTrapdConfigurationMock.mockRejectedValue('bad') + + await triggerUpload(wrapper, file) + + expect(showSnackBarMock).toHaveBeenCalledWith({ msg: 'Failed to upload trap configuration.', error: true }) + }) +}) diff --git a/ui/tests/lib/trapdValidator.test.ts b/ui/tests/lib/trapdValidator.test.ts new file mode 100644 index 000000000000..0ef301f7c39c --- /dev/null +++ b/ui/tests/lib/trapdValidator.test.ts @@ -0,0 +1,694 @@ +import { XMLParser, XMLValidator } from 'fast-xml-parser' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { + AUTH_PROTOCOL_OPTIONS, + AuthProtocol, + AuthProtocols, + MAX_PORT, + MIN_PORT, + PRIVACY_PROTOCOL_OPTIONS, + PrivacyProtocol, + PrivacyProtocols, + SECURITY_LEVEL_OPTIONS, + SecurityLevel, + TRAPD_XML_NAMESPACE, + getDefaultTrapdConfig, + isValidIP, + isValidPort, + isValidSnmpSecurityLevel, + validateTrapdXml +} from '@/lib/trapdValidator' + +// --------------------------------------------------------------------------- +// DOMParser polyfill (fast-xml-parser backed) +// +// happy-dom v9 parses ALL MIME types as HTML, so root.namespaceURI is always +// the XHTML namespace. We replace window.DOMParser with a minimal but correct +// implementation for the subset of the DOM API that validateTrapdXml uses. +// --------------------------------------------------------------------------- + +class FakeElement { + localName: string + namespaceURI: string | null + textContent: string | null = null + private attrs: Record + private children: FakeElement[] + + constructor(localName: string, attrs: Record, children: FakeElement[] = []) { + this.localName = localName + this.attrs = attrs + this.children = children + this.namespaceURI = attrs['xmlns'] ?? null + } + + getAttribute(name: string): string | null { + return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null + } + + getElementsByTagName(tagName: string): FakeElement[] { + const results: FakeElement[] = [] + for (const child of this.children) { + if (child.localName === tagName) results.push(child) + results.push(...child.getElementsByTagName(tagName)) + } + return results + } +} + +class FakeDocument { + documentElement: FakeElement + private parseError: FakeElement | null + + constructor(root: FakeElement, parseError: FakeElement | null = null) { + this.documentElement = root + this.parseError = parseError + } + + querySelector(selector: string): FakeElement | null { + return selector === 'parsererror' ? this.parseError : null + } +} + +function buildElement(tagName: string, node: Record): FakeElement { + const attrs: Record = {} + const children: FakeElement[] = [] + + for (const [key, value] of Object.entries(node)) { + if (key.startsWith('@_')) { + attrs[key.slice(2)] = String(value) + } else if (Array.isArray(value)) { + for (const child of value as Record[]) { + children.push(buildElement(key, child)) + } + } else if (typeof value === 'object' && value !== null) { + children.push(buildElement(key, value as Record)) + } + } + + return new FakeElement(tagName, attrs, children) +} + +const fxpParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name) => name === 'snmpv3-user', + parseAttributeValue: false, + trimValues: false +}) + +class FakeDOMParser { + parseFromString(xmlString: string, _mimeType: string): FakeDocument { + void _mimeType + const validation = XMLValidator.validate(xmlString) + if (validation !== true) { + const errEl = new FakeElement('parsererror', {}) + errEl.textContent = (validation as { err: { msg: string } }).err?.msg ?? 'parse error' + const dummyRoot = new FakeElement('parsererror', {}) + return new FakeDocument(dummyRoot, errEl) + } + + const parsed = fxpParser.parse(xmlString) as Record> + const rootTagName = Object.keys(parsed)[0] + const rootNode = parsed[rootTagName] ?? {} + const root = buildElement(rootTagName, rootNode) + return new FakeDocument(root) + } +} + +beforeAll(() => { + vi.stubGlobal('DOMParser', FakeDOMParser) +}) + +afterAll(() => { + vi.unstubAllGlobals() +}) + +const VALID_NS = TRAPD_XML_NAMESPACE + +/** Build a minimal valid trapd XML string, overriding individual attributes. */ +const buildXml = (overrides: { + root?: string + xmlns?: string | null + address?: string | null + port?: string | null + suspect?: string | null + users?: string +} = {}): string => { + const { + root = 'trapd-configuration', + xmlns = VALID_NS, + address = '*', + port = '162', + suspect = null, + users = '' + } = overrides + + const nsAttr = xmlns === null ? '' : ` xmlns="${xmlns}"` + const addrAttr = address === null ? '' : ` snmp-trap-address="${address}"` + const portAttr = port === null ? '' : ` snmp-trap-port="${port}"` + const suspectAttr = suspect === null ? '' : ` new-suspect-on-trap="${suspect}"` + + if (users) { + return `<${root}${nsAttr}${addrAttr}${portAttr}${suspectAttr}>${users}` + } + return `<${root}${nsAttr}${addrAttr}${portAttr}${suspectAttr} />` +} + +/** Build a element string from an attribute map. */ +const buildUser = (attrs: Record = {}): string => { + const attrStr = Object.entries(attrs) + .map(([k, v]) => `${k}="${v}"`) + .join(' ') + return `` +} + +describe('isValidPort', () => { + it.each([MIN_PORT, MAX_PORT, 162, 10162, 1024])('returns true for valid port %i', (port) => { + expect(isValidPort(port)).toBe(true) + }) + + it('returns false for undefined', () => { + expect(isValidPort(undefined)).toBe(false) + }) + + it('returns false for 0 (below MIN_PORT)', () => { + expect(isValidPort(0)).toBe(false) + }) + + it('returns false for MAX_PORT + 1', () => { + expect(isValidPort(MAX_PORT + 1)).toBe(false) + }) + + it('returns false for negative port', () => { + expect(isValidPort(-1)).toBe(false) + }) + + it('returns false for NaN', () => { + expect(isValidPort(NaN)).toBe(false) + }) +}) + +describe('isValidIP', () => { + it.each(['0.0.0.0', '192.168.1.1', '255.255.255.255', '10.0.0.1'])( + 'returns true for valid IPv4 %s', + (ip) => { + expect(isValidIP(ip)).toBe(true) + } + ) + + it('returns false for too few octets', () => { + expect(isValidIP('192.168.1')).toBe(false) + }) + + it('returns false for too many octets', () => { + expect(isValidIP('192.168.1.1.1')).toBe(false) + }) + + it('returns false for octet > 255', () => { + expect(isValidIP('192.168.1.256')).toBe(false) + }) + + it('returns false for non-numeric octet', () => { + expect(isValidIP('abc.def.ghi.jkl')).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isValidIP('')).toBe(false) + }) + + it('returns false for wildcard *', () => { + expect(isValidIP('*')).toBe(false) + }) +}) + +describe('isValidSnmpSecurityLevel', () => { + it.each([SecurityLevel.NoAuthNoPriv, SecurityLevel.AuthNoPriv, SecurityLevel.AuthPriv])( + 'returns true for valid level %i', + (level) => { + expect(isValidSnmpSecurityLevel(level)).toBe(true) + } + ) + + it('returns false for SecurityLevel.None (0)', () => { + expect(isValidSnmpSecurityLevel(SecurityLevel.None)).toBe(false) + }) + + it('returns false for level 4 (out of range)', () => { + expect(isValidSnmpSecurityLevel(4)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isValidSnmpSecurityLevel(undefined)).toBe(false) + }) +}) + +describe('getDefaultTrapdConfig', () => { + it('returns expected default values', () => { + const config = getDefaultTrapdConfig() + expect(config.snmpTrapAddress).toBe('*') + expect(config.snmpTrapPort).toBe(10162) + expect(config.newSuspectOnTrap).toBe(false) + expect(config.includeRawMessage).toBe(false) + expect(config.threads).toBe(0) + expect(config.queueSize).toBe(10000) + expect(config.batchSize).toBe(1000) + expect(config.batchInterval).toBe(500) + expect(config.useAddressFromVarbind).toBe(false) + expect(config.snmpv3User).toEqual([]) + }) + + it('returns a fresh object each call (no shared reference)', () => { + const a = getDefaultTrapdConfig() + const b = getDefaultTrapdConfig() + a.snmpv3User.push({ securityName: 'x' } as any) + expect(b.snmpv3User).toHaveLength(0) + }) +}) + +describe('SECURITY_LEVEL_OPTIONS', () => { + it('contains exactly three entries', () => { + expect(SECURITY_LEVEL_OPTIONS).toHaveLength(3) + }) + + it('has correct _value strings for all three levels', () => { + const values = SECURITY_LEVEL_OPTIONS.map((o) => o._value) + expect(values).toContain(String(SecurityLevel.NoAuthNoPriv)) + expect(values).toContain(String(SecurityLevel.AuthNoPriv)) + expect(values).toContain(String(SecurityLevel.AuthPriv)) + }) +}) + +describe('AUTH_PROTOCOL_OPTIONS', () => { + it('contains exactly as many entries as AuthProtocols', () => { + expect(AUTH_PROTOCOL_OPTIONS).toHaveLength(AuthProtocols.length) + }) + + it('maps protocol values correctly', () => { + const values = AUTH_PROTOCOL_OPTIONS.map((o) => o._value) + expect(values).toContain(AuthProtocol.MD5) + expect(values).toContain(AuthProtocol.SHA) + expect(values).toContain(AuthProtocol.SHA256) + expect(values).toContain(AuthProtocol.SHA512) + }) +}) + +describe('PRIVACY_PROTOCOL_OPTIONS', () => { + it('contains exactly as many entries as PrivacyProtocols', () => { + expect(PRIVACY_PROTOCOL_OPTIONS).toHaveLength(PrivacyProtocols.length) + }) + + it('maps protocol values correctly', () => { + const values = PRIVACY_PROTOCOL_OPTIONS.map((o) => o._value) + expect(values).toContain(PrivacyProtocol.DES) + expect(values).toContain(PrivacyProtocol.AES) + expect(values).toContain(PrivacyProtocol.AES256) + }) +}) + +describe('validateTrapdXml – XML structure', () => { + it('returns invalid for empty string', () => { + const result = validateTrapdXml('') + expect(result.valid).toBe(false) + expect(result.errors[0].field).toBe('xml') + }) + + it('returns invalid for whitespace-only string', () => { + const result = validateTrapdXml(' \n ') + expect(result.valid).toBe(false) + expect(result.errors[0].field).toBe('xml') + }) + + it('returns invalid for malformed XML', () => { + const result = validateTrapdXml(' { + const result = validateTrapdXml(``) + expect(result.valid).toBe(false) + expect(result.errors[0].field).toBe('root') + expect(result.errors[0].message).toMatch(/trapd-configuration/) + }) + + it('returns invalid for wrong xmlns', () => { + const result = validateTrapdXml(buildXml({ xmlns: 'http://wrong.namespace' })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'xmlns')).toBe(true) + }) + + it('returns invalid when xmlns is omitted', () => { + const result = validateTrapdXml(buildXml({ xmlns: null })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'xmlns')).toBe(true) + }) + + it('returns valid for minimal correct XML', () => { + const result = validateTrapdXml(buildXml()) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) +}) + +describe('validateTrapdXml – snmp-trap-address', () => { + it('returns error when snmp-trap-address is missing', () => { + const result = validateTrapdXml(buildXml({ address: null })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'snmp-trap-address')).toBe(true) + }) + + it('accepts wildcard "*"', () => { + const result = validateTrapdXml(buildXml({ address: '*' })) + expect(result.valid).toBe(true) + }) + + it('accepts a valid IPv4 address', () => { + const result = validateTrapdXml(buildXml({ address: '192.168.1.1' })) + expect(result.valid).toBe(true) + }) + + it('returns error for invalid IPv4 address', () => { + const result = validateTrapdXml(buildXml({ address: '999.0.0.1' })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'snmp-trap-address')).toBe(true) + }) + + it('returns error for hostname (not IPv4)', () => { + const result = validateTrapdXml(buildXml({ address: 'localhost' })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'snmp-trap-address')).toBe(true) + }) +}) + +describe('validateTrapdXml – snmp-trap-port', () => { + it('returns error when snmp-trap-port is missing', () => { + const result = validateTrapdXml(buildXml({ port: null })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'snmp-trap-port')).toBe(true) + }) + + it('accepts MIN_PORT boundary', () => { + const result = validateTrapdXml(buildXml({ port: String(MIN_PORT) })) + expect(result.valid).toBe(true) + }) + + it('accepts MAX_PORT boundary', () => { + const result = validateTrapdXml(buildXml({ port: String(MAX_PORT) })) + expect(result.valid).toBe(true) + }) + + it('returns error for port 0 (below MIN_PORT)', () => { + const result = validateTrapdXml(buildXml({ port: '0' })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'snmp-trap-port')).toBe(true) + }) + + it('returns error for port 65536 (above MAX_PORT)', () => { + const result = validateTrapdXml(buildXml({ port: '65536' })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'snmp-trap-port')).toBe(true) + }) + + it('returns error for non-numeric port', () => { + const result = validateTrapdXml(buildXml({ port: 'abc' })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'snmp-trap-port')).toBe(true) + }) +}) + +describe('validateTrapdXml – new-suspect-on-trap', () => { + it('accepts "true"', () => { + const result = validateTrapdXml(buildXml({ suspect: 'true' })) + expect(result.valid).toBe(true) + }) + + it('accepts "false"', () => { + const result = validateTrapdXml(buildXml({ suspect: 'false' })) + expect(result.valid).toBe(true) + }) + + it('is optional (absent is valid)', () => { + const result = validateTrapdXml(buildXml({ suspect: null })) + expect(result.valid).toBe(true) + }) + + it('returns error for invalid value', () => { + const result = validateTrapdXml(buildXml({ suspect: 'yes' })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field === 'new-suspect-on-trap')).toBe(true) + }) +}) + +describe('validateTrapdXml – snmpv3-user: security-name and security-level', () => { + it('returns error when security-name is missing', () => { + const user = buildUser({ 'security-level': '1' }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('security-name'))).toBe(true) + }) + + it('allows missing security-level (optional)', () => { + const user = buildUser({ 'security-name': 'user1' }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.valid).toBe(true) + expect(result.errors.some((e) => e.field.includes('security-level'))).toBe(false) + }) + + it('returns error for security-level 0 (None)', () => { + const user = buildUser({ 'security-name': 'user1', 'security-level': '0' }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('security-level'))).toBe(true) + }) + + it('returns error for security-level 4 (out of range)', () => { + const user = buildUser({ 'security-name': 'user1', 'security-level': '4' }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('security-level'))).toBe(true) + }) +}) + +describe('validateTrapdXml – snmpv3-user: level 1 (NoAuthNoPriv)', () => { + it('is valid with only security-name and security-level=1', () => { + const user = buildUser({ 'security-name': 'user1', 'security-level': '1' }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.valid).toBe(true) + }) + + it('returns error when auth-protocol is present at level 1', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '1', + 'auth-protocol': 'MD5', + 'auth-passphrase': 'pass' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('auth-protocol'))).toBe(true) + }) + + it('returns error when auth-passphrase is present at level 1', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '1', + 'auth-passphrase': 'pass' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('auth-passphrase'))).toBe(true) + }) + + it('returns error when privacy-protocol is present at level 1', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '1', + 'privacy-protocol': 'DES', + 'privacy-passphrase': 'priv' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('privacy-protocol'))).toBe(true) + }) + + it('returns error when privacy-passphrase is present at level 1', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '1', + 'privacy-passphrase': 'priv' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('privacy-passphrase'))).toBe(true) + }) +}) + +describe('validateTrapdXml – snmpv3-user: level 2 (AuthNoPriv)', () => { + it('is valid with auth-protocol and auth-passphrase at level 2', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '2', + 'auth-protocol': 'SHA', + 'auth-passphrase': 'secret' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.valid).toBe(true) + }) + + it('returns error when auth-protocol is missing at level 2', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '2', + 'auth-passphrase': 'secret' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('auth-protocol'))).toBe(true) + }) + + it('returns error when auth-passphrase is missing at level 2', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '2', + 'auth-protocol': 'MD5' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('auth-passphrase'))).toBe(true) + }) + + it('returns error when privacy-protocol is present at level 2', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '2', + 'auth-protocol': 'MD5', + 'auth-passphrase': 'secret', + 'privacy-protocol': 'DES', + 'privacy-passphrase': 'priv' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('privacy-protocol'))).toBe(true) + }) + + it('returns error when privacy-passphrase is present at level 2', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '2', + 'auth-protocol': 'MD5', + 'auth-passphrase': 'secret', + 'privacy-passphrase': 'priv' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('privacy-passphrase'))).toBe(true) + }) +}) + +describe('validateTrapdXml – snmpv3-user: level 3 (AuthPriv)', () => { + const validLevel3 = { + 'security-name': 'user1', + 'security-level': '3', + 'auth-protocol': 'SHA', + 'auth-passphrase': 'authsecret', + 'privacy-protocol': 'AES', + 'privacy-passphrase': 'privsecret' + } + + it('is valid with all required fields at level 3', () => { + const result = validateTrapdXml(buildXml({ users: buildUser(validLevel3) })) + expect(result.valid).toBe(true) + }) + + it('returns error when auth-protocol is missing at level 3', () => { + const { 'auth-protocol': omittedAuthProtocol, ...attrs } = validLevel3 + void omittedAuthProtocol + const result = validateTrapdXml(buildXml({ users: buildUser(attrs) })) + expect(result.errors.some((e) => e.field.includes('auth-protocol'))).toBe(true) + }) + + it('returns error when auth-passphrase is missing at level 3', () => { + const { 'auth-passphrase': omittedAuthPassphrase, ...attrs } = validLevel3 + void omittedAuthPassphrase + const result = validateTrapdXml(buildXml({ users: buildUser(attrs) })) + expect(result.errors.some((e) => e.field.includes('auth-passphrase'))).toBe(true) + }) + + it('returns error when privacy-protocol is missing at level 3', () => { + const { 'privacy-protocol': omittedPrivacyProtocol, ...attrs } = validLevel3 + void omittedPrivacyProtocol + const result = validateTrapdXml(buildXml({ users: buildUser(attrs) })) + expect(result.errors.some((e) => e.field.includes('privacy-protocol'))).toBe(true) + }) + + it('returns error when privacy-passphrase is missing at level 3', () => { + const { 'privacy-passphrase': omittedPrivacyPassphrase, ...attrs } = validLevel3 + void omittedPrivacyPassphrase + const result = validateTrapdXml(buildXml({ users: buildUser(attrs) })) + expect(result.errors.some((e) => e.field.includes('privacy-passphrase'))).toBe(true) + }) +}) + +describe('validateTrapdXml – auth-protocol dash normalization', () => { + it('accepts "SHA-256" (dash form) as valid auth-protocol', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '2', + 'auth-protocol': 'SHA-256', + 'auth-passphrase': 'secret' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('auth-protocol'))).toBe(false) + }) + + it('rejects completely unknown auth-protocol value', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '2', + 'auth-protocol': 'UNKNOWN', + 'auth-passphrase': 'secret' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('auth-protocol'))).toBe(true) + }) + + it('rejects unknown privacy-protocol value', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '3', + 'auth-protocol': 'MD5', + 'auth-passphrase': 'secret', + 'privacy-protocol': 'UNKNOWN', + 'privacy-passphrase': 'priv' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('privacy-protocol'))).toBe(true) + }) +}) + +describe('validateTrapdXml – privacy-protocol without auth-protocol', () => { + it('returns error when privacy-protocol set but auth-protocol absent', () => { + const user = buildUser({ + 'security-name': 'user1', + 'security-level': '3', + 'privacy-protocol': 'AES', + 'privacy-passphrase': 'priv', + 'auth-passphrase': 'secret' + }) + const result = validateTrapdXml(buildXml({ users: user })) + expect(result.errors.some((e) => e.field.includes('auth-protocol'))).toBe(true) + }) +}) + +describe('validateTrapdXml – multiple snmpv3-user elements', () => { + it('is valid with multiple well-formed users', () => { + const user1 = buildUser({ 'security-name': 'userA', 'security-level': '1' }) + const user2 = buildUser({ + 'security-name': 'userB', + 'security-level': '2', + 'auth-protocol': 'MD5', + 'auth-passphrase': 'pass' + }) + const result = validateTrapdXml(buildXml({ users: user1 + user2 })) + expect(result.valid).toBe(true) + }) + + it('accumulates errors across multiple invalid users', () => { + // user[1]: missing security-name; user[2]: invalid security-level + const user1 = buildUser({ 'security-level': '1' }) + const user2 = buildUser({ 'security-name': 'userB', 'security-level': '99' }) + const result = validateTrapdXml(buildXml({ users: user1 + user2 })) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.field.includes('snmpv3-user[1]'))).toBe(true) + expect(result.errors.some((e) => e.field.includes('snmpv3-user[2]'))).toBe(true) + }) +}) diff --git a/ui/tests/services/snmpConfigService.test.ts b/ui/tests/services/snmpConfigService.test.ts index b46d7063fd1d..ab67fce86024 100644 --- a/ui/tests/services/snmpConfigService.test.ts +++ b/ui/tests/services/snmpConfigService.test.ts @@ -317,6 +317,7 @@ describe('snmpConfigService', () => { }) it('should handle exceptions and return failure result', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) vi.mocked(v2.post).mockRejectedValue(new Error('Network error')) const config: SnmpBaseConfiguration = { @@ -330,6 +331,11 @@ describe('snmpConfigService', () => { expect(v2.post).toHaveBeenCalledWith('/snmp-config/defaults', config) expect(result.success).toBe(false) expect(result.message).toBe('Failed to save SNMP configuration defaults') + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving SNMP config defaults:', + expect.any(Error) + ) + consoleErrorSpy.mockRestore() }) it('should work with SNMPv3 configuration', async () => { diff --git a/ui/tests/stores/trapdConfigStore.test.ts b/ui/tests/stores/trapdConfigStore.test.ts new file mode 100644 index 000000000000..23068c95257e --- /dev/null +++ b/ui/tests/stores/trapdConfigStore.test.ts @@ -0,0 +1,126 @@ +import { getDefaultTrapdConfig } from '@/lib/trapdValidator' +import { getTrapdConfiguration } from '@/services/trapdConfigurationService' +import { useTrapdConfigStore } from '@/stores/trapdConfigStore' +import { CreateEditMode } from '@/types' +import type { TrapConfig } from '@/types/trapConfig' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/services/trapdConfigurationService', () => ({ + getTrapdConfiguration: vi.fn() +})) + +describe('useTrapdConfigStore', () => { + let store: ReturnType + + const trapConfigResponse: TrapConfig = { + snmpTrapAddress: '192.168.0.20', + snmpTrapPort: 1162, + newSuspectOnTrap: true, + includeRawMessage: true, + threads: 8, + queueSize: 12000, + batchSize: 1500, + batchInterval: 700, + useAddressFromVarbind: true, + snmpv3User: [ + { + engineId: null, + securityName: 'alpha-user', + securityLevel: 2, + authProtocol: 'SHA256', + authPassphrase: 'masked-auth', + privacyProtocol: null, + privacyPassphrase: null + } + ] + } + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + store = useTrapdConfigStore() + }) + + it('has the expected initial state', () => { + expect(store.isLoading).toBe(false) + expect(store.trapdConfig).toEqual(getDefaultTrapdConfig()) + expect(store.snmpV3Users).toEqual([]) + expect(store.activeTab).toBe(0) + expect(store.credentialDrawerState).toEqual({ + visible: false, + key: null + }) + expect(store.createUserDrawerState).toEqual({ + visible: false, + mode: CreateEditMode.None, + selectedUserIndex: -1 + }) + }) + + it('fetchTrapConfig updates trapdConfig and snmpV3Users from service response', async () => { + vi.mocked(getTrapdConfiguration).mockResolvedValue(trapConfigResponse) + + await store.fetchTrapConfig() + + expect(getTrapdConfiguration).toHaveBeenCalledTimes(1) + expect(store.trapdConfig).toEqual(trapConfigResponse) + expect(store.snmpV3Users).toEqual(trapConfigResponse.snmpv3User) + }) + + it('fetchTrapConfig propagates errors and keeps prior state unchanged', async () => { + const previousConfig = store.trapdConfig + const previousUsers = store.snmpV3Users + const error = new Error('fetch failed') + vi.mocked(getTrapdConfiguration).mockRejectedValue(error) + + await expect(store.fetchTrapConfig()).rejects.toThrow('fetch failed') + expect(store.trapdConfig).toBe(previousConfig) + expect(store.snmpV3Users).toBe(previousUsers) + }) + + it('openCredentialDrawer sets visibility and key', () => { + store.openCredentialDrawer('authPassphrase') + + expect(store.credentialDrawerState.visible).toBe(true) + expect(store.credentialDrawerState.key).toBe('authPassphrase') + }) + + it('openCredentialDrawer replaces key when called again', () => { + store.openCredentialDrawer('authPassphrase') + store.openCredentialDrawer('privacyPassphrase') + + expect(store.credentialDrawerState.visible).toBe(true) + expect(store.credentialDrawerState.key).toBe('privacyPassphrase') + }) + + it('closeCredentialDrawer hides drawer and clears key', () => { + store.openCredentialDrawer('authPassphrase') + + store.closeCredentialDrawer() + + expect(store.credentialDrawerState.visible).toBe(false) + expect(store.credentialDrawerState.key).toBe(null) + }) + + it('openCreateUserDrawer sets create drawer state', () => { + store.openCreateUserDrawer(CreateEditMode.Edit, 3) + + expect(store.createUserDrawerState.visible).toBe(true) + expect(store.createUserDrawerState.mode).toBe(CreateEditMode.Edit) + expect(store.createUserDrawerState.selectedUserIndex).toBe(3) + }) + + it('closeCreateUserDrawer resets create drawer state to defaults', () => { + store.openCreateUserDrawer(CreateEditMode.Create, 0) + + store.closeCreateUserDrawer() + + expect(store.createUserDrawerState).toEqual({ + visible: false, + mode: CreateEditMode.None, + selectedUserIndex: -1 + }) + }) +}) +