├── .github └── workflows │ └── release.yml ├── .gitignore ├── .travis.yml ├── AUTHORS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── redfish_protocol_validator ├── __init__.py ├── accounts.py ├── console_scripts.py ├── constants.py ├── protocol_details.py ├── redfish_logo.py ├── report.py ├── resources.py ├── security_details.py ├── service_details.py ├── service_requests.py ├── service_responses.py ├── sessions.py ├── system_under_test.py └── utils.py ├── requirements.txt ├── rf_protocol_validator.py ├── setup.py ├── test_conf.json ├── tox.ini └── unittests ├── __init__.py ├── test_accounts.py ├── test_constants.py ├── test_protocol_details.py ├── test_report.py ├── test_resources.py ├── test_security_details.py ├── test_service_details.py ├── test_service_requests.py ├── test_service_responses.py ├── test_sessions.py ├── test_sut.py ├── test_utils.py └── utils.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release and Publish 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: 'Version number' 7 | required: true 8 | changes_1: 9 | description: 'Change entry' 10 | required: true 11 | changes_2: 12 | description: 'Change entry' 13 | required: false 14 | changes_3: 15 | description: 'Change entry' 16 | required: false 17 | changes_4: 18 | description: 'Change entry' 19 | required: false 20 | changes_5: 21 | description: 'Change entry' 22 | required: false 23 | changes_6: 24 | description: 'Change entry' 25 | required: false 26 | changes_7: 27 | description: 'Change entry' 28 | required: false 29 | changes_8: 30 | description: 'Change entry' 31 | required: false 32 | jobs: 33 | release_build: 34 | name: Build the release 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | with: 39 | token: ${{secrets.GITHUB_TOKEN}} 40 | - name: Build the changelog text 41 | run: | 42 | echo 'CHANGES<> $GITHUB_ENV 43 | echo "## [${{github.event.inputs.version}}] - $(date +'%Y-%m-%d')" >> $GITHUB_ENV 44 | echo "- ${{github.event.inputs.changes_1}}" >> $GITHUB_ENV 45 | if [[ -n "${{github.event.inputs.changes_2}}" ]]; then echo "- ${{github.event.inputs.changes_2}}" >> $GITHUB_ENV; fi 46 | if [[ -n "${{github.event.inputs.changes_3}}" ]]; then echo "- ${{github.event.inputs.changes_3}}" >> $GITHUB_ENV; fi 47 | if [[ -n "${{github.event.inputs.changes_4}}" ]]; then echo "- ${{github.event.inputs.changes_4}}" >> $GITHUB_ENV; fi 48 | if [[ -n "${{github.event.inputs.changes_5}}" ]]; then echo "- ${{github.event.inputs.changes_5}}" >> $GITHUB_ENV; fi 49 | if [[ -n "${{github.event.inputs.changes_6}}" ]]; then echo "- ${{github.event.inputs.changes_6}}" >> $GITHUB_ENV; fi 50 | if [[ -n "${{github.event.inputs.changes_7}}" ]]; then echo "- ${{github.event.inputs.changes_7}}" >> $GITHUB_ENV; fi 51 | if [[ -n "${{github.event.inputs.changes_8}}" ]]; then echo "- ${{github.event.inputs.changes_8}}" >> $GITHUB_ENV; fi 52 | echo "" >> $GITHUB_ENV 53 | echo 'EOF' >> $GITHUB_ENV 54 | - name: Update version numbers 55 | run: | 56 | sed -i -E 's/tool_version = .+/tool_version = '\'${{github.event.inputs.version}}\''/' redfish_protocol_validator/console_scripts.py 57 | sed -i -E 's/ version=.+,/ version="'${{github.event.inputs.version}}'",/' setup.py 58 | - name: Update the changelog 59 | run: | 60 | ex CHANGELOG.md <" 70 | git add CHANGELOG.md setup.py redfish_protocol_validator/console_scripts.py 71 | git commit -s -m "${{github.event.inputs.version}} versioning" 72 | git push origin main 73 | - name: Make the release 74 | env: 75 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 76 | run: | 77 | gh release create ${{github.event.inputs.version}} -t ${{github.event.inputs.version}} -n "Changes since last release:"$'\n\n'"$CHANGES" 78 | - name: Set up Python 79 | uses: actions/setup-python@v2 80 | with: 81 | python-version: '3.x' 82 | - name: Install dependencies 83 | run: | 84 | python -m pip install --upgrade pip 85 | pip install setuptools wheel twine 86 | - name: Build the distribution 87 | run: | 88 | python setup.py sdist bdist_wheel 89 | - name: Upload to pypi 90 | uses: pypa/gh-action-pypi-publish@release/v1 91 | with: 92 | password: ${{ secrets.PYPI_API_TOKEN }} 93 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cover 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Protocol validator reports 56 | reports/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 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 | # celery beat schedule file 99 | celerybeat-schedule 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: focal 2 | language: python 3 | cache: 4 | - pip 5 | python: 6 | - '3.7' 7 | - '3.8' 8 | - '3.9' 9 | before_install: 10 | - pip install -U pip 11 | - pip install -U setuptools 12 | - pip install -U wheel 13 | install: 14 | - pip install tox-travis 15 | script: 16 | - tox 17 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Original Contribution: 2 | 3 | * [Bill Dodd](https://github.com/billdodd) - Majec Systems 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.2.6] - 2024-07-19 4 | - Added common entry points to invoking HTTP operations to gracefully handle exception cases 5 | - Updated logic when walking the data model to better handle cases where services do not correctly follow schema definitions 6 | 7 | ## [1.2.5] - 2024-05-03 8 | - Updated existing tests to align with version 1.20.0 of the Redfish Specification 9 | - Added methods to gracefully handle invalid JSON responses 10 | 11 | ## [1.2.4] - 2024-04-19 12 | - Added conditional checking of the 'WWW-Authenticate' header based on the value of the 'HTTPBasicAuth' property 13 | 14 | ## [1.2.3] - 2024-02-03 15 | - Relaxed versions of required modules to not be as strict 16 | 17 | ## [1.2.2] - 2024-01-19 18 | - Several fixes to 'password change required' testing to ensure 'system under test' parameters are passed correctly 19 | 20 | ## [1.2.1] - 2024-01-08 21 | - Removed restrictions on urllib3 versions 22 | 23 | ## [1.2.0] - 2023-10-27 24 | - Version change to fix release assets; no functional changes 25 | 26 | ## [1.1.9] - 2023-09-15 27 | - Added method to poll tasks for tests using PATCH, POST, and DELETE 28 | 29 | ## [1.1.8] - 2023-06-23 30 | - Cleanup of TODO notes throughout the tool 31 | 32 | ## [1.1.7] - 2023-06-16 33 | - Improved password generation for test accounts to inspect min and max password length requirements 34 | - Added 'Allow' header inspection for 'REQ_POST_CREATE_NOT_SUPPORTED' to see if a warning should be used instead of a failure in case the test account creation fails unexpectedly 35 | 36 | ## [1.1.6] - 2023-02-10 37 | - Updated the expected pattern for the ST header in SSDP responses to allow for multi-digit minor versions 38 | 39 | ## [1.1.5] - 2023-01-27 40 | - Corrected the USN pattern for SSDP responses to allow for a multi-digit minor version 41 | 42 | ## [1.1.4] - 2022-11-18 43 | - Tagged the DELETE request to service root as UNSUPPORTED_REQ to better isolate DELETE testing from when it's expected to succeed 44 | 45 | ## [1.1.3] - 2022-11-07 46 | - Corrected the SSDP request format to add a missing CRLF 47 | - Extended the test for checking that and event subscription is deleted when an SSE stream is closed from 3 seconds to 60 seconds 48 | 49 | ## [1.1.2] - 2022-07-22 50 | - Minor updates to script packaging 51 | 52 | ## [1.1.1] - 2022-07-15 53 | - Modified ETag testing to not assume previous ETags are now invalid 54 | - Modified project for PyPI publication 55 | 56 | ## [1.1.0] - 2022-04-07 57 | - Enabled HTTP tracing when 'log-level' is set to 'DEBUG' 58 | - Added step to enable the newly created user account when testing the password change requirements 59 | 60 | ## [1.0.9] - 2021-10-15 61 | - Updated media type tests to skip POST responses that do not provide a response body 62 | 63 | ## [1.0.8] - 2021-10-08 64 | - Corrected HEAD tests to allow for the case where HEAD is not supported 65 | - Corrected PATCH mixed property test to allow for the service to reject the request entirely 66 | - Corrected error response checking for unsupported properties in the PATCH request to allow for the message to be outside of the extended info array 67 | 68 | ## [1.0.7] - 2021-09-17 69 | - Fixed the array truncate test to allow for 'null' padding in responses 70 | - Changed the minimum array size for several array tests from three to two 71 | 72 | ## [1.0.6] - 2021-08-13 73 | - Corrected expected status code for SEC_PRIV_OPERATION_TO_PRIV_MAPPING to be 403 or 404 74 | 75 | ## [1.0.5] - 2021-07-02 76 | - Changed HTTP method for checking Allow header presence on an HTTP 405 response from TRACE to DELETE 77 | - Changed event subscription tests to create subscriptions using IP addresses instead of network names 78 | - Removed message checks for unsupported query parameters 79 | 80 | ## [1.0.4] - 2021-04-23 81 | - Corrected socket connect() call to use hostname instead of netloc 82 | 83 | ## [1.0.3] - 2021-04-02 84 | - Made fix to testing an SSE connection is left open after a session is deleted 85 | 86 | ## [1.0.2] - 2021-02-20 87 | - Fixed bugs in account testing that would cause login failures 88 | - Added exception handling to user account management 89 | 90 | ## [1.0.1] - 2021-02-15 91 | - Added exception handling around redirect and SSE testing to ensure better error reporting 92 | 93 | ## [1.0.0] - 2020-11-06 94 | - Added remaining tests for service responses 95 | 96 | ## [0.9.7] - 2020-10-30 97 | - Added more testing for service responses 98 | 99 | ## [0.9.6] - 2020-10-19 100 | - Added support for integration with the Redfish Test Framework 101 | - Added more assertions for service request handling 102 | 103 | ## [0.9.5] - 2020-09-27 104 | - Added assertions for HTTP headers 105 | - Added assertions for query parameters 106 | - Added assertions for modification requests 107 | 108 | ## [0.9.0] - 2020-07-24 109 | - Initial release 110 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Overview 4 | 5 | This repository is maintained by the [DMTF](https://www.dmtf.org/ "https://www.dmtf.org/"). All contributions are reviewed and approved by members of the organization. 6 | 7 | ## Submitting Issues 8 | 9 | Bugs, feature requests, and questions are all submitted in the "Issues" section for the project. DMTF members are responsible for triaging and addressing issues. 10 | 11 | ## Contribution Process 12 | 13 | 1. Fork the repository. 14 | 2. Make and commit changes. 15 | 3. Make a pull request. 16 | 17 | All contributions must adhere to the BSD 3-Clause License described in the LICENSE.md file, and the [Developer Certificate of Origin](#developer-certificate-of-origin). 18 | 19 | Pull requests are reviewed and approved by DMTF members. 20 | 21 | ## Developer Certificate of Origin 22 | 23 | All contributions must adhere to the [Developer Certificate of Origin (DCO)](http://developercertificate.org "http://developercertificate.org"). 24 | 25 | The DCO is an attestation attached to every contribution made by every developer. In the commit message of the contribution, the developer adds a "Signed-off-by" statement and thereby agrees to the DCO. This can be added by using the `--signoff` parameter with `git commit`. 26 | 27 | Full text of the DCO: 28 | 29 | ``` 30 | Developer Certificate of Origin 31 | Version 1.1 32 | 33 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 34 | 35 | Everyone is permitted to copy and distribute verbatim copies of this 36 | license document, but changing it is not allowed. 37 | 38 | 39 | Developer's Certificate of Origin 1.1 40 | 41 | By making a contribution to this project, I certify that: 42 | 43 | (a) The contribution was created in whole or in part by me and I 44 | have the right to submit it under the open source license 45 | indicated in the file; or 46 | 47 | (b) The contribution is based upon previous work that, to the best 48 | of my knowledge, is covered under an appropriate open source 49 | license and I have the right under that license to submit that 50 | work with modifications, whether created in whole or in part 51 | by me, under the same open source license (unless I am 52 | permitted to submit under a different license), as indicated 53 | in the file; or 54 | 55 | (c) The contribution was provided directly to me by some other 56 | person who certified (a), (b) or (c) and I have not modified 57 | it. 58 | 59 | (d) I understand and agree that this project and the contribution 60 | are public and that a record of the contribution (including all 61 | personal information I submit with it, including my sign-off) is 62 | maintained indefinitely and may be redistributed consistent with 63 | this project or the open source license(s) involved. 64 | ``` 65 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019-2024, Contributing Member(s) of Distributed Management Task 4 | Force, Inc.. All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, 7 | are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation and/or 14 | other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its contributors 17 | may be used to endorse or promote products derived from this software without 18 | specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redfish Protocol Validator 2 | 3 | Copyright 2020-2022 DMTF. All rights reserved. 4 | 5 | ## About 6 | 7 | The Redfish Protocol Validator tests the HTTP protocol behavior of a Redfish service to validate that it conforms to the Redfish Specification. 8 | 9 | ## Installation 10 | 11 | From PyPI: 12 | 13 | pip install redfish_protocol_validator 14 | 15 | From GitHub: 16 | 17 | git clone https://github.com/DMTF/Redfish-Protocol-Validator.git 18 | cd Redfish-Protocol-Validator 19 | python setup.py sdist 20 | pip install dist/redfish_protocol_validator-x.x.x.tar.gz 21 | 22 | ## Requirements 23 | 24 | The Redfish Protocol Validator requires Python3. 25 | 26 | Required external packages: 27 | 28 | ``` 29 | aenum 30 | colorama 31 | pyasn1 32 | pyasn1-modules 33 | requests 34 | sseclient-py 35 | urllib3 36 | ``` 37 | 38 | If installing from GitHub, you may install the external packages by running: 39 | 40 | pip install -r requirements.txt 41 | 42 | ## Usage 43 | 44 | ``` 45 | usage: rf_protocol_validator.py [-h] [--version] --user USER --password 46 | PASSWORD --rhost RHOST [--log-level LOG_LEVEL] 47 | [--report-dir REPORT_DIR] 48 | [--report-type {html,tsv,both}] 49 | [--avoid-http-redirect] 50 | [--no-cert-check | --ca-bundle CA_BUNDLE] 51 | 52 | Validate the protocol conformance of a Redfish service 53 | 54 | required arguments: 55 | --user USER, -u USER the username for authentication 56 | --password PASSWORD, -p PASSWORD 57 | the password for authentication 58 | --rhost RHOST, -r RHOST 59 | address of the Redfish service (with scheme) 60 | 61 | optional arguments: 62 | -h, --help show this help message and exit 63 | --version show program's version number and exit 64 | --log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL} 65 | the logging level (default: WARNING) 66 | --report-dir REPORT_DIR 67 | the directory for generated report files (default: 68 | "reports") 69 | --report-type {html,tsv,both} 70 | the type of report to generate: html, tsv, or both 71 | (default: both) 72 | --avoid-http-redirect 73 | avoid attempts to generate HTTP redirects for services 74 | that do not support HTTP 75 | --no-cert-check disable verification of host SSL certificates 76 | --ca-bundle CA_BUNDLE 77 | the file or directory containing trusted CAs 78 | ``` 79 | 80 | Example: 81 | 82 | rf_protocol_validator -r https://192.168.1.100 -u USERNAME -p PASSWORD 83 | 84 | ## Unit Tests 85 | 86 | The Redfish Protocol Validator unit tests are executed using the `tox` package. 87 | 88 | You may install `tox` by running: 89 | 90 | pip install tox 91 | 92 | Running the unit tests: 93 | 94 | tox 95 | 96 | ## Release Process 97 | 98 | 1. Go to the "Actions" page 99 | 2. Select the "Release and Publish" workflow 100 | 3. Click "Run workflow" 101 | 4. Fill out the form 102 | 5. Click "Run workflow" 103 | -------------------------------------------------------------------------------- /redfish_protocol_validator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMTF/Redfish-Protocol-Validator/e2f28669e4e4cc7dff1634e69a160c779e6752df/redfish_protocol_validator/__init__.py -------------------------------------------------------------------------------- /redfish_protocol_validator/accounts.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import logging 7 | import random 8 | import string 9 | from urllib.parse import urlparse 10 | 11 | import requests 12 | 13 | from redfish_protocol_validator import utils 14 | from redfish_protocol_validator.constants import RequestType, ResourceType 15 | from redfish_protocol_validator.system_under_test import SystemUnderTest 16 | 17 | 18 | def get_user_names(sut: SystemUnderTest, session, 19 | request_type=RequestType.NORMAL): 20 | users = set() 21 | response = sut.get_response('GET', sut.accounts_uri) 22 | if response.status_code == requests.codes.OK: 23 | data = utils.get_response_json(response) 24 | uris = [m.get('@odata.id') for m in data.get('Members', []) if 25 | m.get('@odata.id')] 26 | responses = sut.get_responses_by_method( 27 | 'GET', resource_type=ResourceType.MANAGER_ACCOUNT) 28 | for uri in uris: 29 | if uri in responses: 30 | response = responses[uri] 31 | else: 32 | response = sut.get(uri, session=session) 33 | sut.add_response(uri, response, 34 | resource_type=ResourceType.MANAGER_ACCOUNT, 35 | request_type=request_type) 36 | if response.status_code == requests.codes.OK: 37 | data = utils.get_response_json(response) 38 | user = data.get('UserName') 39 | if user: 40 | users.add(user) 41 | logging.debug('Account usernames: %s' % users) 42 | return users 43 | 44 | 45 | def get_available_roles(sut: SystemUnderTest, session, 46 | request_type=RequestType.NORMAL): 47 | roles = set() 48 | response = sut.get_response('GET', sut.roles_uri) 49 | if response.status_code == requests.codes.OK: 50 | data = utils.get_response_json(response) 51 | uris = [m.get('@odata.id') for m in data.get('Members', []) if 52 | m.get('@odata.id')] 53 | responses = sut.get_responses_by_method( 54 | 'GET', resource_type=ResourceType.ROLE) 55 | for uri in uris: 56 | if uri in responses: 57 | response = responses[uri] 58 | else: 59 | response = sut.get(uri, session=session) 60 | sut.add_response(uri, response, 61 | resource_type=ResourceType.ROLE, 62 | request_type=request_type) 63 | if response.status_code == requests.codes.OK: 64 | data = utils.get_response_json(response) 65 | role = data.get('Id') 66 | if role: 67 | roles.add(role) 68 | logging.debug('Available roles: %s' % roles) 69 | return roles 70 | 71 | 72 | def select_standard_role(sut: SystemUnderTest, session, 73 | request_type=RequestType.NORMAL): 74 | roles = get_available_roles(sut, session, request_type=request_type) 75 | role = None 76 | if 'ReadOnly' in roles: 77 | role = 'ReadOnly' 78 | if not role: 79 | logging.error('Predefined role "ReadOnly" not found') 80 | logging.debug('Role selected for account creation: %s' % role) 81 | return role 82 | 83 | 84 | def new_username(existing_users): 85 | while True: 86 | user = 'rfpv%04x' % random.randrange(2 ** 16) # ex: 'rfpvc91c' 87 | if user not in existing_users: 88 | break 89 | return user 90 | 91 | 92 | def new_password(sut: SystemUnderTest, length=16, upper=1, lower=1, 93 | digits=1, symbols=1): 94 | # Get the min and max password length and override 'length' if needed 95 | # Use either limit if one is specified 96 | response = sut.get_response('GET', sut.account_service_uri) 97 | try: 98 | if response.ok: 99 | data = utils.get_response_json(response) 100 | if 'MinPasswordLength' in data and length < data['MinPasswordLength']: 101 | length = data['MinPasswordLength'] 102 | elif 'MaxPasswordLength' in data and length > data['MaxPasswordLength']: 103 | length = data['MaxPasswordLength'] 104 | except: 105 | pass 106 | 107 | ascii_symbols = '_-.' 108 | pwd = random.sample(string.ascii_uppercase, upper) 109 | pwd.extend(random.sample(string.ascii_lowercase, lower)) 110 | pwd.extend(random.sample(string.digits, digits)) 111 | pwd.extend(random.sample(ascii_symbols, symbols)) 112 | pwd.extend(random.sample(string.ascii_letters, 113 | length - upper - lower - digits - symbols)) 114 | random.shuffle(pwd) 115 | pwd = ''.join(pwd) 116 | sut.add_priv_info(pwd) 117 | return pwd 118 | 119 | 120 | def find_empty_account_slot(sut: SystemUnderTest, session, 121 | request_type=RequestType.NORMAL): 122 | response = sut.get_response('GET', sut.accounts_uri) 123 | data = utils.get_response_json(response) 124 | uris = [m.get('@odata.id') for m in data.get('Members', []) if 125 | m.get('@odata.id')] 126 | responses = sut.get_responses_by_method( 127 | 'GET', resource_type=ResourceType.MANAGER_ACCOUNT) 128 | if uris: 129 | # first slot may be reserved, so move to end of list 130 | uris += [uris.pop(0)] 131 | for uri in uris: 132 | if uri in responses: 133 | response = responses[uri] 134 | else: 135 | response = sut.get(uri, session=session) 136 | sut.add_response(uri, response, 137 | resource_type=ResourceType.MANAGER_ACCOUNT, 138 | request_type=request_type) 139 | if response.status_code == requests.codes.OK: 140 | data = utils.get_response_json(response) 141 | if data.get('UserName') == '' and not data.get('Enabled', True): 142 | return uri 143 | return None 144 | 145 | 146 | def add_account_via_patch(sut: SystemUnderTest, session, user, role, password, 147 | request_type=RequestType.NORMAL): 148 | uri = find_empty_account_slot(sut, session, request_type=request_type) 149 | if not uri: 150 | logging.error('No empty account slot found to create new account') 151 | return None, None, None 152 | payload = {'UserName': user, 153 | 'Password': password} 154 | if role: 155 | payload['RoleId'] = role 156 | headers = utils.get_etag_header(sut, session, uri) 157 | response = sut.patch(uri, json=payload, headers=headers, session=session) 158 | sut.add_response(uri, response, resource_type=ResourceType.MANAGER_ACCOUNT, 159 | request_type=request_type) 160 | success = response.status_code == requests.codes.OK 161 | if success: 162 | response = sut.get(uri, session=session) 163 | if response.ok: 164 | # Enable the account if it not already enabled 165 | data = utils.get_response_json(response) 166 | if 'Enabled' in data and data['Enabled'] is False: 167 | headers = utils.get_etag_header(sut, session, uri) 168 | payload = {'Enabled': True} 169 | sut.patch(uri, json=payload, headers=headers, session=session) 170 | sut.add_response(uri, response, 171 | resource_type=ResourceType.MANAGER_ACCOUNT, 172 | request_type=request_type) 173 | else: 174 | uri = None 175 | return user, password, uri 176 | 177 | 178 | def add_account(sut: SystemUnderTest, session, 179 | request_type=RequestType.NORMAL): 180 | if not sut.accounts_uri: 181 | logging.error('No accounts collection found') 182 | return None, None, None 183 | response = sut.get_response('GET', sut.accounts_uri) 184 | if response.status_code != requests.codes.OK: 185 | logging.error('Accounts collection could not be read') 186 | return None, None, None 187 | 188 | users = get_user_names(sut, session, request_type=request_type) 189 | user = new_username(users) 190 | role = select_standard_role(sut, session) 191 | password = new_password(sut) 192 | 193 | headers = response.headers 194 | methods = [] 195 | if 'Allow' in headers: 196 | methods = [m.strip() for m in headers.get('Allow').split(',')] 197 | 198 | payload = {'UserName': user, 'Password': password} 199 | if role: 200 | payload['RoleId'] = role 201 | response = sut.post(sut.accounts_uri, json=payload, session=session) 202 | sut.add_response(sut.accounts_uri, response, request_type=request_type) 203 | 204 | new_acct_uri = None 205 | success = response.status_code == requests.codes.CREATED 206 | if success: 207 | location = response.headers.get('Location') 208 | if location: 209 | new_acct_uri = urlparse(location).path 210 | else: 211 | new_acct_uri = utils.get_response_json(response).get('@odata.id') 212 | response = sut.get(new_acct_uri, session=session) 213 | sut.add_response(new_acct_uri, response, 214 | resource_type=ResourceType.MANAGER_ACCOUNT, 215 | request_type=request_type) 216 | elif (response.status_code == requests.codes.METHOD_NOT_ALLOWED 217 | or 'POST' not in methods): 218 | return add_account_via_patch(sut, session, user, role, password, 219 | request_type=request_type) 220 | return user, password, new_acct_uri 221 | 222 | 223 | def patch_account(sut: SystemUnderTest, session, acct_uri, 224 | request_type=RequestType.NORMAL): 225 | 226 | if request_type == RequestType.NORMAL: 227 | # patch several props, mix of updatable and non-updatable 228 | pwd = new_password(sut) 229 | payload = {'Password': pwd, 'BogusProp': 'foo'} 230 | headers = utils.get_etag_header(sut, session, acct_uri) 231 | response = sut.patch(acct_uri, json=payload, headers=headers, 232 | session=session) 233 | if response.ok: 234 | new_pwd = pwd 235 | sut.add_response(acct_uri, response, 236 | resource_type=ResourceType.MANAGER_ACCOUNT, 237 | request_type=RequestType.PATCH_MIXED_PROPS) 238 | # patch a single property that can never be updated 239 | payload = {'BogusProp': 'foo'} 240 | headers = utils.get_etag_header(sut, session, acct_uri) 241 | response = sut.patch(acct_uri, json=payload, headers=headers, 242 | session=session) 243 | sut.add_response(acct_uri, response, 244 | resource_type=ResourceType.MANAGER_ACCOUNT, 245 | request_type=RequestType.PATCH_BAD_PROP) 246 | # patch only OData annotations 247 | odata_id = (acct_uri.rstrip('/') if acct_uri.endswith('/') 248 | else acct_uri + '/') 249 | payload = {'@odata.id': odata_id} 250 | headers = utils.get_etag_header(sut, session, acct_uri) 251 | response = sut.patch(acct_uri, json=payload, headers=headers, 252 | session=session) 253 | sut.add_response(acct_uri, response, 254 | resource_type=ResourceType.MANAGER_ACCOUNT, 255 | request_type=RequestType.PATCH_ODATA_PROPS) 256 | 257 | new_pwd = None 258 | # patch with proper ETag 259 | pwd = new_password(sut) 260 | payload = {'Password': pwd} 261 | headers = utils.get_etag_header(sut, session, acct_uri) 262 | response = sut.patch(acct_uri, json=payload, headers=headers, 263 | session=session) 264 | if response.ok: 265 | new_pwd = pwd 266 | sut.add_response(acct_uri, response, 267 | resource_type=ResourceType.MANAGER_ACCOUNT, 268 | request_type=request_type) 269 | if request_type == RequestType.NORMAL and 'If-Match' in headers: 270 | # patch with invalid ETag, which should fail 271 | pwd = new_password(sut) 272 | payload = {'Password': pwd} 273 | new_headers = utils.get_etag_header(sut, session, acct_uri) 274 | bad_headers = {'If-Match': new_headers['If-Match'] + 'foobar'} 275 | r = sut.patch(acct_uri, json=payload, headers=bad_headers, 276 | session=session) 277 | if r.ok: 278 | new_pwd = pwd 279 | sut.add_response(acct_uri, r, 280 | resource_type=ResourceType.MANAGER_ACCOUNT, 281 | request_type=RequestType.BAD_ETAG) 282 | return new_pwd 283 | 284 | 285 | def delete_account_via_patch(sut: SystemUnderTest, session, user, acct_uri, 286 | request_type=RequestType.NORMAL): 287 | response = sut.get_response('GET', acct_uri) 288 | if response.status_code == requests.codes.OK: 289 | data = utils.get_response_json(response) 290 | if data and data.get('UserName') == user: 291 | payload = {'UserName': ''} 292 | if data.get('Enabled', False): 293 | payload['Enabled'] = False 294 | headers = utils.get_etag_header(sut, session, acct_uri) 295 | response = sut.patch(acct_uri, json=payload, headers=headers, 296 | session=session) 297 | sut.add_response(acct_uri, response, 298 | resource_type=ResourceType.MANAGER_ACCOUNT, 299 | request_type=request_type) 300 | else: 301 | logging.error('Delete account via PATCH skipped; username %s ' 302 | 'did not match expected username %s' % 303 | (data.get('UserName'), user)) 304 | else: 305 | logging.error('Delete account via PATCH skipped; could not read ' 306 | 'account uri %s' % acct_uri) 307 | 308 | 309 | def delete_account(sut: SystemUnderTest, session, user, acct_uri, 310 | request_type=RequestType.NORMAL): 311 | response = sut.get_response('GET', acct_uri) 312 | headers = response.headers 313 | if 'Allow' in headers: 314 | methods = [m.strip() for m in headers.get('Allow').split(',')] 315 | if 'DELETE' not in methods: 316 | # if Allow header present and DELETE not listed, delete via PATCH 317 | delete_account_via_patch(sut, session, user, acct_uri, 318 | request_type=request_type) 319 | return 320 | response = sut.delete(acct_uri, session=session) 321 | sut.add_response(acct_uri, response, 322 | resource_type=ResourceType.MANAGER_ACCOUNT, 323 | request_type=request_type) 324 | if response.status_code == requests.codes.METHOD_NOT_ALLOWED: 325 | delete_account_via_patch(sut, session, user, acct_uri, 326 | request_type=request_type) 327 | 328 | 329 | def password_change_required(sut: SystemUnderTest, session, user, password, 330 | uri, data, etag): 331 | if 'PasswordChangeRequired' not in data: 332 | return 333 | # set PasswordChangeRequired if not already set or if the account needs to be enabled 334 | account_enabled = data.get('Enabled', True) 335 | if data['PasswordChangeRequired'] is False or not account_enabled: 336 | payload = { 337 | 'PasswordChangeRequired': True 338 | } 339 | if not account_enabled: 340 | payload['Enabled'] = True 341 | headers = {'If-Match': etag} if etag else {} 342 | response = sut.patch(uri, json=payload, headers=headers, 343 | session=session) 344 | sut.add_response(uri, response, 345 | resource_type=ResourceType.MANAGER_ACCOUNT) 346 | if not response.ok: 347 | return 348 | # create session as new user 349 | payload = { 350 | 'UserName': user, 351 | 'Password': password 352 | } 353 | headers = { 354 | 'OData-Version': '4.0' 355 | } 356 | response = sut.post(sut.sessions_uri, json=payload, 357 | headers=headers, no_session=True) 358 | sut.add_response(sut.sessions_uri, response, 359 | request_type=RequestType.PWD_CHANGE_REQUIRED) 360 | # GET the account 361 | response = sut.get(uri, auth=(user, password), headers=headers, no_session=True) 362 | etag = utils.get_response_etag(response) 363 | sut.add_response(uri, response, resource_type=ResourceType.MANAGER_ACCOUNT, 364 | request_type=RequestType.PWD_CHANGE_REQUIRED) 365 | # try to get protected resource 366 | response = sut.get(sut.sessions_uri, auth=(user, password), headers=headers, 367 | no_session=True) 368 | sut.add_response(sut.sessions_uri, response, 369 | request_type=RequestType.PWD_CHANGE_REQUIRED) 370 | # change password 371 | payload = {'Password': new_password(sut)} 372 | if etag: 373 | headers['If-Match'] = etag 374 | response = sut.patch(uri, json=payload, headers=headers, 375 | no_session=True, auth=(user, password)) 376 | sut.add_response(uri, response, 377 | resource_type=ResourceType.MANAGER_ACCOUNT, 378 | request_type=RequestType.PWD_CHANGE_REQUIRED) 379 | -------------------------------------------------------------------------------- /redfish_protocol_validator/console_scripts.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import argparse 7 | import logging 8 | import sys 9 | from datetime import datetime 10 | from pathlib import Path 11 | 12 | import requests 13 | from urllib3.exceptions import InsecureRequestWarning 14 | from http.client import HTTPConnection 15 | 16 | from redfish_protocol_validator import protocol_details 17 | from redfish_protocol_validator import report 18 | from redfish_protocol_validator import resources 19 | from redfish_protocol_validator import security_details 20 | from redfish_protocol_validator import service_details 21 | from redfish_protocol_validator import service_requests 22 | from redfish_protocol_validator import service_responses 23 | from redfish_protocol_validator import sessions 24 | from redfish_protocol_validator import utils 25 | from redfish_protocol_validator.constants import Result 26 | from redfish_protocol_validator.system_under_test import SystemUnderTest 27 | 28 | tool_version = '1.2.6' 29 | 30 | 31 | def perform_tests(sut: SystemUnderTest): 32 | """Perform the protocol validation tests on the resources.""" 33 | protocol_details.test_protocol_details(sut) 34 | service_requests.test_service_requests(sut) 35 | service_responses.test_service_responses(sut) 36 | service_details.test_service_details(sut) 37 | security_details.test_security_details(sut) 38 | 39 | 40 | def main(): 41 | parser = argparse.ArgumentParser( 42 | description='Validate the protocol conformance of a Redfish service') 43 | parser.add_argument('--version', action='version', 44 | version='Redfish-Protocol-Validator %s' % tool_version) 45 | parser.add_argument('--user', '-u', type=str, required=True, 46 | help='the username for authentication') 47 | parser.add_argument('--password', '-p', type=str, required=True, 48 | help='the password for authentication') 49 | parser.add_argument('--rhost', '-r', type=str, required=True, 50 | help='address of the Redfish service (with scheme)') 51 | parser.add_argument('--log-level', type=str, default='WARNING', 52 | help='the logging level (default: WARNING)', 53 | choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']) 54 | parser.add_argument('--report-dir', type=str, default='reports', 55 | help='the directory for generated report files ' 56 | '(default: "reports")') 57 | parser.add_argument('--report-type', choices=['html', 'tsv', 'both'], 58 | help='the type of report to generate: html, tsv, or ' 59 | 'both (default: both)', default='both') 60 | parser.add_argument('--avoid-http-redirect', action='store_true', 61 | help='avoid attempts to generate HTTP redirects for ' 62 | 'services that do not support HTTP') 63 | cert_g = parser.add_mutually_exclusive_group() 64 | cert_g.add_argument('--no-cert-check', action='store_true', 65 | help='disable verification of host SSL certificates') 66 | cert_g.add_argument('--ca-bundle', type=str, 67 | help='the file or directory containing trusted CAs') 68 | args = parser.parse_args() 69 | 70 | # set logging level 71 | log_level = getattr(logging, args.log_level.upper()) 72 | logging.basicConfig(level=log_level) 73 | if log_level == logging.DEBUG: 74 | HTTPConnection.debuglevel = 1 75 | 76 | # set up cert verify option 77 | verify = args.ca_bundle if args.ca_bundle else not args.no_cert_check 78 | if args.no_cert_check: 79 | requests.packages.urllib3.disable_warnings(InsecureRequestWarning) 80 | 81 | # create report directory if needed 82 | report_dir = Path(args.report_dir) 83 | if not report_dir.is_dir(): 84 | report_dir.mkdir(parents=True) 85 | 86 | sut = SystemUnderTest(args.rhost, args.user, args.password, verify=verify) 87 | sut.set_avoid_http_redirect(args.avoid_http_redirect) 88 | sut.login() 89 | resources.read_target_resources(sut, func=resources.get_default_resources) 90 | no_auth_session = sessions.no_auth_session(sut) 91 | resources.read_uris_no_auth(sut, no_auth_session) 92 | resources.data_modification_requests(sut) 93 | resources.data_modification_requests_no_auth(sut, no_auth_session) 94 | resources.unsupported_requests(sut) 95 | resources.basic_auth_requests(sut) 96 | resources.http_requests(sut) 97 | resources.bad_auth_requests(sut) 98 | sessions.bad_login(sut) 99 | perform_tests(sut) 100 | sut.logout() 101 | utils.print_summary(sut) 102 | current_time = datetime.now() 103 | print('Report output:') 104 | report.json_results(sut, report_dir, current_time, tool_version) 105 | if args.report_type in ('tsv', 'both'): 106 | print(report.tsv_report(sut, report_dir, current_time)) 107 | if args.report_type in ('html', 'both'): 108 | print(report.html_report(sut, report_dir, current_time, tool_version)) 109 | # exit with status 1 if any assertions failed, 0 otherwise 110 | sys.exit(int(sut.summary_count(Result.FAIL) > 0)) 111 | 112 | 113 | if __name__ == "__main__": 114 | main() 115 | -------------------------------------------------------------------------------- /redfish_protocol_validator/protocol_details.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import re 7 | import xml.etree.ElementTree as ET 8 | from urllib.parse import urlparse 9 | 10 | import requests 11 | 12 | from redfish_protocol_validator import utils 13 | from redfish_protocol_validator.constants import Assertion, ResourceType, RequestType, Result 14 | from redfish_protocol_validator.system_under_test import SystemUnderTest 15 | 16 | safe_chars_regex = re.compile( 17 | r"^([A-Za-z0-9!$&'()*+,\-./:;=@_]|%[A-Fa-f0-9]{2})*\Z") 18 | encoded_char_regex = re.compile(r"%[A-Fa-f0-9]{2}") 19 | 20 | # See RFC 7232, section 2.3 Etags - valid chars within the double quotes 21 | # of the opaque-tag can be 0x21, 0x23-0x7F, 0x80-0xFF 22 | etag_regex = re.compile(r'^(W/)?"[\x21\x23-\xFF]*"$') 23 | 24 | 25 | def split_path(uri): 26 | """ 27 | Split the URI into path[?query][#fragment] 28 | 29 | :param uri: the URI to split 30 | :return: tuple of path, query, fragment 31 | """ 32 | parsed = urlparse(uri) 33 | return parsed.path, parsed.query, parsed.fragment 34 | 35 | 36 | def safe_uri(uri): 37 | """ 38 | Determine if URI is safe (does not use RFC 1738 unsafe character) 39 | 40 | :param uri: URI to check 41 | :return: True if URI is safe, False otherwise 42 | """ 43 | path, query, frag = split_path(uri) 44 | safe = True 45 | for part in (path, query, frag): 46 | safe = safe and safe_chars_regex.search(part) 47 | return safe 48 | 49 | 50 | def encoded_char_in_uri(uri): 51 | """ 52 | Determine if path or frag of URI contain any percent-encoded characters 53 | 54 | :param uri: URI to check 55 | :return: True if encoded chars found in path or frag, False otherwise 56 | """ 57 | path, query, frag = split_path(uri) 58 | encoded = False 59 | for part in (path, frag): 60 | encoded = encoded or encoded_char_regex.search(part) 61 | return encoded 62 | 63 | 64 | def check_relative_ref(uri): 65 | result = Result.PASS 66 | msg = 'Test passed' 67 | parsed = urlparse(uri) 68 | if uri.startswith('///'): 69 | result = Result.FAIL 70 | msg = ('Relative reference %s should not start with a triple ' 71 | 'forward slash (///)' % uri) 72 | elif uri.startswith('//'): 73 | if not parsed.netloc: 74 | result = Result.FAIL 75 | msg = ('Relative reference %s does not include the expected ' 76 | 'authority (network-path)' % uri) 77 | elif not parsed.path: 78 | result = Result.FAIL 79 | msg = ('Relative reference %s does not include the expected ' 80 | 'absolute-path' % uri) 81 | return result, msg 82 | 83 | 84 | def response_is_json(uri, response): 85 | result = Result.PASS 86 | msg = 'Test passed' 87 | if response.status_code in [requests.codes.OK, requests.codes.CREATED]: 88 | try: 89 | response.json() 90 | except ValueError as e: 91 | result = Result.FAIL 92 | msg = ('%s request to URI %s did not return JSON response: %s' % 93 | (response.request.method, uri, repr(e))) 94 | else: 95 | result = Result.FAIL 96 | msg = ('%s request to URI %s received status %s' % 97 | (response.request.method, uri, response.status_code)) 98 | return result, msg 99 | 100 | 101 | def response_content_type_is_json(uri, response): 102 | header = response.headers.get('Content-Type', '') 103 | media_type = utils.get_response_media_type(response) 104 | if media_type == 'application/json': 105 | return Result.PASS, 'Test passed' 106 | else: 107 | msg = ('%s request to URI %s received Content-Type header "%s"; ' 108 | 'expected media type "application/json"' % 109 | (response.request.method, uri, header)) 110 | return Result.FAIL, msg 111 | 112 | 113 | def check_slash_redfish(uri, response): 114 | expected = {"v1": "/redfish/v1/"} 115 | result, msg = response_is_json(uri, response) 116 | if result == Result.PASS: 117 | if response.json() != expected: 118 | result = Result.FAIL 119 | msg = ('Content of %s resource contained %s; expected %s' % 120 | (uri, response.json(), expected)) 121 | return result, msg 122 | 123 | 124 | def response_is_xml(uri, response): 125 | result = Result.PASS 126 | msg = 'Test passed' 127 | if response.status_code == requests.codes.OK: 128 | try: 129 | ET.fromstring(response.text) 130 | except ET.ParseError as e: 131 | result = Result.FAIL 132 | msg = ('%s request to URI %s did not return XML response: %s' % 133 | (response.request.method, uri, repr(e))) 134 | else: 135 | result = Result.FAIL 136 | msg = ('%s request to URI %s received status %s' % 137 | (response.request.method, uri, response.status_code)) 138 | return result, msg 139 | 140 | 141 | def check_etag_present(uri, response): 142 | etag = response.headers.get('ETag') 143 | if etag: 144 | return Result.PASS, 'Test passed' 145 | else: 146 | msg = ('Response from %s request to ManagerAccount URI %s did not ' 147 | 'return an ETag header' % (response.request.method, uri)) 148 | return Result.FAIL, msg 149 | 150 | 151 | def check_etag_valid(etag): 152 | return bool(etag_regex.search(etag)) 153 | 154 | 155 | def test_uri(sut: SystemUnderTest, uri, response): 156 | """Perform tests on the URI format and encoding.""" 157 | 158 | # Test Assertion.PROTO_URI_SAFE_CHARS 159 | safe = safe_uri(uri) 160 | result = Result.PASS if safe else Result.FAIL 161 | msg = 'Test passed' if safe else 'URI contains one or more unsafe chars' 162 | sut.log(result, response.request.method, response.status_code, uri, 163 | Assertion.PROTO_URI_SAFE_CHARS, msg) 164 | 165 | # Test Assertion.PROTO_URI_NO_ENCODED_CHARS 166 | encoded = encoded_char_in_uri(uri) 167 | result = Result.PASS if not encoded else Result.FAIL 168 | msg = ('Test passed' if not encoded else 169 | 'URI contains one or more percent-encoded chars') 170 | sut.log(result, response.request.method, response.status_code, uri, 171 | Assertion.PROTO_URI_NO_ENCODED_CHARS, msg) 172 | 173 | # Test Assertion.PROTO_URI_RELATIVE_REFS 174 | result, msg = check_relative_ref(uri) 175 | sut.log(result, response.request.method, response.status_code, uri, 176 | Assertion.PROTO_URI_RELATIVE_REFS, msg) 177 | 178 | 179 | def test_http_supported_methods(sut: SystemUnderTest): 180 | """Perform tests on the supported HTTP methods.""" 181 | # Test Assertion.PROTO_HTTP_SUPPORTED_METHODS 182 | for method in ['GET', 'POST', 'PATCH', 'DELETE']: 183 | responses = sut.get_responses_by_method(method) 184 | if not responses: 185 | sut.log(Result.NOT_TESTED, method, '', '', 186 | Assertion.PROTO_HTTP_SUPPORTED_METHODS, 187 | '%s not tested' % method) 188 | continue 189 | passed = False 190 | for uri, response in responses.items(): 191 | if 200 <= response.status_code < 300: 192 | sut.log(Result.PASS, method, response.status_code, '', 193 | Assertion.PROTO_HTTP_SUPPORTED_METHODS, 194 | '%s supported' % method) 195 | passed = True 196 | break 197 | if not passed: 198 | sut.log(Result.FAIL, method, '', '', 199 | Assertion.PROTO_HTTP_SUPPORTED_METHODS, 200 | 'No %s requests had a successful response' % method) 201 | 202 | 203 | def test_http_unsupported_methods(sut: SystemUnderTest): 204 | """Perform tests on unsupported HTTP methods.""" 205 | # Test Assertion.PROTO_HTTP_UNSUPPORTED_METHODS 206 | for uri, response in sut.get_all_responses(request_type=RequestType.UNSUPPORTED_REQ): 207 | if (response.status_code == requests.codes.METHOD_NOT_ALLOWED or 208 | response.status_code == requests.codes.NOT_IMPLEMENTED): 209 | sut.log(Result.PASS, response.request.method, response.status_code, uri, 210 | Assertion.PROTO_HTTP_UNSUPPORTED_METHODS, 'Test passed') 211 | else: 212 | msg = ('The service response returned status code %s; expected %s or %s' 213 | % (response.status_code, requests.codes.METHOD_NOT_ALLOWED, 214 | requests.codes.NOT_IMPLEMENTED)) 215 | sut.log(Result.FAIL, response.request.method, response.status_code, uri, 216 | Assertion.PROTO_HTTP_UNSUPPORTED_METHODS, msg) 217 | 218 | 219 | def test_media_types(sut: SystemUnderTest, uri, response): 220 | """Perform tests of the supported media types.""" 221 | if (uri != '/redfish/v1/$metadata' and response.request.method != 'HEAD' 222 | and response.status_code in [requests.codes.OK, 223 | requests.codes.CREATED]): 224 | if (response.status_code == requests.codes.CREATED and response.request.method == 'POST' and 225 | len(response.text) == 0): 226 | sut.log(Result.NOT_TESTED, response.request.method, response.status_code, uri, 227 | Assertion.PROTO_JSON_ALL_RESOURCES, 'No response body') 228 | sut.log(Result.NOT_TESTED, response.request.method, response.status_code, uri, 229 | Assertion.PROTO_JSON_RFC, 'No response body') 230 | else: 231 | # Test Assertion.PROTO_JSON_ALL_RESOURCES 232 | result, msg = response_content_type_is_json(uri, response) 233 | sut.log(result, response.request.method, response.status_code, uri, 234 | Assertion.PROTO_JSON_ALL_RESOURCES, msg) 235 | 236 | # Test Assertion.PROTO_JSON_RFC 237 | result, msg = response_is_json(uri, response) 238 | sut.log(result, response.request.method, response.status_code, uri, 239 | Assertion.PROTO_JSON_RFC, msg) 240 | 241 | # Test Assertion.PROTO_JSON_ACCEPTED 242 | if response.request.body: 243 | if response.status_code in [requests.codes.OK, requests.codes.CREATED, 244 | requests.codes.NOT_ACCEPTABLE, 245 | requests.codes.UNSUPPORTED_MEDIA_TYPE]: 246 | if (response.status_code == requests.codes.CREATED and response.request.method == 'POST' and 247 | len(response.text) == 0): 248 | sut.log(Result.NOT_TESTED, response.request.method, response.status_code, uri, 249 | Assertion.PROTO_JSON_ACCEPTED, 'No response body') 250 | else: 251 | result, msg = response_is_json(uri, response) 252 | sut.log(result, response.request.method, response.status_code, 253 | uri, Assertion.PROTO_JSON_ACCEPTED, msg) 254 | 255 | 256 | def test_valid_etag(sut: SystemUnderTest, uri, response): 257 | """Perform tests for RFC7232 ETag support.""" 258 | # Test Assertion.PROTO_ETAG_RFC7232 259 | if (response.request.method != 'HEAD' and response.status_code 260 | in [requests.codes.OK, requests.codes.CREATED]): 261 | etag = response.headers.get('ETag') 262 | source = 'header' 263 | if (etag is None and utils.get_response_media_type(response) 264 | == 'application/json'): 265 | data = utils.get_response_json(response) 266 | if '@odata.etag' in data: 267 | source = 'property' 268 | etag = data.get('@odata.etag') 269 | if etag is not None: 270 | if check_etag_valid(etag): 271 | sut.log(Result.PASS, response.request.method, 272 | response.status_code, uri, 273 | Assertion.PROTO_ETAG_RFC7232, 'Test passed') 274 | else: 275 | msg = ('Response from %s request to URI %s returned invalid ' 276 | 'ETag %s value %s' 277 | % (response.request.method, uri, source, etag)) 278 | sut.log(Result.FAIL, response.request.method, 279 | response.status_code, uri, 280 | Assertion.PROTO_ETAG_RFC7232, msg) 281 | 282 | 283 | def test_account_etags(sut: SystemUnderTest): 284 | """Perform tests for ETag support on ManagerAccount GET.""" 285 | # Test Assertion.PROTO_ETAG_ON_GET_ACCOUNT 286 | responses = sut.get_responses_by_method( 287 | 'GET', resource_type=ResourceType.MANAGER_ACCOUNT) 288 | for uri, response in responses.items(): 289 | if response.status_code == requests.codes.OK: 290 | result, msg = check_etag_present(uri, response) 291 | sut.log(result, response.request.method, response.status_code, uri, 292 | Assertion.PROTO_ETAG_ON_GET_ACCOUNT, msg) 293 | 294 | 295 | def test_standard_uris(sut: SystemUnderTest, uri, response): 296 | """Perform tests on the standard, spec-defined URIs.""" 297 | 298 | if response.request.method == 'GET': 299 | # Test Assertion.PROTO_STD_URI_SERVICE_ROOT 300 | if uri == '/redfish/v1/': 301 | result, msg = response_is_json(uri, response) 302 | sut.log(result, response.request.method, response.status_code, 303 | uri, Assertion.PROTO_STD_URI_SERVICE_ROOT, msg) 304 | 305 | # Test Assertion.PROTO_STD_URI_VERSION 306 | if uri == '/redfish': 307 | result, msg = check_slash_redfish(uri, response) 308 | sut.log(result, response.request.method, response.status_code, 309 | uri, Assertion.PROTO_STD_URI_VERSION, msg) 310 | 311 | # Test Assertion.PROTO_STD_URIS_SUPPORTED 312 | if uri in ['/redfish', '/redfish/v1/', '/redfish/v1/odata']: 313 | result, msg = response_is_json(uri, response) 314 | sut.log(result, response.request.method, response.status_code, 315 | uri, Assertion.PROTO_STD_URIS_SUPPORTED, msg) 316 | if uri == '/redfish/v1/$metadata': 317 | result, msg = response_is_xml(uri, response) 318 | sut.log(result, response.request.method, response.status_code, 319 | uri, Assertion.PROTO_STD_URIS_SUPPORTED, msg) 320 | 321 | # Test Assertion.PROTO_STD_URI_SERVICE_ROOT_REDIRECT 322 | if uri == '/redfish/v1': 323 | result, msg = response_is_json(uri, response) 324 | sut.log(result, response.request.method, response.status_code, 325 | uri, Assertion.PROTO_STD_URI_SERVICE_ROOT_REDIRECT, msg) 326 | 327 | 328 | def test_protocol_details(sut: SystemUnderTest): 329 | """Perform tests from the 'Protocol details' section of the spec.""" 330 | for uri, response in sut.get_all_responses(): 331 | test_uri(sut, uri, response) 332 | test_media_types(sut, uri, response) 333 | test_valid_etag(sut, uri, response) 334 | test_standard_uris(sut, uri, response) 335 | test_http_supported_methods(sut) 336 | test_http_unsupported_methods(sut) 337 | test_account_etags(sut) 338 | -------------------------------------------------------------------------------- /redfish_protocol_validator/report.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import html as html_mod 7 | import json 8 | from datetime import datetime 9 | 10 | from redfish_protocol_validator import redfish_logo 11 | from redfish_protocol_validator.constants import Result 12 | from redfish_protocol_validator.system_under_test import SystemUnderTest 13 | 14 | html_template = """ 15 | 16 | 17 | Redfish Protocol Validator Test Summary 18 | 45 | 46 | 47 | 48 | 61 | 62 | 63 | 68 | 69 | 70 | 74 | 75 | {} 76 |
49 |

##### Redfish Protocol Validator Test Report #####

50 |

\"DMTF

52 |

53 | https://github.com/DMTF/Redfish-Protocol-Validator

54 | Tool Version: {}
55 | {}

56 | This tool is provided and maintained by the DMTF. For feedback, please 57 | open issues
in the tool's Github repository: 58 | 59 | https://github.com/DMTF/Redfish-Protocol-Validator/issues
60 |
64 | System: {}/redfish/v1/, User: {}, Password: {}
65 | Product: {}
66 | Manufacturer: {}, Model: {}, Firmware version: {}
67 |
71 |
Results Summary
72 |
Pass: {}, Warning: {}, Fail: {}, Not tested: {}
73 |
77 | 78 | """ 79 | 80 | section_header_html = """ 81 | 82 | 83 | 86 | 87 |
84 | {} 85 |
88 | """ 89 | 90 | sections = [ 91 | ('PROTO_', 'Protocol Details'), 92 | ('REQ_', 'Service Requests'), 93 | ('RESP_', 'Service Responses'), 94 | ('SERV_', 'Service Details'), 95 | ('SEC_', 'Security Details'), 96 | ] 97 | 98 | 99 | def report_name(time, ext): 100 | prefix = 'RedfishProtocolValidationReport' 101 | name = prefix + datetime.strftime(time, '_%m_%d_%Y_%H%M%S.' + ext) 102 | return name 103 | 104 | 105 | def tsv_report(sut: SystemUnderTest, report_dir, time): 106 | file = report_dir / report_name(time, 'tsv') 107 | with open(str(file), 'w', encoding='utf-8') as fd: 108 | header = ('Assertion\tMethod\tStatus code\tURI\tResult\tMessage\t' 109 | 'Requirement\n') 110 | fd.write(header) 111 | for prefix, _ in sections: 112 | for assertion, results in sorted( 113 | sut.results.items(), key=lambda x: x[0].name): 114 | if not assertion.name.startswith(prefix): 115 | continue 116 | for r in results: 117 | line = '{}\t{}\t{}\t{}\t{}\t{}\t{}\n'.format( 118 | assertion.name, r['method'], r['status'], r['uri'], 119 | r['result'].name, r['msg'], assertion.value) 120 | fd.write(line) 121 | return str(file) 122 | 123 | 124 | def html_report(sut: SystemUnderTest, report_dir, time, tool_version): 125 | file = report_dir / report_name(time, 'html') 126 | html = '' 127 | for prefix, section_name in sections: 128 | html += section_header_html.format(section_name) 129 | for assertion, results in sorted( 130 | sut.results.items(), key=lambda x: x[0].name): 131 | if not assertion.name.startswith(prefix): 132 | continue 133 | html += '' 134 | html += ('' 135 | .format(assertion.name, assertion.value)) 136 | html += ('' 137 | '' 138 | '' 139 | .format('Result', 'Method', 'Status code', 'URI', 140 | 'Message')) 141 | for r in results: 142 | result_class = '' 143 | if r['result'] == Result.PASS: 144 | result_class = 'class="pass"' 145 | elif r['result'] == Result.WARN: 146 | result_class = 'class="warn"' 147 | elif r['result'] == Result.FAIL: 148 | result_class = 'class="fail"' 149 | html += ('' 150 | '' 151 | .format(result_class, r['result'].name, r['method'], 152 | r['status'], r['uri'], 153 | html_mod.escape(r['msg']))) 154 | html += '
{}: "{}"
{}{}{}{}{}
{}{}{}{}{}
' 155 | with open(str(file), 'w', encoding='utf-8') as fd: 156 | fd.write(html_template.format(redfish_logo.logo, tool_version, 157 | time.strftime('%c'), sut.rhost, 158 | sut.username, '********', 159 | sut.product, sut.manufacturer, 160 | sut.model, sut.firmware_version, 161 | sut.summary_count(Result.PASS), 162 | sut.summary_count(Result.WARN), 163 | sut.summary_count(Result.FAIL), 164 | sut.summary_count(Result.NOT_TESTED), 165 | html)) 166 | return str(file) 167 | 168 | 169 | def json_results(sut: SystemUnderTest, report_dir, time, tool_version): 170 | file = report_dir / 'results.json' 171 | results = { 172 | 'ToolName': 'Redfish-Protocol-Validator v%s' % tool_version, 173 | 'Timestamp': { 174 | 'DateTime': '{:%Y-%m-%dT%H:%M:%S%Z}'.format(time) 175 | }, 176 | 'Service': { 177 | 'BaseURL': sut.rhost, 178 | 'Manufacturer': sut.manufacturer, 179 | 'Product': sut.product, 180 | 'Model': sut.model, 181 | 'FirmwareVersion': sut.firmware_version 182 | }, 183 | 'TestResults': { 184 | 'Protocol Validations': { 185 | 'pass': sut.summary_count(Result.PASS), 186 | 'fail': sut.summary_count(Result.FAIL), 187 | 'skip': sut.summary_count(Result.NOT_TESTED), 188 | 'warn': sut.summary_count(Result.WARN) 189 | }, 190 | 'ErrorMessages': [] 191 | } 192 | } 193 | with open(str(file), 'w', encoding='utf-8') as fd: 194 | json.dump(results, fd, indent=4) 195 | -------------------------------------------------------------------------------- /redfish_protocol_validator/resources.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import logging 7 | import random 8 | 9 | import requests 10 | 11 | from redfish_protocol_validator import accounts as acct 12 | from redfish_protocol_validator import sessions 13 | from redfish_protocol_validator import utils 14 | from redfish_protocol_validator.constants import RequestType, ResourceType 15 | from redfish_protocol_validator.system_under_test import SystemUnderTest 16 | 17 | 18 | def set_mfr_model_fw(sut: SystemUnderTest, data): 19 | sep_uuid = data.get('ServiceEntryPointUUID', '').lower() 20 | if (sut.model is None or 21 | (sut.service_uuid and sut.service_uuid == sep_uuid)): 22 | sut.set_manufacturer(data.get('Manufacturer', 'N/A')) 23 | sut.set_model(data.get('Model', 'N/A')) 24 | sut.set_firmware_version(data.get('FirmwareVersion', 'N/A')) 25 | 26 | 27 | def set_mgr_net_proto_uri(sut: SystemUnderTest, data): 28 | sep_uuid = data.get('ServiceEntryPointUUID', '').lower() 29 | if (sut.mgr_net_proto_uri is None or 30 | (sut.service_uuid and sut.service_uuid == sep_uuid)): 31 | sut.set_mgr_net_proto_uri( 32 | data.get('NetworkProtocol', {}).get('@odata.id', '')) 33 | 34 | 35 | def find_certificates(sut: SystemUnderTest, data): 36 | uri = data.get('NetworkProtocol', {}).get('@odata.id') 37 | if uri: 38 | r = sut.get(uri) 39 | yield {'uri': uri, 'response': r} 40 | if r.ok: 41 | d = utils.get_response_json(r) 42 | coll_uri = d.get('HTTPS', {}).get('Certificates', {}).get('@odata.id') 43 | if coll_uri: 44 | r = sut.get(coll_uri) 45 | yield {'uri': coll_uri, 'response': r} 46 | if r.ok: 47 | d = utils.get_response_json(r) 48 | for m in d.get('Members', []): 49 | uri = m.get('@odata.id') 50 | if uri: 51 | # uncomment next 2 lines if we need to read certs 52 | # r = sut.get(uri) 53 | # yield {'uri': uri, 'response': r} 54 | sut.add_cert(coll_uri, uri) 55 | 56 | 57 | def get_default_resources(sut: SystemUnderTest, uri='/redfish/v1/', 58 | uris=None): 59 | """ 60 | Generator function to retrieve the default set of resources to test 61 | 62 | :param sut: SystemUnderTest object 63 | :param uri: the starting URI (default is '/redfish/v1/') 64 | :param uris: a list of specific URIs to retrieve 65 | :return: dict elements containing the URI and `requests` response 66 | """ 67 | # do GETs on spec-defined URIs 68 | yield {'uri': '/redfish', 'response': sut.get('/redfish')} 69 | yield {'uri': '/redfish/v1/odata', 'response': 70 | sut.get('/redfish/v1/odata')} 71 | yield {'uri': '/redfish/v1', 'response': 72 | sut.get('/redfish/v1')} 73 | yield {'uri': '/redfish/v1/$metadata', 'response': 74 | sut.get('/redfish/v1/$metadata', 75 | headers={'accept': 'application/xml'})} 76 | yield {'uri': '/redfish/v1/openapi.yaml', 77 | 'request_type': RequestType.YAML, 78 | 'response': sut.get('/redfish/v1/openapi.yaml', 79 | headers={'accept': 'application/yaml'})} 80 | 81 | # do HEAD on the service root 82 | r = sut.head(uri) 83 | yield {'uri': uri, 'response': r} 84 | # do GET on the service root 85 | r = sut.get(uri) 86 | yield {'uri': uri, 'response': r} 87 | root = utils.get_response_json(r) if r.status_code == requests.codes.OK else {} 88 | 89 | sut.set_version(root.get('RedfishVersion', '1.0.0')) 90 | sut.set_product(root.get('Product', 'N/A')) 91 | sut.set_service_uuid(root.get('UUID')) 92 | sut.set_supported_query_params(root.get('ProtocolFeaturesSupported', {})) 93 | 94 | for prop in ['Systems', 'Chassis']: 95 | uri = root.get(prop, {}).get('@odata.id') 96 | if uri: 97 | sut.set_nav_prop_uri(prop, uri) 98 | r = sut.get(uri) 99 | yield {'uri': uri, 'response': r} 100 | if r.ok: 101 | data = utils.get_response_json(r) 102 | if 'Members' in data and len(data['Members']): 103 | uri = data['Members'][0].get('@odata.id') 104 | if uri: 105 | r = sut.get(uri) 106 | yield {'uri': uri, 'response': r} 107 | 108 | uri = root.get('Managers', {}).get('@odata.id') 109 | if uri: 110 | sut.set_nav_prop_uri('Managers', uri) 111 | r = sut.get(uri) 112 | yield {'uri': uri, 'response': r} 113 | if r.ok: 114 | data = utils.get_response_json(r) 115 | for m in data.get('Members', []): 116 | uri = m.get('@odata.id') 117 | if uri: 118 | r = sut.get(uri) 119 | yield {'uri': uri, 'response': r} 120 | if r.ok: 121 | d = utils.get_response_json(r) 122 | set_mfr_model_fw(sut, d) 123 | set_mgr_net_proto_uri(sut, d) 124 | for c in find_certificates(sut, d): 125 | yield c 126 | 127 | uri = root.get('AccountService', {}).get('@odata.id') 128 | if uri: 129 | sut.set_nav_prop_uri('AccountService', uri) 130 | r = sut.get(uri) 131 | yield {'uri': uri, 'response': r} 132 | if r.ok: 133 | data = utils.get_response_json(r) 134 | uri = data.get('PrivilegeMap', {}).get('@odata.id') 135 | if uri: 136 | sut.set_nav_prop_uri('PrivilegeMap', uri) 137 | for prop in ['Accounts', 'Roles']: 138 | uri = data.get(prop, {}).get('@odata.id') 139 | if uri: 140 | r = sut.get(uri) 141 | yield {'uri': uri, 'response': r} 142 | sut.set_nav_prop_uri(prop, uri) 143 | if r.ok: 144 | d = utils.get_response_json(r) 145 | if prop == 'Accounts': 146 | resource_type = ResourceType.MANAGER_ACCOUNT 147 | # get accounts up to sut.username 148 | for m in d.get('Members', []): 149 | uri = m.get('@odata.id') 150 | if uri: 151 | r = sut.get(uri) 152 | yield {'uri': uri, 'response': r, 153 | 'resource_type': resource_type} 154 | if r.ok: 155 | sut.add_user(utils.get_response_json(r)) 156 | if (utils.get_response_json(r).get('UserName') 157 | == sut.username): 158 | break 159 | 160 | else: 161 | resource_type = ResourceType.ROLE 162 | # get all the roles 163 | for m in d.get('Members', []): 164 | uri = m.get('@odata.id') 165 | if uri: 166 | r = sut.get(uri) 167 | yield {'uri': uri, 'response': r, 168 | 'resource_type': resource_type} 169 | if r.ok: 170 | sut.add_role(utils.get_response_json(r)) 171 | 172 | uri = root.get('SessionService', {}).get('@odata.id') 173 | if uri: 174 | r = sut.get(uri) 175 | yield {'uri': uri, 'response': r} 176 | if r.ok: 177 | data = utils.get_response_json(r) 178 | uri = data.get('Sessions', {}).get('@odata.id') 179 | if uri: 180 | r = sut.get(uri) 181 | yield {'uri': uri, 'response': r} 182 | if r.ok: 183 | data = utils.get_response_json(r) 184 | if 'Members' in data and len(data['Members']): 185 | uri = data['Members'][0].get('@odata.id') 186 | if uri: 187 | r = sut.get(uri) 188 | yield {'uri': uri, 'response': r} 189 | 190 | uri = root.get('EventService', {}).get('@odata.id') 191 | if uri: 192 | sut.set_nav_prop_uri('EventService', uri) 193 | r = sut.get(uri) 194 | yield {'uri': uri, 'response': r} 195 | if r.ok: 196 | data = utils.get_response_json(r) 197 | uri = data.get('Subscriptions', {}).get('@odata.id') 198 | if uri: 199 | sut.set_nav_prop_uri('Subscriptions', uri) 200 | uri = data.get('ServerSentEventUri') 201 | if uri: 202 | sut.set_server_sent_event_uri(uri) 203 | r, event_dest_uri = utils.get_sse_stream(sut) 204 | if event_dest_uri: 205 | sut.set_event_dest_uri(event_dest_uri) 206 | if r is not None: 207 | yield {'uri': uri, 'response': r, 208 | 'request_type': RequestType.STREAMING} 209 | 210 | uri = root.get('CertificateService', {}).get('@odata.id') 211 | if uri: 212 | sut.set_nav_prop_uri('CertificateService', uri) 213 | r = sut.get(uri) 214 | yield {'uri': uri, 'response': r} 215 | 216 | 217 | def read_target_resources(sut: SystemUnderTest, uri='/redfish/v1/', 218 | uris=None, func=get_default_resources): 219 | """ 220 | Read the target resources using the specified generator function 221 | 222 | :param sut: SystemUnderTest object 223 | :param uri: the starting URI (default is '/redfish/v1/') 224 | :param uris: a list of specific URIs to retrieve 225 | :param func: generator function (`get_default_resources`, 226 | `get_all_resources`, or `get_select_resources`) 227 | """ 228 | for r in func(sut, uri=uri, uris=uris): 229 | response = r['response'] 230 | uri = r['uri'] 231 | resource_type = r.get('resource_type') 232 | request_type = r.get('request_type', RequestType.NORMAL) 233 | sut.add_response(uri, response, resource_type=resource_type, 234 | request_type=request_type) 235 | 236 | 237 | def create_account(sut: SystemUnderTest, session, 238 | request_type=RequestType.NORMAL): 239 | # Create test account 240 | user, password, new_acct_uri = acct.add_account(sut, session, 241 | request_type=request_type) 242 | return user, password, new_acct_uri 243 | 244 | 245 | def patch_account(sut: SystemUnderTest, session, acct_uri, 246 | request_type=RequestType.NORMAL): 247 | # PATCH account 248 | return acct.patch_account(sut, session, acct_uri, 249 | request_type=request_type) 250 | 251 | 252 | def patch_other_account(sut: SystemUnderTest, session, user, password): 253 | """Create a new account and try to modify it with other creds""" 254 | new_user, new_password, new_acct_uri = None, None, None 255 | try: 256 | new_user, new_password, new_acct_uri = create_account( 257 | sut, session, request_type=RequestType.NORMAL) 258 | if new_acct_uri: 259 | new_session = requests.Session() 260 | new_session.auth = (user, password) 261 | new_session.verify = sut.verify 262 | pwd = patch_account(sut, new_session, new_acct_uri, 263 | request_type=RequestType.MODIFY_OTHER) 264 | if pwd: 265 | new_password = pwd 266 | except Exception as e: 267 | logging.error('Caught exception while creating or patching other ' 268 | 'account; Exception: %s; continuing with test' % str(e)) 269 | return new_user, new_password, new_acct_uri 270 | 271 | 272 | def delete_account(sut: SystemUnderTest, session, user, acct_uri, 273 | request_type=RequestType.NORMAL): 274 | # DELETE account 275 | if acct_uri: 276 | acct.delete_account(sut, session, user, acct_uri, 277 | request_type=request_type) 278 | 279 | 280 | def data_modification_requests(sut: SystemUnderTest): 281 | new_session_uri, _ = sessions.create_session(sut) 282 | if new_session_uri: 283 | sessions.delete_session(sut, sut.session, new_session_uri, 284 | request_type=RequestType.NORMAL) 285 | new_user, new_pwd, new_uri = None, None, None 286 | other_user, other_pwd, other_uri = None, None, None 287 | try: 288 | new_user, new_pwd, new_uri = create_account( 289 | sut, sut.session, request_type=RequestType.NORMAL) 290 | if new_uri: 291 | response = sut.get(new_uri) 292 | sut.add_response(new_uri, response) 293 | if response.ok: 294 | etag = utils.get_response_etag(response) 295 | data = utils.get_response_json(response) 296 | if 'PasswordChangeRequired' in data: 297 | acct.password_change_required(sut, sut.session, new_user, 298 | new_pwd, new_uri, data, etag) 299 | pwd = patch_account(sut, sut.session, new_uri, 300 | request_type=RequestType.NORMAL) 301 | if pwd: 302 | new_pwd = pwd 303 | other_user, other_pwd, other_uri = patch_other_account( 304 | sut, sut.session, new_user, new_pwd) 305 | except Exception as e: 306 | logging.error('Caught exception while creating or patching accounts; ' 307 | 'Exception: %s; continuing with test' % str(e)) 308 | finally: 309 | if new_uri: 310 | delete_account(sut, sut.session, new_user, new_uri, 311 | request_type=RequestType.NORMAL) 312 | if other_uri: 313 | delete_account(sut, sut.session, other_user, other_uri, 314 | request_type=RequestType.NORMAL) 315 | 316 | 317 | def data_modification_requests_no_auth(sut: SystemUnderTest, no_auth_session): 318 | new_session_uri, _ = sessions.create_session(sut) 319 | if new_session_uri: 320 | r = sessions.delete_session(sut, no_auth_session, new_session_uri, 321 | request_type=RequestType.NO_AUTH) 322 | if not r.ok: 323 | sessions.delete_session(sut, sut.session, new_session_uri, 324 | request_type=RequestType.NORMAL) 325 | user, password, new_acct_uri = create_account( 326 | sut, no_auth_session, request_type=RequestType.NO_AUTH) 327 | if not new_acct_uri: 328 | user, password, new_acct_uri = create_account( 329 | sut, sut.session, request_type=RequestType.NORMAL) 330 | if new_acct_uri: 331 | patch_account(sut, no_auth_session, new_acct_uri, 332 | request_type=RequestType.NO_AUTH) 333 | delete_account(sut, no_auth_session, user, new_acct_uri, 334 | request_type=RequestType.NO_AUTH) 335 | delete_account(sut, sut.session, user, new_acct_uri, 336 | request_type=RequestType.NORMAL) 337 | 338 | 339 | def unsupported_requests(sut: SystemUnderTest): 340 | # Data modification requests on the service root are never allowed 341 | uri = '/redfish/v1/' 342 | response = sut.delete(uri) 343 | sut.add_response(uri, response, request_type=RequestType.UNSUPPORTED_REQ) 344 | response = sut.patch(uri, json={}) 345 | sut.add_response(uri, response, request_type=RequestType.UNSUPPORTED_REQ) 346 | response = sut.post(uri, json={}) 347 | sut.add_response(uri, response, request_type=RequestType.UNSUPPORTED_REQ) 348 | # Unsupported HTTP methods return HTTP 501 349 | response = sut.request('FAKEMETHODFORTEST', uri) 350 | sut.add_response(uri, response, request_type=RequestType.UNSUPPORTED_REQ) 351 | 352 | 353 | def basic_auth_requests(sut: SystemUnderTest): 354 | headers = { 355 | 'OData-Version': '4.0' 356 | } 357 | uri = sut.sessions_uri 358 | # good request 359 | r = sut.get(uri, headers=headers, auth=(sut.username, sut.password), 360 | no_session=True) 361 | sut.add_response(uri, r, request_type=RequestType.BASIC_AUTH) 362 | 363 | 364 | def http_requests(sut: SystemUnderTest): 365 | headers = { 366 | 'OData-Version': '4.0' 367 | } 368 | if sut.scheme == 'http': 369 | http_rhost = sut.rhost 370 | elif sut.scheme == 'https': 371 | http_rhost = 'http' + sut.rhost[5:] 372 | else: 373 | logging.warning('Unexpected scheme (%s) for remote host %s found, ' 374 | 'expected http or https; skipping http requests' % 375 | (sut.scheme, sut.rhost)) 376 | return 377 | 378 | redirect_msg = ('Caught %s while trying to trigger a redirect. To avoid ' 379 | 'this warning and speed up the validation run, try adding ' 380 | 'the --avoid-http-redirect command-line argument.') 381 | 382 | uri = '/redfish/v1/' 383 | if sut.scheme == 'http': 384 | # already using http, just fetch previous NO_AUTH response 385 | r = sut.get_response('GET', uri, request_type=RequestType.NO_AUTH) 386 | if r is not None: 387 | sut.add_response(uri, r, request_type=RequestType.HTTP_NO_AUTH) 388 | elif not sut.avoid_http_redirect: 389 | # request using HTTP and no auth (should fail or redirect to HTTPS) 390 | try: 391 | r = requests.get(http_rhost + uri, headers=headers, 392 | verify=sut.verify) 393 | sut.add_response(uri, r, request_type=RequestType.HTTP_NO_AUTH) 394 | except Exception as e: 395 | logging.warning(redirect_msg % e.__class__.__name__) 396 | sut.set_avoid_http_redirect(True) 397 | 398 | uri = sut.sessions_uri 399 | if sut.scheme == 'http': 400 | # already using http, just fetch previous BASIC_AUTH response 401 | r = sut.get_response('GET', uri, request_type=RequestType.BASIC_AUTH) 402 | if r is not None: 403 | sut.add_response(uri, r, request_type=RequestType.HTTP_BASIC_AUTH) 404 | # already using http, just fetch previous NO_AUTH response 405 | r = sut.get_response('GET', uri, request_type=RequestType.NO_AUTH) 406 | if r is not None: 407 | sut.add_response(uri, r, request_type=RequestType.HTTP_NO_AUTH) 408 | elif not sut.avoid_http_redirect: 409 | # request using HTTP and basic auth (should fail or redirect to HTTPS) 410 | try: 411 | r = requests.get(http_rhost + uri, headers=headers, 412 | auth=(sut.username, sut.password), 413 | verify=sut.verify) 414 | sut.add_response(uri, r, request_type=RequestType.HTTP_BASIC_AUTH) 415 | except Exception as e: 416 | logging.warning(redirect_msg % e.__class__.__name__) 417 | sut.set_avoid_http_redirect(True) 418 | # request using HTTP and no auth (should fail or redirect to HTTPS) 419 | try: 420 | r = requests.get(http_rhost + uri, headers=headers, 421 | verify=sut.verify) 422 | sut.add_response(uri, r, request_type=RequestType.HTTP_NO_AUTH) 423 | except Exception as e: 424 | logging.warning(redirect_msg % e.__class__.__name__) 425 | sut.set_avoid_http_redirect(True) 426 | 427 | 428 | def bad_auth_requests(sut: SystemUnderTest): 429 | headers = { 430 | 'OData-Version': '4.0' 431 | } 432 | # request with bad basic auth 433 | # Keep these invalid basic auth attempts to a minimum. Some services will 434 | # block clients after a number of failed attempts. 435 | # e.g. "Login attempt alert for rfpv66af from 192.168.1.101 using REDFISH, 436 | # IP will be blocked for 600 seconds." 437 | uri = sut.sessions_uri 438 | h = headers.copy() 439 | r = sut.get(uri, headers=h, auth=(acct.new_username(set()), acct.new_password(sut)), 440 | no_session=True) 441 | sut.add_response(uri, r, request_type=RequestType.BAD_AUTH) 442 | # request with bad auth token 443 | token = 'rfpv%012x' % random.randrange(2 ** 48) # ex: 'rfpv9e40b1f54c8a' 444 | sut.add_priv_info(token) 445 | uri = '/redfish/v1/RPVfoobar' 446 | h = headers.copy() 447 | h.update({'X-Auth-Token': token}) 448 | r = sut.get(uri, headers=h, no_session=True) 449 | sut.add_response(uri, r, request_type=RequestType.BAD_AUTH) 450 | 451 | 452 | def read_uris_no_auth(sut: SystemUnderTest, session): 453 | for uri in sut.get_all_uris(): 454 | response = sut.get(uri, session=session) 455 | sut.add_response(uri, response, request_type=RequestType.NO_AUTH) 456 | -------------------------------------------------------------------------------- /redfish_protocol_validator/sessions.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import logging 7 | from urllib.parse import urlparse 8 | 9 | import requests 10 | 11 | from redfish_protocol_validator import accounts 12 | from redfish_protocol_validator.constants import RequestType 13 | from redfish_protocol_validator.system_under_test import SystemUnderTest 14 | 15 | 16 | def bad_login(sut: SystemUnderTest): 17 | """Try to login with bad credentials""" 18 | # Keep these invalid basic auth attempts to a minimum. Some services will 19 | # block clients after a number of failed attempts. 20 | # e.g. "Login attempt alert for rfpv66af from 192.168.1.101 using REDFISH, 21 | # IP will be blocked for 600 seconds." 22 | payload = { 23 | 'UserName': accounts.new_username(set()), 24 | 'Password': accounts.new_password(sut) 25 | } 26 | headers = { 27 | 'OData-Version': '4.0' 28 | } 29 | response = sut.post(sut.sessions_uri, json=payload, 30 | headers=headers, no_session=True) 31 | sut.add_response(sut.sessions_uri, response, 32 | request_type=RequestType.BAD_AUTH) 33 | 34 | 35 | def create_session(sut: SystemUnderTest): 36 | payload = { 37 | 'UserName': sut.username, 38 | 'Password': sut.password 39 | } 40 | headers = { 41 | 'OData-Version': '4.0', 42 | 'Content-Type': 'application/json;charset=utf-8' 43 | } 44 | response = sut.post(sut.sessions_uri, json=payload, 45 | headers=headers, no_session=True) 46 | if not response.ok: 47 | logging.warning('session POST status: %s, response: %s' % ( 48 | response.status_code, response.text)) 49 | # creating a session with NO_AUTH is also NORMAL, so register both types 50 | sut.add_response(sut.sessions_uri, response, 51 | request_type=RequestType.NORMAL) 52 | sut.add_response(sut.sessions_uri, response, 53 | request_type=RequestType.NO_AUTH) 54 | new_session_uri = None 55 | token = None 56 | if response.ok: 57 | location = response.headers.get('Location') 58 | if location: 59 | new_session_uri = urlparse(location).path 60 | token = response.headers.get('X-Auth-Token') 61 | return new_session_uri, token 62 | 63 | 64 | def delete_session(sut: SystemUnderTest, session, session_uri, 65 | request_type=RequestType.NORMAL): 66 | response = sut.delete(session_uri, session=session) 67 | sut.add_response(session_uri, response, request_type=request_type) 68 | return response 69 | 70 | 71 | def no_auth_session(sut: SystemUnderTest): 72 | session = requests.Session() 73 | session.verify = sut.verify 74 | return session 75 | -------------------------------------------------------------------------------- /redfish_protocol_validator/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import http.client 7 | import io 8 | import logging 9 | import math 10 | import re 11 | import socket 12 | import time 13 | from collections import namedtuple 14 | 15 | import colorama 16 | import requests 17 | import sseclient 18 | 19 | from redfish_protocol_validator.constants import Result, SSDP_REDFISH 20 | 21 | _color_map = { 22 | Result.PASS: (colorama.Fore.GREEN, colorama.Style.RESET_ALL), 23 | Result.WARN: (colorama.Fore.YELLOW, colorama.Style.RESET_ALL), 24 | Result.FAIL: (colorama.Fore.RED, colorama.Style.RESET_ALL), 25 | Result.NOT_TESTED: ('', ''), 26 | } 27 | 28 | 29 | def get_response_media_type(response): 30 | header = response.headers.get('Content-Type', '') 31 | return header.split(';', 1)[0].strip().lower() 32 | 33 | 34 | def get_response_media_type_charset(response): 35 | header = response.headers.get('Content-Type', '') 36 | if ';' in header: 37 | return header.split(';', 1)[1].strip().lower() 38 | 39 | 40 | def get_etag_header(sut, session, uri): 41 | response = sut.get(uri, session=session) 42 | etag = None 43 | if response.ok: 44 | etag = response.headers.get('ETag') 45 | return {'If-Match': etag} if etag else {} 46 | 47 | 48 | def get_response_etag(response: requests.Response): 49 | etag = None 50 | if response.ok: 51 | etag = response.headers.get('ETag') 52 | if not etag: 53 | if get_response_media_type(response) == 'application/json': 54 | data = get_response_json(response) 55 | etag = data.get('@odata.etag') 56 | return etag 57 | 58 | 59 | def get_extended_error(response: requests.Response): 60 | message = '' 61 | try: 62 | data = get_response_json(response) 63 | if 'error' in data: 64 | error = data['error'] 65 | if 'message' in error: 66 | message = error['message'] 67 | elif 'code' in error: 68 | message = error['code'] 69 | ext_info = error.get('@Message.ExtendedInfo', []) 70 | if isinstance(ext_info, list) and len(ext_info) > 0: 71 | if 'Message' in ext_info[0]: 72 | message = ext_info[0]['Message'] 73 | elif 'MessageId' in ext_info[0]: 74 | message = ext_info[0]['MessageId'] 75 | except Exception: 76 | pass 77 | return message 78 | 79 | 80 | def get_response_json(response: requests.Response): 81 | try: 82 | data = response.json() 83 | if not isinstance(data, dict): 84 | data = {} 85 | logging.error('%s on %s did not return a JSON object; assuming ' 86 | 'an empty object' % (response.request.method, 87 | response.request.path_url)) 88 | except: 89 | data = {} 90 | logging.error('%s on %s did not return valid JSON; assuming ' 91 | 'an empty object' % (response.request.method, 92 | response.request.path_url)) 93 | return data 94 | 95 | 96 | def build_exception_response(exception, uri, method): 97 | response = requests.Response() 98 | response.status_code = 600 99 | response.reason = "Exception" 100 | response.url = uri 101 | response.request = requests.Request() 102 | response.request.method = method 103 | response.request.body = None 104 | response.request.path_url = uri 105 | logging.error('%s on %s caused %s exception; building HTTP 600 ' 106 | 'response' % (method, uri, type(exception).__name__)) 107 | return response 108 | 109 | 110 | def get_extended_info_message_keys(body: dict): 111 | data = [] 112 | if 'error' in body and '@Message.ExtendedInfo' in body['error']: 113 | data = body['error']['@Message.ExtendedInfo'] 114 | elif '@Message.ExtendedInfo' in body: 115 | data = body['@Message.ExtendedInfo'] 116 | return {d['MessageId'].split('.')[-1] for d in data if 'MessageId' in d} 117 | 118 | 119 | def is_text_in_extended_error(text: str, body: dict): 120 | data = [] 121 | if 'error' in body and '@Message.ExtendedInfo' in body['error']: 122 | data = body['error']['@Message.ExtendedInfo'] 123 | elif 'error' in body and 'message' in body['error']: 124 | # Simple error message; just inspect the message string 125 | if text in body['error']['message']: 126 | return True 127 | elif '@Message.ExtendedInfo' in body: 128 | data = body['@Message.ExtendedInfo'] 129 | for d in data: 130 | if (text in d.get('Message', '') or text in d.get('Resolution', '') 131 | or text in d.get('MessageArgs', '')): 132 | return True 133 | return False 134 | 135 | 136 | def poll_task(sut, response, session=None): 137 | """ 138 | Polls a task monitor for a final response 139 | 140 | :param sut: The system under test 141 | :param response: The response object from the original operation 142 | :param session: Session object if using a session other than the sut's 143 | 144 | :return: A response object at the end of the task 145 | """ 146 | # If the response doesn't show 202 Accepted, there's nothing to poll 147 | if response.status_code != requests.codes.ACCEPTED: 148 | return response 149 | if session is None: 150 | session = sut.session 151 | if session is None: 152 | # If no session is set up, don't poll 153 | # May want to revisit this later; there are only a few tests that do 154 | # not use the requests sessions, and they are not expected to produce 155 | # tasks 156 | return response 157 | 158 | # Get the task monitor URI and poll it 159 | task_monitor = response.headers.get('Location') 160 | if task_monitor: 161 | # Try for up to 1 minute at 5 second intervals 162 | for _ in range(12): 163 | time.sleep(5) 164 | response = sut.get(task_monitor, session=session) 165 | # Once the task is done, break out 166 | if response.status_code != requests.codes.ACCEPTED: 167 | break 168 | return response 169 | 170 | 171 | def get_sse_stream(sut): 172 | response = None 173 | event_dest_uri = None 174 | subs = set() 175 | try: 176 | # get the "before" set of EventDestination URIs 177 | if sut.subscriptions_uri: 178 | r = sut.get(sut.subscriptions_uri, allow_exception=True) 179 | if r.status_code == requests.codes.OK: 180 | data = get_response_json(r) 181 | subs = set([m.get('@odata.id') for m in data.get('Members', []) 182 | if '@odata.id' in m]) 183 | 184 | if sut.server_sent_event_uri: 185 | response = sut.get(sut.server_sent_event_uri, 186 | stream=True, allow_exception=True) 187 | if response is not None and response.ok and sut.subscriptions_uri: 188 | # get the "after" set of EventDestination URIs 189 | r = sut.get(sut.subscriptions_uri, allow_exception=True) 190 | if r.status_code == requests.codes.OK: 191 | data = get_response_json(r) 192 | new_subs = set([m.get('@odata.id') for m in 193 | data.get('Members', []) if '@odata.id' in m]) 194 | diff = new_subs.difference(subs) 195 | if len(diff) == 1: 196 | event_dest_uri = diff.pop() 197 | elif len(diff) == 0: 198 | logging.debug('No EventDestination resource created when ' 199 | 'SSE stream opened') 200 | else: 201 | logging.debug('More than one (%s) EventDestination ' 202 | 'resources created when SSE stream opened' 203 | % len(diff)) 204 | except Exception as e: 205 | logging.warning('Caught %s while opening SSE stream and getting ' 206 | 'EventDestination URI' % e.__class__.__name__) 207 | return response, event_dest_uri 208 | 209 | 210 | def _summary_format(sut, result): 211 | count = sut.summary_count(result) 212 | start, end = ('', '') 213 | if count: 214 | start, end = _color_map[result] 215 | return start, count, end 216 | 217 | 218 | def print_summary(sut): 219 | colorama.init() 220 | pass_start, passed, pass_end = _summary_format(sut, Result.PASS) 221 | warn_start, warned, warn_end = _summary_format(sut, Result.WARN) 222 | fail_start, failed, fail_end = _summary_format(sut, Result.FAIL) 223 | no_test_start, not_tested, no_test_end = ( 224 | _summary_format(sut, Result.NOT_TESTED)) 225 | print('Summary - %sPASS: %s%s, %sWARN: %s%s, %sFAIL: %s%s, ' 226 | '%sNOT_TESTED: %s%s' % ( 227 | pass_start, passed, pass_end, 228 | warn_start, warned, warn_end, 229 | fail_start, failed, fail_end, 230 | no_test_start, not_tested, no_test_end)) 231 | colorama.deinit() 232 | 233 | 234 | class SSEClientTimeout(sseclient.SSEClient): 235 | """Extend SSEClient to provide an optional timeout parameter so we don't 236 | read the SSE stream forever. 237 | """ 238 | def __init__(self, event_source, char_enc='utf-8', timeout=None): 239 | super(SSEClientTimeout, self).__init__(event_source, char_enc=char_enc) 240 | self._timeout_secs = timeout 241 | self._timeout_at = None 242 | 243 | def _read(self): 244 | if self._timeout_secs and self._timeout_at is None: 245 | self._timeout_at = time.time() + self._timeout_secs 246 | for data in super(SSEClientTimeout, self)._read(): 247 | if self._timeout_secs and time.time() >= self._timeout_at: 248 | # stop generator if timeout reached 249 | return 250 | yield data 251 | 252 | 253 | def redfish_version_to_tuple(version: str): 254 | Version = namedtuple('Version', ['major', 'minor', 'errata']) 255 | Version.__new__.__defaults__ = (0, 0) 256 | return Version(*tuple(map(int, version.split('.')))) 257 | 258 | 259 | def normalize_media_type(media_type): 260 | """See section 3.1.1.1 of RFC 7231 261 | e.g. text/HTML; Charset="UTF-8" -> text/html;charset=utf-8""" 262 | if ';' in media_type: 263 | mtype, param = media_type.split(';', 2) 264 | mtype = mtype.strip() 265 | param = param.replace("'", "").replace('"', '').strip() 266 | media_type = mtype + ';' + param 267 | return media_type.lower() 268 | 269 | 270 | class FakeSocket(io.BytesIO): 271 | """Helper class to force raw data into an HTTP Response structure""" 272 | def makefile(self, *args, **kwargs): 273 | return self 274 | 275 | 276 | def sanitize(number, minimum, maximum=None): 277 | """ Sanity check a given number. 278 | 279 | :param number: the number to sanitize 280 | :param minimum: the minimum acceptable number 281 | :param maximum: the maximum acceptable number (optional) 282 | 283 | if maximum is not given sanitize return the given value superior 284 | at minimum 285 | 286 | :returns: an integer who respect the given allowed minimum and maximum 287 | """ 288 | if number < minimum: 289 | number = minimum 290 | elif maximum is not None and number > maximum: 291 | number = maximum 292 | return number 293 | 294 | 295 | def uuid_from_usn(usn, pattern): 296 | m = pattern.search(usn.lower()) 297 | if m: 298 | return m.group(1) 299 | 300 | 301 | def process_ssdp_response(response, discovered_services, pattern): 302 | response.begin() 303 | uuid = uuid_from_usn(response.getheader('USN'), pattern) 304 | if uuid: 305 | discovered_services[uuid] = response.headers 306 | 307 | 308 | redfish_usn_pattern = re.compile( 309 | r'^uuid:([a-f0-9\-]+)::urn:dmtf-org:service:redfish-rest:1(:\d+)?$') 310 | 311 | redfish_st_pattern = re.compile( 312 | r'^urn:dmtf-org:service:redfish-rest:1(:\d+)?$') 313 | 314 | uuid_pattern = re.compile(r'^uuid:([a-f0-9\-]+).*$') 315 | 316 | 317 | def discover_ssdp(port=1900, ttl=2, response_time=3, iface=None, 318 | protocol='ipv4', pattern=uuid_pattern, 319 | search_target=SSDP_REDFISH): 320 | """Discovers Redfish services via SSDP 321 | 322 | :param port: the port to use for the SSDP request 323 | :type port: int 324 | :param ttl: the time-to-live value for the request 325 | :type ttl: int 326 | :param response_time: the number of seconds in which a service can respond 327 | :type response_time: int 328 | :param iface: the interface to use for the request; None for all 329 | :type iface: string 330 | :param protocol: the protocol to use for the request; 'ipv4' or 'ipv6' 331 | :type protocol: string 332 | :param pattern: compiled re pattern for the expected USN header 333 | :type pattern: SRE_Pattern 334 | :param search_target: the search target to discover (default: Redfish ST) 335 | :type search_target: string 336 | 337 | :returns: a set of discovery data 338 | """ 339 | valid_protocols = ('ipv4', 'ipv6') 340 | if protocol == 'ipv6': 341 | mcast_ip = 'ff02::c' 342 | mcast_connection = (mcast_ip, port, 0, 0) 343 | af_type = socket.AF_INET6 344 | elif protocol == 'ipv4': 345 | mcast_ip = '239.255.255.250' 346 | mcast_connection = (mcast_ip, port) 347 | af_type = socket.AF_INET 348 | else: 349 | raise ValueError("Invalid protocol type. Expected one of: {}" 350 | .format(valid_protocols)) 351 | 352 | ttl = sanitize(ttl, minimum=1, maximum=255) 353 | response_time = sanitize(response_time, minimum=1) 354 | 355 | # Initialize the multicast data 356 | msearch_str = ( 357 | 'M-SEARCH * HTTP/1.1\r\n' 358 | 'Host: {}:{}\r\n' 359 | 'Man: "ssdp:discover"\r\n' 360 | "ST: {}\r\n" 361 | "MX: {}\r\n\r\n" 362 | ).format(mcast_ip, port, search_target, response_time) 363 | socket.setdefaulttimeout(response_time + 2) 364 | 365 | # Set up the socket and send the request 366 | sock = socket.socket(af_type, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 367 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 368 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) 369 | if iface: 370 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, 371 | str(iface+'\0').encode('utf-8')) 372 | sock.sendto(bytearray(msearch_str, 'utf-8'), mcast_connection) 373 | 374 | # On the same socket, wait for responses 375 | discovered_services = {} 376 | while True: 377 | try: 378 | process_ssdp_response( 379 | http.client.HTTPResponse(FakeSocket(sock.recv(1024))), 380 | discovered_services, pattern 381 | ) 382 | except socket.timeout: 383 | # We hit the timeout; done waiting for responses 384 | break 385 | 386 | sock.close() 387 | return discovered_services 388 | 389 | 390 | def hex_to_binary_str(hex_str: str): 391 | """Convert hex string to binary string 392 | 393 | @param hex_str: the hex string to convert 394 | @return: the binary string or None if the input is not a hex string 395 | """ 396 | try: 397 | return bin(int(hex_str, 16))[2:].zfill(len(hex_str) * 4) 398 | except ValueError: 399 | pass 400 | 401 | 402 | def monobit_frequency(bit_str: str): 403 | """Frequency (Monobit) Test 404 | 405 | Determine whether the number of ones and zeros in a sequence are 406 | approximately the same as would be expected for a truly random sequence. 407 | 408 | See section 2.1 in this NIST publication: 409 | https://tsapps.nist.gov/publication/get_pdf.cfm?pub_id=906762 410 | """ 411 | obs_sum = 0 412 | n = len(bit_str) 413 | for i in range(n): 414 | obs_sum += 1 if bit_str[i] == '1' else -1 415 | obs_stat = abs(obs_sum) / math.sqrt(n) 416 | p = math.erfc(obs_stat / math.sqrt(2)) 417 | return p 418 | 419 | 420 | def runs(bit_str: str): 421 | """Runs Test 422 | 423 | Determine whether the number of runs of ones and zeros of various 424 | lengths is as expected for a random sequence. 425 | 426 | See section 2.3 in this NIST publication: 427 | https://tsapps.nist.gov/publication/get_pdf.cfm?pub_id=906762 428 | """ 429 | n = len(bit_str) 430 | 431 | # pre-test 432 | ones = 0.0 433 | for i in range(n): 434 | ones += int(bit_str[i]) 435 | pi = ones / n 436 | tau = 2.0 / math.sqrt(n) 437 | if abs(pi - 0.5) >= tau or ones == n: 438 | # pre-test failed; do not run this test 439 | return 0.0 440 | 441 | # Runs test 442 | v_n = 1 443 | for i in range(n-1): 444 | v_n += 0 if bit_str[i] == bit_str[i+1] else 1 445 | p = math.erfc(abs(v_n - 2 * n * pi * (1 - pi)) / ( 446 | 2 * math.sqrt(2 * n) * pi * (1 - pi))) 447 | return p 448 | 449 | 450 | def random_sequence(token: str): 451 | """Run randomness tests on the given security token 452 | 453 | @param token: the security token to test as a hex string 454 | @return: None if token is not hex, True if token is random, False otherwise 455 | """ 456 | bit_str = hex_to_binary_str(token) 457 | if bit_str is None: 458 | return None 459 | 460 | for func in [monobit_frequency, runs]: 461 | p = func(bit_str) 462 | logging.debug('P-value of %s test for token %s is %s' % 463 | (func.__name__, token, p)) 464 | if p < 0.01: 465 | # print('P-value of %s test for token %s is %s' % 466 | # (func.__name__, token, p)) 467 | return False 468 | return True 469 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aenum 2 | colorama 3 | pyasn1 4 | pyasn1-modules 5 | requests 6 | sseclient-py 7 | urllib3 8 | -------------------------------------------------------------------------------- /rf_protocol_validator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | from redfish_protocol_validator.console_scripts import main 7 | 8 | if __name__ == '__main__': 9 | main() 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | from setuptools import setup 7 | from codecs import open 8 | 9 | with open("README.md", "r", "utf-8") as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name="redfish_protocol_validator", 14 | version="1.2.6", 15 | description="Redfish Protocol Validator", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | author="DMTF, https://www.dmtf.org/standards/feedback", 19 | license="BSD 3-clause \"New\" or \"Revised License\"", 20 | classifiers=[ 21 | "Development Status :: 5 - Production/Stable", 22 | "License :: OSI Approved :: BSD License", 23 | "Programming Language :: Python", 24 | "Topic :: Communications" 25 | ], 26 | keywords="Redfish", 27 | url="https://github.com/DMTF/Redfish-Protocol-Validator", 28 | packages=["redfish_protocol_validator"], 29 | entry_points={ 30 | 'console_scripts': ['rf_protocol_validator=redfish_protocol_validator.console_scripts:main'] 31 | }, 32 | install_requires=["aenum", "colorama", "pyasn1", "pyasn1-modules", 33 | "requests>=2.30.0", "sseclient-py", "urllib3"] 34 | ) 35 | -------------------------------------------------------------------------------- /test_conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": { 3 | "command": "$interpreter rf_protocol_validator.py -r $base_url -u $username -p $password --report-dir $output_subdir --no-cert-check" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39,pep8 3 | skip_missing_interpreters = True 4 | 5 | [testenv] 6 | usedevelop = True 7 | install_command = pip install {opts} {packages} 8 | deps = 9 | coverage 10 | fixtures 11 | nose 12 | nose-timer 13 | commands = 14 | nosetests \ 15 | --with-timer \ 16 | --with-coverage --cover-erase --cover-package=redfish_protocol_validator \ 17 | --cover-inclusive --cover-tests --cover-html \ 18 | --cover-html-dir=.cover {posargs} 19 | 20 | [testenv:pep8] 21 | basepython = python3.9 22 | deps = flake8 23 | commands = flake8 24 | -------------------------------------------------------------------------------- /unittests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMTF/Redfish-Protocol-Validator/e2f28669e4e4cc7dff1634e69a160c779e6752df/unittests/__init__.py -------------------------------------------------------------------------------- /unittests/test_accounts.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import string 7 | import unittest 8 | from unittest import mock, TestCase 9 | 10 | import requests 11 | 12 | from redfish_protocol_validator import accounts 13 | from redfish_protocol_validator.constants import ResourceType 14 | from redfish_protocol_validator.system_under_test import SystemUnderTest 15 | from unittests.utils import add_response 16 | 17 | 18 | class Accounts(TestCase): 19 | 20 | def setUp(self): 21 | super(Accounts, self).setUp() 22 | self.sut = SystemUnderTest('http://127.0.0.1:8000', 'oper', 'xyzzy') 23 | self.sut.set_sessions_uri('/redfish/v1/SessionService/Sessions') 24 | self.accounts_uri = '/redfish/v1/AccountService/Accounts' 25 | self.account_uri1 = '/redfish/v1/AccountService/Accounts/1' 26 | self.account_uri2 = '/redfish/v1/AccountService/Accounts/2' 27 | self.account_uri3 = '/redfish/v1/AccountService/Accounts/3' 28 | self.roles_uri = '/redfish/v1/AccountService/Roles' 29 | self.role_uri1 = '/redfish/v1/AccountService/Roles/ReadOnlyUser' 30 | self.role_uri2 = '/redfish/v1/AccountService/Roles/Administrator' 31 | self.sut.set_nav_prop_uri('Accounts', self.accounts_uri) 32 | self.sut.set_nav_prop_uri('Roles', self.roles_uri) 33 | self.session = mock.MagicMock(spec=requests.Session) 34 | payload = { 35 | 'Members': [ 36 | {'@odata.id': self.account_uri1}, 37 | {'@odata.id': self.account_uri2}, 38 | {'@odata.id': self.account_uri3} 39 | ] 40 | } 41 | add_response(self.sut, self.accounts_uri, json=payload) 42 | payload = {'UserName': 'Administrator', 'Enabled': True} 43 | add_response(self.sut, self.account_uri1, json=payload, 44 | res_type=ResourceType.MANAGER_ACCOUNT) 45 | payload = {'UserName': '', 'Enabled': False} 46 | add_response(self.sut, self.account_uri3, json=payload, 47 | res_type=ResourceType.MANAGER_ACCOUNT) 48 | payload = { 49 | 'Members': [ 50 | {'@odata.id': self.role_uri1}, 51 | {'@odata.id': self.role_uri2} 52 | ] 53 | } 54 | add_response(self.sut, self.roles_uri, json=payload) 55 | payload = {'Id': 'ReadOnly'} 56 | add_response(self.sut, self.role_uri1, json=payload, 57 | res_type=ResourceType.ROLE) 58 | 59 | def test_get_user_names(self): 60 | users = accounts.get_user_names(self.sut, self.session) 61 | self.assertEqual(users, {'Administrator'}) 62 | 63 | def test_select_standard_role_pass(self): 64 | role = accounts.select_standard_role(self.sut, self.session) 65 | self.assertEqual(role, 'ReadOnly') 66 | 67 | @mock.patch('redfish_protocol_validator.accounts.logging.error') 68 | def test_select_standard_role_fail(self, mock_logging_error): 69 | payload = {'Id': 'Guest'} 70 | add_response(self.sut, self.role_uri1, json=payload, 71 | res_type=ResourceType.ROLE) 72 | role = accounts.select_standard_role(self.sut, self.session) 73 | self.assertIsNone(role) 74 | self.assertEqual(mock_logging_error.call_count, 1) 75 | args = mock_logging_error.call_args[0] 76 | self.assertIn('Predefined role "ReadOnly" not found', args[0]) 77 | 78 | def test_new_username(self): 79 | existing = {'admin', 'root', 'alice', 'bob'} 80 | user1 = accounts.new_username(existing) 81 | self.assertFalse(user1 in existing) 82 | self.assertTrue(user1.startswith('rfpv')) 83 | self.assertEqual(len(user1), 8) 84 | existing.add(user1) 85 | user2 = accounts.new_username(existing) 86 | self.assertFalse(user2 in existing) 87 | self.assertTrue(user2.startswith('rfpv')) 88 | self.assertEqual(len(user2), 8) 89 | self.assertNotEqual(user1, user2) 90 | 91 | def test_new_password(self): 92 | pass1 = accounts.new_password(self.sut) 93 | self.assertEqual(len(pass1), 16) 94 | self.assertTrue(set(pass1).intersection(set(string.ascii_uppercase))) 95 | self.assertTrue(set(pass1).intersection(set(string.ascii_lowercase))) 96 | self.assertTrue(set(pass1).intersection(set(string.digits))) 97 | pass2 = accounts.new_password(self.sut) 98 | self.assertEqual(len(pass2), 16) 99 | self.assertTrue(set(pass2).intersection(set(string.ascii_uppercase))) 100 | self.assertTrue(set(pass2).intersection(set(string.ascii_lowercase))) 101 | self.assertTrue(set(pass2).intersection(set(string.digits))) 102 | self.assertNotEqual(pass1, pass2) 103 | pass3 = accounts.new_password(self.sut, symbols=1) 104 | self.assertEqual(len(pass3), 16) 105 | self.assertTrue(set(pass3).intersection(set(string.ascii_uppercase))) 106 | self.assertTrue(set(pass3).intersection(set(string.ascii_lowercase))) 107 | self.assertTrue(set(pass3).intersection(set(string.digits))) 108 | self.assertTrue(set(pass3).intersection(set('_-.'))) 109 | 110 | def test_add_account_via_patch_pass(self): 111 | self.session.post.return_value.status_code = ( 112 | requests.codes.METHOD_NOT_ALLOWED) 113 | self.session.patch.return_value.status_code = requests.codes.OK 114 | user, pwd, uri = accounts.add_account(self.sut, self.session) 115 | self.assertEqual(len(user), 8) 116 | self.assertTrue(user.startswith('rfpv')) 117 | self.assertEqual(uri, self.account_uri3) 118 | 119 | def test_add_account_via_patch_enable(self): 120 | etag = '0123456789abcdef' 121 | self.session.get.return_value.status_code = requests.codes.OK 122 | self.session.get.return_value.ok = True 123 | self.session.get.return_value.json.return_value = {'Enabled': False} 124 | self.session.get.return_value.headers = {'ETag': etag} 125 | self.session.post.return_value.status_code = ( 126 | requests.codes.METHOD_NOT_ALLOWED) 127 | self.session.patch.return_value.status_code = requests.codes.OK 128 | user, pwd, uri = accounts.add_account(self.sut, self.session) 129 | self.session.patch.assert_called_with( 130 | self.sut.rhost + self.account_uri3, json={'Enabled': True}, 131 | headers={'If-Match': etag}, auth=None, timeout=30) 132 | 133 | @mock.patch('redfish_protocol_validator.accounts.logging.error') 134 | def test_add_account_via_patch_fail1(self, mock_logging_error): 135 | payload = {'UserName': 'alice', 'Enabled': True} 136 | add_response(self.sut, self.account_uri3, json=payload, 137 | res_type=ResourceType.MANAGER_ACCOUNT) 138 | self.session.post.return_value.status_code = ( 139 | requests.codes.METHOD_NOT_ALLOWED) 140 | self.session.patch.return_value.status_code = requests.codes.OK 141 | user, pwd, uri = accounts.add_account(self.sut, self.session) 142 | self.assertIsNone(user) 143 | self.assertIsNone(uri) 144 | self.assertEqual(mock_logging_error.call_count, 1) 145 | args = mock_logging_error.call_args[0] 146 | self.assertIn('No empty account slot found', args[0]) 147 | 148 | def test_add_account_via_patch_fail2(self): 149 | self.session.post.return_value.status_code = ( 150 | requests.codes.METHOD_NOT_ALLOWED) 151 | self.session.patch.return_value.status_code = ( 152 | requests.codes.BAD_REQUEST) 153 | user, pwd, uri = accounts.add_account(self.sut, self.session) 154 | self.assertIsNone(uri) 155 | 156 | def test_add_account_pass(self): 157 | new_uri = '/redfish/v1/AccountService/Accounts/4' 158 | self.session.post.return_value.status_code = requests.codes.CREATED 159 | self.session.post.return_value.headers = { 160 | 'Location': self.sut.rhost + new_uri 161 | } 162 | user, pwd, uri = accounts.add_account(self.sut, self.session) 163 | self.assertEqual(len(user), 8) 164 | self.assertTrue(user.startswith('rfpv')) 165 | self.assertEqual(uri, new_uri) 166 | 167 | @mock.patch('redfish_protocol_validator.accounts.logging.error') 168 | def test_add_account_no_collection(self, mock_logging_error): 169 | self.sut.set_nav_prop_uri('Accounts', None) 170 | user, pwd, uri = accounts.add_account(self.sut, self.session) 171 | self.assertIsNone(user) 172 | self.assertIsNone(uri) 173 | self.assertEqual(mock_logging_error.call_count, 1) 174 | args = mock_logging_error.call_args[0] 175 | self.assertIn('No accounts collection found', args[0]) 176 | 177 | @mock.patch('redfish_protocol_validator.accounts.logging.error') 178 | def test_add_account_collection_get_fail(self, mock_logging_error): 179 | add_response(self.sut, self.accounts_uri, json={}, 180 | status_code=requests.codes.NOT_FOUND) 181 | user, pwd, uri = accounts.add_account(self.sut, self.session) 182 | self.assertIsNone(user) 183 | self.assertIsNone(uri) 184 | self.assertEqual(mock_logging_error.call_count, 1) 185 | args = mock_logging_error.call_args[0] 186 | self.assertIn('Accounts collection could not be read', args[0]) 187 | 188 | def test_add_account_collection_allow_header_post(self): 189 | payload = { 190 | 'Members': [ 191 | {'@odata.id': self.account_uri1}, 192 | {'@odata.id': self.account_uri2}, 193 | {'@odata.id': self.account_uri3} 194 | ] 195 | } 196 | headers = {'Allow': 'GET, POST'} 197 | add_response(self.sut, self.accounts_uri, json=payload, 198 | headers=headers) 199 | new_uri = '/redfish/v1/AccountService/Accounts/4' 200 | self.session.post.return_value.status_code = requests.codes.CREATED 201 | self.session.post.return_value.headers = { 202 | 'Location': self.sut.rhost + new_uri 203 | } 204 | user, pwd, uri = accounts.add_account(self.sut, self.session) 205 | self.assertEqual(len(user), 8) 206 | self.assertTrue(user.startswith('rfpv')) 207 | self.assertEqual(uri, new_uri) 208 | 209 | def test_add_account_collection_allow_header_no_post(self): 210 | payload = { 211 | 'Members': [ 212 | {'@odata.id': self.account_uri1}, 213 | {'@odata.id': self.account_uri2}, 214 | {'@odata.id': self.account_uri3} 215 | ] 216 | } 217 | headers = {'Allow': 'GET'} 218 | add_response(self.sut, self.accounts_uri, json=payload, 219 | headers=headers) 220 | self.session.patch.return_value.status_code = requests.codes.OK 221 | user, pwd, uri = accounts.add_account(self.sut, self.session) 222 | self.assertEqual(len(user), 8) 223 | self.assertTrue(user.startswith('rfpv')) 224 | self.assertEqual(uri, self.account_uri3) 225 | 226 | def test_add_account_no_location_header(self): 227 | new_uri = '/redfish/v1/AccountService/Accounts/4' 228 | self.session.post.return_value.status_code = requests.codes.CREATED 229 | self.session.post.return_value.headers = {} 230 | self.session.post.return_value.json.return_value = { 231 | '@odata.id': new_uri 232 | } 233 | user, pwd, uri = accounts.add_account(self.sut, self.session) 234 | self.assertEqual(len(user), 8) 235 | self.assertTrue(user.startswith('rfpv')) 236 | self.assertEqual(uri, new_uri) 237 | 238 | def test_patch_account1(self): 239 | self.session.get.return_value.status_code = requests.codes.OK 240 | self.session.get.return_value.ok = True 241 | self.session.get.return_value.headers = {} 242 | uri = '/redfish/v1/AccountService/Accounts/4' 243 | accounts.patch_account(self.sut, self.session, uri) 244 | self.assertEqual(self.session.patch.call_count, 4) 245 | 246 | def test_patch_account2(self): 247 | self.session.get.return_value.status_code = requests.codes.OK 248 | self.session.get.return_value.ok = True 249 | self.session.get.return_value.headers = {'ETag': '0123456789abcdef'} 250 | uri = '/redfish/v1/AccountService/Accounts/4' 251 | accounts.patch_account(self.sut, self.session, uri) 252 | self.assertEqual(self.session.patch.call_count, 5) 253 | 254 | def test_delete_account_via_patch_pass(self): 255 | self.session.delete.return_value.status_code = ( 256 | requests.codes.METHOD_NOT_ALLOWED) 257 | self.session.patch.return_value.status_code = requests.codes.OK 258 | accounts.delete_account(self.sut, self.session, 'Administrator', 259 | self.account_uri1) 260 | self.assertEqual(self.session.delete.call_count, 1) 261 | self.assertEqual(self.session.patch.call_count, 1) 262 | 263 | @mock.patch('redfish_protocol_validator.accounts.logging.error') 264 | def test_delete_account_via_patch_bad_username(self, mock_logging_error): 265 | self.session.delete.return_value.status_code = ( 266 | requests.codes.METHOD_NOT_ALLOWED) 267 | self.session.patch.return_value.status_code = requests.codes.OK 268 | accounts.delete_account(self.sut, self.session, 'bad_name', 269 | self.account_uri1) 270 | self.assertEqual(self.session.delete.call_count, 1) 271 | self.session.patch.assert_not_called() 272 | self.assertEqual(mock_logging_error.call_count, 1) 273 | args = mock_logging_error.call_args[0] 274 | self.assertIn('did not match expected username', args[0]) 275 | 276 | @mock.patch('redfish_protocol_validator.accounts.logging.error') 277 | def test_delete_account_via_patch_get_failed(self, mock_logging_error): 278 | self.session.delete.return_value.status_code = ( 279 | requests.codes.METHOD_NOT_ALLOWED) 280 | self.session.patch.return_value.status_code = requests.codes.OK 281 | payload = {'UserName': 'Administrator', 'Enabled': True} 282 | add_response(self.sut, self.account_uri1, json=payload, 283 | status_code=requests.codes.NOT_FOUND, 284 | res_type=ResourceType.MANAGER_ACCOUNT) 285 | 286 | accounts.delete_account(self.sut, self.session, 'Administrator', 287 | self.account_uri1) 288 | self.assertEqual(self.session.delete.call_count, 1) 289 | self.session.patch.assert_not_called() 290 | self.assertEqual(mock_logging_error.call_count, 1) 291 | args = mock_logging_error.call_args[0] 292 | self.assertIn('could not read account uri', args[0]) 293 | 294 | def test_delete_account_pass(self): 295 | self.session.delete.return_value.status_code = requests.codes.OK 296 | accounts.delete_account(self.sut, self.session, 'Administrator', 297 | self.account_uri1) 298 | self.assertEqual(self.session.delete.call_count, 1) 299 | 300 | def test_delete_account_allow_header_delete(self): 301 | payload = {'UserName': 'Administrator', 'Enabled': True} 302 | headers = {'Allow': 'GET, POST, DELETE'} 303 | add_response(self.sut, self.account_uri1, json=payload, 304 | status_code=requests.codes.NOT_FOUND, 305 | res_type=ResourceType.MANAGER_ACCOUNT, 306 | headers=headers) 307 | self.session.delete.return_value.status_code = requests.codes.OK 308 | accounts.delete_account(self.sut, self.session, 'Administrator', 309 | self.account_uri1) 310 | self.assertEqual(self.session.delete.call_count, 1) 311 | 312 | def test_delete_account_allow_header_no_delete(self): 313 | payload = {'UserName': 'Administrator', 'Enabled': True} 314 | headers = {'Allow': 'GET, POST'} 315 | add_response(self.sut, self.account_uri1, json=payload, 316 | res_type=ResourceType.MANAGER_ACCOUNT, 317 | headers=headers) 318 | self.session.patch.return_value.status_code = requests.codes.OK 319 | accounts.delete_account(self.sut, self.session, 'Administrator', 320 | self.account_uri1) 321 | self.assertEqual(self.session.patch.call_count, 1) 322 | 323 | @mock.patch('redfish_protocol_validator.accounts.requests.get') 324 | @mock.patch('redfish_protocol_validator.accounts.requests.post') 325 | @mock.patch('redfish_protocol_validator.accounts.requests.patch') 326 | def test_password_change_required1(self, mock_patch, mock_post, mock_get): 327 | user = 'bob' 328 | pwd = 'xyzzy' 329 | payload = { 330 | 'PasswordChangeRequired': True 331 | } 332 | etag = 'A89B031B62' 333 | accounts.password_change_required(self.sut, self.session, user, pwd, 334 | self.account_uri1, payload, etag) 335 | self.assertEqual(mock_get.call_count, 2) 336 | self.assertEqual(mock_post.call_count, 1) 337 | self.assertEqual(mock_patch.call_count, 1) 338 | 339 | @mock.patch('redfish_protocol_validator.accounts.requests.get') 340 | @mock.patch('redfish_protocol_validator.accounts.requests.post') 341 | @mock.patch('redfish_protocol_validator.accounts.requests.patch') 342 | def test_password_change_required2(self, mock_patch, mock_post, mock_get): 343 | user = 'bob' 344 | pwd = 'xyzzy' 345 | payload = { 346 | 'PasswordChangeRequired': False 347 | } 348 | etag = 'A89B031B62' 349 | request = mock.Mock(spec=requests.Request) 350 | request.method = 'PATCH' 351 | response = mock.Mock(spec=requests.Response) 352 | response.status_code = requests.codes.BAD_REQUEST 353 | response.ok = False 354 | response.headers = {} 355 | response.request = request 356 | 357 | self.session.patch.return_value = response 358 | accounts.password_change_required(self.sut, self.session, user, pwd, 359 | self.account_uri1, payload, etag) 360 | self.assertEqual(mock_get.call_count, 0) 361 | self.assertEqual(mock_post.call_count, 0) 362 | self.assertEqual(mock_patch.call_count, 0) 363 | 364 | @mock.patch('redfish_protocol_validator.accounts.requests.get') 365 | @mock.patch('redfish_protocol_validator.accounts.requests.post') 366 | @mock.patch('redfish_protocol_validator.accounts.requests.patch') 367 | def test_password_change_required_no_prop(self, mock_patch, mock_post, 368 | mock_get): 369 | user = 'bob' 370 | pwd = 'xyzzy' 371 | payload = {} 372 | etag = 'A89B031B62' 373 | accounts.password_change_required(self.sut, self.session, user, pwd, 374 | self.account_uri1, payload, etag) 375 | self.assertEqual(mock_get.call_count, 0) 376 | self.assertEqual(mock_post.call_count, 0) 377 | self.assertEqual(mock_patch.call_count, 0) 378 | 379 | 380 | if __name__ == '__main__': 381 | unittest.main() 382 | -------------------------------------------------------------------------------- /unittests/test_constants.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import unittest 7 | from unittest import TestCase 8 | 9 | from redfish_protocol_validator.constants import Assertion, ResourceType, Result 10 | 11 | 12 | class Constants(TestCase): 13 | def setUp(self): 14 | super(Constants, self).setUp() 15 | 16 | def test_assertion_repr(self): 17 | self.assertEqual(repr(Assertion.PROTO_URI_SAFE_CHARS), 18 | '') 19 | 20 | def test_resource_type_repr(self): 21 | self.assertEqual(repr(ResourceType.MANAGER_ACCOUNT), 22 | '') 23 | 24 | def test_result_repr(self): 25 | self.assertEqual(repr(Result.PASS), '') 26 | 27 | 28 | if __name__ == '__main__': 29 | unittest.main() 30 | -------------------------------------------------------------------------------- /unittests/test_protocol_details.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import string 7 | import unittest 8 | from unittest import mock, TestCase 9 | 10 | import requests 11 | 12 | from redfish_protocol_validator import protocol_details as proto 13 | from redfish_protocol_validator.constants import Assertion, RequestType, ResourceType, Result 14 | from redfish_protocol_validator.system_under_test import SystemUnderTest 15 | from unittests.utils import add_response, get_result 16 | 17 | 18 | class ProtocolDetails(TestCase): 19 | 20 | def _add_get_responses(self, status=requests.codes.OK): 21 | add_response(self.sut, '/redfish', 'GET', status, 22 | json={'v1': '/redfish/v1/'}) 23 | add_response(self.sut, '/redfish/v1/', 'GET', status, 24 | json={'foo': 'bar'}) 25 | add_response(self.sut, '/redfish/v1', 'GET', status, 26 | json={'foo': 'bar'}) 27 | add_response(self.sut, '/redfish/v1/odata', 'GET', status, 28 | json={'foo': 'bar'}) 29 | add_response(self.sut, '/redfish/v1/AccountService/Accounts/1', 'GET', 30 | status, json={'foo': 'bar'}, 31 | res_type=ResourceType.MANAGER_ACCOUNT) 32 | add_response(self.sut, '/redfish/v1/$metadata', 'GET', status, 33 | text='') 34 | add_response(self.sut, self.sut.server_sent_event_uri, 'GET', status, 35 | text=': stream keep-alive', 36 | request_type=RequestType.STREAMING) 37 | 38 | def setUp(self): 39 | super(ProtocolDetails, self).setUp() 40 | self.sut = SystemUnderTest('http://127.0.0.1:8000', 'oper', 'xyzzy') 41 | sse_uri = '/redfish/v1/EventService/SSE' 42 | self.sut.set_server_sent_event_uri(sse_uri) 43 | self._add_get_responses(status=requests.codes.OK) 44 | add_response(self.sut, '/redfish/v1/AccountService/Accounts', 'POST', 45 | requests.codes.CREATED, json={'foo': 'bar'}, 46 | request_payload='{"UserName": "bob"}') 47 | 48 | def test_split_path(self): 49 | path, query, frag = proto.split_path('/foo/Bar/x?q1#f1') 50 | self.assertEqual(path, '/foo/Bar/x') 51 | self.assertEqual(query, 'q1') 52 | self.assertEqual(frag, 'f1') 53 | path, query, frag = proto.split_path('/foo/Bar/x#f2') 54 | self.assertEqual(path, '/foo/Bar/x') 55 | self.assertEqual(query, '') 56 | self.assertEqual(frag, 'f2') 57 | path, query, frag = proto.split_path('/foo/Bar/x?q2') 58 | self.assertEqual(path, '/foo/Bar/x') 59 | self.assertEqual(query, 'q2') 60 | self.assertEqual(frag, '') 61 | path, query, frag = proto.split_path('/foo/Bar/x#f3?q3') 62 | self.assertEqual(path, '/foo/Bar/x') 63 | self.assertEqual(query, '') 64 | self.assertEqual(frag, 'f3?q3') 65 | 66 | def test_safe_uri(self): 67 | # positive 68 | self.assertTrue(proto.safe_uri('/foo/Bar/0')) 69 | self.assertTrue(proto.safe_uri('/foo/Bar.1')) 70 | self.assertTrue(proto.safe_uri('/foo/Bar-2')) 71 | self.assertTrue(proto.safe_uri('/foo/Bar+3')) 72 | self.assertTrue(proto.safe_uri('/foo/Bar_4')) 73 | self.assertTrue(proto.safe_uri('/foo/Bar!5')) 74 | self.assertTrue(proto.safe_uri('/foo/Bar$6')) 75 | self.assertTrue(proto.safe_uri('/foo/Bar&7')) 76 | self.assertTrue(proto.safe_uri('/foo/Bar\'8\'')) 77 | self.assertTrue(proto.safe_uri('/foo/Bar(9)')) 78 | self.assertTrue(proto.safe_uri('/foo/Bar*1')) 79 | self.assertTrue(proto.safe_uri('/foo/Bar:1')) 80 | self.assertTrue(proto.safe_uri('/foo/Bar;1')) 81 | self.assertTrue(proto.safe_uri('/foo/Bar=1')) 82 | self.assertTrue(proto.safe_uri('/foo/Bar@1')) 83 | self.assertTrue(proto.safe_uri('/foo/Bar#Fans/0')) 84 | self.assertTrue(proto.safe_uri('/foo/Bar?only')) 85 | self.assertTrue(proto.safe_uri('/foo/Bar?1#2')) 86 | self.assertTrue(proto.safe_uri('/foo/Bar?select=X%20Y')) 87 | self.assertTrue(proto.safe_uri('/foo/Bar?select=X%2dY')) 88 | self.assertTrue(proto.safe_uri('/foo/Bar?select=X%bCY')) 89 | self.assertTrue(proto.safe_uri('/foo/Bar%201')) 90 | # negative 91 | self.assertFalse(proto.safe_uri('/foo/Bar 1')) 92 | self.assertFalse(proto.safe_uri('/foo/Bar<1>')) 93 | self.assertFalse(proto.safe_uri('/foo/Bar"1')) 94 | self.assertFalse(proto.safe_uri('/foo/Bar%1')) 95 | self.assertFalse(proto.safe_uri('/foo/Bar{1}')) 96 | self.assertFalse(proto.safe_uri('/foo/Bar|1')) 97 | self.assertFalse(proto.safe_uri('/foo/Bar\\1')) 98 | self.assertFalse(proto.safe_uri('/foo/Bar^1')) 99 | self.assertFalse(proto.safe_uri('/foo/Bar~1')) 100 | self.assertFalse(proto.safe_uri('/foo/Bar[1]')) 101 | self.assertFalse(proto.safe_uri('/foo/Bar`1')) 102 | self.assertFalse(proto.safe_uri('/foo/Bar#1#2')) 103 | self.assertFalse(proto.safe_uri('/foo/Bar#1?2')) 104 | self.assertFalse(proto.safe_uri('/foo/Bar?1?2')) 105 | self.assertFalse(proto.safe_uri('/foo/Bar%1')) 106 | self.assertFalse(proto.safe_uri('/foo/Bar?select=A%2')) 107 | self.assertFalse(proto.safe_uri('/foo/Bar?select=A%2GB')) 108 | 109 | def test_no_encoded_char_in_uri(self): 110 | # positive 111 | self.assertFalse(proto.encoded_char_in_uri('/foo/Bar%1')) 112 | self.assertFalse(proto.encoded_char_in_uri('/foo/Bar%1G')) 113 | self.assertFalse(proto.encoded_char_in_uri('/foo/Bar?q=X%20Y#x/y')) 114 | # negative 115 | self.assertTrue(proto.encoded_char_in_uri('/foo/Bar#a/x%20y')) 116 | self.assertTrue(proto.encoded_char_in_uri('/foo/Bar#a/x%BC0y')) 117 | self.assertTrue(proto.encoded_char_in_uri('/foo/Bar%1Ca#a/x/y')) 118 | self.assertTrue(proto.encoded_char_in_uri('/foo/Bar%B2#a/x/y')) 119 | self.assertTrue(proto.encoded_char_in_uri('/foo/Bar%aF#a/x/y')) 120 | 121 | def test_check_etag_valid(self): 122 | punctuation = string.punctuation.replace('"', '') # no double quote 123 | # positive 124 | self.assertTrue(proto.check_etag_valid('""')) 125 | self.assertTrue(proto.check_etag_valid('W/""')) 126 | self.assertTrue(proto.check_etag_valid('"xyzzy"')) 127 | self.assertTrue(proto.check_etag_valid('W/"xyzzy"')) 128 | self.assertTrue(proto.check_etag_valid('"abcXYZ123"')) 129 | self.assertTrue(proto.check_etag_valid('"%s"' % punctuation)) 130 | self.assertTrue(proto.check_etag_valid('"\x7B\x7F\x80\xB8\xFF"')) 131 | # negative 132 | self.assertFalse(proto.check_etag_valid('\'\'')) 133 | self.assertFalse(proto.check_etag_valid('xyzzy')) 134 | self.assertFalse(proto.check_etag_valid('"xy zy"')) 135 | self.assertFalse(proto.check_etag_valid('"xy"zy"')) 136 | self.assertFalse(proto.check_etag_valid('W/"xy\x00zy"')) 137 | self.assertFalse(proto.check_etag_valid('"xy\x13zy"')) 138 | self.assertFalse(proto.check_etag_valid('w/"xyzzy"')) 139 | self.assertFalse(proto.check_etag_valid('W\\"xyzzy"')) 140 | self.assertFalse(proto.check_etag_valid('W/"xyzzy')) 141 | self.assertFalse(proto.check_etag_valid('xyzzy"')) 142 | self.assertFalse(proto.check_etag_valid('"xyzzy" ')) 143 | self.assertFalse(proto.check_etag_valid(' "xyzzy"')) 144 | 145 | def test_check_slash_redfish(self): 146 | response = mock.Mock(spec=requests.Response) 147 | request = mock.Mock(spec=requests.Request) 148 | request.method = 'GET' 149 | response.request = request 150 | # positive 151 | response.status_code = requests.codes.OK 152 | response.json.return_value = {"v1": "/redfish/v1/"} 153 | result, msg = proto.check_slash_redfish('/redfish', response) 154 | self.assertEqual(result, Result.PASS) 155 | self.assertIn('Test passed', msg) 156 | # negative 157 | response.status_code = requests.codes.NOT_FOUND 158 | response.json.return_value = None 159 | result, msg = proto.check_slash_redfish('/redfish', response) 160 | self.assertEqual(result, Result.FAIL) 161 | self.assertIn('GET request to URI /redfish received status 404', msg) 162 | # negative 163 | response.status_code = requests.codes.OK 164 | response.json.return_value = {"v1": "/redfish/v1"} 165 | result, msg = proto.check_slash_redfish('/redfish', response) 166 | self.assertEqual(result, Result.FAIL) 167 | self.assertIn('Content of /redfish resource contained ', msg) 168 | 169 | def test_response_is_json(self): 170 | response = mock.Mock(spec=requests.Response) 171 | request = mock.Mock(spec=requests.Request) 172 | request.method = 'GET' 173 | response.request = request 174 | # positive 175 | response.status_code = requests.codes.OK 176 | response.json.return_value = {"foo": "bar"} 177 | result, msg = proto.response_is_json('/redfish/v1/', response) 178 | self.assertEqual(result, Result.PASS) 179 | self.assertIn('Test passed', msg) 180 | # negative 181 | response.status_code = requests.codes.NOT_FOUND 182 | response.json.return_value = None 183 | result, msg = proto.response_is_json('/redfish/v1/', response) 184 | self.assertEqual(result, Result.FAIL) 185 | self.assertIn('received status 404', msg) 186 | # negative 187 | response.status_code = requests.codes.OK 188 | response.json.side_effect = ValueError('Error parsing JSON') 189 | result, msg = proto.response_is_json('/redfish/v1/', response) 190 | self.assertEqual(result, Result.FAIL) 191 | self.assertIn('did not return JSON response', msg) 192 | 193 | def test_response_is_xml(self): 194 | response = mock.Mock(spec=requests.Response) 195 | request = mock.Mock(spec=requests.Request) 196 | request.method = 'GET' 197 | response.request = request 198 | # positive 199 | response.status_code = requests.codes.OK 200 | response.text = '' 201 | result, msg = proto.response_is_xml('/redfish/v1/$metadata', response) 202 | self.assertEqual(result, Result.PASS) 203 | self.assertIn('Test passed', msg) 204 | # negative 205 | response.status_code = requests.codes.NOT_FOUND 206 | response.text = None 207 | result, msg = proto.response_is_xml('/redfish/v1/$metadata', response) 208 | self.assertEqual(result, Result.FAIL) 209 | self.assertIn('received status 404', msg) 210 | # negative 211 | response.status_code = requests.codes.OK 212 | response.text = '{"foo": "bar"}' 213 | result, msg = proto.response_is_xml('/redfish/v1/$metadata', response) 214 | self.assertEqual(result, Result.FAIL) 215 | self.assertIn('did not return XML response', msg) 216 | 217 | def test_relative_ref(self): 218 | # positive 219 | result, msg = proto.check_relative_ref('//localhost/redfish/v1') 220 | self.assertEqual(result, Result.PASS) 221 | result, msg = proto.check_relative_ref('//example.com/redfish/v1') 222 | self.assertEqual(result, Result.PASS) 223 | result, msg = proto.check_relative_ref('//127.0.0.1:8000/redfish/v1') 224 | self.assertEqual(result, Result.PASS) 225 | result, msg = proto.check_relative_ref('/redfish/v1') 226 | self.assertEqual(result, Result.PASS) 227 | # negative 228 | result, msg = proto.check_relative_ref('///example.com/redfish/v1') 229 | self.assertEqual(result, Result.FAIL) 230 | self.assertIn('should not start with a triple forward slash', msg) 231 | result, msg = proto.check_relative_ref('//localhost') 232 | self.assertEqual(result, Result.FAIL) 233 | self.assertIn('does not include the expected absolute-path', msg) 234 | result, msg = proto.check_relative_ref('//') 235 | self.assertEqual(result, Result.FAIL) 236 | self.assertIn('does not include the expected authority', msg) 237 | 238 | def test_response_content_type_is_json(self): 239 | response = mock.Mock(spec=requests.Response) 240 | request = mock.Mock(spec=requests.Request) 241 | request.method = 'GET' 242 | response.request = request 243 | response.headers = { 244 | 'Content-Type': 'application/json' 245 | } 246 | r, msg = proto.response_content_type_is_json('/redfish/v1/foo', 247 | response) 248 | self.assertEqual(r, Result.PASS) 249 | self.assertEqual('Test passed', msg) 250 | response.headers = { 251 | 'Content-Type': 'application/JSON' 252 | } 253 | r, msg = proto.response_content_type_is_json('/redfish/v1/foo', 254 | response) 255 | self.assertEqual(r, Result.PASS) 256 | self.assertEqual('Test passed', msg) 257 | response.headers = { 258 | 'Content-Type': 'application/json; charset=utf-8' 259 | } 260 | r, msg = proto.response_content_type_is_json('/redfish/v1/foo', 261 | response) 262 | self.assertEqual(r, Result.PASS) 263 | self.assertEqual('Test passed', msg) 264 | response.headers = {} 265 | r, msg = proto.response_content_type_is_json('/redfish/v1/foo', 266 | response) 267 | self.assertEqual(r, Result.FAIL) 268 | self.assertIn('expected media type', msg) 269 | response.headers = { 270 | 'Content-Type': 'application/xml' 271 | } 272 | r, msg = proto.response_content_type_is_json('/redfish/v1/foo', 273 | response) 274 | self.assertEqual(r, Result.FAIL) 275 | self.assertIn('expected media type', msg) 276 | 277 | def test_check_etag_present(self): 278 | response = mock.Mock(spec=requests.Response) 279 | request = mock.Mock(spec=requests.Request) 280 | request.method = 'GET' 281 | response.request = request 282 | response.headers = { 283 | 'ETag': '48305216' 284 | } 285 | r, msg = proto.check_etag_present('/redfish/v1/foo', response) 286 | self.assertEqual(r, Result.PASS) 287 | self.assertEqual('Test passed', msg) 288 | response.headers = {} 289 | r, msg = proto.check_etag_present('/redfish/v1/foo', response) 290 | self.assertEqual(r, Result.FAIL) 291 | self.assertIn('did not return an ETag', msg) 292 | 293 | def test_test_valid_etag_fail(self): 294 | uri = '/redfish/v1/foo' 295 | response = add_response( 296 | self.sut, uri, 'GET', status_code=requests.codes.OK, 297 | headers={'ETag': 'W/" 9573"'}) # space char not allowed 298 | proto.test_valid_etag(self.sut, uri, response) 299 | result = get_result(self.sut, Assertion.PROTO_ETAG_RFC7232, 300 | 'GET', uri) 301 | self.assertIsNotNone(result) 302 | self.assertEqual(Result.FAIL, result['result']) 303 | self.assertIn('Response from GET request to URI %s returned invalid ' 304 | 'ETag header value' % uri, result['msg']) 305 | 306 | def test_test_valid_etag_pass(self): 307 | uri = '/redfish/v1/foo' 308 | response = add_response( 309 | self.sut, uri, 'GET', status_code=requests.codes.OK, 310 | headers={}, json={'@odata.etag': '"4A30/CF16"'}) 311 | proto.test_valid_etag(self.sut, uri, response) 312 | result = get_result(self.sut, Assertion.PROTO_ETAG_RFC7232, 313 | 'GET', uri) 314 | self.assertIsNotNone(result) 315 | self.assertEqual(Result.PASS, result['result']) 316 | 317 | def test_test_http_supported_methods_pass(self): 318 | proto.test_http_supported_methods(self.sut) 319 | result = get_result(self.sut, Assertion.PROTO_HTTP_SUPPORTED_METHODS, 320 | 'GET', '') 321 | self.assertIsNotNone(result) 322 | self.assertEqual(Result.PASS, result['result']) 323 | 324 | def test_test_http_supported_methods_fail(self): 325 | self._add_get_responses(status=requests.codes.NOT_FOUND) 326 | proto.test_http_supported_methods(self.sut) 327 | result = get_result(self.sut, Assertion.PROTO_HTTP_SUPPORTED_METHODS, 328 | 'GET', '') 329 | self.assertIsNotNone(result) 330 | self.assertEqual(Result.FAIL, result['result']) 331 | self.assertIn('No GET requests had a successful response', 332 | result['msg']) 333 | 334 | def test_test_http_unsupported_methods_pass1(self): 335 | add_response(self.sut, '/redfish/v1/', 'DELETE', 336 | requests.codes.METHOD_NOT_ALLOWED, 337 | request_type=RequestType.UNSUPPORTED_REQ) 338 | proto.test_http_unsupported_methods(self.sut) 339 | result = get_result(self.sut, Assertion.PROTO_HTTP_UNSUPPORTED_METHODS, 340 | 'DELETE', '/redfish/v1/') 341 | self.assertIsNotNone(result) 342 | self.assertEqual(Result.PASS, result['result']) 343 | 344 | def test_test_http_unsupported_methods_pass2(self): 345 | add_response(self.sut, '/redfish/v1/', 'DELETE', 346 | requests.codes.NOT_IMPLEMENTED, 347 | request_type=RequestType.UNSUPPORTED_REQ) 348 | proto.test_http_unsupported_methods(self.sut) 349 | result = get_result(self.sut, Assertion.PROTO_HTTP_UNSUPPORTED_METHODS, 350 | 'DELETE', '/redfish/v1/') 351 | self.assertIsNotNone(result) 352 | self.assertEqual(Result.PASS, result['result']) 353 | 354 | def test_test_http_unsupported_methods_fail(self): 355 | add_response(self.sut, '/redfish/v1/', 'DELETE', 356 | requests.codes.BAD_REQUEST, 357 | request_type=RequestType.UNSUPPORTED_REQ) 358 | proto.test_http_unsupported_methods(self.sut) 359 | result = get_result(self.sut, Assertion.PROTO_HTTP_UNSUPPORTED_METHODS, 360 | 'DELETE', '/redfish/v1/') 361 | self.assertIsNotNone(result) 362 | self.assertEqual(Result.FAIL, result['result']) 363 | self.assertIn('The service response returned status code %s; expected ' 364 | '%s or %s' % (requests.codes.BAD_REQUEST, 365 | requests.codes.METHOD_NOT_ALLOWED, 366 | requests.codes.NOT_IMPLEMENTED), 367 | result['msg']) 368 | 369 | def test_test_protocol_details_cover(self): 370 | proto.test_protocol_details(self.sut) 371 | 372 | 373 | if __name__ == '__main__': 374 | unittest.main() 375 | -------------------------------------------------------------------------------- /unittests/test_report.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import unittest 7 | from datetime import datetime 8 | from pathlib import Path 9 | from unittest import mock, TestCase 10 | 11 | from redfish_protocol_validator.constants import Assertion, Result 12 | from redfish_protocol_validator.report import html_report, json_results, tsv_report 13 | from redfish_protocol_validator.system_under_test import SystemUnderTest 14 | 15 | 16 | class Report(TestCase): 17 | def setUp(self): 18 | super(Report, self).setUp() 19 | self.report_dir = Path('reports') 20 | self.current_time = datetime.now() 21 | self.sut = SystemUnderTest('http://127.0.0.1:8000', 'oper', 'xyzzy') 22 | self.sut.log(Result.PASS, 'GET', 200, '/redfish/v1/foo', 23 | Assertion.PROTO_JSON_RFC, 'Test passed') 24 | self.sut.log(Result.PASS, 'GET', 200, '/redfish/v1/bar', 25 | Assertion.PROTO_JSON_RFC, 'Test passed') 26 | self.sut.log(Result.FAIL, 'GET', 200, '/redfish/v1/accounts/1', 27 | Assertion.PROTO_ETAG_ON_GET_ACCOUNT, 28 | 'did not return an ETag') 29 | self.sut.log(Result.WARN, 'GET', 204, '/redfish/v1/baz', 30 | Assertion.PROTO_STD_URIS_SUPPORTED, 31 | 'some warning message') 32 | 33 | @mock.patch("builtins.open", new_callable=mock.mock_open) 34 | def test_tsv_report(self, mock_file): 35 | handle = mock_file() 36 | tsv_report(self.sut, self.report_dir, self.current_time) 37 | # one write() for the header plus one for each of the four log results 38 | self.assertEqual(handle.write.call_count, 5) 39 | 40 | @mock.patch("builtins.open", new_callable=mock.mock_open) 41 | def test_html_report(self, mock_file): 42 | handle = mock_file() 43 | html_report(self.sut, self.report_dir, self.current_time, '0.6.0') 44 | # HTML report is generated with one write() call 45 | self.assertEqual(handle.write.call_count, 1) 46 | 47 | @mock.patch("builtins.open", new_callable=mock.mock_open) 48 | def test_json_results(self, mock_file): 49 | handle = mock_file() 50 | json_results(self.sut, self.report_dir, self.current_time, '0.6.0') 51 | # json_results() calls json.dump() which calls write() 1 or more times 52 | self.assertGreaterEqual(handle.write.call_count, 1) 53 | 54 | 55 | if __name__ == '__main__': 56 | unittest.main() 57 | -------------------------------------------------------------------------------- /unittests/test_resources.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import unittest 7 | from unittest import mock, TestCase 8 | 9 | import requests 10 | 11 | from redfish_protocol_validator import resources 12 | from redfish_protocol_validator.constants import RequestType 13 | from redfish_protocol_validator.system_under_test import SystemUnderTest 14 | from unittests.utils import add_response 15 | 16 | 17 | class Resources(TestCase): 18 | def setUp(self): 19 | super(Resources, self).setUp() 20 | self.sut = SystemUnderTest('https://127.0.0.1:8000', 'oper', 'xyzzy') 21 | self.sut.set_sessions_uri('/redfish/v1/SessionService/Sessions') 22 | self.session = mock.MagicMock(spec=requests.Session) 23 | self.sut._set_session(self.session) 24 | self.no_auth_session = mock.Mock(spec=requests.Session) 25 | add_response(self.sut, '/redfish/v1/', 'GET', requests.codes.OK) 26 | add_response(self.sut, self.sut.sessions_uri, 'GET', requests.codes.OK) 27 | add_response(self.sut, '/redfish/v1/', 'GET', requests.codes.OK, 28 | request_type=RequestType.BASIC_AUTH) 29 | add_response(self.sut, self.sut.sessions_uri, 'GET', requests.codes.OK, 30 | request_type=RequestType.BASIC_AUTH) 31 | add_response(self.sut, '/redfish/v1/', 'GET', requests.codes.OK, 32 | request_type=RequestType.NO_AUTH) 33 | add_response(self.sut, self.sut.sessions_uri, 'GET', 34 | requests.codes.UNAUTHORIZED, 35 | request_type=RequestType.NO_AUTH) 36 | add_response(self.sut, self.sut.sessions_uri, 'POST', 37 | requests.codes.CREATED, 38 | request_type=RequestType.NO_AUTH) 39 | add_response(self.sut, '/redfish/v1/AccountsService/Accounts/3', 40 | 'PATCH', requests.codes.NOT_ALLOWED, 41 | request_type=RequestType.NO_AUTH) 42 | 43 | def test_get_default_resources(self): 44 | req = mock.Mock(spec=requests.Request) 45 | req.method = 'GET' 46 | res = mock.Mock(spec=requests.Response) 47 | res.status_code = requests.codes.OK 48 | res.json.return_value = {} 49 | res.request = req 50 | service_root = mock.Mock(spec=requests.Response) 51 | service_root.status_code = requests.codes.OK 52 | service_root.json.return_value = { 53 | 'Systems': { 54 | '@odata.id': '/redfish/v1/Systems' 55 | }, 56 | 'Chassis': { 57 | '@odata.id': '/redfish/v1/Chassis' 58 | }, 59 | 'Managers': { 60 | '@odata.id': '/redfish/v1/Managers' 61 | }, 62 | 'AccountService': { 63 | '@odata.id': '/redfish/v1/AccountService' 64 | }, 65 | 'SessionService': { 66 | '@odata.id': '/redfish/v1/SessionService' 67 | }, 68 | 'Links': { 69 | 'Sessions': { 70 | '@odata.id': '/redfish/v1/SessionService/Sessions' 71 | } 72 | }, 73 | 'EventService': { 74 | '@odata.id': '/redfish/v1/EventService' 75 | }, 76 | 'CertificateService': { 77 | '@odata.id': '/redfish/v1/CertificateService' 78 | } 79 | } 80 | service_root.request = req 81 | coll1 = mock.Mock(spec=requests.Response) 82 | coll1.status_code = requests.codes.OK 83 | coll1.json.return_value = { 84 | 'Members': [ 85 | { 86 | '@odata.id': '/redfish/v1/Foo/1' 87 | } 88 | ] 89 | } 90 | coll1.request = req 91 | coll2 = mock.Mock(spec=requests.Response) 92 | coll2.status_code = requests.codes.OK 93 | coll2.json.return_value = { 94 | 'Members': [ 95 | { 96 | '@odata.id': '/redfish/v1/Foo/1' 97 | }, 98 | { 99 | '@odata.id': '/redfish/v1/Foo/2' 100 | } 101 | ] 102 | } 103 | coll2.request = req 104 | manager = mock.Mock(spec=requests.Response) 105 | manager.status_code = requests.codes.OK 106 | np_uri = '/redfish/v1/Managers/1/NetworkProtocol' 107 | manager.json.return_value = { 108 | 'NetworkProtocol': { 109 | '@odata.id': np_uri 110 | } 111 | } 112 | manager.request = req 113 | net_proto = mock.Mock(spec=requests.Response) 114 | net_proto.status_code = requests.codes.OK 115 | net_proto.json.return_value = { 116 | 'HTTPS': { 117 | 'Certificates': { 118 | '@odata.id': np_uri + '/HTTPS/Certificates' 119 | } 120 | }, 121 | } 122 | net_proto.request = req 123 | account_service = mock.Mock(spec=requests.Response) 124 | account_service.status_code = requests.codes.OK 125 | account_service.json.return_value = { 126 | 'Accounts': { 127 | '@odata.id': '/redfish/v1/AccountService/Accounts' 128 | }, 129 | 'Roles': { 130 | '@odata.id': '/redfish/v1/AccountService/Roles' 131 | }, 132 | 'PrivilegeMap': { 133 | '@odata.id': '/redfish/v1/AccountService/PrivilegeMap' 134 | } 135 | } 136 | account_service.request = req 137 | acct = mock.Mock(spec=requests.Response) 138 | acct.status_code = requests.codes.OK 139 | acct.json.return_value = { 140 | 'UserName': self.sut.username, 141 | 'RoleId': 'Administrator' 142 | } 143 | acct.request = req 144 | session_service = mock.Mock(spec=requests.Response) 145 | session_service.status_code = requests.codes.OK 146 | session_service.json.return_value = { 147 | 'Sessions': { 148 | '@odata.id': '/redfish/v1/SessionService/Sessions' 149 | } 150 | } 151 | session_service.request = req 152 | event_service = mock.Mock(spec=requests.Response) 153 | event_service.status_code = requests.codes.OK 154 | event_service.json.return_value = { 155 | 'ServerSentEventUri': '/redfish/v1/EventService/SSE', 156 | 'Subscriptions': { 157 | "@odata.id": '/redfish/v1/EventService/Subscriptions' 158 | } 159 | } 160 | event_service.request = req 161 | 162 | self.session.get.side_effect = [ 163 | res, res, res, res, res, 164 | service_root, coll1, res, coll1, res, 165 | coll1, manager, net_proto, coll1, 166 | account_service, coll1, acct, coll1, res, 167 | session_service, coll1, res, 168 | event_service, coll1, res, coll2, 169 | res 170 | ] 171 | resources.read_target_resources( 172 | self.sut, func=resources.get_default_resources) 173 | self.assertEqual(self.session.get.call_count, 27) 174 | 175 | def test_set_mfr_model_fw(self): 176 | uuid1 = "92384634-2938-2342-8820-489239905423" 177 | uuid2 = '85775665-c110-4b85-8989-e6162170b3ec' 178 | member1 = { 179 | 'Manufacturer': 'Contoso', 180 | 'Model': 'Contoso 2001', 181 | 'FirmwareVersion': '1.01', 182 | } 183 | member2 = { 184 | 'ServiceEntryPointUUID': uuid2, 185 | 'Manufacturer': 'Contoso', 186 | 'Model': 'Contoso 2002', 187 | 'FirmwareVersion': '2.02', 188 | } 189 | member3 = { 190 | 'ServiceEntryPointUUID': uuid1, 191 | 'Manufacturer': 'Contoso', 192 | 'Model': 'Contoso 2003', 193 | 'FirmwareVersion': '3.03', 194 | } 195 | sut = SystemUnderTest('https://127.0.0.1:8000', 'oper', 'xyzzy') 196 | for d in [member1, member2, member3]: 197 | sut.set_service_uuid(uuid1) 198 | resources.set_mfr_model_fw(sut, d) 199 | self.assertEqual(sut.firmware_version, '3.03') 200 | self.assertEqual(sut.manufacturer, 'Contoso') 201 | self.assertEqual(sut.model, 'Contoso 2003') 202 | sut = SystemUnderTest('https://127.0.0.1:8000', 'oper', 'xyzzy') 203 | for d in [member1, member2, member3]: 204 | sut.set_service_uuid(uuid2) 205 | resources.set_mfr_model_fw(sut, d) 206 | self.assertEqual(sut.firmware_version, '2.02') 207 | self.assertEqual(sut.manufacturer, 'Contoso') 208 | self.assertEqual(sut.model, 'Contoso 2002') 209 | sut = SystemUnderTest('https://127.0.0.1:8000', 'oper', 'xyzzy') 210 | for d in [member1, member2]: 211 | sut.set_service_uuid(uuid1) 212 | resources.set_mfr_model_fw(sut, d) 213 | self.assertEqual(sut.firmware_version, '1.01') 214 | self.assertEqual(sut.manufacturer, 'Contoso') 215 | self.assertEqual(sut.model, 'Contoso 2001') 216 | sut = SystemUnderTest('https://127.0.0.1:8000', 'oper', 'xyzzy') 217 | for d in [member1, member2, member3]: 218 | sut.set_service_uuid(None) 219 | resources.set_mfr_model_fw(sut, d) 220 | self.assertEqual(sut.firmware_version, '1.01') 221 | self.assertEqual(sut.manufacturer, 'Contoso') 222 | self.assertEqual(sut.model, 'Contoso 2001') 223 | 224 | @mock.patch('redfish_protocol_validator.sessions.create_session') 225 | @mock.patch('redfish_protocol_validator.sessions.delete_session') 226 | @mock.patch('redfish_protocol_validator.accounts.add_account') 227 | @mock.patch('redfish_protocol_validator.accounts.patch_account') 228 | @mock.patch('redfish_protocol_validator.accounts.delete_account') 229 | @mock.patch('redfish_protocol_validator.accounts.password_change_required') 230 | def test_data_modification_requests(self, mock_pwd_change, mock_del_acct, 231 | mock_patch_acct, mock_add_acct, 232 | mock_delete_session, 233 | mock_create_session): 234 | session_uri = '/redfish/v1/SessionService/Sessions/7' 235 | mock_create_session.return_value = session_uri, 'my-token' 236 | user = 'rfpvc91c' 237 | pwd = 'pa325e6a' 238 | acct_uri = '/redfish/v1/AccountService/Accounts/5' 239 | mock_add_acct.return_value = (user, pwd, acct_uri) 240 | request = mock.Mock(spec=requests.Request) 241 | request.method = 'GET' 242 | response = mock.Mock(spec=requests.Response) 243 | response.status_code = requests.codes.OK 244 | response.headers = {} 245 | response.json.return_value = {'PasswordChangeRequired': False} 246 | response.request = request 247 | self.session.get.return_value = response 248 | resources.data_modification_requests(self.sut) 249 | mock_create_session.assert_called_once_with(self.sut) 250 | mock_delete_session.assert_called_once_with( 251 | self.sut, self.session, session_uri, 252 | request_type=RequestType.NORMAL) 253 | mock_add_acct.assert_any_call( 254 | self.sut, self.session, request_type=RequestType.NORMAL) 255 | mock_patch_acct.assert_any_call( 256 | self.sut, self.session, acct_uri, request_type=RequestType.NORMAL) 257 | mock_del_acct.assert_any_call( 258 | self.sut, self.session, user, acct_uri, 259 | request_type=RequestType.NORMAL) 260 | self.assertEqual(mock_add_acct.call_count, 2) 261 | self.assertEqual(mock_patch_acct.call_count, 2) 262 | self.assertEqual(mock_del_acct.call_count, 2) 263 | self.assertEqual(mock_pwd_change.call_count, 1) 264 | 265 | @mock.patch('redfish_protocol_validator.sessions.logging.error') 266 | @mock.patch('redfish_protocol_validator.sessions.create_session') 267 | @mock.patch('redfish_protocol_validator.sessions.delete_session') 268 | @mock.patch('redfish_protocol_validator.accounts.add_account') 269 | @mock.patch('redfish_protocol_validator.accounts.patch_account') 270 | @mock.patch('redfish_protocol_validator.accounts.delete_account') 271 | @mock.patch('redfish_protocol_validator.accounts.password_change_required') 272 | def test_data_modification_requests_exception( 273 | self, mock_pwd_change, mock_del_acct, mock_patch_acct, 274 | mock_add_acct, mock_delete_session, mock_create_session, 275 | mock_error): 276 | session_uri = '/redfish/v1/SessionService/Sessions/7' 277 | mock_create_session.return_value = session_uri, 'my-token' 278 | user = 'rfpvc91c' 279 | pwd = 'pa325e6a' 280 | acct_uri = '/redfish/v1/AccountService/Accounts/5' 281 | mock_add_acct.return_value = (user, pwd, acct_uri) 282 | request = mock.Mock(spec=requests.Request) 283 | request.method = 'GET' 284 | response = mock.Mock(spec=requests.Response) 285 | response.status_code = requests.codes.OK 286 | response.headers = {} 287 | response.json.return_value = {'PasswordChangeRequired': False} 288 | response.request = request 289 | self.session.get.return_value = response 290 | mock_patch_acct.side_effect = ConnectionError 291 | resources.data_modification_requests(self.sut) 292 | mock_create_session.assert_called_once_with(self.sut) 293 | mock_delete_session.assert_called_once_with( 294 | self.sut, self.session, session_uri, 295 | request_type=RequestType.NORMAL) 296 | mock_add_acct.assert_any_call( 297 | self.sut, self.session, request_type=RequestType.NORMAL) 298 | mock_patch_acct.assert_any_call( 299 | self.sut, self.session, acct_uri, request_type=RequestType.NORMAL) 300 | mock_del_acct.assert_any_call( 301 | self.sut, self.session, user, acct_uri, 302 | request_type=RequestType.NORMAL) 303 | self.assertEqual(mock_add_acct.call_count, 1) 304 | self.assertEqual(mock_patch_acct.call_count, 1) 305 | self.assertEqual(mock_del_acct.call_count, 1) 306 | self.assertEqual(mock_pwd_change.call_count, 1) 307 | args = mock_error.call_args[0] 308 | self.assertIn('Caught exception while creating or patching', args[0]) 309 | 310 | @mock.patch('redfish_protocol_validator.sessions.create_session') 311 | @mock.patch('redfish_protocol_validator.sessions.delete_session') 312 | @mock.patch('redfish_protocol_validator.accounts.add_account') 313 | @mock.patch('redfish_protocol_validator.accounts.patch_account') 314 | @mock.patch('redfish_protocol_validator.accounts.delete_account') 315 | def test_data_modification_requests_no_auth( 316 | self, mock_del_acct, mock_patch_acct, mock_add_acct, 317 | mock_delete_session, mock_create_session): 318 | session_uri = '/redfish/v1/SessionService/Sessions/8' 319 | mock_create_session.return_value = session_uri, 'my-token' 320 | user = 'rfpvc91c' 321 | pwd = 'pa325e6a' 322 | acct_uri = '/redfish/v1/AccountService/Accounts/6' 323 | mock_add_acct.side_effect = [ 324 | (user, pwd, None), 325 | (user, pwd, acct_uri) 326 | ] 327 | mock_delete_session.return_value.ok = False 328 | mock_add_acct.return_value = () 329 | resources.data_modification_requests_no_auth( 330 | self.sut, self.no_auth_session) 331 | mock_create_session.assert_called_once_with(self.sut) 332 | self.assertEqual(mock_delete_session.call_count, 2) 333 | mock_delete_session.assert_called_with( 334 | self.sut, self.session, session_uri, 335 | request_type=RequestType.NORMAL) 336 | self.assertEqual(mock_add_acct.call_count, 2) 337 | mock_add_acct.assert_called_with( 338 | self.sut, self.session, request_type=RequestType.NORMAL) 339 | mock_patch_acct.assert_called_once_with( 340 | self.sut, self.no_auth_session, acct_uri, 341 | request_type=RequestType.NO_AUTH) 342 | self.assertEqual(mock_del_acct.call_count, 2) 343 | mock_del_acct.assert_called_with( 344 | self.sut, self.session, user, acct_uri, 345 | request_type=RequestType.NORMAL) 346 | 347 | @mock.patch('redfish_protocol_validator.sessions.logging.error') 348 | @mock.patch('redfish_protocol_validator.accounts.add_account') 349 | def test_patch_other_account_exception( 350 | self, mock_add_acct, mock_error): 351 | user = 'rfpvc91c' 352 | pwd = 'pa325e6a' 353 | mock_add_acct.side_effect = ConnectionError 354 | resources.patch_other_account(self.sut, self.session, user, pwd) 355 | args = mock_error.call_args[0] 356 | self.assertIn('Caught exception while creating or patching', args[0]) 357 | 358 | def test_unsupported_requests(self): 359 | request = mock.Mock(spec=requests.Request) 360 | request.method = 'DELETE' 361 | response = mock.Mock(spec=requests.Response) 362 | response.status_code = requests.codes.METHOD_NOT_ALLOWED 363 | response.request = request 364 | self.session.request.return_value = response 365 | resources.unsupported_requests(self.sut) 366 | self.session.request.called_once_with( 367 | 'DELETE', self.sut.rhost + '/redfish/v1/') 368 | 369 | @mock.patch('redfish_protocol_validator.sessions.requests.get') 370 | def test_basic_auth_requests(self, mock_get): 371 | headers = {'OData-Version': '4.0'} 372 | mock_get.return_value.status_code = requests.codes.OK 373 | resources.basic_auth_requests(self.sut) 374 | mock_get.assert_any_call(self.sut.rhost + self.sut.sessions_uri, json=None, 375 | headers=headers, auth=(self.sut.username, 376 | self.sut.password), 377 | stream=False, verify=self.sut.verify, timeout=30) 378 | responses = self.sut.get_responses_by_method( 379 | 'GET', request_type=RequestType.BASIC_AUTH) 380 | self.assertEqual(len(responses), 2) 381 | 382 | @mock.patch('redfish_protocol_validator.sessions.requests.get') 383 | def test_http_requests_https_scheme(self, mock_get): 384 | headers = {'OData-Version': '4.0'} 385 | if self.sut.scheme == 'https': 386 | http_rhost = 'http' + self.sut.rhost[5:] 387 | else: 388 | http_rhost = self.sut.rhost 389 | request = mock.Mock(spec=requests.Request) 390 | request.method = 'GET' 391 | response = mock.Mock(spec=requests.Response) 392 | response.status_code = requests.codes.OK 393 | response.request = request 394 | mock_get.return_value = response 395 | resources.http_requests(self.sut) 396 | mock_get.assert_any_call(http_rhost + self.sut.sessions_uri, 397 | headers=headers, auth=(self.sut.username, 398 | self.sut.password), 399 | verify=self.sut.verify) 400 | responses = self.sut.get_responses_by_method( 401 | 'GET', request_type=RequestType.HTTP_BASIC_AUTH) 402 | self.assertEqual(len(responses), 1) 403 | responses = self.sut.get_responses_by_method( 404 | 'GET', request_type=RequestType.HTTP_NO_AUTH) 405 | self.assertEqual(len(responses), 2) 406 | 407 | @mock.patch('redfish_protocol_validator.resources.logging.warning') 408 | @mock.patch('redfish_protocol_validator.resources.requests.get') 409 | def test_http_requests_https_scheme_exception(self, mock_get, mock_warn): 410 | mock_get.side_effect = ConnectionError 411 | mock_sut = mock.MagicMock(spec=SystemUnderTest) 412 | mock_sut.scheme = 'https' 413 | mock_sut.avoid_http_redirect = False 414 | resources.http_requests(mock_sut) 415 | args = mock_warn.call_args[0] 416 | self.assertIn('Caught ConnectionError while trying to trigger', 417 | args[0]) 418 | 419 | @mock.patch('redfish_protocol_validator.sessions.requests.get') 420 | def test_http_requests_http_scheme(self, mock_get): 421 | sut = SystemUnderTest('http://127.0.0.1:8000', 'oper', 'xyzzy') 422 | sut.set_sessions_uri('/redfish/v1/SessionService/Sessions') 423 | add_response(sut, '/redfish/v1/', 'GET', requests.codes.OK, 424 | request_type=RequestType.NO_AUTH) 425 | add_response(sut, sut.sessions_uri, 'GET', requests.codes.OK, 426 | request_type=RequestType.BASIC_AUTH) 427 | add_response(sut, sut.sessions_uri, 'GET', 428 | requests.codes.UNAUTHORIZED, 429 | request_type=RequestType.NO_AUTH) 430 | mock_get.return_value.status_code = requests.codes.OK 431 | resources.http_requests(sut) 432 | responses = sut.get_responses_by_method( 433 | 'GET', request_type=RequestType.HTTP_BASIC_AUTH) 434 | self.assertEqual(len(responses), 1) 435 | responses = sut.get_responses_by_method( 436 | 'GET', request_type=RequestType.HTTP_NO_AUTH) 437 | self.assertEqual(len(responses), 2) 438 | 439 | @mock.patch('redfish_protocol_validator.sessions.logging.warning') 440 | def test_http_requests_other_scheme(self, mock_warning): 441 | sut = SystemUnderTest('ftp://127.0.0.1:8000', 'oper', 'xyzzy') 442 | resources.http_requests(sut) 443 | args = mock_warning.call_args[0] 444 | self.assertIn('Unexpected scheme (ftp)', args[0]) 445 | 446 | @mock.patch('redfish_protocol_validator.sessions.requests.get') 447 | def test_bad_auth_requests(self, mock_get): 448 | request = mock.Mock(spec=requests.Request) 449 | request.method = 'GET' 450 | response = mock.Mock(spec=requests.Response) 451 | response.status_code = requests.codes.UNAUTHORIZED 452 | response.request = request 453 | mock_get.return_value = response 454 | resources.bad_auth_requests(self.sut) 455 | self.assertEqual(mock_get.call_count, 2) 456 | responses = self.sut.get_responses_by_method( 457 | 'GET', request_type=RequestType.BAD_AUTH) 458 | self.assertEqual(len(responses), 2) 459 | 460 | def test_read_uris_no_auth(self): 461 | session = mock.Mock(spec=requests.Session) 462 | session.get.return_value.status_code = requests.codes.OK 463 | resources.read_uris_no_auth(self.sut, session) 464 | self.assertEqual(session.get.call_count, 2) 465 | 466 | 467 | if __name__ == '__main__': 468 | unittest.main() 469 | -------------------------------------------------------------------------------- /unittests/test_sessions.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import unittest 7 | from unittest import mock, TestCase 8 | 9 | import requests 10 | 11 | from redfish_protocol_validator import sessions 12 | from redfish_protocol_validator.system_under_test import SystemUnderTest 13 | 14 | 15 | class Sessions(TestCase): 16 | def setUp(self): 17 | super(Sessions, self).setUp() 18 | self.sut = SystemUnderTest('http://127.0.0.1:8000', 'oper', 'xyzzy') 19 | self.sut.set_sessions_uri('/redfish/v1/SessionService/Sessions') 20 | self.headers = { 21 | 'OData-Version': '4.0' 22 | } 23 | 24 | @mock.patch('redfish_protocol_validator.sessions.requests.post') 25 | def test_bad_login(self, mock_post): 26 | post_resp = mock.Mock(spec=requests.Response) 27 | post_resp.status_code = requests.codes.BAD_REQUEST 28 | request = mock.Mock(spec=requests.Request) 29 | request.method = 'POST' 30 | post_resp.request = request 31 | mock_post.return_value = post_resp 32 | sessions.bad_login(self.sut) 33 | self.assertEqual(mock_post.call_count, 1) 34 | 35 | @mock.patch('redfish_protocol_validator.sessions.requests.post') 36 | def test_create_session(self, mock_post): 37 | token = '87a5cd20' 38 | url = 'http://127.0.0.1:8000/redfish/v1/sessions/1234' 39 | uri = '/redfish/v1/sessions/1234' 40 | mock_post.return_value.status_code = requests.codes.OK 41 | mock_post.return_value.headers = { 42 | 'Location': url, 43 | 'X-Auth-Token': token 44 | } 45 | new_uri, _ = sessions.create_session(self.sut) 46 | self.assertEqual(uri, new_uri) 47 | 48 | @mock.patch('redfish_protocol_validator.sessions.requests.post') 49 | @mock.patch('redfish_protocol_validator.sessions.logging.warning') 50 | def test_create_session_post_fail(self, mock_warning, mock_post): 51 | mock_post.return_value.status_code = requests.codes.BAD_REQUEST 52 | mock_post.return_value.ok = False 53 | sessions.create_session(self.sut) 54 | self.assertEqual(mock_warning.call_count, 1) 55 | args = mock_warning.call_args[0] 56 | self.assertIn('session POST status: 400', args[0]) 57 | 58 | def test_delete_session(self): 59 | uri = '/redfish/v1/sessions/1234' 60 | session = mock.Mock(spec=requests.Session) 61 | session.delete.return_value.status_code = requests.codes.OK 62 | sessions.delete_session(self.sut, session, uri) 63 | session.delete.assert_called_once_with(self.sut.rhost + uri, 64 | json=None, headers=None, 65 | auth=None, timeout=30) 66 | 67 | @mock.patch('redfish_protocol_validator.sessions.requests.Session') 68 | def test_no_auth_session(self, mock_session): 69 | session = mock.Mock(spec=requests.Session) 70 | mock_session.return_value = session 71 | sessions.no_auth_session(self.sut) 72 | self.assertEqual(mock_session.call_count, 1) 73 | self.assertEqual(self.sut.verify, session.verify) 74 | 75 | 76 | if __name__ == '__main__': 77 | unittest.main() 78 | -------------------------------------------------------------------------------- /unittests/test_sut.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import unittest 7 | from unittest import mock, TestCase 8 | 9 | import requests 10 | 11 | from redfish_protocol_validator.constants import Assertion, ResourceType, Result 12 | from redfish_protocol_validator.system_under_test import SystemUnderTest 13 | from unittests.utils import add_response 14 | 15 | 16 | class Sut(TestCase): 17 | 18 | def setUp(self): 19 | super(Sut, self).setUp() 20 | self.rhost = 'http://127.0.0.1:8000' 21 | self.username = 'oper' 22 | self.password = 'xyzzy' 23 | self.version = '1.6.0' 24 | self.version_tuple = (1, 6, 0) 25 | self.sessions_uri = '/redfish/v1/sessions' 26 | self.active_session_uri = '/redfish/v1/sessions/12345' 27 | self.active_session_key = 'token98765' 28 | self.session = mock.Mock() 29 | self.systems_uri = '/redfish/v1/Systems' 30 | self.managers_uri = '/redfish/v1/Managers' 31 | self.chassis_uri = '/redfish/v1/Chassis' 32 | self.account_service_uri = '/redfish/v1/AccountService' 33 | self.accounts_uri = '/redfish/v1/accounts' 34 | self.roles_uri = '/redfish/v1/roles' 35 | self.cert_service_uri = '/redfish/v1/CertificateService' 36 | self.event_service_uri = '/redfish/v1/EventService' 37 | self.privilege_registry_uri = '/redfish/v1/AccountService/PrivilegeMap' 38 | self.sut = SystemUnderTest(self.rhost, self.username, self.password) 39 | self.sut.set_avoid_http_redirect(False) 40 | self.sut.set_version(self.version) 41 | self.sut.set_nav_prop_uri('Accounts', self.accounts_uri) 42 | self.sut.set_nav_prop_uri('Roles', self.roles_uri) 43 | self.sut.set_nav_prop_uri('Systems', self.systems_uri) 44 | self.sut.set_nav_prop_uri('Managers', self.managers_uri) 45 | self.sut.set_nav_prop_uri('Chassis', self.chassis_uri) 46 | self.sut.set_nav_prop_uri('AccountService', self.account_service_uri) 47 | self.sut.set_nav_prop_uri('CertificateService', self.cert_service_uri) 48 | self.sut.set_nav_prop_uri('EventService', self.event_service_uri) 49 | self.sut.set_nav_prop_uri('PrivilegeMap', self.privilege_registry_uri) 50 | add_response(self.sut, '/redfish/v1/foo', 51 | status_code=requests.codes.NOT_FOUND) 52 | add_response(self.sut, '/redfish/v1/foo') 53 | add_response(self.sut, '/redfish/v1/bar') 54 | add_response(self.sut, '/redfish/v1/baz') 55 | add_response(self.sut, '/redfish/v1/foo', method='POST', 56 | status_code=requests.codes.CREATED) 57 | add_response(self.sut, '/redfish/v1/bar', method='PATCH', 58 | status_code=requests.codes.OK) 59 | add_response(self.sut, '/redfish/v1/accounts/1', 60 | res_type=ResourceType.MANAGER_ACCOUNT) 61 | add_response(self.sut, '/redfish/v1/accounts/1', method='PATCH', 62 | res_type=ResourceType.MANAGER_ACCOUNT) 63 | add_response(self.sut, '/redfish/v1/roles/1', 64 | status_code=requests.codes.NOT_FOUND, 65 | res_type=ResourceType.ROLE) 66 | self.sut.add_user({'UserName': 'alice', 'RoleId': 'Operator'}) 67 | self.sut.add_user({'UserName': 'bob', 'RoleId': 'ReadOnly'}) 68 | self.sut.add_role({'Id': 'ReadOnly', 69 | 'AssignedPrivileges': ['Login', 'ConfigureSelf'], 70 | 'OemPrivileges': ['ConfigureFoo']}) 71 | self.sut.add_role({'RoleId': 'Operator', 72 | 'AssignedPrivileges': ['Login', 'ConfigureSelf', 73 | 'ConfigureComponents'], 74 | 'OemPrivileges': ['ConfigureBar']}) 75 | self.sut.log(Result.PASS, 'GET', 200, '/redfish/v1/foo', 76 | Assertion.PROTO_JSON_RFC, 'Test passed') 77 | self.sut.log(Result.PASS, 'GET', 200, '/redfish/v1/bar', 78 | Assertion.PROTO_JSON_RFC, 'Test passed') 79 | self.sut.log(Result.FAIL, 'GET', 200, '/redfish/v1/accounts/1', 80 | Assertion.PROTO_ETAG_ON_GET_ACCOUNT, 81 | 'did not return an ETag') 82 | self.headers = { 83 | 'OData-Version': '4.0' 84 | } 85 | 86 | def test_init(self): 87 | self.assertEqual(self.sut.rhost, self.rhost) 88 | self.assertEqual(self.sut.username, self.username) 89 | self.assertEqual(self.sut.password, self.password) 90 | 91 | def test_set_version(self): 92 | self.sut.set_version('1.2.3') 93 | self.assertEqual(self.sut.version_tuple, (1, 2, 3)) 94 | self.assertEqual(self.sut.version_string, '1.2.3') 95 | self.sut.set_version('1.2.3b') 96 | # '1.2.3b' will get a parse error, so will default to 1.0.0 97 | self.assertEqual(self.sut.version_tuple, (1, 0, 0)) 98 | self.assertEqual(self.sut.version_string, '1.0.0') 99 | 100 | def test_version(self): 101 | self.assertEqual(self.sut.version_tuple, self.version_tuple) 102 | self.assertEqual(self.sut.version_string, self.version) 103 | 104 | def test_sessions_uri(self): 105 | self.sut.set_sessions_uri(self.sessions_uri) 106 | self.assertEqual(self.sut.sessions_uri, self.sessions_uri) 107 | 108 | def test_active_session_uri(self): 109 | self.sut._set_active_session_uri(self.active_session_uri) 110 | self.assertEqual(self.sut.active_session_uri, self.active_session_uri) 111 | self.sut._set_active_session_uri(None) 112 | self.assertIsNone(self.sut.active_session_uri) 113 | 114 | def test_active_session_key(self): 115 | self.sut._set_active_session_key(self.active_session_key) 116 | self.assertEqual(self.sut.active_session_key, self.active_session_key) 117 | 118 | def test_session(self): 119 | self.sut._set_session(self.session) 120 | self.assertEqual(self.sut.session, self.session) 121 | 122 | def test_systems_uri(self): 123 | self.assertEqual(self.sut.systems_uri, self.systems_uri) 124 | 125 | def test_managers_uri(self): 126 | self.assertEqual(self.sut.managers_uri, self.managers_uri) 127 | 128 | def test_chassis_uri(self): 129 | self.assertEqual(self.sut.chassis_uri, self.chassis_uri) 130 | 131 | def test_account_service_uri(self): 132 | self.assertEqual(self.sut.account_service_uri, 133 | self.account_service_uri) 134 | 135 | def test_accounts_uri(self): 136 | self.assertEqual(self.sut.accounts_uri, self.accounts_uri) 137 | 138 | def test_roles_uri(self): 139 | self.assertEqual(self.sut.roles_uri, self.roles_uri) 140 | 141 | def test_cert_service_uri(self): 142 | self.assertEqual(self.sut.certificate_service_uri, 143 | self.cert_service_uri) 144 | 145 | def test_event_service_uri(self): 146 | self.assertEqual(self.sut.event_service_uri, 147 | self.event_service_uri) 148 | 149 | def test_privilege_registry_uri(self): 150 | self.assertEqual(self.sut.privilege_registry_uri, 151 | self.privilege_registry_uri) 152 | 153 | @mock.patch('redfish_protocol_validator.system_under_test.logging.error') 154 | def test_bad_nav_prop(self, mock_error): 155 | self.sut.set_nav_prop_uri('Foo', '/redfish/v1/Foo') 156 | self.assertEqual(mock_error.call_count, 1) 157 | args = mock_error.call_args[0] 158 | self.assertIn('set_nav_prop_uri() called with', args[0]) 159 | 160 | def test_get_responses_by_method(self): 161 | responses = self.sut.get_responses_by_method('GET') 162 | self.assertEqual(len(responses), 5) 163 | responses = self.sut.get_responses_by_method('POST') 164 | self.assertEqual(len(responses), 1) 165 | responses = self.sut.get_responses_by_method('PATCH') 166 | self.assertEqual(len(responses), 2) 167 | responses = self.sut.get_responses_by_method( 168 | 'GET', resource_type=ResourceType.MANAGER_ACCOUNT) 169 | self.assertEqual(len(responses), 1) 170 | responses = self.sut.get_responses_by_method( 171 | 'PATCH', resource_type=ResourceType.MANAGER_ACCOUNT) 172 | self.assertEqual(len(responses), 1) 173 | responses = self.sut.get_responses_by_method( 174 | 'GET', resource_type=ResourceType.ROLE) 175 | self.assertEqual(len(responses), 1) 176 | 177 | def test_get_response(self): 178 | response = self.sut.get_response('GET', '/redfish/v1/foo') 179 | self.assertEqual(response.status_code, requests.codes.OK) 180 | response = self.sut.get_response('POST', '/redfish/v1/foo') 181 | self.assertEqual(response.status_code, requests.codes.CREATED) 182 | response = self.sut.get_response('GET', '/redfish/v1/asdfgh') 183 | self.assertIsNone(response) 184 | response = self.sut.get_response('GET', '/redfish/v1/accounts/1') 185 | self.assertEqual(response.status_code, requests.codes.OK) 186 | response = self.sut.get_response('GET', '/redfish/v1/roles/1') 187 | self.assertEqual(response.status_code, requests.codes.NOT_FOUND) 188 | 189 | def test_get_all_responses(self): 190 | count = sum(1 for _ in self.sut.get_all_responses()) 191 | self.assertEqual(count, 8) 192 | count = sum(1 for _ in self.sut.get_all_responses( 193 | resource_type=ResourceType.MANAGER_ACCOUNT 194 | )) 195 | self.assertEqual(count, 2) 196 | count = sum(1 for _ in self.sut.get_all_responses( 197 | resource_type=ResourceType.ROLE 198 | )) 199 | self.assertEqual(count, 1) 200 | 201 | def test_get_all_uris(self): 202 | uris = self.sut.get_all_uris() 203 | self.assertEqual(len(uris), 5) 204 | 205 | def test_get_all_uris_resource_type(self): 206 | uris = self.sut.get_all_uris( 207 | resource_type=ResourceType.MANAGER_ACCOUNT) 208 | self.assertEqual(len(uris), 1) 209 | 210 | def test_get_users(self): 211 | users = self.sut.get_users() 212 | self.assertEqual(users, 213 | {'alice': {'UserName': 'alice', 'RoleId': 'Operator'}, 214 | 'bob': {'UserName': 'bob', 'RoleId': 'ReadOnly'}}) 215 | 216 | def test_get_user(self): 217 | user = self.sut.get_user('alice') 218 | self.assertEqual(user, {'UserName': 'alice', 'RoleId': 'Operator'}) 219 | user = self.sut.get_user('bob') 220 | self.assertEqual(user, {'UserName': 'bob', 'RoleId': 'ReadOnly'}) 221 | user = self.sut.get_user('carol') 222 | self.assertIsNone(user) 223 | 224 | def test_get_user_role(self): 225 | role = self.sut.get_user_role('alice') 226 | self.assertEqual(role, 'Operator') 227 | role = self.sut.get_user_role('bob') 228 | self.assertEqual(role, 'ReadOnly') 229 | role = self.sut.get_user_role('carol') 230 | self.assertIsNone(role) 231 | 232 | def test_get_user_privs(self): 233 | privs = self.sut.get_user_privs('alice') 234 | self.assertEqual(privs, ['Login', 'ConfigureSelf', 235 | 'ConfigureComponents']) 236 | privs = self.sut.get_user_privs('bob') 237 | self.assertEqual(privs, ['Login', 'ConfigureSelf']) 238 | privs = self.sut.get_user_privs('carol') 239 | self.assertIsNone(privs) 240 | 241 | def test_get_user_oem_privs(self): 242 | privs = self.sut.get_user_oem_privs('alice') 243 | self.assertEqual(privs, ['ConfigureBar']) 244 | privs = self.sut.get_user_oem_privs('bob') 245 | self.assertEqual(privs, ['ConfigureFoo']) 246 | privs = self.sut.get_user_oem_privs('carol') 247 | self.assertIsNone(privs) 248 | 249 | def test_get_roles(self): 250 | roles = self.sut.get_roles() 251 | self.assertEqual( 252 | roles, 253 | {'ReadOnly': {'Id': 'ReadOnly', 254 | 'AssignedPrivileges': ['Login', 'ConfigureSelf'], 255 | 'OemPrivileges': ['ConfigureFoo']}, 256 | 'Operator': {'RoleId': 'Operator', 257 | 'AssignedPrivileges': ['Login', 'ConfigureSelf', 258 | 'ConfigureComponents'], 259 | 'OemPrivileges': ['ConfigureBar']}}) 260 | 261 | def test_get_role(self): 262 | role = self.sut.get_role('ReadOnly') 263 | self.assertEqual( 264 | role, 265 | {'Id': 'ReadOnly', 266 | 'AssignedPrivileges': ['Login', 'ConfigureSelf'], 267 | 'OemPrivileges': ['ConfigureFoo']}) 268 | role = self.sut.get_role('Operator') 269 | self.assertEqual( 270 | role, 271 | {'RoleId': 'Operator', 272 | 'AssignedPrivileges': ['Login', 'ConfigureSelf', 273 | 'ConfigureComponents'], 274 | 'OemPrivileges': ['ConfigureBar']}) 275 | role = self.sut.get_role('Custom1') 276 | self.assertIsNone(role) 277 | 278 | def test_get_role_privs(self): 279 | privs = self.sut.get_role_privs('ReadOnly') 280 | self.assertEqual(privs, ['Login', 'ConfigureSelf']) 281 | privs = self.sut.get_role_privs('Operator') 282 | self.assertEqual(privs, ['Login', 'ConfigureSelf', 283 | 'ConfigureComponents']) 284 | privs = self.sut.get_role_privs('Custom1') 285 | self.assertIsNone(privs) 286 | 287 | def test_get_role_oem_privs(self): 288 | privs = self.sut.get_role_oem_privs('ReadOnly') 289 | self.assertEqual(privs, ['ConfigureFoo']) 290 | privs = self.sut.get_role_oem_privs('Operator') 291 | self.assertEqual(privs, ['ConfigureBar']) 292 | privs = self.sut.get_role_oem_privs('Custom1') 293 | self.assertIsNone(privs) 294 | 295 | def test_results(self): 296 | results = self.sut.results 297 | self.assertEqual(len(results.get(Assertion.PROTO_JSON_RFC)), 2) 298 | self.assertEqual( 299 | len(results.get(Assertion.PROTO_ETAG_ON_GET_ACCOUNT)), 1) 300 | self.assertEqual( 301 | len(results.get(Assertion.PROTO_JSON_ACCEPTED, {})), 0) 302 | 303 | def test_summary(self): 304 | self.assertEqual(self.sut.summary_count(Result.PASS), 2) 305 | self.assertEqual(self.sut.summary_count(Result.FAIL), 1) 306 | self.assertEqual(self.sut.summary_count(Result.WARN), 0) 307 | self.assertEqual(self.sut.summary_count(Result.NOT_TESTED), 0) 308 | 309 | @mock.patch('redfish_protocol_validator.system_under_test.requests.get') 310 | def test_get_sessions_uri_default(self, mock_get): 311 | mock_get.return_value.status_code = requests.codes.OK 312 | uri = self.sut._get_sessions_uri(self.headers) 313 | self.assertEqual(uri, '/redfish/v1/SessionService/Sessions') 314 | 315 | @mock.patch('redfish_protocol_validator.system_under_test.requests.get') 316 | def test_get_sessions_uri_via_links(self, mock_get): 317 | response = mock.Mock(spec=requests.Response) 318 | response.status_code = requests.codes.OK 319 | response.json.return_value = { 320 | 'Links': { 321 | 'Sessions': { 322 | '@odata.id': '/redfish/v1/Sessions' 323 | } 324 | } 325 | } 326 | mock_get.return_value = response 327 | uri = self.sut._get_sessions_uri(self.headers) 328 | self.assertEqual(uri, '/redfish/v1/Sessions') 329 | 330 | @mock.patch('redfish_protocol_validator.system_under_test.requests.get') 331 | def test_get_sessions_uri_via_session_service(self, mock_get): 332 | response1 = mock.Mock(spec=requests.Response) 333 | response1.status_code = requests.codes.OK 334 | response1.json.return_value = { 335 | 'SessionService': { 336 | '@odata.id': '/redfish/v1/SessionService' 337 | } 338 | } 339 | response2 = mock.Mock(spec=requests.Response) 340 | response2.status_code = requests.codes.OK 341 | response2.json.return_value = { 342 | 'Sessions': { 343 | '@odata.id': '/redfish/v1/Sessions' 344 | } 345 | } 346 | mock_get.side_effect = [response1, response2] 347 | uri = self.sut._get_sessions_uri(self.headers) 348 | self.assertEqual(uri, '/redfish/v1/Sessions') 349 | 350 | @mock.patch('redfish_protocol_validator.system_under_test.requests.get') 351 | @mock.patch('redfish_protocol_validator.system_under_test.requests.post') 352 | @mock.patch('redfish_protocol_validator.system_under_test.requests.Session') 353 | def test_login(self, mock_session, mock_post, mock_get): 354 | mock_get.return_value.status_code = requests.codes.OK 355 | post_resp = mock.Mock(spec=requests.Response) 356 | post_resp.status_code = requests.codes.OK 357 | token = '87a5cd20' 358 | url = 'http://127.0.0.1:8000/redfish/v1/sessions/1234' 359 | post_resp.headers = { 360 | 'Location': url, 361 | 'X-Auth-Token': token 362 | } 363 | mock_post.return_value = post_resp 364 | mock_session.return_value.headers = {} 365 | session = self.sut.login() 366 | self.assertIsNotNone(session) 367 | self.assertEqual(session.headers.get('X-Auth-Token'), token) 368 | self.assertEqual(self.sut.active_session_uri, 369 | '/redfish/v1/sessions/1234') 370 | self.assertEqual(self.sut.active_session_key, token) 371 | 372 | @mock.patch('redfish_protocol_validator.system_under_test.requests.get') 373 | @mock.patch('redfish_protocol_validator.system_under_test.requests.post') 374 | def test_login_basic_auth(self, mock_post, mock_get): 375 | mock_get.return_value.status_code = requests.codes.OK 376 | post_resp = mock.Mock(spec=requests.Response) 377 | post_resp.status_code = requests.codes.BAD_REQUEST 378 | post_resp.ok = False 379 | mock_post.return_value = post_resp 380 | session = self.sut.login() 381 | self.assertIsNotNone(session) 382 | self.assertIsNone(self.sut.active_session_uri) 383 | self.assertIsNone(self.sut.active_session_key) 384 | self.assertEqual(session.auth, (self.sut.username, self.sut.password)) 385 | 386 | @mock.patch('redfish_protocol_validator.system_under_test.requests.get') 387 | @mock.patch('redfish_protocol_validator.system_under_test.requests.post') 388 | @mock.patch('redfish_protocol_validator.system_under_test.requests.Session') 389 | def test_login_no_token_header(self, mock_session, mock_post, mock_get): 390 | mock_get.return_value.status_code = requests.codes.OK 391 | post_resp = mock.Mock(spec=requests.Response) 392 | post_resp.status_code = requests.codes.OK 393 | url = 'http://127.0.0.1:8000/redfish/v1/sessions/1234' 394 | post_resp.headers = { 395 | 'Location': url 396 | } 397 | mock_post.return_value = post_resp 398 | mock_session.return_value.headers = {} 399 | session = self.sut.login() 400 | self.assertIsNotNone(session) 401 | self.assertIsNone(session.headers.get('X-Auth-Token')) 402 | self.assertEqual(self.sut.active_session_uri, 403 | '/redfish/v1/sessions/1234') 404 | self.assertIsNone(self.sut.active_session_key) 405 | self.assertEqual(session.auth, (self.sut.username, self.sut.password)) 406 | 407 | def test_logout_pass(self): 408 | token = '87a5cd20' 409 | url = 'http://127.0.0.1:8000/redfish/v1/sessions/1234' 410 | self.sut._set_active_session_key(token) 411 | self.sut._set_active_session_uri(url) 412 | self.assertIsNotNone(self.sut.active_session_key) 413 | self.assertIsNotNone(self.sut.active_session_uri) 414 | session = mock.Mock(spec=requests.Session) 415 | session.delete.return_value.status_code = requests.codes.OK 416 | session.delete.return_value.ok = True 417 | self.sut._set_session(session) 418 | self.sut.logout() 419 | self.assertIsNone(self.sut.active_session_key) 420 | self.assertIsNone(self.sut.active_session_uri) 421 | 422 | @mock.patch('redfish_protocol_validator.system_under_test.logging.error') 423 | def test_logout_fail(self, mock_error): 424 | token = '87a5cd20' 425 | url = 'http://127.0.0.1:8000/redfish/v1/sessions/1234' 426 | self.sut._set_active_session_key(token) 427 | self.sut._set_active_session_uri(url) 428 | session = mock.Mock(spec=requests.Session) 429 | session.delete.return_value.status_code = requests.codes.BAD_REQUEST 430 | session.delete.return_value.ok = False 431 | self.sut._set_session(session) 432 | self.sut.logout() 433 | self.assertEqual(mock_error.call_count, 1) 434 | 435 | 436 | if __name__ == '__main__': 437 | unittest.main() 438 | -------------------------------------------------------------------------------- /unittests/test_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import unittest 7 | from unittest import mock, TestCase 8 | 9 | import colorama 10 | import requests 11 | 12 | from redfish_protocol_validator import utils 13 | from redfish_protocol_validator.constants import Assertion, Result, SSDP_REDFISH 14 | from redfish_protocol_validator.system_under_test import SystemUnderTest 15 | 16 | 17 | class MyTimeout(OSError): 18 | def __init__(self, *args, **kwargs): 19 | pass 20 | 21 | 22 | class Utils(TestCase): 23 | def setUp(self): 24 | super(Utils, self).setUp() 25 | self.sut = SystemUnderTest('http://127.0.0.1:8000', 'oper', 'xyzzy') 26 | self.uri = '/redfish/v1/AccountsService/Accounts/3' 27 | self.etag = 'A89B031B62' 28 | response = mock.Mock(spec=requests.Response) 29 | response.status_code = requests.codes.OK 30 | response.ok = True 31 | response.headers = mock.Mock() 32 | response.headers.get.return_value = self.etag 33 | session = mock.Mock(spec=requests.Session) 34 | session.get.return_value = response 35 | self.response = response 36 | self.session = session 37 | self.sut.log(Result.PASS, 'GET', 200, '/redfish/v1/foo', 38 | Assertion.PROTO_JSON_RFC, 'Test passed') 39 | self.sut.log(Result.PASS, 'GET', 200, '/redfish/v1/bar', 40 | Assertion.PROTO_JSON_RFC, 'Test passed') 41 | 42 | def test_get_etag_header_good(self): 43 | headers = utils.get_etag_header(self.sut, self.session, self.uri) 44 | self.assertEqual(headers, {'If-Match': self.etag}) 45 | 46 | def test_get_etag_header_no_header(self): 47 | self.response.headers.get.return_value = None 48 | headers = utils.get_etag_header(self.sut, self.session, self.uri) 49 | self.assertEqual(headers, {}) 50 | 51 | def test_get_etag_header_get_fail(self): 52 | self.response.status_code = requests.codes.NOT_FOUND 53 | self.response.ok = False 54 | headers = utils.get_etag_header(self.sut, self.session, self.uri) 55 | self.assertEqual(headers, {}) 56 | 57 | def test_get_response_etag_from_header(self): 58 | etag = utils.get_response_etag(self.response) 59 | self.assertEqual(etag, self.etag) 60 | 61 | def test_get_response_etag_from_property(self): 62 | response = mock.Mock(requests.Response) 63 | response.headers = mock.Mock() 64 | response.headers.get.side_effect = [None, 'application/json'] 65 | odata_etag = '9F5DA024' 66 | response.json.return_value = {'@odata.etag': odata_etag} 67 | etag = utils.get_response_etag(response) 68 | self.assertEqual(etag, odata_etag) 69 | 70 | def test_get_extended_error(self): 71 | response = mock.Mock(spec=requests.Response) 72 | body = { 73 | "error": { 74 | "@Message.ExtendedInfo": [ 75 | { 76 | "MessageId": "Base.1.4.NoValidSession", 77 | "Message": "No valid session found" 78 | } 79 | ] 80 | } 81 | } 82 | response.json.return_value = body 83 | msg = utils.get_extended_error(response) 84 | self.assertEqual(msg, 'No valid session found') 85 | 86 | body = { 87 | "error": { 88 | "@Message.ExtendedInfo": [ 89 | { 90 | "MessageId": "Base.1.4.NoValidSession" 91 | } 92 | ] 93 | } 94 | } 95 | response.json.return_value = body 96 | msg = utils.get_extended_error(response) 97 | self.assertEqual(msg, 'Base.1.4.NoValidSession') 98 | 99 | body = { 100 | "error": { 101 | "code": "Base.1.8.GeneralError", 102 | "message": "A general error has occurred. See Resolution for " 103 | "information on how to resolve the error.", 104 | "@Message.ExtendedInfo": [] 105 | } 106 | } 107 | response.json.return_value = body 108 | msg = utils.get_extended_error(response) 109 | self.assertEqual(msg, 'A general error has occurred. See Resolution ' 110 | 'for information on how to resolve the error.') 111 | 112 | body = { 113 | "error": { 114 | "code": "Base.1.8.GeneralError", 115 | "@Message.ExtendedInfo": [] 116 | } 117 | } 118 | response.json.return_value = body 119 | msg = utils.get_extended_error(response) 120 | self.assertEqual(msg, 'Base.1.8.GeneralError') 121 | 122 | response.json.side_effect = ValueError 123 | msg = utils.get_extended_error(response) 124 | self.assertEqual(msg, '') 125 | 126 | def test_get_extended_info_message_keys(self): 127 | body = { 128 | "error": { 129 | "@Message.ExtendedInfo": [ 130 | { 131 | "MessageId": "Base.1.0.Success" 132 | }, 133 | { 134 | "MessageId": "Base.1.0.PasswordChangeRequired" 135 | } 136 | ] 137 | } 138 | } 139 | keys = utils.get_extended_info_message_keys(body) 140 | self.assertEqual(keys, {'Success', 'PasswordChangeRequired'}) 141 | body = { 142 | "@Message.ExtendedInfo": [ 143 | { 144 | "MessageId": "Base.1.0.Success" 145 | }, 146 | { 147 | "MessageId": "Base.1.0.PasswordChangeRequired" 148 | } 149 | ] 150 | } 151 | keys = utils.get_extended_info_message_keys(body) 152 | self.assertEqual(keys, {'Success', 'PasswordChangeRequired'}) 153 | body = {} 154 | keys = utils.get_extended_info_message_keys(body) 155 | self.assertEqual(keys, set()) 156 | 157 | @mock.patch('builtins.print') 158 | @mock.patch('redfish_protocol_validator.utils.colorama.init') 159 | @mock.patch('redfish_protocol_validator.utils.colorama.deinit') 160 | def test_print_summary_all_pass(self, mock_colorama_deinit, 161 | mock_colorama_init, mock_print): 162 | utils.print_summary(self.sut) 163 | self.assertEqual(mock_print.call_count, 1) 164 | self.assertEqual(mock_colorama_init.call_count, 1) 165 | self.assertEqual(mock_colorama_deinit.call_count, 1) 166 | args = mock_print.call_args[0] 167 | self.assertIn('PASS: 2', args[0]) 168 | self.assertIn('FAIL: 0', args[0]) 169 | self.assertIn('WARN: 0', args[0]) 170 | self.assertIn('NOT_TESTED: 0', args[0]) 171 | self.assertIn(colorama.Fore.GREEN, args[0]) 172 | self.assertNotIn(colorama.Fore.RED, args[0]) 173 | self.assertNotIn(colorama.Fore.YELLOW, args[0]) 174 | 175 | @mock.patch('builtins.print') 176 | @mock.patch('redfish_protocol_validator.utils.colorama.init') 177 | @mock.patch('redfish_protocol_validator.utils.colorama.deinit') 178 | def test_print_summary_not_all_pass(self, mock_colorama_deinit, 179 | mock_colorama_init, mock_print): 180 | self.sut.log(Result.FAIL, 'GET', 200, '/redfish/v1/accounts/1', 181 | Assertion.PROTO_ETAG_ON_GET_ACCOUNT, 182 | 'did not return an ETag') 183 | self.sut.log(Result.WARN, 'PATCH', 200, '/redfish/v1/accounts/1', 184 | Assertion.PROTO_ETAG_ON_GET_ACCOUNT, 185 | 'some warning message') 186 | self.sut.log(Result.NOT_TESTED, 'TRACE', 500, '/redfish/v1/', 187 | Assertion.PROTO_HTTP_UNSUPPORTED_METHODS, 188 | 'some other message') 189 | utils.print_summary(self.sut) 190 | self.assertEqual(mock_print.call_count, 1) 191 | self.assertEqual(mock_colorama_init.call_count, 1) 192 | self.assertEqual(mock_colorama_deinit.call_count, 1) 193 | args = mock_print.call_args[0] 194 | self.assertIn('PASS: 2', args[0]) 195 | self.assertIn('FAIL: 1', args[0]) 196 | self.assertIn('WARN: 1', args[0]) 197 | self.assertIn('NOT_TESTED: 1', args[0]) 198 | # TODO(bdodd): Do these work on Windows systems? 199 | self.assertIn(colorama.Fore.GREEN, args[0]) 200 | self.assertIn(colorama.Fore.RED, args[0]) 201 | self.assertIn(colorama.Fore.YELLOW, args[0]) 202 | 203 | def test_redfish_version_to_tuple(self): 204 | v = utils.redfish_version_to_tuple('1.0.6') 205 | self.assertEqual(v, (1, 0, 6)) 206 | self.assertEqual(v.major, 1) 207 | self.assertEqual(v.minor, 0) 208 | self.assertEqual(v.errata, 6) 209 | v = utils.redfish_version_to_tuple('1') 210 | self.assertEqual(v, (1, 0, 0)) 211 | v = utils.redfish_version_to_tuple('1.0') 212 | self.assertEqual(v, (1, 0, 0)) 213 | v = utils.redfish_version_to_tuple('1.0.0') 214 | self.assertEqual(v, (1, 0, 0)) 215 | v = utils.redfish_version_to_tuple('1.9') 216 | self.assertEqual(v, (1, 9, 0)) 217 | v = utils.redfish_version_to_tuple('2.0.1') 218 | self.assertEqual(v, (2, 0, 1)) 219 | with self.assertRaises(ValueError): 220 | utils.redfish_version_to_tuple('') 221 | with self.assertRaises(ValueError): 222 | utils.redfish_version_to_tuple('1.0.6b') 223 | with self.assertRaises(TypeError): 224 | utils.redfish_version_to_tuple('1.6.0.2') 225 | 226 | def test_normalize_media_type(self): 227 | n = utils.normalize_media_type('text/html;charset=utf-8') 228 | self.assertEqual(n, 'text/html;charset=utf-8') 229 | n = utils.normalize_media_type('text/html;charset=UTF-8') 230 | self.assertEqual(n, 'text/html;charset=utf-8') 231 | n = utils.normalize_media_type('Text/HTML;Charset="utf-8"') 232 | self.assertEqual(n, 'text/html;charset=utf-8') 233 | n = utils.normalize_media_type('text/html; charset="utf-8"') 234 | self.assertEqual(n, 'text/html;charset=utf-8') 235 | 236 | def test_sanitize(self): 237 | self.assertEqual(20, utils.sanitize(20, minimum=1, maximum=255)) 238 | self.assertEqual(1, utils.sanitize(0, minimum=1, maximum=255)) 239 | self.assertEqual(255, utils.sanitize(256, minimum=1, maximum=255)) 240 | self.assertEqual(1, utils.sanitize(1, minimum=1)) 241 | self.assertEqual(1, utils.sanitize(0, minimum=1)) 242 | self.assertEqual(256, utils.sanitize(256, minimum=1)) 243 | pass 244 | 245 | @mock.patch('redfish_protocol_validator.utils.socket') 246 | @mock.patch('redfish_protocol_validator.utils.http.client') 247 | def test_discover_ssdp_ipv4(self, mock_http_client, mock_socket): 248 | mock_sock = mock.Mock() 249 | mock_sock.recv.return_value = b'foo' 250 | mock_socket.socket.return_value = mock_sock 251 | mock_socket.timeout = MyTimeout 252 | mock_http_client.HTTPResponse.side_effect = MyTimeout 253 | services = utils.discover_ssdp() 254 | self.assertEqual({}, services) 255 | 256 | @mock.patch('redfish_protocol_validator.utils.socket') 257 | @mock.patch('redfish_protocol_validator.utils.http.client') 258 | def test_discover_ssdp_ipv6(self, mock_http_client, mock_socket): 259 | mock_sock = mock.Mock() 260 | mock_sock.recv.return_value = b'foo' 261 | mock_socket.socket.return_value = mock_sock 262 | mock_socket.timeout = MyTimeout 263 | mock_http_client.HTTPResponse.side_effect = MyTimeout 264 | services = utils.discover_ssdp(protocol='ipv6') 265 | self.assertEqual({}, services) 266 | 267 | @mock.patch('redfish_protocol_validator.utils.socket') 268 | @mock.patch('redfish_protocol_validator.utils.http.client') 269 | def test_discover_ssdp_iface(self, mock_http_client, mock_socket): 270 | mock_sock = mock.Mock() 271 | mock_sock.recv.return_value = b'foo' 272 | mock_socket.socket.return_value = mock_sock 273 | mock_socket.timeout = MyTimeout 274 | mock_http_client.HTTPResponse.side_effect = MyTimeout 275 | services = utils.discover_ssdp(iface='eth0') 276 | self.assertEqual({}, services) 277 | 278 | @mock.patch('redfish_protocol_validator.utils.socket') 279 | @mock.patch('redfish_protocol_validator.utils.http.client') 280 | def test_discover_ssdp_bad_proto(self, mock_http_client, mock_socket): 281 | mock_sock = mock.Mock() 282 | mock_sock.recv.return_value = b'foo' 283 | mock_socket.socket.return_value = mock_sock 284 | mock_socket.timeout = MyTimeout 285 | mock_http_client.HTTPResponse.side_effect = MyTimeout 286 | with self.assertRaises(ValueError): 287 | utils.discover_ssdp(protocol='ipsec') 288 | 289 | def test_process_ssdp_response(self): 290 | mock_response = mock.Mock() 291 | uuid = '92384634-2938-2342-8820-489239905423' 292 | usn = 'uuid:%s::urn:dmtf-org:service:redfish-rest:1:0' % uuid 293 | mock_response.getheader.return_value = usn 294 | headers = { 295 | 'ST': SSDP_REDFISH, 296 | 'USN': usn, 297 | 'AL': 'http://0.0.0.0:8007/redfish/v1' 298 | } 299 | mock_response.headers = headers 300 | discovered_services = {} 301 | utils.process_ssdp_response(mock_response, discovered_services, 302 | utils.redfish_usn_pattern) 303 | expected = { 304 | uuid: headers 305 | } 306 | self.assertEqual(discovered_services, expected) 307 | self.assertEqual(discovered_services.get(uuid, {}).get('USN'), usn) 308 | self.assertEqual(discovered_services.get(uuid, {}).get('ST'), 309 | SSDP_REDFISH) 310 | self.assertEqual(discovered_services.get(uuid, {}).get('AL'), 311 | 'http://0.0.0.0:8007/redfish/v1') 312 | 313 | def test_fake_socket(self): 314 | sock = utils.FakeSocket(b'foo') 315 | s = sock.makefile() 316 | self.assertEqual(s, sock) 317 | 318 | def test_hex_to_binary_str(self): 319 | self.assertEqual('01111100', utils.hex_to_binary_str('7c')) 320 | self.assertEqual('000000001010101111000001001000111110111111111111', 321 | utils.hex_to_binary_str('00ABC123EFFF')) 322 | self.assertIsNone(utils.hex_to_binary_str('3VyZS4=')) 323 | 324 | def test_monobit_frequency(self): 325 | p = utils.monobit_frequency('1011010101') 326 | self.assertTrue(0.52708 <= p <= 0.52709) 327 | p = utils.monobit_frequency( 328 | '11001001000011111101101010100010001000010110100011' 329 | '00001000110100110001001100011001100010100010111000') 330 | self.assertTrue(0.10959 <= p <= 0.10960) 331 | 332 | def test_runs(self): 333 | p = utils.runs('1111111111') 334 | self.assertEqual(p, 0.0) 335 | p = utils.runs('1001101011') 336 | self.assertTrue(0.14723 <= p <= 0.14724) 337 | p = utils.runs( 338 | '11001001000011111101101010100010001000010110100011' 339 | '00001000110100110001001100011001100010100010111000') 340 | self.assertTrue(0.50079 <= p <= 0.50080) 341 | 342 | def test_random_sequence(self): 343 | self.assertFalse(utils.random_sequence('00040000')) 344 | self.assertFalse(utils.random_sequence('fffffffe')) 345 | self.assertFalse(utils.random_sequence('0000ffff')) 346 | token = 'C90FDAA22168C234C4C6628B8' 347 | self.assertTrue(utils.random_sequence(token)) 348 | self.assertIsNone(utils.random_sequence('c3VyZS4=')) 349 | 350 | 351 | if __name__ == '__main__': 352 | unittest.main() 353 | -------------------------------------------------------------------------------- /unittests/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright Notice: 2 | # Copyright 2020-2022 DMTF. All rights reserved. 3 | # License: BSD 3-Clause License. For full text see link: 4 | # https://github.com/DMTF/Redfish-Protocol-Validator/blob/main/LICENSE.md 5 | 6 | import json as json_util 7 | from unittest import mock 8 | 9 | import requests 10 | 11 | from redfish_protocol_validator.constants import RequestType, ResourceType 12 | from redfish_protocol_validator.system_under_test import SystemUnderTest 13 | 14 | 15 | def add_response(sut: SystemUnderTest, uri, method='GET', 16 | status_code=requests.codes.OK, json=None, text=None, 17 | res_type=None, request_payload=None, encoding=None, 18 | request_type=RequestType.NORMAL, headers=None): 19 | response = mock.MagicMock(spec=requests.Response) 20 | response.status_code = status_code 21 | response.ok = True if status_code < 400 else False 22 | response.url = sut.rhost + uri 23 | response.encoding = encoding 24 | request = mock.Mock(spec=requests.Request) 25 | request.method = method 26 | request.path_url = uri 27 | if json is not None: 28 | response.json.return_value = json 29 | response.headers = { 30 | 'Content-Type': 'application/json', 31 | 'Content-Length': len(json_util.dumps(json)) 32 | } 33 | response.text = str(json) 34 | elif text is not None: 35 | response.text = text 36 | response.headers = { 37 | 'Content-Type': 'application/xml', 38 | 'Content-Length': len(text) 39 | } 40 | else: 41 | response.text = '' 42 | response.headers = {} 43 | if headers is not None: 44 | response.headers.update(headers) 45 | request.body = request_payload 46 | if res_type == ResourceType.MANAGER_ACCOUNT: 47 | response.headers['ETag'] = '48305216' 48 | response.request = request 49 | sut.add_response(uri, response, resource_type=res_type, 50 | request_type=request_type) 51 | return response 52 | 53 | 54 | def get_result(sut: SystemUnderTest, assertion, method, uri): 55 | results = sut.results.get(assertion, []) 56 | for result in reversed(results): 57 | if result.get('method') == method and result.get('uri') == uri: 58 | return result 59 | return None 60 | --------------------------------------------------------------------------------