- |
+ |
No results found.
|
@@ -65,7 +69,8 @@
:disabled="credentialsLoading"
data-test="scv-drawer-cancel-button"
@click="drawerOpen = false"
- >Cancel
+ >Cancel
@@ -108,7 +113,7 @@ const onItemSelected = (item: ScvSearchItem) => {
emit('item-selected', item)
}
-watch (() => props.isOpen, (newVal) => {
+watch(() => props.isOpen, (newVal) => {
drawerOpen.value = newVal
})
@@ -138,7 +143,7 @@ watch (() => props.isOpen, (newVal) => {
@include table.table-condensed;
border: 1px solid var(variables.$border-on-surface);
- > thead {
+ >thead {
background-color: var(variables.$border-on-surface);
padding: 1em;
text-transform: uppercase;
@@ -163,3 +168,4 @@ watch (() => props.isOpen, (newVal) => {
}
}
+
diff --git a/ui/src/components/TrapConfiguration/CreateSnmpV3User.vue b/ui/src/components/TrapConfiguration/CreateSnmpV3User.vue
deleted file mode 100644
index 9eddeda31578..000000000000
--- a/ui/src/components/TrapConfiguration/CreateSnmpV3User.vue
+++ /dev/null
@@ -1,235 +0,0 @@
-
-
-
-
-
-
-
Credential properties
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ui/src/components/TrapConfiguration/GeneralConfiguration.vue b/ui/src/components/TrapConfiguration/GeneralConfiguration.vue
deleted file mode 100644
index f8870e0b533a..000000000000
--- a/ui/src/components/TrapConfiguration/GeneralConfiguration.vue
+++ /dev/null
@@ -1,219 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ui/src/components/TrapConfiguration/SearchExistingCredential.vue b/ui/src/components/TrapConfiguration/SearchExistingCredential.vue
deleted file mode 100644
index 860a6f691bb9..000000000000
--- a/ui/src/components/TrapConfiguration/SearchExistingCredential.vue
+++ /dev/null
@@ -1,178 +0,0 @@
-
-
-
-
-
Use an existing credential
-
-
-
Finding existing credentials based on search
-
-
-
-
-
- Search
-
-
-
-
-
-
- | Alias |
- Key |
-
-
-
-
- | {{ user.alias }} |
- {{ user.value }} |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ui/src/components/TrapdConfiguration/CreateSnmpV3User.vue b/ui/src/components/TrapdConfiguration/CreateSnmpV3User.vue
new file mode 100644
index 000000000000..16c67d83ea52
--- /dev/null
+++ b/ui/src/components/TrapdConfiguration/CreateSnmpV3User.vue
@@ -0,0 +1,464 @@
+
+
+
+
+
+
+
Credential properties
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/TrapdConfiguration/Dialog/DeleteUserConfirmationDialog.vue b/ui/src/components/TrapdConfiguration/Dialog/DeleteUserConfirmationDialog.vue
new file mode 100644
index 000000000000..5fbd09d69543
--- /dev/null
+++ b/ui/src/components/TrapdConfiguration/Dialog/DeleteUserConfirmationDialog.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
Are you sure you want to delete this SNMPv3 user with security name "{{ store.snmpV3Users[props.index]?.securityName }}"?
+
Note: This action cannot be undone.
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/TrapdConfiguration/GeneralConfiguration.vue b/ui/src/components/TrapdConfiguration/GeneralConfiguration.vue
new file mode 100644
index 000000000000..4e37567591de
--- /dev/null
+++ b/ui/src/components/TrapdConfiguration/GeneralConfiguration.vue
@@ -0,0 +1,366 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/TrapConfiguration/SnmpV3UserManagement.vue b/ui/src/components/TrapdConfiguration/SnmpV3UserManagement.vue
similarity index 67%
rename from ui/src/components/TrapConfiguration/SnmpV3UserManagement.vue
rename to ui/src/components/TrapdConfiguration/SnmpV3UserManagement.vue
index a0ef08253e37..bfc5752d0c7b 100644
--- a/ui/src/components/TrapConfiguration/SnmpV3UserManagement.vue
+++ b/ui/src/components/TrapdConfiguration/SnmpV3UserManagement.vue
@@ -13,7 +13,7 @@
Add User
@@ -47,21 +47,23 @@
v-for="(user, index) in tableRecords"
:key="index"
>
- {{ user.username }} |
+ {{ user.securityName }} |
{{ user.securityLevel }} |
- {{ user.authenticationProtocol }} |
+ {{ user.authProtocol }} |
{{ user.privacyProtocol }} |
@@ -74,12 +76,21 @@
+
-
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 @@
+
+
+
+
+
+
+
+ General Configuration
+ SNMPv3 User Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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: ''
+ },
+ ScvSearchDrawer: ScvSearchDrawerStub,
+ FeatherButton: {
+ props: ['dataTest', 'disabled'],
+ 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}${root}>`
+ }
+ 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
+ })
+ })
+})
+
|