├── .release-please-manifest.json ├── tests ├── conftest.py ├── integration │ ├── test_common.py │ ├── test_ncclient.py │ ├── test_netconf_client.py │ └── test_scrapli_netconf.py └── unit │ └── test_netconfserver.py ├── release-please-config.json ├── .github └── workflows │ ├── release-please.yml │ ├── tests.yml │ └── publish.yml ├── pytest_netconf ├── version.py ├── __init__.py ├── exceptions.py ├── pytest_plugin.py ├── constants.py ├── settings.py ├── sshserver.py └── netconfserver.py ├── pyproject.toml ├── CHANGELOG.md ├── .gitignore ├── README.md └── LICENSE /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.2.0" 3 | } 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # pytest_plugins = ["pytest_netconf.pytest_plugin"] 2 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "changelog-path": "CHANGELOG.md", 5 | "release-type": "python", 6 | "bump-minor-pre-major": true, 7 | "bump-patch-for-minor-pre-major": false, 8 | "draft": false, 9 | "prerelease": false 10 | } 11 | }, 12 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - develop 5 | 6 | name: release-please 7 | 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Generate app token 13 | uses: actions/create-github-app-token@v1 14 | id: app-token 15 | with: 16 | app-id: ${{ secrets.BOT_APP_ID }} 17 | private-key: ${{ secrets.BOT_PRIVATE_KEY }} 18 | - uses: googleapis/release-please-action@v4 19 | id: release 20 | with: 21 | token: ${{ steps.app-token.outputs.token }} 22 | -------------------------------------------------------------------------------- /pytest_netconf/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | __version__ = "0.2.0" 18 | -------------------------------------------------------------------------------- /pytest_netconf/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | from .netconfserver import NetconfServer 18 | from .version import __version__ 19 | 20 | __all__ = ["NetconfServer"] 21 | -------------------------------------------------------------------------------- /pytest_netconf/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | 18 | class UnexpectedRequestError(Exception): 19 | """Indicates that a request has been received that has no predefined response.""" 20 | 21 | 22 | class RequestError(Exception): 23 | """Indicates that an error occurred when processing the request.""" 24 | -------------------------------------------------------------------------------- /pytest_netconf/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import pytest 18 | 19 | from pytest_netconf import NetconfServer 20 | 21 | 22 | @pytest.fixture(scope="function") 23 | def netconf_server(): 24 | """ 25 | Pytest fixture to create and start a mock NETCONF server. 26 | """ 27 | server = NetconfServer() 28 | server.start() 29 | yield server 30 | server.stop() # pragma: no cover 31 | -------------------------------------------------------------------------------- /pytest_netconf/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | RPC_REPLY_OK = """ 18 | 19 | 20 | """ 21 | 22 | RPC_REPLY_ERROR = """ 23 | 24 | 25 | {type} 26 | {tag} 27 | error 28 | {message} 29 | 30 | """ 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytest-netconf" 3 | version = "0.2.0" 4 | description = "A pytest plugin that provides a mock NETCONF (RFC6241/RFC6242) server for local testing." 5 | authors = ["Adam Kirchberger "] 6 | license = "Apache License 2.0" 7 | readme = "README.md" 8 | keywords = [ 9 | "Netconf", 10 | "Network automation", 11 | "Network engineering", 12 | "Network testing" 13 | ] 14 | classifiers = [ 15 | "Framework :: Pytest", 16 | "Intended Audience :: Developers", 17 | "Intended Audience :: Telecommunications Industry", 18 | "Topic :: System :: Networking", 19 | "Topic :: Software Development :: Testing", 20 | "Topic :: Software Development :: Testing :: Mocking", 21 | "License :: OSI Approved :: Apache Software License", 22 | ] 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | pytest = "^8.3.2" 26 | ncclient = "^0.6.15 || ^0.7.0" 27 | pytest-rerunfailures = "^14.0" 28 | scrapli-netconf = "^2024.7.30 || ^2025.0.0" 29 | netconf-client = "^3.1.1" 30 | coverage = "^7.6.1" 31 | 32 | [tool.pytest.ini_options] 33 | addopts = "--reruns 1 -vv" 34 | 35 | [tool.poetry.dependencies] 36 | python = "^3.8" 37 | paramiko = "^3.4.0" 38 | 39 | [build-system] 40 | requires = ["poetry-core"] 41 | build-backend = "poetry.core.masonry.api" 42 | 43 | [tool.poetry.plugins.pytest11] 44 | pytest_netconf = "pytest_netconf.pytest_plugin" 45 | -------------------------------------------------------------------------------- /pytest_netconf/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import os 18 | import typing as t 19 | from dataclasses import dataclass 20 | 21 | 22 | @dataclass 23 | class Settings: 24 | """ 25 | pytest-netconf settings. 26 | """ 27 | 28 | base_version: t.Literal["1.0", "1.1"] = os.getenv("PYTEST_NETCONF_VERSION", "1.1") 29 | host: str = os.getenv("PYTEST_NETCONF_HOST", "localhost") 30 | port: int = int(os.getenv("PYTEST_NETCONF_PORT", "8830")) 31 | username: t.Optional[str] = os.getenv("PYTEST_NETCONF_USERNAME") 32 | password: t.Optional[str] = os.getenv("PYTEST_NETCONF_PASSWORD") 33 | authorized_key: t.Optional[str] = os.getenv("PYTEST_NETCONF_AUTHORIZED_KEY") 34 | allocate_pty: bool = bool( 35 | (os.getenv("PYTEST_NETCONF_AUTHORIZED_KEY", "true")).lower() == "true" 36 | ) 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | concurrency: 3 | group: ${{ github.head_ref || github.run_id }} 4 | cancel-in-progress: true 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | test: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ubuntu-latest] 14 | # os: [ubuntu-latest, macos-latest, windows-latest] 15 | python-version: [3.8, 3.9, "3.10", 3.11, 3.12] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v3 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install Python Poetry 23 | shell: bash 24 | run: pip install poetry 25 | - name: Configure poetry 26 | shell: bash 27 | run: python -m poetry config virtualenvs.in-project true 28 | - name: Show poetry version 29 | run: poetry --version 30 | - name: Install dependencies 31 | run: poetry install 32 | - name: Test with pytest 33 | run: poetry run pytest -vv 34 | 35 | coverage: 36 | runs-on: ubuntu-latest 37 | needs: test 38 | steps: 39 | - uses: actions/checkout@v3 40 | - uses: actions/setup-python@v3 41 | with: 42 | python-version: "3.10" 43 | - name: Install Python Poetry 44 | shell: bash 45 | run: pip install poetry 46 | - name: Configure poetry 47 | shell: bash 48 | run: python -m poetry config virtualenvs.in-project true 49 | - name: Show poetry version 50 | run: poetry --version 51 | - name: Install dependencies 52 | run: poetry install 53 | - name: Test with pytest 54 | run: | 55 | MODNAME=$(echo "${PWD##*/}" | sed 's/-/_/g') 56 | poetry run coverage run --source $MODNAME --parallel-mode -m pytest 57 | poetry run coverage combine 58 | poetry run coverage report 59 | poetry run coverage xml 60 | - name: Upload coverage 61 | uses: codecov/codecov-action@v3 62 | with: 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | 8 | jobs: 9 | build-wheel: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - name: Install poetry 17 | run: pip install poetry 18 | - name: Configure poetry 19 | run: python -m poetry config virtualenvs.in-project true 20 | - name: Show poetry version 21 | run: poetry --version 22 | - name: Build wheel 23 | run: poetry build --format wheel 24 | - uses: actions/upload-artifact@v4 25 | with: 26 | name: ${{ github.event.repository.name }}.wheel 27 | path: dist/ 28 | 29 | upload-github: 30 | needs: [build-wheel] 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/download-artifact@v4 35 | with: 36 | name: ${{ github.event.repository.name }}.wheel 37 | path: dist/ 38 | - name: Generate app token 39 | uses: actions/create-github-app-token@v1 40 | id: app-token 41 | with: 42 | app-id: ${{ secrets.BOT_APP_ID }} 43 | private-key: ${{ secrets.BOT_PRIVATE_KEY }} 44 | - name: Process module name and version number 45 | id: process_names 46 | # Replace hyphens with underscores for module name 47 | # Remove v from version number as poetry builds wheel without 'v' 48 | run: | 49 | module_name=$(echo "${GITHUB_REPOSITORY##*/}" | sed 's/-/_/g') 50 | version_number=$(echo "${GITHUB_REF##*/}" | sed 's/^v//') 51 | echo "module_name=$module_name" >> $GITHUB_ENV 52 | echo "version_number=$version_number" >> $GITHUB_ENV 53 | - name: Upload package to Github 54 | uses: actions/upload-release-asset@v1.0.2 55 | env: 56 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 57 | with: 58 | upload_url: ${{ github.event.release.upload_url }} 59 | asset_path: dist/${{ env.module_name }}-${{ env.version_number }}-py3-none-any.whl 60 | asset_name: ${{ env.module_name }}-${{ env.version_number }}-py3-none-any.whl 61 | asset_content_type: application/zip 62 | 63 | upload-pypi: 64 | needs: [build-wheel] 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: actions/download-artifact@v4 69 | with: 70 | name: ${{ github.event.repository.name }}.wheel 71 | path: dist/ 72 | - name: Publish to PyPI 73 | if: startsWith(github.ref, 'refs/tags') 74 | uses: pypa/gh-action-pypi-publish@release/v1 75 | with: 76 | password: ${{ secrets.PYPI_API_TOKEN }} 77 | -------------------------------------------------------------------------------- /tests/integration/test_common.py: -------------------------------------------------------------------------------- 1 | from pytest_netconf import NetconfServer 2 | 3 | from ncclient import manager 4 | 5 | 6 | def test_when_server_restarted_then_connection_passes(netconf_server: NetconfServer): 7 | # GIVEN initial connection to server 8 | with manager.connect( 9 | host="localhost", 10 | port=8830, 11 | username="admin", 12 | password="admin", 13 | hostkey_verify=False, 14 | ) as m: 15 | assert m.connected 16 | 17 | # WHEN server is stopped and then started again 18 | netconf_server.stop() 19 | netconf_server.start() 20 | 21 | # THEN expect reconnection to succeed 22 | with manager.connect( 23 | host="localhost", 24 | port=8830, 25 | username="admin", 26 | password="admin", 27 | hostkey_verify=False, 28 | ) as m: 29 | assert m.connected 30 | 31 | 32 | def test_when_server_started_twice_then_no_error_occurs(netconf_server: NetconfServer): 33 | # GIVEN server is running 34 | assert netconf_server.running 35 | 36 | # WHEN attempting to start the server again 37 | netconf_server.start() 38 | 39 | # THEN server remains running and no errors occur 40 | assert netconf_server.running 41 | 42 | def test_when_server_stopped_twice_then_no_error_occurs(netconf_server: NetconfServer): 43 | # GIVEN server is stopped 44 | netconf_server.stop() 45 | assert not netconf_server.running 46 | 47 | # WHEN attempting to stop the server again 48 | netconf_server.stop() 49 | 50 | # THEN server remains stopped and no errors occur 51 | assert not netconf_server.running 52 | 53 | 54 | def test_when_server_stopped_without_connection(netconf_server: NetconfServer): 55 | # GIVEN server is running 56 | assert netconf_server.running 57 | 58 | # GIVEN no connection attempt is made 59 | 60 | # WHEN stopping the server 61 | netconf_server.stop() 62 | 63 | # THEN server stops cleanly 64 | assert not netconf_server.running 65 | 66 | 67 | def test_when_checking_call_count_then_close_and_hello_not_included(netconf_server): 68 | # GIVEN server request and response 69 | handler = netconf_server.expect_request( 70 | '' 71 | '' 72 | "" 73 | "" 74 | ).respond_with( 75 | """ 76 | 77 | 79 | 80 | 81 | 82 | """ 83 | ) 84 | 85 | # WHEN fetching rpc response from server 86 | with manager.connect( 87 | host="localhost", 88 | port=8830, 89 | username="admin", 90 | password="admin", 91 | hostkey_verify=False, 92 | ) as m: 93 | m.get_config(source="running").data_xml 94 | 95 | # THEN expect calls to be made 96 | assert netconf_server.was_called() 97 | assert netconf_server.get_call_count() == 1 98 | assert handler.was_called() 99 | assert handler.get_call_count() == 1 100 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.2.0](https://github.com/nomios-opensource/pytest-netconf/compare/v0.1.1...v0.2.0) (2025-11-03) 4 | 5 | 6 | ### Features 7 | 8 | * add call tracking ([21f20aa](https://github.com/nomios-opensource/pytest-netconf/commit/21f20aa2fc95b3694c9dd5cd0ca31893e66a73b9)) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * hanging when attempting to start already running server ([38f45b5](https://github.com/nomios-opensource/pytest-netconf/commit/38f45b59f0201135f3b1ee92e22a94b9254d4dd2)) 14 | * server not shutting down socket cleanly ([20122c9](https://github.com/nomios-opensource/pytest-netconf/commit/20122c966e857edc80b39c2d101dc7e468cb6daa)) 15 | * update dependencies and netconf clients ([430fcba](https://github.com/nomios-opensource/pytest-netconf/commit/430fcba5a2701b6289db838897bc517302a0253c)) 16 | 17 | ## [0.1.1](https://github.com/nomios-opensource/pytest-netconf/compare/v0.1.0...v0.1.1) (2025-01-06) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * server restart hanging ([#5](https://github.com/nomios-opensource/pytest-netconf/issues/5)) ([397858a](https://github.com/nomios-opensource/pytest-netconf/commit/397858a9f7c0fb3632a4ba256f3b7fa47727c417)) 23 | * update dependencies ([140d31f](https://github.com/nomios-opensource/pytest-netconf/commit/140d31fc536bbba2a1db8ca10ea6379356fa3f3e)) 24 | * update dependencies ([03857e0](https://github.com/nomios-opensource/pytest-netconf/commit/03857e0542e329464698fdf6c8caa88175f2f48e)) 25 | 26 | 27 | ### Documentation 28 | 29 | * add installation instructions ([12ae0c9](https://github.com/nomios-opensource/pytest-netconf/commit/12ae0c92ed8fd56af8d6802754fb1efa51b6f9be)) 30 | * update codecov badge ([addbcfd](https://github.com/nomios-opensource/pytest-netconf/commit/addbcfdbb7489af033d822896c14fa27fc89d5c7)) 31 | 32 | ## 0.1.0 (2024-08-08) 33 | 34 | 35 | ### Features 36 | 37 | * add NETCONF SSH server ([7c4594c](https://github.com/nomios-opensource/pytest-netconf/commit/7c4594c124c91aa0560ea4d7d6e1add492dc6202)) 38 | * add SSH password authentication ([9f01ce0](https://github.com/nomios-opensource/pytest-netconf/commit/9f01ce0b3a8366e8d7bbeafb0143b0187f9445ff)) 39 | * add SSH public key authentication ([1fe8cc3](https://github.com/nomios-opensource/pytest-netconf/commit/1fe8cc3c8c1f2518da0611aa37ef43760a190b6c)) 40 | * add support for NETCONF base version 1.0 and 1.1 ([322e68a](https://github.com/nomios-opensource/pytest-netconf/commit/322e68a1cb31648065de713230c657c38630bc3a)) 41 | * add support for regex request matching ([bc29e4d](https://github.com/nomios-opensource/pytest-netconf/commit/bc29e4d090305c8d38f561401a499e64bc213411)) 42 | * add tests for popular NETCONF clients ([c4c963a](https://github.com/nomios-opensource/pytest-netconf/commit/c4c963a3ce8472d53e90acf25511fbd03ce93439)) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * add test for publickey auth using wrong key ([1bb8067](https://github.com/nomios-opensource/pytest-netconf/commit/1bb8067a6b242d1172a2f557eb1f727a665064a1)) 48 | * channel close error ([06264c4](https://github.com/nomios-opensource/pytest-netconf/commit/06264c4b4d3badd5de187f581a6bed6e11d0e3eb)) 49 | * coverage bug when testing pytest plugins ([ac27e25](https://github.com/nomios-opensource/pytest-netconf/commit/ac27e2542630fc81b8978400506604eddce29c49)) 50 | * ncclient disconnect test ([2124520](https://github.com/nomios-opensource/pytest-netconf/commit/21245206419d2806007e474437385d45ae0067ca)) 51 | * pytest-cov threading bug ([6de51c5](https://github.com/nomios-opensource/pytest-netconf/commit/6de51c55e1839adaadc433172e6e0d4e4ae7a926)) 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | testing 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 111 | .pdm.toml 112 | .pdm-python 113 | .pdm-build/ 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | .idea/ 164 | 165 | # Visual Studio Code 166 | .vscode 167 | -------------------------------------------------------------------------------- /pytest_netconf/sshserver.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import logging 18 | import threading 19 | import paramiko 20 | 21 | from .settings import Settings 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | class SSHServer(paramiko.ServerInterface): 28 | """An SSH server.""" 29 | 30 | def __init__(self, settings: Settings): 31 | """ 32 | Initialise the SSH server. 33 | 34 | Args: 35 | settings (Settings): The SSH server settings. 36 | """ 37 | self.event = threading.Event() 38 | self._settings = settings 39 | 40 | def check_channel_request(self, kind: str, _: int) -> int: 41 | """ 42 | Check if a channel request is of type 'session'. 43 | 44 | Args: 45 | kind (str): The type of channel requested. 46 | 47 | Returns: 48 | int: The status of the channel request. 49 | """ 50 | return ( 51 | paramiko.OPEN_SUCCEEDED 52 | if kind == "session" 53 | else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED 54 | ) 55 | 56 | def check_channel_pty_request(self, *_) -> bool: 57 | """ 58 | Determine if a PTY can be provided. 59 | 60 | Returns: 61 | bool: True if the PTY has been allocated, else False. 62 | """ 63 | return self._settings.allocate_pty 64 | 65 | def get_allowed_auths(self, username: str) -> str: 66 | return "password,publickey" 67 | 68 | def check_auth_password(self, username: str, password: str) -> int: 69 | """ 70 | Validate the username and password for authentication. 71 | 72 | Args: 73 | username (str): The username provided for authentication. 74 | password (str): The password provided for authentication. 75 | 76 | Returns: 77 | int: The status of the authentication request. 78 | """ 79 | logger.debug("trying password auth for user: %s", self._settings.username) 80 | if not self._settings.username and not self._settings.password: 81 | logger.info("password auth successful using any username and password") 82 | return paramiko.AUTH_SUCCESSFUL 83 | if username == self._settings.username and password == self._settings.password: 84 | logger.info("password auth successful username and password match") 85 | return paramiko.AUTH_SUCCESSFUL 86 | return paramiko.AUTH_FAILED 87 | 88 | def check_auth_publickey(self, username: str, key: paramiko.PKey) -> int: 89 | """ 90 | Validate the username and SSH key for authentication. 91 | 92 | Args: 93 | username (str): The username provided for authentication. 94 | key (paramiko.PKey): The public key provided for authentication. 95 | 96 | Returns: 97 | int: The status of the authentication request. 98 | """ 99 | logger.debug("trying publickey auth for user: %s", self._settings.username) 100 | if ( 101 | username == self._settings.username 102 | and self._settings.authorized_key 103 | and f"{key.get_name()} {key.get_base64()}" == self._settings.authorized_key 104 | ): 105 | logger.info("publickey auth successful username and key match") 106 | return paramiko.AUTH_SUCCESSFUL 107 | return paramiko.AUTH_FAILED 108 | 109 | def check_channel_subsystem_request(self, _: paramiko.Channel, name: str) -> bool: 110 | """ 111 | Check if the requested subsystem is 'netconf'. 112 | 113 | Args: 114 | name (str): The name of the subsystem requested. 115 | 116 | Returns: 117 | bool: True if the subsystem is 'netconf', False otherwise. 118 | """ 119 | if name == "netconf": 120 | self.event.set() 121 | return True 122 | return False # pragma: no cover 123 | -------------------------------------------------------------------------------- /tests/integration/test_ncclient.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import paramiko 3 | from pytest_netconf import NetconfServer 4 | 5 | from ncclient import manager 6 | from ncclient.transport.errors import ( 7 | AuthenticationError, 8 | TransportError, 9 | ) 10 | from ncclient.operations.rpc import RPCError 11 | 12 | 13 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 14 | def test_when_full_request_and_response_is_defined_then_response_is_returned( 15 | base_version, 16 | netconf_server: NetconfServer, 17 | ): 18 | # GIVEN server base version 19 | netconf_server.base_version = base_version 20 | 21 | # GIVEN server request and response are defined 22 | netconf_server.expect_request( 23 | '' 24 | '' 25 | "" 26 | "" 27 | ).respond_with( 28 | """ 29 | 30 | 32 | 33 | 34 | 35 | eth0 36 | 37 | 38 | 39 | """ 40 | ) 41 | 42 | # WHEN fetching rpc response from server 43 | with manager.connect( 44 | host="localhost", 45 | port=8830, 46 | username="admin", 47 | password="admin", 48 | hostkey_verify=False, 49 | ) as m: 50 | response = m.get_config(source="running").data_xml 51 | 52 | # THEN expect response 53 | assert ( 54 | """ 55 | 56 | 57 | eth0 58 | 59 | 60 | """ 61 | in response 62 | ) 63 | 64 | 65 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 66 | def test_when_regex_request_and_response_is_defined_then_response_is_returned( 67 | base_version, 68 | netconf_server: NetconfServer, 69 | ): 70 | # GIVEN server base version 71 | netconf_server.base_version = base_version 72 | 73 | # GIVEN server request and response are defined 74 | netconf_server.expect_request( 75 | ".*.*" 76 | ).respond_with( 77 | """ 78 | 79 | 81 | 82 | 83 | 84 | eth0 85 | 86 | 87 | 88 | """ 89 | ) 90 | 91 | # WHEN fetching rpc response from server 92 | with manager.connect( 93 | host="localhost", 94 | port=8830, 95 | username="admin", 96 | password="admin", 97 | hostkey_verify=False, 98 | ) as m: 99 | response = m.get_config(source="running").data_xml 100 | 101 | # THEN expect response 102 | assert ( 103 | """ 104 | 105 | 106 | eth0 107 | 108 | 109 | """ 110 | in response 111 | ) 112 | 113 | 114 | def test_when_unexpected_request_received_then_error_response_is_returned( 115 | netconf_server: NetconfServer, 116 | ): 117 | # GIVEN no server request and response are defined 118 | netconf_server 119 | 120 | # WHEN fetching rpc response from server 121 | with pytest.raises(RPCError) as error: 122 | with manager.connect( 123 | host="localhost", 124 | port=8830, 125 | username="admin", 126 | password="admin", 127 | hostkey_verify=False, 128 | manager_params={"timeout": 10}, 129 | ) as m: 130 | m.foo(source="running") 131 | 132 | # THEN 133 | assert ( 134 | str(error.value) 135 | == "pytest-netconf: requested rpc is unknown and has no response defined" 136 | ) 137 | 138 | 139 | def test_when_server_stops_then_client_error_is_raised( 140 | netconf_server: NetconfServer, 141 | ): 142 | # GIVEN netconf connection 143 | with pytest.raises(TransportError) as error: 144 | with manager.connect( 145 | host="localhost", 146 | port=8830, 147 | username="admin", 148 | password="admin", 149 | hostkey_verify=False, 150 | ) as m: 151 | pass 152 | # WHEN server stops 153 | netconf_server.stop() 154 | 155 | # THEN expect error 156 | assert error 157 | 158 | 159 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 160 | def test_when_defining_custom_capabilities_then_server_returns_them( 161 | base_version, 162 | netconf_server: NetconfServer, 163 | ): 164 | # GIVEN server version 165 | netconf_server.base_version = base_version 166 | 167 | # GIVEN extra capabilities 168 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:foo:1.1") 169 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:bar:1.1") 170 | 171 | # WHEN receiving server capabilities connection to server 172 | with manager.connect( 173 | host="localhost", 174 | port=8830, 175 | username="admin", 176 | password="admin", 177 | hostkey_verify=False, 178 | ) as m: 179 | server_capabilities = m.server_capabilities 180 | 181 | # THEN expect to see capabilities 182 | assert f"urn:ietf:params:netconf:base:{base_version}" in server_capabilities 183 | assert "urn:ietf:params:netconf:capability:foo:1.1" in server_capabilities 184 | assert "urn:ietf:params:netconf:capability:bar:1.1" in server_capabilities 185 | 186 | 187 | def test_when_connecting_using_no_username_or_password_then_authentication_passes( 188 | netconf_server: NetconfServer, 189 | ): 190 | # GIVEN no username and password have been defined 191 | netconf_server.username = None 192 | netconf_server.password = None 193 | 194 | # WHEN connecting using random credentials 195 | with manager.connect( 196 | host="localhost", 197 | port=8830, 198 | username="foo", 199 | password="bar", 200 | hostkey_verify=False, 201 | ) as m: 202 | # THEN expect to be connected 203 | assert m.connected 204 | 205 | 206 | def test_when_connecting_using_username_and_password_then_authentication_passes( 207 | netconf_server: NetconfServer, 208 | ): 209 | # GIVEN username and password have been defined 210 | netconf_server.username = "admin" 211 | netconf_server.password = "password" 212 | 213 | # WHEN connecting using correct credentials 214 | with manager.connect( 215 | host="localhost", 216 | port=8830, 217 | username="admin", 218 | password="password", 219 | hostkey_verify=False, 220 | ) as m: 221 | # THEN expect to be connected 222 | assert m.connected 223 | 224 | 225 | def test_when_connecting_using_username_and_password_then_authentication_fails( 226 | netconf_server: NetconfServer, 227 | ): 228 | # GIVEN username and password have been defined 229 | netconf_server.username = "admin" 230 | netconf_server.password = "password" 231 | 232 | # WHEN connecting using wrong credentials 233 | with pytest.raises(AuthenticationError) as error: 234 | with manager.connect( 235 | host="localhost", 236 | port=8830, 237 | username="foo", 238 | password="bar", 239 | hostkey_verify=False, 240 | ): 241 | ... 242 | 243 | # THEN expect error 244 | assert error 245 | 246 | 247 | def test_when_connecting_using_username_and_rsa_key_then_authentication_passes( 248 | netconf_server, tmp_path 249 | ): 250 | # GIVEN generated key 251 | key_filepath = (tmp_path / "key").as_posix() 252 | key = paramiko.RSAKey.generate(bits=2048) 253 | key.write_private_key_file(key_filepath) 254 | 255 | # GIVEN SSH username and key have been defined 256 | netconf_server.username = "admin" 257 | netconf_server.authorized_key = f"{key.get_name()} {key.get_base64()}" 258 | 259 | # WHEN connecting using key credentials 260 | with manager.connect( 261 | host="localhost", 262 | port=8830, 263 | username="admin", 264 | key_filename=key_filepath, 265 | hostkey_verify=False, 266 | ) as m: 267 | # THEN expect to be connected 268 | assert m.connected 269 | 270 | 271 | def test_when_connecting_using_username_and_wrong_key_then_authentication_fails( 272 | netconf_server, tmp_path 273 | ): 274 | # GIVEN generated key 275 | key_filepath = (tmp_path / "key").as_posix() 276 | key = paramiko.RSAKey.generate(bits=2048) 277 | key.write_private_key_file(key_filepath) 278 | 279 | # GIVEN SSH username and a different key have been defined 280 | netconf_server.username = "admin" 281 | netconf_server.authorized_key = f"foobar" 282 | 283 | # WHEN connecting using wrong key 284 | with pytest.raises(AuthenticationError) as error: 285 | with manager.connect( 286 | host="localhost", 287 | port=8830, 288 | username="foo", 289 | key_filename=key_filepath, 290 | hostkey_verify=False, 291 | ): 292 | ... 293 | 294 | # THEN expect error 295 | assert error 296 | -------------------------------------------------------------------------------- /tests/integration/test_netconf_client.py: -------------------------------------------------------------------------------- 1 | import paramiko.ssh_exception 2 | import pytest 3 | import paramiko 4 | from pytest_netconf import NetconfServer 5 | 6 | from netconf_client.connect import connect_ssh 7 | from netconf_client.ncclient import Manager 8 | from netconf_client.error import RpcError 9 | 10 | 11 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 12 | def test_when_full_request_and_response_is_defined_then_response_is_returned( 13 | base_version, 14 | netconf_server: NetconfServer, 15 | ): 16 | # GIVEN server base version 17 | netconf_server.base_version = base_version 18 | 19 | # GIVEN server request and response are defined 20 | netconf_server.expect_request( 21 | '' 22 | '' 23 | "" 24 | ).respond_with( 25 | """ 26 | 27 | 29 | 30 | 31 | 32 | eth0 33 | 34 | 35 | 36 | """ 37 | ) 38 | 39 | # WHEN fetching rpc response from server 40 | with connect_ssh( 41 | host="localhost", 42 | port=8830, 43 | username="admin", 44 | password="admin", 45 | ) as session: 46 | manager = Manager(session=session) 47 | response = manager.get_config(source="running").data_xml 48 | 49 | # THEN expect response 50 | assert ( 51 | """ 52 | 53 | 54 | eth0 55 | 56 | 57 | """.strip( 58 | "\n" 59 | ) 60 | in response.decode() 61 | ) 62 | 63 | 64 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 65 | def test_when_regex_request_and_response_is_defined_then_response_is_returned( 66 | base_version, 67 | netconf_server: NetconfServer, 68 | ): 69 | # GIVEN server base version 70 | netconf_server.base_version = base_version 71 | 72 | # GIVEN server request and response are defined 73 | netconf_server.expect_request("get-config").respond_with( 74 | """ 75 | 76 | 78 | 79 | 80 | 81 | eth0 82 | 83 | 84 | 85 | """ 86 | ) 87 | 88 | # WHEN fetching rpc response from server 89 | with connect_ssh( 90 | host="localhost", 91 | port=8830, 92 | username="admin", 93 | password="admin", 94 | ) as session: 95 | manager = Manager(session=session) 96 | response = manager.get_config(source="running").data_xml 97 | 98 | # THEN expect response 99 | # THEN expect response 100 | assert ( 101 | """ 102 | 103 | 104 | eth0 105 | 106 | 107 | """.strip( 108 | "\n" 109 | ) 110 | in response.decode() 111 | ) 112 | 113 | 114 | def test_when_unexpected_request_received_then_error_response_is_returned( 115 | netconf_server: NetconfServer, 116 | ): 117 | # GIVEN no server request and response are defined 118 | netconf_server 119 | 120 | # WHEN fetching rpc response from server 121 | with pytest.raises(RpcError) as error: 122 | with connect_ssh( 123 | host="localhost", 124 | port=8830, 125 | username="admin", 126 | password="admin", 127 | ) as session: 128 | Manager(session=session).get_config(source="running") 129 | 130 | # THEN expect error response 131 | assert ( 132 | str(error.value) 133 | == "pytest-netconf: requested rpc is unknown and has no response defined" 134 | ) 135 | 136 | 137 | def test_when_server_stops_then_client_error_is_raised( 138 | netconf_server: NetconfServer, 139 | ): 140 | # GIVEN netconf connection 141 | with pytest.raises(OSError) as error: 142 | with connect_ssh( 143 | host="localhost", 144 | port=8830, 145 | username="admin", 146 | password="admin", 147 | general_timeout=5, 148 | ) as session: 149 | manager = Manager(session=session) 150 | 151 | # WHEN server stops 152 | netconf_server.stop() 153 | manager.get_config() # and a request is attempted 154 | session.session_id # needed to probe session 155 | 156 | # THEN expect error 157 | assert str(error.value) == "Socket is closed" 158 | 159 | 160 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 161 | def test_when_defining_custom_capabilities_then_server_returns_them( 162 | base_version, 163 | netconf_server: NetconfServer, 164 | ): 165 | # GIVEN server version 166 | netconf_server.base_version = base_version 167 | 168 | # GIVEN extra capabilities 169 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:foo:1.1") 170 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:bar:1.1") 171 | 172 | # WHEN receiving server capabilities connection to server 173 | with connect_ssh( 174 | host="localhost", 175 | port=8830, 176 | username="admin", 177 | password="admin", 178 | ) as session: 179 | server_capabilities = session.server_capabilities 180 | 181 | # THEN expect to see capabilities 182 | assert f"urn:ietf:params:netconf:base:{base_version}" in server_capabilities 183 | assert "urn:ietf:params:netconf:capability:foo:1.1" in server_capabilities 184 | assert "urn:ietf:params:netconf:capability:bar:1.1" in server_capabilities 185 | 186 | 187 | def test_when_connecting_using_no_username_or_password_then_authentication_passes( 188 | netconf_server: NetconfServer, 189 | ): 190 | # GIVEN no username and password have been defined 191 | netconf_server 192 | netconf_server.username = None 193 | netconf_server.password = None 194 | 195 | # WHEN connecting using random credentials 196 | with connect_ssh( 197 | host="localhost", 198 | port=8830, 199 | username="foo", 200 | password="bar", 201 | ) as session: 202 | # THEN expect to be connected 203 | assert session.session_id 204 | 205 | 206 | def test_when_connecting_using_username_and_password_then_authentication_passes( 207 | netconf_server: NetconfServer, 208 | ): 209 | # GIVEN username and password have been defined 210 | netconf_server.username = "admin" 211 | netconf_server.password = "password" 212 | 213 | # WHEN connecting using correct credentials 214 | with connect_ssh( 215 | host="localhost", 216 | port=8830, 217 | username="admin", 218 | password="password", 219 | ) as session: 220 | # THEN expect to be connected 221 | assert session.session_id 222 | 223 | 224 | def test_when_connecting_using_username_and_password_then_authentication_fails( 225 | netconf_server: NetconfServer, 226 | ): 227 | # GIVEN username and password have been defined 228 | netconf_server.username = "admin" 229 | netconf_server.password = "password" 230 | 231 | # WHEN connecting using wrong credentials 232 | with pytest.raises(paramiko.ssh_exception.AuthenticationException) as error: 233 | with connect_ssh( 234 | host="localhost", 235 | port=8830, 236 | username="foo", 237 | password="bar", 238 | ) as session: 239 | Manager(session=session) 240 | 241 | # THEN expect error 242 | assert "Authentication failed." in str(error) 243 | 244 | 245 | def test_when_connecting_using_username_and_rsa_key_then_authentication_passes( 246 | netconf_server, tmp_path 247 | ): 248 | # GIVEN generated key 249 | key_filepath = (tmp_path / "key").as_posix() 250 | key = paramiko.RSAKey.generate(bits=2048) 251 | key.write_private_key_file(key_filepath) 252 | 253 | # GIVEN SSH username and key have been defined 254 | netconf_server.username = "admin" 255 | netconf_server.authorized_key = f"{key.get_name()} {key.get_base64()}" 256 | 257 | # WHEN connecting using key credentials 258 | with connect_ssh( 259 | host="localhost", 260 | port=8830, 261 | username="admin", 262 | password=None, 263 | key_filename=key_filepath, 264 | ) as session: 265 | # THEN expect to be connected 266 | assert session.session_id 267 | 268 | def test_when_connecting_using_username_and_wrong_key_then_authentication_fails( 269 | netconf_server, tmp_path 270 | ): 271 | # GIVEN generated key 272 | key_filepath = (tmp_path / "key").as_posix() 273 | key = paramiko.RSAKey.generate(bits=2048) 274 | key.write_private_key_file(key_filepath) 275 | 276 | # GIVEN SSH username and a different key have been defined 277 | netconf_server.username = "admin" 278 | netconf_server.authorized_key = f"foobar" 279 | 280 | # WHEN connecting using wrong key 281 | with pytest.raises(paramiko.ssh_exception.AuthenticationException) as error: 282 | with connect_ssh( 283 | host="localhost", 284 | port=8830, 285 | username="foo", 286 | password=None, 287 | key_filename=key_filepath, 288 | ) as session: 289 | Manager(session=session) 290 | 291 | # THEN expect error 292 | assert "Authentication failed." in str(error) 293 | -------------------------------------------------------------------------------- /tests/unit/test_netconfserver.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import patch, MagicMock 3 | 4 | from pytest_netconf.netconfserver import NetconfServer 5 | from pytest_netconf.exceptions import RequestError 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "prop_name,prop_value", 10 | [ 11 | ("base_version", "1.1"), 12 | ("host", "localhost"), 13 | ("port", 1234), 14 | ("username", "foo"), 15 | ("password", "bar"), 16 | ("authorized_key", "specialkey"), 17 | ], 18 | ) 19 | def test_when_setting_server_settings_then_value_is_returned(prop_name, prop_value): 20 | # GIVEN netconf server instance 21 | nc = NetconfServer() 22 | 23 | # GIVEN settings property has been set 24 | setattr(nc, prop_name, prop_value) 25 | 26 | # WHEN accessing property 27 | val = getattr(nc, prop_name) 28 | 29 | # THEN expect value 30 | assert val == prop_value 31 | 32 | # THEN expect internal settings instance to also match 33 | assert getattr(nc.settings, prop_name) == prop_value 34 | 35 | 36 | def test_when_setting_invalid_server_base_version_then_error_is_raised(): 37 | # GIVEN netconf server instance 38 | nc = NetconfServer() 39 | 40 | # WHEN setting invalid base version 41 | with pytest.raises(ValueError) as error: 42 | nc.base_version = "99" 43 | 44 | # THEN expect error 45 | assert str(error.value) == "Invalid NETCONF base version 99: must be '1.0' or '1.1'" 46 | 47 | 48 | @patch("socket.socket", autospec=True) 49 | def test_when_server_bind_port_in_use_error_is_raised(mock_socket): 50 | # GIVEN socket raises error 51 | mock_socket.side_effect = OSError(48, "Address already in use") 52 | 53 | # GIVEN netconf server instance 54 | nc = NetconfServer() 55 | nc.port = 8830 56 | 57 | # WHEN calling bind socket 58 | with pytest.raises(OSError) as error: 59 | nc._bind_socket() 60 | 61 | # THEN expect error 62 | assert str(error.value) == "could not bind to port 8830" 63 | 64 | 65 | @patch("socket.socket", autospec=True) 66 | def test_when_server_bind_generic_error_then_error_is_raised(mock_socket): 67 | # GIVEN socket raises error 68 | mock_socket.side_effect = OSError(13, "Permission denied") 69 | 70 | # GIVEN netconf server instance 71 | nc = NetconfServer() 72 | nc.port = 8830 73 | 74 | # WHEN calling bind socket 75 | with pytest.raises(OSError) as error: 76 | nc._bind_socket() 77 | 78 | # THEN expect error 79 | assert str(error.value) == "[Errno 13] Permission denied" 80 | 81 | 82 | def test_when_handle_request_has_unknown_error_then_error_is_raised(): 83 | # GIVEN netconf server instance which is running 84 | nc = NetconfServer() 85 | nc.running = True 86 | 87 | # GIVEN patched function that raises error 88 | nc._process_buffer = MagicMock(side_effect=RuntimeError("foo")) 89 | 90 | # WHEN calling handle requests 91 | with pytest.raises(RequestError) as error: 92 | nc._handle_requests(MagicMock()) 93 | 94 | # THEN expect our error to pass through 95 | assert str(error.value) == "failed to handle request: foo" 96 | 97 | 98 | def test_when_process_buffer_receives_base11_missing_size_then_false_is_returned( 99 | caplog, 100 | ): 101 | # GIVEN netconf server instance which is running 102 | nc = NetconfServer() 103 | nc.running = True 104 | nc._hello_sent = True 105 | nc.base_version = "1.1" 106 | 107 | # WHEN calling process buffer 108 | result = nc._process_buffer(buffer=b"999\nfoo\n##\n", channel=MagicMock()) 109 | 110 | # THEN expect result to be false 111 | assert result is False 112 | 113 | # THEN expect log message 114 | assert "parse error: Invalid content or chunk size format" in caplog.text 115 | 116 | 117 | def test_when_extract_base11_invalid_length_then_error_is_raised( 118 | caplog, 119 | ): 120 | # GIVEN netconf server instance which is running 121 | nc = NetconfServer() 122 | 123 | # WHEN calling extract method 124 | with pytest.raises(ValueError) as error: 125 | nc._extract_base11_content_and_length("#999\nfoobar\n##\n") 126 | 127 | # THEN expect error 128 | assert str(error.value) == "received invalid chunk size expected=6 received=999" 129 | 130 | 131 | @pytest.mark.parametrize( 132 | "test_input,expected", 133 | [ 134 | ( 135 | """ 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | """, 144 | "101", 145 | ), 146 | ( 147 | """ 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | """, 156 | "unknown", 157 | ), 158 | ( 159 | """ 160 | <> 161 | """, 162 | "unknown", 163 | ), 164 | ], 165 | ids=["valid-101", "unknown-missing", "unknown-invalid"], 166 | ) 167 | def test_when_extract_message_id_then_string_is_returned(test_input, expected): 168 | # GIVEN input rpc 169 | request = test_input 170 | 171 | # GIVEN netconf server instance which is running 172 | nc = NetconfServer() 173 | 174 | # WHEN extracting message id 175 | message_id = nc._extract_message_id(request) 176 | 177 | # THEN expect result 178 | assert message_id == expected 179 | 180 | 181 | def test_when_no_requests_made_then_was_called_returns_false(): 182 | # GIVEN netconf server instance 183 | nc = NetconfServer() 184 | 185 | # WHEN checking if server was called 186 | result = nc.was_called() 187 | 188 | # THEN expect false 189 | assert result is False 190 | 191 | 192 | def test_when_no_requests_made_then_call_count_returns_zero(): 193 | # GIVEN netconf server instance 194 | nc = NetconfServer() 195 | 196 | # WHEN getting call count 197 | result = nc.get_call_count() 198 | 199 | # THEN expect zero 200 | assert result == 0 201 | 202 | 203 | @patch("paramiko.Channel") 204 | def test_when_request_made_then_was_called_returns_true(mock_channel): 205 | # GIVEN netconf server instance 206 | nc = NetconfServer() 207 | 208 | # GIVEN mock channel 209 | mock_channel.sendall = MagicMock() 210 | 211 | # GIVEN configured request and response 212 | nc.expect_request("get").respond_with("") 213 | 214 | # WHEN sending a response (to an made up request) 215 | nc._send_response("", mock_channel) 216 | 217 | # THEN expect was_called to return true 218 | assert nc.was_called() is True 219 | 220 | 221 | @patch("paramiko.Channel") 222 | def test_when_multiple_requests_made_then_call_count_returns_correct_number( 223 | mock_channel, 224 | ): 225 | # GIVEN netconf server instance 226 | nc = NetconfServer() 227 | 228 | # GIVEN mock channel 229 | mock_channel.sendall = MagicMock() 230 | 231 | # GIVEN configured request and response 232 | nc.expect_request("get").respond_with("") 233 | 234 | # WHEN sending multiple responses (simulating multiple requests) 235 | nc._send_response("", mock_channel) 236 | nc._send_response("", mock_channel) 237 | nc._send_response("", mock_channel) 238 | 239 | # THEN expect call count to be 3 240 | assert nc.get_call_count() == 3 241 | 242 | 243 | def test_when_no_matching_requests_made_then_request_handler_was_called_returns_false(): 244 | # GIVEN netconf server instance 245 | nc = NetconfServer() 246 | 247 | # GIVEN request handler 248 | handler = nc.expect_request("get") 249 | 250 | # WHEN checking if handler was called (no requests made) 251 | result = handler.was_called() 252 | 253 | # THEN expect false 254 | assert result is False 255 | 256 | 257 | def test_when_no_matching_requests_made_then_request_handler_call_count_returns_zero(): 258 | # GIVEN netconf server instance 259 | nc = NetconfServer() 260 | 261 | # GIVEN request handler 262 | handler = nc.expect_request("get") 263 | 264 | # WHEN getting call count (no requests made) 265 | result = handler.get_call_count() 266 | 267 | # THEN expect zero 268 | assert result == 0 269 | 270 | 271 | @patch("paramiko.Channel") 272 | def test_when_matching_request_made_then_request_handler_was_called_returns_true( 273 | mock_channel, 274 | ): 275 | # GIVEN netconf server instance 276 | nc = NetconfServer() 277 | 278 | # GIVEN request handler 279 | handler = nc.expect_request("get").respond_with("") 280 | 281 | # GIVEN mock channel 282 | mock_channel.sendall = MagicMock() 283 | 284 | # WHEN sending a matching request 285 | nc._send_response("", mock_channel) 286 | 287 | # THEN expect handler was_called to return true 288 | assert handler.was_called() is True 289 | 290 | 291 | @patch("paramiko.Channel") 292 | def test_when_multiple_matching_requests_made_then_request_handler_call_count_returns_correct_number( 293 | mock_channel, 294 | ): 295 | # GIVEN netconf server instance 296 | nc = NetconfServer() 297 | 298 | # GIVEN edit request handler 299 | edit_handler = nc.expect_request("edit").respond_with("") 300 | 301 | # GIVEN get request handler 302 | get_handler = nc.expect_request("get").respond_with("") 303 | 304 | # GIVEN mock channel 305 | mock_channel.sendall = MagicMock() 306 | 307 | # WHEN sending multiple matching requests 308 | nc._send_response("", mock_channel) 309 | nc._send_response("", mock_channel) 310 | 311 | # AND sending non-matching request 312 | nc._send_response("", mock_channel) 313 | 314 | # THEN expect total calls to be 3 315 | assert nc.was_called() 316 | assert nc.get_call_count() == 3 317 | 318 | # THEN expect get handler call count to be 2 319 | assert get_handler.was_called() 320 | assert get_handler.get_call_count() == 2 321 | 322 | # THEN expect edit handler to be called once 323 | assert edit_handler.was_called() 324 | assert edit_handler.get_call_count() == 1 325 | -------------------------------------------------------------------------------- /tests/integration/test_scrapli_netconf.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | import pytest 3 | import paramiko 4 | from pytest_netconf import NetconfServer 5 | 6 | from scrapli_netconf.driver import NetconfDriver 7 | from scrapli.exceptions import ScrapliConnectionError 8 | 9 | 10 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 11 | def test_when_full_request_and_response_is_defined_then_response_is_returned( 12 | base_version, 13 | netconf_server: NetconfServer, 14 | ): 15 | # GIVEN server base version 16 | netconf_server.base_version = base_version 17 | 18 | # GIVEN server request and response are defined 19 | netconf_server.expect_request( 20 | "\n" # scrapli seems to send new line for 1.0 21 | if base_version == "1.0" 22 | else "" 23 | "\n" 24 | '' 25 | "" 26 | "" 27 | ).respond_with( 28 | """ 29 | 30 | 32 | 33 | 34 | 35 | eth0 36 | 37 | 38 | 39 | """ 40 | ) 41 | 42 | # WHEN fetching rpc response from server 43 | with NetconfDriver( 44 | host="localhost", 45 | port=8830, 46 | auth_username="admin", 47 | auth_password="admin", 48 | auth_strict_key=False, 49 | strip_namespaces=True, 50 | ) as conn: 51 | response = conn.get_config(source="running").xml_result 52 | 53 | # THEN expect response 54 | assert ( 55 | """ 56 | 57 | eth0 58 | 59 | 60 | """ 61 | == etree.tostring( 62 | response.find(".//data/"), 63 | pretty_print=True, 64 | ).decode() 65 | ) 66 | 67 | 68 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 69 | def test_when_regex_request_and_response_is_defined_then_response_is_returned( 70 | base_version, 71 | netconf_server: NetconfServer, 72 | ): 73 | # GIVEN server base version 74 | netconf_server.base_version = base_version 75 | 76 | # GIVEN server request and response are defined 77 | netconf_server.expect_request("get-config").respond_with( 78 | """ 79 | 80 | 82 | 83 | 84 | 85 | eth0 86 | 87 | 88 | 89 | """ 90 | ) 91 | 92 | # WHEN fetching rpc response from server 93 | with NetconfDriver( 94 | host="localhost", 95 | port=8830, 96 | auth_username="admin", 97 | auth_password="admin", 98 | auth_strict_key=False, 99 | strip_namespaces=True, 100 | ) as conn: 101 | response = conn.get_config(source="running").xml_result 102 | 103 | # THEN expect response 104 | assert ( 105 | """ 106 | 107 | eth0 108 | 109 | 110 | """ 111 | == etree.tostring( 112 | response.find(".//data/"), 113 | pretty_print=True, 114 | ).decode() 115 | ) 116 | 117 | 118 | def test_when_unexpected_request_received_then_error_response_is_returned( 119 | netconf_server: NetconfServer, 120 | ): 121 | # GIVEN no server request and response are defined 122 | netconf_server 123 | 124 | # WHEN fetching rpc response from server 125 | with NetconfDriver( 126 | host="localhost", 127 | port=8830, 128 | auth_username="admin", 129 | auth_password="admin", 130 | auth_strict_key=False, 131 | timeout_ops=5, 132 | ) as conn: 133 | response = conn.get_config(source="running").result 134 | 135 | # THEN expect error response 136 | assert ( 137 | response 138 | == """ 139 | 140 | rpc 141 | operation-failed 142 | error 143 | pytest-netconf: requested rpc is unknown and has no response defined 144 | 145 | 146 | """ 147 | ) 148 | 149 | 150 | def test_when_server_stops_then_client_error_is_raised( 151 | netconf_server: NetconfServer, 152 | ): 153 | # GIVEN netconf connection 154 | with pytest.raises(ScrapliConnectionError) as error: 155 | with NetconfDriver( 156 | host="localhost", 157 | port=8830, 158 | auth_username="admin", 159 | auth_password="admin", 160 | auth_strict_key=False, 161 | ) as conn: 162 | # WHEN server stops 163 | netconf_server.stop() 164 | conn.get_config() # and a request is attempted 165 | 166 | # THEN expect error 167 | assert ( 168 | str(error.value) 169 | == "encountered EOF reading from transport; typically means the device closed the connection" 170 | ) 171 | 172 | 173 | @pytest.mark.parametrize("base_version", ["1.0", "1.1"]) 174 | def test_when_defining_custom_capabilities_then_server_returns_them( 175 | base_version, 176 | netconf_server: NetconfServer, 177 | ): 178 | # GIVEN server version 179 | netconf_server.base_version = base_version 180 | 181 | # GIVEN extra capabilities 182 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:foo:1.1") 183 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:bar:1.1") 184 | 185 | # WHEN receiving server capabilities connection to server 186 | with NetconfDriver( 187 | host="localhost", 188 | port=8830, 189 | auth_username="admin", 190 | auth_password="admin", 191 | auth_strict_key=False, 192 | ) as conn: 193 | server_capabilities = conn.server_capabilities 194 | 195 | # THEN expect to see capabilities 196 | assert f"urn:ietf:params:netconf:base:{base_version}" in server_capabilities 197 | assert "urn:ietf:params:netconf:capability:foo:1.1" in server_capabilities 198 | assert "urn:ietf:params:netconf:capability:bar:1.1" in server_capabilities 199 | 200 | 201 | def test_when_connecting_using_no_username_or_password_then_authentication_passes( 202 | netconf_server: NetconfServer, 203 | ): 204 | # GIVEN no username and password have been defined 205 | netconf_server.username = None 206 | netconf_server.password = None 207 | 208 | # WHEN connecting using random credentials 209 | with NetconfDriver( 210 | host="localhost", 211 | port=8830, 212 | auth_username="foo", 213 | auth_password="bar", 214 | auth_strict_key=False, 215 | ) as conn: 216 | # THEN expect to be connected 217 | assert conn.isalive() 218 | 219 | 220 | def test_when_connecting_using_username_and_password_then_authentication_passes( 221 | netconf_server: NetconfServer, 222 | ): 223 | # GIVEN username and password have been defined 224 | netconf_server.username = "admin" 225 | netconf_server.password = "password" 226 | 227 | # WHEN connecting using correct credentials 228 | with NetconfDriver( 229 | host="localhost", 230 | port=8830, 231 | auth_username="admin", 232 | auth_password="password", 233 | auth_strict_key=False, 234 | ) as conn: 235 | # THEN expect to be connected 236 | assert conn.isalive() 237 | 238 | 239 | def test_when_connecting_using_username_and_password_then_authentication_fails( 240 | netconf_server: NetconfServer, 241 | ): 242 | # GIVEN username and password have been defined 243 | netconf_server.username = "admin" 244 | netconf_server.password = "password" 245 | 246 | # WHEN connecting using wrong credentials 247 | with pytest.raises(ScrapliConnectionError) as error: 248 | with NetconfDriver( 249 | host="localhost", 250 | port=8830, 251 | auth_username="foo", 252 | auth_password="bar", 253 | auth_strict_key=False, 254 | ): 255 | pass 256 | 257 | # THEN expect error 258 | assert "permission denied, please try again." in str(error) 259 | 260 | 261 | def test_when_connecting_using_username_and_rsa_key_then_authentication_passes( 262 | netconf_server, tmp_path 263 | ): 264 | # GIVEN generated key 265 | key_filepath = (tmp_path / "key").as_posix() 266 | key = paramiko.RSAKey.generate(bits=2048) 267 | key.write_private_key_file(key_filepath) 268 | 269 | # GIVEN SSH username and key have been defined 270 | netconf_server.username = "admin" 271 | netconf_server.authorized_key = f"{key.get_name()} {key.get_base64()}" 272 | 273 | # WHEN connecting using key credentials 274 | with NetconfDriver( 275 | host="localhost", 276 | port=8830, 277 | auth_username="admin", 278 | auth_private_key=key_filepath, 279 | auth_strict_key=False, 280 | ) as conn: 281 | # THEN expect to be connected 282 | assert conn.isalive() 283 | 284 | def test_when_connecting_using_username_and_wrong_key_then_authentication_fails( 285 | netconf_server, tmp_path 286 | ): 287 | # GIVEN generated key 288 | key_filepath = (tmp_path / "key").as_posix() 289 | key = paramiko.RSAKey.generate(bits=2048) 290 | key.write_private_key_file(key_filepath) 291 | 292 | # GIVEN SSH username and a different key have been defined 293 | netconf_server.username = "admin" 294 | netconf_server.authorized_key = f"foobar" 295 | 296 | # WHEN connecting using wrong key 297 | with pytest.raises(ScrapliConnectionError) as error: 298 | with NetconfDriver( 299 | host="localhost", 300 | port=8830, 301 | auth_username="foo", 302 | auth_private_key=key_filepath, 303 | auth_strict_key=False, 304 | ): 305 | pass 306 | 307 | # THEN expect error 308 | assert "permission denied, please try again." in str(error) 309 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-netconf 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/nomios-opensource/pytest-netconf/publish.yml) 4 | [![codecov](https://codecov.io/gh/nomios-opensource/pytest-netconf/branch/develop/graph/badge.svg?token=iKZNzUr2LI)](https://codecov.io/gh/nomios-opensource/pytest-netconf) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-netconf) 6 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/pytest-netconf) 7 | ![GitHub License](https://img.shields.io/github/license/nomios-opensource/pytest-netconf) 8 | 9 | A pytest plugin that provides a mock NETCONF (RFC6241/RFC6242) server for local testing. 10 | 11 | `pytest-netconf` is authored by [Adam Kirchberger](https://github.com/adamkirchberger), governed as a [benevolent dictatorship](CODE_OF_CONDUCT.md), and distributed under [license](LICENSE). 12 | 13 | ## Introduction 14 | 15 | Testing NETCONF devices has traditionally required maintaining labs with multiple vendor devices which can be complex and resource-intensive. Additionally, spinning up virtual devices for testing purposes is often time-consuming and too slow for CICD pipelines. This plugin provides a convenient way to mock the behavior and responses of these NETCONF devices. 16 | 17 | ## Features 18 | 19 | - **NETCONF server**, a real SSH server is run locally which enables testing using actual network connections instead of patching. 20 | - **Predefined requests and responses**, define specific NETCONF requests and responses to meet your testing needs. 21 | - **Capability testing**, define specific capabilities you want the server to support and test their responses. 22 | - **Authentication testing**, test error handling for authentication issues (supports password or key auth). 23 | - **Connection testing**, test error handling when tearing down connections unexpectedly. 24 | 25 | ## NETCONF Clients 26 | 27 | The clients below have been tested 28 | 29 | - `ncclient` :white_check_mark: 30 | - `netconf-client` :white_check_mark: 31 | - `scrapli-netconf` :white_check_mark: 32 | 33 | ## Installation 34 | 35 | Install using `pip install pytest-netconf` or `poetry add --group dev pytest-netconf` 36 | 37 | ## Quickstart 38 | 39 | The plugin will install a pytest fixture named `netconf_server`, which will start an SSH server with settings you provide, and **only** reply to requests which you define with corresponding responses. 40 | 41 | For more use cases see [examples](#examples) 42 | 43 | 44 | ```python 45 | # Configure server settings 46 | netconf_server.username = None # allow any username 47 | netconf_server.password = None # allow any password 48 | netconf_server.port = 8830 # default value 49 | 50 | # Configure a request and response 51 | netconf_server.expect_request( 52 | '' 53 | '' 54 | "" 55 | "" 56 | ).respond_with( 57 | """ 58 | 59 | 61 | 62 | 63 | 64 | eth0 65 | 66 | 67 | 68 | 69 | """ 70 | ) 71 | ``` 72 | 73 | ## Examples 74 | 75 |
76 | Get Config 77 |
78 | 79 | ```python 80 | from pytest_netconf import NetconfServer 81 | from ncclient import manager 82 | 83 | 84 | def test_netconf_get_config( 85 | netconf_server: NetconfServer, 86 | ): 87 | # GIVEN server request and response 88 | netconf_server.expect_request( 89 | '' 90 | '' 91 | "" 92 | "" 93 | ).respond_with( 94 | """ 95 | 96 | 98 | 99 | 100 | 101 | eth0 102 | 103 | 104 | 105 | """ 106 | ) 107 | 108 | # WHEN fetching rpc response from server 109 | with manager.connect( 110 | host="localhost", 111 | port=8830, 112 | username="admin", 113 | password="admin", 114 | hostkey_verify=False, 115 | ) as m: 116 | response = m.get_config(source="running").data_xml 117 | 118 | # THEN expect response 119 | assert ( 120 | """ 121 | 122 | 123 | eth0 124 | 125 | 126 | """ 127 | in response 128 | ) 129 | ``` 130 |
131 | 132 |
133 | Authentication Fail 134 |
135 | 136 | ```python 137 | from pytest_netconf import NetconfServer 138 | from ncclient import manager 139 | from ncclient.transport.errors import AuthenticationError 140 | 141 | 142 | def test_netconf_auth_fail( 143 | netconf_server: NetconfServer, 144 | ): 145 | # GIVEN username and password have been defined 146 | netconf_server.username = "admin" 147 | netconf_server.password = "password" 148 | 149 | # WHEN connecting using wrong credentials 150 | with pytest.raises(AuthenticationError) as error: 151 | with manager.connect( 152 | host="localhost", 153 | port=8830, 154 | username="foo", 155 | password="bar", 156 | hostkey_verify=False, 157 | ): 158 | ... 159 | 160 | # THEN expect error 161 | assert error 162 | ``` 163 |
164 | 165 |
166 | Custom Capabilities 167 |
168 | 169 | ```python 170 | from pytest_netconf import NetconfServer 171 | from ncclient import manager 172 | 173 | 174 | def test_netconf_capabilities( 175 | netconf_server: NetconfServer, 176 | ): 177 | # GIVEN extra capabilities 178 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:foo:1.1") 179 | netconf_server.capabilities.append("urn:ietf:params:netconf:capability:bar:1.1") 180 | 181 | # WHEN receiving server capabilities 182 | with manager.connect( 183 | host="localhost", 184 | port=8830, 185 | username="admin", 186 | password="admin", 187 | hostkey_verify=False, 188 | ) as m: 189 | server_capabilities = m.server_capabilities 190 | 191 | # THEN expect to see capabilities 192 | assert "urn:ietf:params:netconf:capability:foo:1.1" in server_capabilities 193 | assert "urn:ietf:params:netconf:capability:bar:1.1" in server_capabilities 194 | ``` 195 |
196 | 197 |
198 | Server Disconnect 199 |
200 | 201 | ```python 202 | from pytest_netconf import NetconfServer 203 | from ncclient import manager 204 | from ncclient.transport.errors import TransportError 205 | 206 | 207 | def test_netconf_server_disconnect( 208 | netconf_server: NetconfServer, 209 | ): 210 | # GIVEN netconf connection 211 | with pytest.raises(TransportError) as error: 212 | with manager.connect( 213 | host="localhost", 214 | port=8830, 215 | username="admin", 216 | password="admin", 217 | hostkey_verify=False, 218 | ) as m: 219 | pass 220 | # WHEN server stops 221 | netconf_server.stop() 222 | 223 | # THEN expect error 224 | assert str(error.value) == "Not connected to NETCONF server" 225 | ``` 226 |
227 | 228 |
229 | Key Auth 230 |
231 | 232 | ```python 233 | from pytest_netconf import NetconfServer 234 | from ncclient import manager 235 | 236 | 237 | def test_netconf_key_auth( 238 | netconf_server: NetconfServer, 239 | ): 240 | # GIVEN SSH username and authorized key 241 | netconf_server.username = "admin" 242 | netconf_server.authorized_key = "ssh-rsa AAAAB3NzaC1yc..." 243 | 244 | # WHEN connecting using key credentials 245 | with manager.connect( 246 | host="localhost", 247 | port=8830, 248 | username="admin", 249 | key_filename=key_filepath, 250 | hostkey_verify=False, 251 | ) as m: 252 | # THEN expect to be connected 253 | assert m.connected 254 | ``` 255 |
256 | 257 |
258 | Call Tracking 259 |
260 | 261 | ```python 262 | 263 | def test_call_tracking(netconf_server): 264 | # GIVEN server request and response 265 | handler = netconf_server.expect_request( 266 | '' 267 | '' 268 | "" 269 | "" 270 | ).respond_with( 271 | """ 272 | 273 | 275 | 276 | 277 | 278 | eth0 279 | 280 | 281 | 282 | 283 | """ 284 | ) 285 | 286 | # WHEN fetching rpc response from server 287 | with manager.connect( 288 | host="localhost", 289 | port=8830, 290 | username="admin", 291 | password="admin", 292 | hostkey_verify=False, 293 | ) as m: 294 | m.get_config(source="running").data_xml 295 | 296 | # THEN expect calls to be made 297 | assert netconf_server.was_called() 298 | assert netconf_server.get_call_count() == 1 299 | assert handler.was_called() 300 | assert handler.get_call_count() == 1 301 | ``` 302 |
303 | 304 | 305 | ## Call Tracking 306 | 307 | Call tracking is implemented to verify received client requests and responses sent by the server. 308 | 309 | Look at the [Examples](#examples) section to see how this can being used. 310 | 311 | **Server-level tracking** 312 | - `netconf_server.was_called()` - Check if any requests were made 313 | - `netconf_server.get_call_count()` - Get total number of requests 314 | 315 | **Request handler tracking** 316 | - `handler.was_called()` - Check if this specific request pattern was called 317 | - `handler.get_call_count()` - Get number of calls for this pattern 318 | 319 | ## Versioning 320 | 321 | Releases will follow semantic versioning (major.minor.patch). Before 1.0.0 breaking changes can be included in a minor release, therefore we highly recommend pinning this package. 322 | 323 | ## Contributing 324 | 325 | Suggest a [feature]() or report a [bug](). Read our developer [guide](CONTRIBUTING.md). 326 | 327 | ## License 328 | 329 | pytest-netconf is distributed under the Apache 2.0 [license](LICENSE). 330 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /pytest_netconf/netconfserver.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Nomios UK&I 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import re 18 | import time 19 | import socket 20 | import logging 21 | import threading 22 | import typing as t 23 | from enum import Enum 24 | from dataclasses import dataclass 25 | import xml.etree.ElementTree as ET 26 | import paramiko 27 | 28 | from .settings import Settings 29 | from .exceptions import UnexpectedRequestError, RequestError 30 | from .sshserver import SSHServer 31 | from .constants import RPC_REPLY_OK, RPC_REPLY_ERROR 32 | 33 | 34 | @dataclass 35 | class CallRecord: 36 | """ 37 | Historic record of a request and response. 38 | """ 39 | 40 | request: str 41 | response: str 42 | message_id: str 43 | 44 | 45 | logging.basicConfig(level=logging.INFO) 46 | logger = logging.getLogger(__name__) 47 | 48 | 49 | class NetconfBaseVersion(Enum): 50 | """ 51 | NETCONF protocol versions. 52 | """ 53 | 54 | BASE_10 = "1.0" 55 | BASE_11 = "1.1" 56 | 57 | 58 | class NetconfServer: 59 | """A NETCONF server implementation.""" 60 | 61 | BASE_10_DELIMETER = b"]]>]]>" 62 | BASE_10_TEMPLATE = "{content}]]>]]>" 63 | BASE_11_DELIMETER = b"\n##\n" 64 | BASE_11_PATTERN = r"#(\d+)\n(.*)\n##$" 65 | BASE_11_TEMPLATE = "\n#{length}\n{content}\n##\n" 66 | SESSION_ID = 1 67 | 68 | def __init__(self): 69 | """ 70 | Initialise the NETCONF server. 71 | """ 72 | self.settings: Settings = Settings() 73 | self._base_version: NetconfBaseVersion = NetconfBaseVersion( 74 | self.settings.base_version 75 | ) 76 | self._server_socket: t.Optional[socket.socket] = None 77 | self._client_socket: t.Optional[socket.socket] = None 78 | self.running: bool = False 79 | self._thread: t.Optional[threading.Thread] = None 80 | self._hello_sent: bool = False 81 | self.capabilities: t.List[str] = [] 82 | 83 | self.responses: t.List[t.Tuple[str, str]] = [] 84 | self._call_history: t.List[CallRecord] = [] 85 | 86 | @property 87 | def host(self) -> str: 88 | """ 89 | Get the host address for the server. 90 | 91 | Returns: 92 | str: The host address. 93 | """ 94 | return self.settings.host 95 | 96 | @host.setter 97 | def host(self, value: str): 98 | """ 99 | Set the host address for the server. 100 | 101 | Args: 102 | value (str): The new host address. 103 | """ 104 | self.settings.host = value 105 | 106 | @property 107 | def port(self) -> int: 108 | """ 109 | Get the port number for the server. 110 | 111 | Returns: 112 | int: The port number. 113 | """ 114 | return self.settings.port 115 | 116 | @port.setter 117 | def port(self, value: int): 118 | """ 119 | Set the port number for the server. 120 | 121 | Args: 122 | value (int): The new port number. 123 | """ 124 | assert isinstance(value, int), "port value must be int" 125 | self.settings.port = value 126 | 127 | @property 128 | def username(self) -> str: 129 | """ 130 | Get the username for authentication. 131 | 132 | Returns: 133 | str: The username. 134 | """ 135 | return self.settings.username 136 | 137 | @username.setter 138 | def username(self, value: str): 139 | """ 140 | Set the username for authentication. 141 | 142 | Args: 143 | value (str): The new username. 144 | """ 145 | self.settings.username = value 146 | 147 | @property 148 | def password(self) -> t.Optional[str]: 149 | """ 150 | Get the password for authentication. 151 | 152 | Returns: 153 | t.Optional[str]: The password, if set. 154 | """ 155 | return self.settings.password 156 | 157 | @password.setter 158 | def password(self, value: t.Optional[str]): 159 | """ 160 | Set the password for authentication. 161 | 162 | Args: 163 | value (t.Optional[str]): The new password. 164 | """ 165 | self.settings.password = value 166 | 167 | @property 168 | def base_version(self) -> str: 169 | """ 170 | Get the base NETCONF protocol version. 171 | 172 | Returns: 173 | str: The base version. 174 | """ 175 | return self._base_version.value 176 | 177 | @base_version.setter 178 | def base_version(self, value: str): 179 | """ 180 | Set the base NETCONF protocol version. 181 | 182 | Args: 183 | value (str): The new base version. 184 | 185 | Raises: 186 | ValueError: when version is invalid. 187 | """ 188 | try: 189 | self._base_version = NetconfBaseVersion(value) 190 | except ValueError as e: 191 | raise ValueError( 192 | f"Invalid NETCONF base version {value}: must be '1.0' or '1.1'" 193 | ) from e 194 | 195 | @property 196 | def authorized_key(self) -> t.Optional[paramiko.PKey]: 197 | """ 198 | Get the SSH authorized key authentication. 199 | 200 | Returns: 201 | t.Optional[paramiko.PKey]: The SSH authorized key, if set. 202 | """ 203 | return self.settings.authorized_key 204 | 205 | @authorized_key.setter 206 | def authorized_key(self, value: t.Optional[paramiko.PKey]): 207 | """ 208 | Set the SSH authorized key for authentication. 209 | 210 | Args: 211 | value (t.Optional[paramiko.PKey]): The new SSH authorized key string. 212 | """ 213 | self.settings.authorized_key = value 214 | 215 | def _hello_response(self) -> str: 216 | """ 217 | Return a hello response based on NETCONF version. 218 | 219 | Returns: 220 | str: The XML hello response. 221 | """ 222 | response = f""" 223 | 224 | 225 | urn:ietf:params:netconf:base:{self._base_version.value}""" 226 | 227 | # Add additional capabilities 228 | for capability in self.capabilities: 229 | response += f""" 230 | {capability}""" 231 | 232 | response += f""" 233 | 234 | {NetconfServer.SESSION_ID} 235 | """ 236 | 237 | return response.strip("\n") 238 | 239 | def was_called(self) -> bool: 240 | """ 241 | Check if any requests have been made to the server. 242 | 243 | Returns: 244 | bool: True if at least one request was made, False otherwise. 245 | """ 246 | return len(self._call_history) > 0 247 | 248 | def get_call_count(self) -> int: 249 | """ 250 | Get the total number of requests made to the server. 251 | 252 | Returns: 253 | int: The number of requests made. 254 | """ 255 | return len(self._call_history) 256 | 257 | def start(self) -> None: 258 | """ 259 | Start the mock NETCONF server. 260 | 261 | Raises: 262 | OSError: If the server fails to bind to the specified port. 263 | """ 264 | if self.running: 265 | logger.warning("server is already running") 266 | return 267 | 268 | self.running = True 269 | self._hello_sent = False # reset in case of restart 270 | self._bind_socket() 271 | self._thread = threading.Thread(target=self._run) 272 | self._thread.start() 273 | time.sleep(1) # Give the server a moment to start 274 | 275 | def stop(self) -> None: 276 | """Stop the NETCONF server.""" 277 | if not self.running: 278 | logger.warning("server is already stopped") 279 | return 280 | 281 | self.running = False 282 | if self._client_socket: 283 | self._client_socket.close() 284 | if self._server_socket: 285 | try: 286 | self._server_socket.shutdown(socket.SHUT_RDWR) 287 | except OSError: # pragma: no cover 288 | pass # already shutdown 289 | self._server_socket.close() 290 | if self._thread: 291 | self._thread.join() 292 | 293 | def _bind_socket(self) -> None: 294 | """ 295 | Bind the server socket to the specified host and port. 296 | 297 | Raises: 298 | OSError: If the server fails to bind to the specified port. 299 | """ 300 | for _ in range(5): # Retry up to 5 times 301 | try: 302 | self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 303 | self._server_socket.setsockopt( 304 | socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 305 | ) 306 | self._server_socket.bind((self.settings.host, self.settings.port)) 307 | self._server_socket.listen(1) 308 | return 309 | except OSError as e: 310 | if e.errno == 48: # Address already in use 311 | logger.warning("port %d in use, retrying...", self.port) 312 | time.sleep(1) 313 | else: 314 | raise e 315 | raise OSError( 316 | f"could not bind to port {self.port}", 317 | ) 318 | 319 | def _run(self) -> None: 320 | """Run the server to accept connections and process requests.""" 321 | channel = None 322 | transport = None 323 | try: 324 | self._client_socket, _ = self._server_socket.accept() 325 | transport = paramiko.Transport(self._client_socket) 326 | transport.add_server_key(paramiko.RSAKey.generate(2048)) 327 | server = SSHServer(self.settings) 328 | transport.start_server(server=server) 329 | channel = transport.accept(20) 330 | 331 | if channel is None: 332 | logger.error("channel was not created") 333 | return 334 | 335 | # Wait for the subsystem request 336 | server.event.wait(10) 337 | 338 | if server.event.is_set(): 339 | self._handle_requests(channel) 340 | 341 | except (ConnectionAbortedError, OSError): 342 | if not self.running: 343 | return # expected during shutdown 344 | raise # unexpected # pragma: no cover 345 | finally: 346 | if channel: 347 | try: 348 | channel.close() 349 | except EOFError: # pragma: no cover 350 | pass 351 | if transport: 352 | transport.close() 353 | 354 | def _handle_requests(self, channel: paramiko.Channel) -> None: 355 | """ 356 | Handle incoming requests on the channel. 357 | 358 | Args: 359 | channel (paramiko.Channel): The communication channel with the client. 360 | """ 361 | buffer = bytearray() 362 | while self.running: 363 | try: 364 | # Send hello 365 | if not self._hello_sent: 366 | channel.sendall( 367 | NetconfServer.BASE_10_TEMPLATE.format( 368 | content=self._hello_response() 369 | ).encode() 370 | ) 371 | self._hello_sent = True 372 | 373 | data = channel.recv(4096) 374 | if not data: 375 | break 376 | buffer.extend(data) 377 | logger.debug("received data: %s", data.decode()) 378 | while True: 379 | processed = self._process_buffer(buffer, channel) 380 | if not processed: 381 | break 382 | except UnexpectedRequestError as e: 383 | logger.error("unexpected request error: %s", e) 384 | except Exception as e: 385 | msg = "failed to handle request: %s" 386 | logger.error(msg, e) 387 | logger.exception(e) 388 | raise RequestError(msg % e) 389 | 390 | def _process_buffer(self, buffer: bytearray, channel: paramiko.Channel) -> bool: 391 | """ 392 | Process the buffered data to extract requests and send responses. 393 | 394 | Args: 395 | buffer (bytearray): The current buffer containing request data. 396 | channel (paramiko.Channel): The communication channel with the client. 397 | 398 | Returns: 399 | bool: True if a complete request was processed, else False. 400 | 401 | Raises: 402 | UnexpectedRequestError: when the request has no defined response. 403 | """ 404 | # Handle client hello 405 | if b"hello" in buffer and b"capabilities" in buffer: 406 | logger.info("received client hello") 407 | del buffer[ 408 | : buffer.index(NetconfServer.BASE_10_DELIMETER) 409 | + len(NetconfServer.BASE_10_DELIMETER) 410 | ] 411 | return True 412 | 413 | # Handle NETCONF v1.0 414 | elif ( 415 | self._base_version is NetconfBaseVersion.BASE_10 416 | and NetconfServer.BASE_10_DELIMETER in buffer 417 | ): 418 | request_end_index = buffer.index(NetconfServer.BASE_10_DELIMETER) 419 | request = buffer[:request_end_index].decode() 420 | del buffer[: request_end_index + len(NetconfServer.BASE_10_DELIMETER)] 421 | logger.debug("processed request: %s", request) 422 | 423 | # Handle NETCONF v1.1 424 | elif ( 425 | self._base_version is NetconfBaseVersion.BASE_11 426 | and NetconfServer.BASE_11_DELIMETER in buffer 427 | ): 428 | try: 429 | buffer_str = buffer.decode() 430 | length, request_content = self._extract_base11_content_and_length( 431 | buffer_str 432 | ) 433 | logger.debug( 434 | "extracted content length=%d content: %s", length, request_content 435 | ) 436 | except ValueError as e: 437 | logger.error("parse error: %s", e) 438 | return False # Wait for more data if parsing fails 439 | 440 | request = request_content 441 | request_len = len( 442 | NetconfServer.BASE_11_TEMPLATE.format(length=length, content=request) 443 | ) 444 | del buffer[:request_len] 445 | else: 446 | logger.debug("waiting for more data...") 447 | return False # Wait for more data 448 | 449 | self._send_response(request, channel) 450 | logger.debug("buffer after processing: %s", buffer) 451 | return True 452 | 453 | def _extract_base11_content_and_length(self, buffer_str: str) -> t.Tuple[int, str]: 454 | """ 455 | Extract the base 1.1 length value and content from string.. 456 | 457 | Args: 458 | buffer_str (str): The input buffer string. 459 | 460 | Returns: 461 | t.Tuple[int, str]: The length value and the extracted content. 462 | 463 | Raises: 464 | ValueError: When length value cannot be parsed or is invalid. 465 | """ 466 | 467 | if m := re.search(NetconfServer.BASE_11_PATTERN, buffer_str, flags=re.DOTALL): 468 | length = int(m.group(1)) 469 | content = m.group(2) 470 | 471 | if len(content) != length: 472 | raise ValueError( 473 | f"received invalid chunk size expected={len(content)} received={length}", 474 | ) 475 | 476 | return length, content 477 | 478 | raise ValueError(f"Invalid content or chunk size format") 479 | 480 | def _extract_message_id(self, request: str) -> str: 481 | """ 482 | Extract the message-id from an XML request. 483 | 484 | Args: 485 | request (str): The XML request string. 486 | 487 | Returns: 488 | str: The extracted message-id, or 'unknown' if parsing fails. 489 | """ 490 | try: 491 | root = ET.fromstring(request) 492 | return root.get("message-id", "unknown") 493 | except ET.ParseError as e: 494 | logger.error("failed to parse XML request: %s", e) 495 | return "unknown" 496 | 497 | def _get_response(self, request: str) -> t.Optional[str]: 498 | """ 499 | Get the appropriate response for a given request. 500 | 501 | Args: 502 | request (str): The request string to match against defined responses. 503 | 504 | Returns: 505 | t.Optional[str]: The matched response string, or None if no match is found. 506 | """ 507 | 508 | for pattern, response in self.responses: 509 | formatted_pattern = pattern.format( 510 | message_id=self._extract_message_id(request), 511 | session_id=NetconfServer.SESSION_ID, 512 | ) 513 | 514 | # Check for exact match or regex match 515 | if (formatted_pattern == request) or re.search( 516 | formatted_pattern, request, flags=re.DOTALL 517 | ): 518 | return re.sub(r"^\s+|\s+$", "", response) 519 | 520 | return None 521 | 522 | def _send_response(self, request: str, channel: paramiko.Channel) -> None: 523 | """ 524 | Send a response to the client based on the request and protocol version. 525 | 526 | Args: 527 | request (str): The client's request. 528 | channel (paramiko.Channel): The communication channel with the client. 529 | 530 | Raises: 531 | UnexpectedRequestError: when the request has no defined response. 532 | """ 533 | message_id = self._extract_message_id(request) 534 | response = self._get_response(request) 535 | 536 | def _fmt_response(_res: str) -> str: 537 | """Helper to format response depending on base version.""" 538 | return ( 539 | NetconfServer.BASE_10_TEMPLATE.format(content=_res.strip("\n")) 540 | if self._base_version is NetconfBaseVersion.BASE_10 541 | else NetconfServer.BASE_11_TEMPLATE.format( 542 | length=len(_res.strip("\n")), content=_res.strip("\n") 543 | ) 544 | ) 545 | 546 | def _send_and_record(_response: str) -> None: 547 | """Send response and record the call.""" 548 | formatted = _fmt_response(_response) 549 | channel.sendall(formatted.encode()) 550 | 551 | call_record = CallRecord( 552 | request=request, 553 | response=_response, 554 | message_id=message_id, 555 | ) 556 | self._call_history.append(call_record) 557 | 558 | if "close-session" in request: 559 | channel.sendall( 560 | _fmt_response(RPC_REPLY_OK.format(message_id=message_id)).encode() 561 | ) 562 | 563 | elif response: 564 | _send_and_record(response.format(message_id=message_id)) 565 | else: 566 | error_response = RPC_REPLY_ERROR.format( 567 | type="rpc", 568 | message_id=message_id, 569 | tag="operation-failed", 570 | message="pytest-netconf: requested rpc is unknown and has no response defined", 571 | ) 572 | _send_and_record(error_response) 573 | raise UnexpectedRequestError( 574 | f"Received request which has no response defined: {request}" 575 | ) 576 | 577 | def expect_request( 578 | self, request_pattern: t.Union[str, t.Pattern[str]] 579 | ) -> "NetconfServer.RequestHandler": 580 | """ 581 | Handle expected requests. 582 | 583 | Args: 584 | request_pattern (t.Union[str, Pattern[str]]): The expected request pattern. 585 | 586 | Returns: 587 | NetconfServer.RequestHandler: A RequestHandler to set the response for the request. 588 | """ 589 | return self.RequestHandler(self, request_pattern) 590 | 591 | class RequestHandler: 592 | """Helper class to set responses for expected requests and track calls.""" 593 | 594 | def __init__( 595 | self, server: "NetconfServer", request_pattern: t.Union[str, t.Pattern[str]] 596 | ): 597 | """ 598 | Initialize the RequestHandler. 599 | 600 | Args: 601 | server (NetconfServer): The server instance to set the response on. 602 | request_pattern (t.Union[str, Pattern[str]]): The expected request pattern. 603 | """ 604 | self.server = server 605 | self._request_pattern = request_pattern 606 | 607 | def respond_with(self, response: str) -> "NetconfServer.RequestHandler": 608 | """ 609 | Set the response for the specified request pattern. 610 | 611 | Args: 612 | response (str): The response to associate with the request pattern. 613 | 614 | Returns: 615 | NetconfServer.RequestHandler: The current instance for chaining. 616 | """ 617 | self.server.responses.append((self._request_pattern, response)) 618 | return self 619 | 620 | def was_called(self) -> bool: 621 | """ 622 | Check if any requests matching this handler's pattern have been made. 623 | 624 | Returns: 625 | bool: True if at least one matching request was made, False otherwise. 626 | """ 627 | return self.get_call_count() > 0 628 | 629 | def get_call_count(self) -> int: 630 | """ 631 | Get the number of requests that matched this handler's pattern. 632 | 633 | Returns: 634 | int: The number of matching requests made. 635 | """ 636 | count = 0 637 | for call_record in self.server._call_history: 638 | if self._request_matches_pattern(call_record.request): 639 | count += 1 640 | return count 641 | 642 | def _request_matches_pattern(self, request: str) -> bool: 643 | """ 644 | Check if a request matches this handler's pattern. 645 | 646 | Args: 647 | request (str): The request to check. 648 | 649 | Returns: 650 | bool: True if the request matches the pattern, False otherwise. 651 | """ 652 | formatted_pattern = self._request_pattern.format( 653 | message_id=self.server._extract_message_id(request), 654 | session_id=self.server.SESSION_ID, 655 | ) 656 | 657 | # Check for exact match or regex match 658 | return (formatted_pattern == request) or bool( 659 | re.search(formatted_pattern, request, flags=re.DOTALL) 660 | ) 661 | --------------------------------------------------------------------------------