Repository: aristanetworks/anta (python)

Issue:
Add the test case to verify SNMP user.
Sample output

DLFW1#show snmp user

User name      : $SNMP_USER_NAME
Security model : v3
Engine ID      : -----------
Authentication : SHA
Privacy        : AES-128
Group          : $SNMP_GROUP_NAME

Pull request description:
feat(anta): Added the test case to verify SNMP user
# Description

Verifies the SNMP user configurations for specified version(s).

    - Verifies that the valid user name and group name.
    - Ensures that the SNMP v3 security model, the user authentication and privacy settings aligning with version-specific requirements.

    Expected Results
    ----------------
    * Success: The test will pass if the provided SNMP user and all specified parameters are correctly configured.
    * Failure: The test will fail if the provided SNMP user is not configured or specified parameters are not correctly configured.

Fixes #852 

# Checklist:

- [X] My code follows the style guidelines of this project
- [X] I have performed a self-review of my code
- [X] I have run pre-commit for code linting and typing (`pre-commit run`)
- [x] I have made corresponding changes to the documentation
- [X] I have added tests that prove my fix is effective or that my feature works
- [X] New and existing unit tests pass locally with my changes (`tox -e testenv`)


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1961ec0c..42b0ce1c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -46,7 +46,7 @@ repos:
           - '<!--|  ~|  -->'
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.9.0
+    rev: v0.9.1
     hooks:
     - id: ruff
       name: Run Ruff linter
diff --git a/anta/custom_types.py b/anta/custom_types.py
index bd6a7b8d..4763be49 100644
--- a/anta/custom_types.py
+++ b/anta/custom_types.py
@@ -208,7 +208,6 @@ SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus
 SnmpErrorCounter = Literal[
     "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs"
 ]
-
 IPv4RouteType = Literal[
     "connected",
     "static",
@@ -238,3 +237,6 @@ IPv4RouteType = Literal[
     "Route Cache Route",
     "CBF Leaked Route",
 ]
+SnmpVersion = Literal["v1", "v2c", "v3"]
+SnmpHashingAlgorithm = Literal["MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512"]
+SnmpEncryptionAlgorithm = Literal["AES-128", "AES-192", "AES-256", "DES"]
diff --git a/anta/input_models/snmp.py b/anta/input_models/snmp.py
new file mode 100644
index 00000000..680d617c
--- /dev/null
+++ b/anta/input_models/snmp.py
@@ -0,0 +1,35 @@
+# Copyright (c) 2023-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Module containing input models for SNMP tests."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict
+
+from anta.custom_types import SnmpEncryptionAlgorithm, SnmpHashingAlgorithm, SnmpVersion
+
+
+class SnmpUser(BaseModel):
+    """Model for a SNMP User."""
+
+    model_config = ConfigDict(extra="forbid")
+    username: str
+    """SNMP user name."""
+    group_name: str
+    """SNMP group for the user."""
+    version: SnmpVersion
+    """SNMP protocol version."""
+    auth_type: SnmpHashingAlgorithm | None = None
+    """User authentication algorithm. Can be provided in the `VerifySnmpUser` test."""
+    priv_type: SnmpEncryptionAlgorithm | None = None
+    """User privacy algorithm. Can be provided in the `VerifySnmpUser` test."""
+
+    def __str__(self) -> str:
+        """Return a human-readable string representation of the SnmpUser for reporting.
+
+        Examples
+        --------
+        - User: Test Group: Test_Group Version: v2c
+        """
+        return f"User: {self.username} Group: {self.group_name} Version: {self.version}"


Test patch:
diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py
index 36b97ba2..ef741043 100644
--- a/anta/tests/snmp.py
+++ b/anta/tests/snmp.py
@@ -9,7 +9,10 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, ClassVar, get_args
 
+from pydantic import field_validator
+
 from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu
+from anta.input_models.snmp import SnmpUser
 from anta.models import AntaCommand, AntaTest
 from anta.tools import get_value
 
@@ -339,3 +342,79 @@ class VerifySnmpErrorCounters(AntaTest):
             self.result.is_success()
         else:
             self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}")
+
+
+class VerifySnmpUser(AntaTest):
+    """Verifies the SNMP user configurations.
+
+    This test performs the following checks for each specified user:
+
+      1. User exists in SNMP configuration.
+      2. Group assignment is correct.
+      3. For SNMPv3 users only:
+          - Authentication type matches (if specified)
+          - Privacy type matches (if specified)
+
+    Expected Results
+    ----------------
+    * Success: If all of the following conditions are met:
+        - All users exist with correct group assignments.
+        - SNMPv3 authentication and privacy types match specified values.
+    * Failure: If any of the following occur:
+        - User not found in SNMP configuration.
+        - Incorrect group assignment.
+        - For SNMPv3: Mismatched authentication or privacy types.
+
+    Examples
+    --------
+    ```yaml
+    anta.tests.snmp:
+      - VerifySnmpUser:
+          snmp_users:
+            - username: test
+              group_name: test_group
+              version: v3
+              auth_type: MD5
+              priv_type: AES-128
+    ```
+    """
+
+    categories: ClassVar[list[str]] = ["snmp"]
+    commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp user", revision=1)]
+
+    class Input(AntaTest.Input):
+        """Input model for the VerifySnmpUser test."""
+
+        snmp_users: list[SnmpUser]
+        """List of SNMP users."""
+
+        @field_validator("snmp_users")
+        @classmethod
+        def validate_snmp_users(cls, snmp_users: list[SnmpUser]) -> list[SnmpUser]:
+            """Validate that 'auth_type' or 'priv_type' field is provided in each SNMPv3 user."""
+            for user in snmp_users:
+                if user.version == "v3" and not (user.auth_type or user.priv_type):
+                    msg = f"{user} 'auth_type' or 'priv_type' field is required with 'version: v3'"
+                    raise ValueError(msg)
+            return snmp_users
+
+    @AntaTest.anta_test
+    def test(self) -> None:
+        """Main test function for VerifySnmpUser."""
+        self.result.is_success()
+
+        for user in self.inputs.snmp_users:
+            # Verify SNMP user details.
+            if not (user_details := get_value(self.instance_commands[0].json_output, f"usersByVersion.{user.version}.users.{user.username}")):
+                self.result.is_failure(f"{user} - Not found")
+                continue
+
+            if user.group_name != (act_group := user_details.get("groupName", "Not Found")):
+                self.result.is_failure(f"{user} - Incorrect user group - Actual: {act_group}")
+
+            if user.version == "v3":
+                if user.auth_type and (act_auth_type := get_value(user_details, "v3Params.authType", "Not Found")) != user.auth_type:
+                    self.result.is_failure(f"{user} - Incorrect authentication type - Expected: {user.auth_type} Actual: {act_auth_type}")
+
+                if user.priv_type and (act_encryption := get_value(user_details, "v3Params.privType", "Not Found")) != user.priv_type:
+                    self.result.is_failure(f"{user} - Incorrect privacy type - Expected: {user.priv_type} Actual: {act_encryption}")
diff --git a/docs/api/tests.snmp.md b/docs/api/tests.snmp.md
index 85c147e2..eec97202 100644
--- a/docs/api/tests.snmp.md
+++ b/docs/api/tests.snmp.md
@@ -7,7 +7,10 @@ anta_title: ANTA catalog for SNMP tests
   ~ that can be found in the LICENSE file.
   -->
 
+# Tests
+
 ::: anta.tests.snmp
+
     options:
       show_root_heading: false
       show_root_toc_entry: false
@@ -18,3 +21,16 @@ anta_title: ANTA catalog for SNMP tests
       filters:
         - "!test"
         - "!render"
+
+# Input models
+
+::: anta.input_models.snmp
+
+    options:
+      show_root_heading: false
+      show_root_toc_entry: false
+      show_bases: false
+      merge_init_into_class: false
+      anta_hide_test_module_description: true
+      show_labels: true
+      filters: ["!^__str__"]
diff --git a/examples/tests.yaml b/examples/tests.yaml
index 0b573616..c2e00f00 100644
--- a/examples/tests.yaml
+++ b/examples/tests.yaml
@@ -746,6 +746,14 @@ anta.tests.snmp:
   - VerifySnmpStatus:
       # Verifies if the SNMP agent is enabled.
       vrf: default
+  - VerifySnmpUser:
+      # Verifies the SNMP user configurations.
+      snmp_users:
+        - username: test
+          group_name: test_group
+          version: v3
+          auth_type: MD5
+          priv_type: AES-128
 anta.tests.software:
   - VerifyEOSExtensions:
       # Verifies that all EOS extensions installed on the device are enabled for boot persistence.
diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py
index 195ef298..d2eb6b87 100644
--- a/tests/units/anta_tests/test_snmp.py
+++ b/tests/units/anta_tests/test_snmp.py
@@ -15,6 +15,7 @@ from anta.tests.snmp import (
     VerifySnmpLocation,
     VerifySnmpPDUCounters,
     VerifySnmpStatus,
+    VerifySnmpUser,
 )
 from tests.units.anta_tests import test
 
@@ -319,4 +320,168 @@ DATA: list[dict[str, Any]] = [
             ],
         },
     },
+    {
+        "name": "success",
+        "test": VerifySnmpUser,
+        "eos_data": [
+            {
+                "usersByVersion": {
+                    "v1": {
+                        "users": {
+                            "Test1": {
+                                "groupName": "TestGroup1",
+                            },
+                        }
+                    },
+                    "v2c": {
+                        "users": {
+                            "Test2": {
+                                "groupName": "TestGroup2",
+                            },
+                        }
+                    },
+                    "v3": {
+                        "users": {
+                            "Test3": {
+                                "groupName": "TestGroup3",
+                                "v3Params": {"authType": "SHA-384", "privType": "AES-128"},
+                            },
+                            "Test4": {"groupName": "TestGroup3", "v3Params": {"authType": "SHA-512", "privType": "AES-192"}},
+                        }
+                    },
+                }
+            }
+        ],
+        "inputs": {
+            "snmp_users": [
+                {"username": "Test1", "group_name": "TestGroup1", "version": "v1"},
+                {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"},
+                {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"},
+                {"username": "Test4", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"},
+            ]
+        },
+        "expected": {"result": "success"},
+    },
+    {
+        "name": "failure-not-configured",
+        "test": VerifySnmpUser,
+        "eos_data": [
+            {
+                "usersByVersion": {
+                    "v3": {
+                        "users": {
+                            "Test3": {
+                                "groupName": "TestGroup3",
+                                "v3Params": {"authType": "SHA-384", "privType": "AES-128"},
+                            },
+                        }
+                    },
+                }
+            }
+        ],
+        "inputs": {
+            "snmp_users": [
+                {"username": "Test1", "group_name": "TestGroup1", "version": "v1"},
+                {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"},
+                {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"},
+                {"username": "Test4", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"},
+            ]
+        },
+        "expected": {
+            "result": "failure",
+            "messages": [
+                "User: Test1 Group: TestGroup1 Version: v1 - Not found",
+                "User: Test2 Group: TestGroup2 Version: v2c - Not found",
+                "User: Test4 Group: TestGroup3 Version: v3 - Not found",
+            ],
+        },
+    },
+    {
+        "name": "failure-incorrect-group",
+        "test": VerifySnmpUser,
+        "eos_data": [
+            {
+                "usersByVersion": {
+                    "v1": {
+                        "users": {
+                            "Test1": {
+                                "groupName": "TestGroup2",
+                            },
+                        }
+                    },
+                    "v2c": {
+                        "users": {
+                            "Test2": {
+                                "groupName": "TestGroup1",
+                            },
+                        }
+                    },
+                    "v3": {},
+                }
+            }
+        ],
+        "inputs": {
+            "snmp_users": [
+                {"username": "Test1", "group_name": "TestGroup1", "version": "v1"},
+                {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"},
+            ]
+        },
+        "expected": {
+            "result": "failure",
+            "messages": [
+                "User: Test1 Group: TestGroup1 Version: v1 - Incorrect user group - Actual: TestGroup2",
+                "User: Test2 Group: TestGroup2 Version: v2c - Incorrect user group - Actual: TestGroup1",
+            ],
+        },
+    },
+    {
+        "name": "failure-incorrect-auth-encryption",
+        "test": VerifySnmpUser,
+        "eos_data": [
+            {
+                "usersByVersion": {
+                    "v1": {
+                        "users": {
+                            "Test1": {
+                                "groupName": "TestGroup1",
+                            },
+                        }
+                    },
+                    "v2c": {
+                        "users": {
+                            "Test2": {
+                                "groupName": "TestGroup2",
+                            },
+                        }
+                    },
+                    "v3": {
+                        "users": {
+                            "Test3": {
+                                "groupName": "TestGroup3",
+                                "v3Params": {"authType": "SHA-512", "privType": "AES-192"},
+                            },
+                            "Test4": {"groupName": "TestGroup4", "v3Params": {"authType": "SHA-384", "privType": "AES-128"}},
+                        }
+                    },
+                }
+            }
+        ],
+        "inputs": {
+            "snmp_users": [
+                {"username": "Test1", "group_name": "TestGroup1", "version": "v1"},
+                {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"},
+                {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"},
+                {"username": "Test4", "group_name": "TestGroup4", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"},
+            ]
+        },
+        "expected": {
+            "result": "failure",
+            "messages": [
+                "User: Test3 Group: TestGroup3 Version: v3 - Incorrect authentication type - Expected: SHA-384 Actual: SHA-512",
+                "User: Test3 Group: TestGroup3 Version: v3 - Incorrect privacy type - Expected: AES-128 Actual: AES-192",
+                "User: Test4 Group: TestGroup4 Version: v3 - Incorrect authentication type - Expected: SHA-512 Actual: SHA-384",
+                "User: Test4 Group: TestGroup4 Version: v3 - Incorrect privacy type - Expected: AES-192 Actual: AES-128",
+            ],
+        },
+    },
 ]
diff --git a/tests/units/input_models/test_snmp.py b/tests/units/input_models/test_snmp.py
new file mode 100644
index 00000000..94551ca7
--- /dev/null
+++ b/tests/units/input_models/test_snmp.py
@@ -0,0 +1,44 @@
+# Copyright (c) 2023-2025 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Tests for anta.input_models.snmp.py."""
+
+# pylint: disable=C0302
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from pydantic import ValidationError
+
+from anta.tests.snmp import VerifySnmpUser
+
+if TYPE_CHECKING:
+    from anta.input_models.snmp import SnmpUser
+
+
+class TestVerifySnmpUserInput:
+    """Test anta.tests.snmp.VerifySnmpUser.Input."""
+
+    @pytest.mark.parametrize(
+        ("snmp_users"),
+        [
+            pytest.param([{"username": "test", "group_name": "abc", "version": "v1", "auth_type": None, "priv_type": None}], id="valid-v1"),
+            pytest.param([{"username": "test", "group_name": "abc", "version": "v2c", "auth_type": None, "priv_type": None}], id="valid-v2c"),
+            pytest.param([{"username": "test", "group_name": "abc", "version": "v3", "auth_type": "SHA", "priv_type": "AES-128"}], id="valid-v3"),
+        ],
+    )
+    def test_valid(self, snmp_users: list[SnmpUser]) -> None:
+        """Test VerifySnmpUser.Input valid inputs."""
+        VerifySnmpUser.Input(snmp_users=snmp_users)
+
+    @pytest.mark.parametrize(
+        ("snmp_users"),
+        [
+            pytest.param([{"username": "test", "group_name": "abc", "version": "v3", "auth_type": None, "priv_type": None}], id="invalid-v3"),
+        ],
+    )
+    def test_invalid(self, snmp_users: list[SnmpUser]) -> None:
+        """Test VerifySnmpUser.Input invalid inputs."""
+        with pytest.raises(ValidationError):
+            VerifySnmpUser.Input(snmp_users=snmp_users)