├── .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 | 
4 | [](https://codecov.io/gh/nomios-opensource/pytest-netconf)
5 | 
6 | 
7 | 
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 |
--------------------------------------------------------------------------------