diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index ee731ac..9d62758 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -18,6 +18,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python test dependencies + run: pip install pytest pyyaml pytest-cov + + - name: Run Python unit tests + run: pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing + - name: Set up Node.js uses: actions/setup-node@v6 with: @@ -29,9 +40,17 @@ jobs: working-directory: webui run: npm ci - - name: Run WebUI tests + - name: Run WebUI tests with coverage working-directory: webui - run: npm test -- --run + run: npm run test:coverage -- --run + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml,webui/coverage/lcov.info + flags: python,webui + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false build-and-push-images: runs-on: ubuntu-latest diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index c9788f1..5f959f1 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -18,6 +18,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python test dependencies + run: pip install pytest pyyaml pytest-cov + + - name: Run Python unit tests + run: pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing + - name: Set up Node.js uses: actions/setup-node@v6 with: @@ -29,9 +40,17 @@ jobs: working-directory: webui run: npm ci - - name: Run WebUI tests + - name: Run WebUI tests with coverage working-directory: webui - run: npm test -- --run + run: npm run test:coverage -- --run + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml,webui/coverage/lcov.info + flags: python,webui + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false build-images: runs-on: ubuntu-latest diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 861578e..cb2a0ed 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -19,6 +19,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python test dependencies + run: pip install pytest pyyaml pytest-cov + + - name: Run Python unit tests + run: pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing + - name: Set up Node.js uses: actions/setup-node@v6 with: @@ -30,9 +41,17 @@ jobs: working-directory: webui run: npm ci - - name: Run WebUI tests + - name: Run WebUI tests with coverage working-directory: webui - run: npm test -- --run + run: npm run test:coverage -- --run + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml,webui/coverage/lcov.info + flags: python,webui + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false build-and-push-images: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 55a6272..a2f74f3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,10 @@ Thumbs.db /src/v5_4/__pycache__/ /src/v5_5/__pycache__/ /contrib/devportal/redocly/src/__pycache__/ + +# Test coverage artifacts # +########################### +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f474a70..0f20223 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,29 @@ To suggest a feature or enhancement, please create an issue on GitHub with the l - Fork the repo, create a branch, implement your changes, add any relevant tests, submit a PR when your changes are **tested** and ready for review. +## Running Tests + +### Python unit tests + +Run from the repository root: + +```bash +pip3 install pytest pyyaml pytest-cov +python3 -m pytest tests/ -v --cov=src --cov-report=term-missing +``` + +### WebUI tests + +Run from the `webui/` directory: + +```bash +cd webui +npm install +npm test -- --run +``` + +Both suites are run automatically in GitHub Actions. A failing test will block CI builds. + ## Code Guidelines ### Git Guidelines diff --git a/README.md b/README.md index 704333a..170e5a2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # NGINX-Declarative-API [![Project Status: Active โ€“ The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) +[![codecov](https://codecov.io/gh/f5devcentral/NGINX-Declarative-API/branch/main/graph/badge.svg)](https://codecov.io/gh/f5devcentral/NGINX-Declarative-API) NGINX Declarative API enables users to manage **NGINX configurations** in a modern **declarative style**. Instead of modifying configurations manually or using low-level APIs, this project simplifies operational workflows by allowing users to express desired configurations as a single JSON object. The API abstracts the complexity of managing NGINX configurations, empowering developers, operators, and automation systems to integrate seamlessly with NGINX. @@ -227,6 +228,25 @@ NGINX Declarative API can be deployed on: * Kubernetes using [manifests](/contrib/kubernetes) * Kubernetes using a [Helm chart](contrib/helm/nginx-declarative-api) +## ๐Ÿงช Running unit tests + +Python unit tests cover the core utility and configuration-patching modules. Run them from the repository root: + +```bash +pip3 install pytest pyyaml +python3 -m pytest tests/ -v +``` + +WebUI tests use [Vitest](https://vitest.dev/) and can be run from the `webui/` directory: + +```bash +cd webui +npm install +npm test -- --run +``` + +Both test suites run automatically in GitHub Actions on every pull request and release build. + ## ๐Ÿณ Building Docker images Docker images can be built and run using the Docker compose [script](/contrib/docker-compose) provided diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e4575e0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +""" +Add src/ to sys.path so tests can import project modules without installing them. +""" +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) diff --git a/tests/test_declaration_patcher.py b/tests/test_declaration_patcher.py new file mode 100644 index 0000000..ecb81b8 --- /dev/null +++ b/tests/test_declaration_patcher.py @@ -0,0 +1,256 @@ +""" +Tests for v5_5/DeclarationPatcher.py and v5_4/DeclarationPatcher.py + +Each patcher function follows a consistent contract: + - add: name not yet present โ†’ appended + - update: name already present, len > 1 โ†’ replaced + - delete: name already present, len == 1 (name-only dict) โ†’ removed +""" +import copy +import pytest + +import v5_5.DeclarationPatcher as patcher_v55 +import v5_4.DeclarationPatcher as patcher_v54 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _decl_with_http_servers(*servers): + return {'declaration': {'http': {'servers': list(servers)}}} + +def _decl_with_http_upstreams(*upstreams): + return {'declaration': {'http': {'upstreams': list(upstreams)}}} + +def _decl_with_layer4_servers(*servers): + return {'declaration': {'layer4': {'servers': list(servers)}}} + +def _decl_with_layer4_upstreams(*upstreams): + return {'declaration': {'layer4': {'upstreams': list(upstreams)}}} + +def _decl_with_nms_policies(*policies): + return {'output': {'nms': {'policies': list(policies)}}} + +def _decl_with_nms_certificates(*certs): + return {'output': {'nms': {'certificates': list(certs)}}} + + +# --------------------------------------------------------------------------- +# patchHttpServer +# --------------------------------------------------------------------------- + +class TestPatchHttpServer: + def test_add_to_empty_declaration(self): + result = patcher_v55.patchHttpServer({}, {'name': 'srv1', 'listen': '80'}) + assert len(result['declaration']['http']['servers']) == 1 + assert result['declaration']['http']['servers'][0]['name'] == 'srv1' + + def test_add_new_server(self): + decl = _decl_with_http_servers({'name': 'existing', 'listen': '80'}) + result = patcher_v55.patchHttpServer(decl, {'name': 'new', 'listen': '443'}) + names = [s['name'] for s in result['declaration']['http']['servers']] + assert 'existing' in names + assert 'new' in names + + def test_update_existing_server(self): + decl = _decl_with_http_servers({'name': 'srv1', 'listen': '80'}) + result = patcher_v55.patchHttpServer(decl, {'name': 'srv1', 'listen': '443'}) + servers = result['declaration']['http']['servers'] + assert len(servers) == 1 + assert servers[0]['listen'] == '443' + + def test_delete_server_by_name_only(self): + decl = _decl_with_http_servers({'name': 'srv1', 'listen': '80'}, {'name': 'srv2', 'listen': '443'}) + result = patcher_v55.patchHttpServer(decl, {'name': 'srv1'}) + names = [s['name'] for s in result['declaration']['http']['servers']] + assert 'srv1' not in names + assert 'srv2' in names + + def test_v54_add_server(self): + result = patcher_v54.patchHttpServer({}, {'name': 'srv1', 'listen': '80'}) + assert result['declaration']['http']['servers'][0]['name'] == 'srv1' + + +class TestPatchHttpUpstream: + def test_add_upstream(self): + result = patcher_v55.patchHttpUpstream({}, {'name': 'up1', 'origin': []}) + assert result['declaration']['http']['upstreams'][0]['name'] == 'up1' + + def test_update_upstream(self): + decl = _decl_with_http_upstreams({'name': 'up1', 'origin': ['10.0.0.1']}) + result = patcher_v55.patchHttpUpstream(decl, {'name': 'up1', 'origin': ['10.0.0.2']}) + assert result['declaration']['http']['upstreams'][0]['origin'] == ['10.0.0.2'] + + def test_delete_upstream(self): + decl = _decl_with_http_upstreams({'name': 'up1', 'origin': []}, {'name': 'up2', 'origin': []}) + result = patcher_v55.patchHttpUpstream(decl, {'name': 'up1'}) + names = [u['name'] for u in result['declaration']['http']['upstreams']] + assert 'up1' not in names + assert 'up2' in names + + def test_v54_add_upstream(self): + result = patcher_v54.patchHttpUpstream({}, {'name': 'up1', 'origin': []}) + assert result['declaration']['http']['upstreams'][0]['name'] == 'up1' + + +# --------------------------------------------------------------------------- +# patchStreamServer (layer4) +# --------------------------------------------------------------------------- + +class TestPatchStreamServer: + def test_add_to_empty(self): + result = patcher_v55.patchStreamServer({}, {'name': 'l4srv', 'listen': '3306'}) + assert result['declaration']['layer4']['servers'][0]['name'] == 'l4srv' + + def test_add_new_server(self): + decl = _decl_with_layer4_servers({'name': 'existing', 'listen': '3306'}) + result = patcher_v55.patchStreamServer(decl, {'name': 'new', 'listen': '5432'}) + names = [s['name'] for s in result['declaration']['layer4']['servers']] + assert set(names) == {'existing', 'new'} + + def test_update_server(self): + decl = _decl_with_layer4_servers({'name': 'srv', 'listen': '3306'}) + result = patcher_v55.patchStreamServer(decl, {'name': 'srv', 'listen': '5432'}) + assert result['declaration']['layer4']['servers'][0]['listen'] == '5432' + + def test_delete_server(self): + decl = _decl_with_layer4_servers({'name': 'srv1', 'listen': '3306'}, {'name': 'srv2', 'listen': '5432'}) + result = patcher_v55.patchStreamServer(decl, {'name': 'srv1'}) + names = [s['name'] for s in result['declaration']['layer4']['servers']] + assert 'srv1' not in names + assert 'srv2' in names + + def test_v54_add_server(self): + result = patcher_v54.patchStreamServer({}, {'name': 'l4srv', 'listen': '3306'}) + assert result['declaration']['layer4']['servers'][0]['name'] == 'l4srv' + + +class TestPatchStreamUpstream: + def test_add_upstream(self): + result = patcher_v55.patchStreamUpstream({}, {'name': 'l4up', 'origin': []}) + assert result['declaration']['layer4']['upstreams'][0]['name'] == 'l4up' + + def test_update_upstream(self): + decl = _decl_with_layer4_upstreams({'name': 'l4up', 'origin': ['10.0.0.1']}) + result = patcher_v55.patchStreamUpstream(decl, {'name': 'l4up', 'origin': ['10.0.0.2']}) + assert result['declaration']['layer4']['upstreams'][0]['origin'] == ['10.0.0.2'] + + def test_delete_upstream(self): + decl = _decl_with_layer4_upstreams({'name': 'u1', 'origin': []}, {'name': 'u2', 'origin': []}) + result = patcher_v55.patchStreamUpstream(decl, {'name': 'u1'}) + names = [u['name'] for u in result['declaration']['layer4']['upstreams']] + assert 'u1' not in names + assert 'u2' in names + + +# --------------------------------------------------------------------------- +# patchNAPPolicies +# --------------------------------------------------------------------------- + +class TestPatchNAPPolicies: + BASE_POLICY = { + 'type': 'app_protect', + 'name': 'my-policy', + 'active_tag': 'v1', + 'versions': [{'tag': 'v1', 'contents': {'content': 'http://example.com/policy.json'}}], + } + + def test_missing_output_returns_unchanged(self): + decl = {'declaration': {}} + result = patcher_v55.patchNAPPolicies(decl, self.BASE_POLICY) + assert result == decl + + def test_missing_nms_returns_unchanged(self): + decl = {'output': {}} + result = patcher_v55.patchNAPPolicies(decl, self.BASE_POLICY) + assert result == decl + + def test_missing_policies_returns_unchanged(self): + decl = {'output': {'nms': {}}} + result = patcher_v55.patchNAPPolicies(decl, self.BASE_POLICY) + assert result == decl + + def test_add_policy(self): + decl = _decl_with_nms_policies() + new_policy = copy.deepcopy(self.BASE_POLICY) + result = patcher_v55.patchNAPPolicies(decl, new_policy) + assert len(result['output']['nms']['policies']) == 1 + assert result['output']['nms']['policies'][0]['name'] == 'my-policy' + + def test_update_existing_policy(self): + old_policy = {'type': 'app_protect', 'name': 'my-policy', 'active_tag': 'v1', + 'versions': [{'tag': 'v1', 'contents': {'content': 'old'}}]} + decl = _decl_with_nms_policies(old_policy) + updated = copy.deepcopy(self.BASE_POLICY) + updated['active_tag'] = 'v2' + result = patcher_v55.patchNAPPolicies(decl, updated) + policies = result['output']['nms']['policies'] + assert len(policies) == 1 + assert policies[0]['active_tag'] == 'v2' + + def test_delete_policy_empty_versions(self): + existing = copy.deepcopy(self.BASE_POLICY) + decl = _decl_with_nms_policies(existing) + delete_req = {'type': 'app_protect', 'name': 'my-policy', 'active_tag': '', 'versions': []} + result = patcher_v55.patchNAPPolicies(decl, delete_req) + assert result['output']['nms']['policies'] == [] + + def test_v54_add_policy(self): + decl = _decl_with_nms_policies() + result = patcher_v54.patchNAPPolicies(decl, copy.deepcopy(self.BASE_POLICY)) + assert result['output']['nms']['policies'][0]['name'] == 'my-policy' + + +# --------------------------------------------------------------------------- +# patchCertificates +# --------------------------------------------------------------------------- + +class TestPatchCertificates: + BASE_CERT = {'type': 'certificate', 'name': 'my-cert', 'contents': {'content': 'CERTDATA'}} + + def test_missing_output_returns_unchanged(self): + decl = {} + result = patcher_v55.patchCertificates(decl, self.BASE_CERT) + assert result == decl + + def test_missing_nms_returns_unchanged(self): + decl = {'output': {}} + result = patcher_v55.patchCertificates(decl, self.BASE_CERT) + assert result == decl + + def test_missing_certificates_returns_unchanged(self): + decl = {'output': {'nms': {}}} + result = patcher_v55.patchCertificates(decl, self.BASE_CERT) + assert result == decl + + def test_add_certificate(self): + decl = _decl_with_nms_certificates() + result = patcher_v55.patchCertificates(decl, copy.deepcopy(self.BASE_CERT)) + assert len(result['output']['nms']['certificates']) == 1 + assert result['output']['nms']['certificates'][0]['name'] == 'my-cert' + + def test_update_certificate(self): + old = {'type': 'certificate', 'name': 'my-cert', 'contents': {'content': 'OLD'}} + decl = _decl_with_nms_certificates(old) + updated = {'type': 'certificate', 'name': 'my-cert', 'contents': {'content': 'NEW'}} + result = patcher_v55.patchCertificates(decl, updated) + assert result['output']['nms']['certificates'][0]['contents']['content'] == 'NEW' + + def test_delete_certificate_when_existing_has_empty_contents(self): + # When the EXISTING cert has empty contents, patching it causes deletion (not appended). + existing_empty = {'type': 'certificate', 'name': 'my-cert', 'contents': {}} + decl = _decl_with_nms_certificates( + existing_empty, + {'type': 'key', 'name': 'other-key', 'contents': {'content': 'KEY'}}, + ) + patch = {'type': 'certificate', 'name': 'my-cert', 'contents': {'content': 'NEW'}} + result = patcher_v55.patchCertificates(decl, patch) + names = [c['name'] for c in result['output']['nms']['certificates']] + assert 'my-cert' not in names + assert 'other-key' in names + + def test_v54_add_certificate(self): + decl = _decl_with_nms_certificates() + result = patcher_v54.patchCertificates(decl, copy.deepcopy(self.BASE_CERT)) + assert result['output']['nms']['certificates'][0]['name'] == 'my-cert' diff --git a/tests/test_misc_utils.py b/tests/test_misc_utils.py new file mode 100644 index 0000000..1cc8016 --- /dev/null +++ b/tests/test_misc_utils.py @@ -0,0 +1,199 @@ +""" +Tests for v5_5/MiscUtils.py and v5_4/MiscUtils.py + +Covers pure utility functions that have no external service dependencies. +""" +import io +import base64 +import json +import uuid +import pytest + +import v5_5.MiscUtils as utils_v55 +import v5_4.MiscUtils as utils_v54 + + +# --------------------------------------------------------------------------- +# getDictKey +# --------------------------------------------------------------------------- + +class TestGetDictKey: + def test_top_level_key(self): + d = {'a': 1} + assert utils_v55.getDictKey(d, 'a') == 1 + + def test_nested_key(self): + d = {'a': {'b': {'c': 42}}} + assert utils_v55.getDictKey(d, 'a.b.c') == 42 + + def test_missing_key_returns_none(self): + d = {'a': {'b': 1}} + assert utils_v55.getDictKey(d, 'a.x') is None + + def test_missing_top_level_returns_none(self): + d = {'a': 1} + assert utils_v55.getDictKey(d, 'z') is None + + def test_custom_separator(self): + d = {'a': {'b': 99}} + assert utils_v55.getDictKey(d, 'a/b', separator='/') == 99 + + def test_empty_dict(self): + assert utils_v55.getDictKey({}, 'a.b') is None + + # v5_4 has the same implementation + def test_v54_nested_key(self): + d = {'x': {'y': 'hello'}} + assert utils_v54.getDictKey(d, 'x.y') == 'hello' + + +# --------------------------------------------------------------------------- +# regex_replace +# --------------------------------------------------------------------------- + +class TestRegexReplace: + def test_simple_replace(self): + assert utils_v55.regex_replace('hello world', r'world', 'there') == 'hello there' + + def test_pattern_replace(self): + assert utils_v55.regex_replace('abc123def', r'\d+', 'NUM') == 'abcNUMdef' + + def test_no_match(self): + assert utils_v55.regex_replace('hello', r'xyz', 'ABC') == 'hello' + + def test_replace_all_occurrences(self): + result = utils_v55.regex_replace('aXbXcX', r'X', '-') + assert result == 'a-b-c-' + + def test_v54_regex_replace(self): + assert utils_v54.regex_replace('foo bar', r'\s', '_') == 'foo_bar' + + +# --------------------------------------------------------------------------- +# yaml_or_json (accepts a file-like object due to json.load usage) +# --------------------------------------------------------------------------- + +class TestYamlOrJson: + def test_detects_json(self): + doc = io.StringIO('{"key": "value"}') + assert utils_v55.yaml_or_json(doc) == 'json' + + def test_detects_yaml(self): + doc = io.StringIO('key: value\n') + assert utils_v55.yaml_or_json(doc) == 'yaml' + + def test_empty_object_json(self): + doc = io.StringIO('{}') + assert utils_v55.yaml_or_json(doc) == 'json' + + def test_v54_detects_json(self): + doc = io.StringIO('{"a": 1}') + assert utils_v54.yaml_or_json(doc) == 'json' + + def test_v54_detects_yaml(self): + doc = io.StringIO('a: 1\n') + assert utils_v54.yaml_or_json(doc) == 'yaml' + + +# --------------------------------------------------------------------------- +# yaml_to_json +# --------------------------------------------------------------------------- + +class TestYamlToJson: + def test_simple_mapping(self): + yaml_input = 'name: nginx\nversion: "1.0"' + result = json.loads(utils_v55.yaml_to_json(yaml_input)) + assert result['name'] == 'nginx' + assert result['version'] == '1.0' + + def test_nested_mapping(self): + yaml_input = 'outer:\n inner: value' + result = json.loads(utils_v55.yaml_to_json(yaml_input)) + assert result['outer']['inner'] == 'value' + + def test_list(self): + yaml_input = 'items:\n - one\n - two' + result = json.loads(utils_v55.yaml_to_json(yaml_input)) + assert result['items'] == ['one', 'two'] + + def test_v54_yaml_to_json(self): + result = json.loads(utils_v54.yaml_to_json('key: val')) + assert result['key'] == 'val' + + +# --------------------------------------------------------------------------- +# json_to_yaml +# --------------------------------------------------------------------------- + +class TestJsonToYaml: + def test_simple_object(self): + import yaml + result = utils_v55.json_to_yaml('{"name": "nginx"}') + parsed = yaml.safe_load(result) + assert parsed['name'] == 'nginx' + + def test_nested_object(self): + import yaml + result = utils_v55.json_to_yaml('{"a": {"b": 1}}') + parsed = yaml.safe_load(result) + assert parsed['a']['b'] == 1 + + def test_v54_json_to_yaml(self): + import yaml + result = utils_v54.json_to_yaml('{"x": "y"}') + parsed = yaml.safe_load(result) + assert parsed['x'] == 'y' + + +# --------------------------------------------------------------------------- +# getuniqueid +# --------------------------------------------------------------------------- + +class TestGetUniqueId: + def test_returns_uuid(self): + result = utils_v55.getuniqueid() + # Should be a valid UUID + assert isinstance(result, uuid.UUID) + + def test_returns_unique_values(self): + ids = {utils_v55.getuniqueid() for _ in range(100)} + assert len(ids) == 100 + + def test_v54_returns_uuid(self): + result = utils_v54.getuniqueid() + assert isinstance(result, uuid.UUID) + + +# --------------------------------------------------------------------------- +# resolveFQDN +# --------------------------------------------------------------------------- + +class TestResolveFQDN: + def test_invalid_fqdn_returns_false(self): + ok, _ = utils_v55.resolveFQDN('this.domain.does.not.exist.invalid') + assert ok is False + + def test_v54_invalid_fqdn(self): + ok, _ = utils_v54.resolveFQDN('this.domain.does.not.exist.invalid') + assert ok is False + + +# --------------------------------------------------------------------------- +# isBase64 (v5_5 only) +# --------------------------------------------------------------------------- + +class TestIsBase64: + def test_valid_base64(self): + encoded = base64.b64encode(b'hello world').decode('utf-8') + assert utils_v55.isBase64(encoded) is True + + def test_not_base64(self): + assert utils_v55.isBase64('this is not base64!!!') is False + + def test_empty_string(self): + # base64.b64encode(b'') == b'' and base64.b64decode(b'') == b'' + # bytes('', 'utf-8') == b'' so empty string is technically valid base64 + assert utils_v55.isBase64('') is True + + def test_json_string_not_base64(self): + assert utils_v55.isBase64('{"key": "value"}') is False diff --git a/tests/test_openapi_parser.py b/tests/test_openapi_parser.py new file mode 100644 index 0000000..6587799 --- /dev/null +++ b/tests/test_openapi_parser.py @@ -0,0 +1,214 @@ +""" +Tests for v5_5/OpenAPIParser.py + +The v5_4 implementation is identical, so a single test suite covers both. +""" +import pytest + +import v5_5.OpenAPIParser as oap_module +import v5_4.OpenAPIParser as oap_v54_module + +OpenAPIParser = oap_module.OpenAPIParser +OpenAPIParser_v54 = oap_v54_module.OpenAPIParser + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +OPENAPI_3_SCHEMA = { + 'openapi': '3.0.0', + 'info': {'title': 'Test API', 'version': '1.0.0'}, + 'servers': [ + {'url': 'https://api.example.com', 'description': 'Production'}, + {'url': 'https://staging.example.com'}, + ], + 'paths': { + '/users': { + 'get': { + 'summary': 'List users', + 'description': 'Returns all users', + 'operationId': 'listUsers', + 'parameters': [ + { + 'name': 'limit', + 'in': 'query', + 'description': 'Max results', + 'required': False, + 'schema': {'type': 'integer', 'default': '20'}, + } + ], + }, + 'post': { + 'summary': 'Create user', + 'description': 'Creates a new user', + 'operationId': 'createUser', + }, + }, + '/users/{id}': { + 'get': { + 'summary': 'Get user', + 'description': 'Returns a single user', + 'operationId': 'getUser', + }, + 'delete': { + 'summary': 'Delete user', + 'description': 'Deletes a user', + 'operationId': 'deleteUser', + }, + }, + }, +} + +SWAGGER_2_SCHEMA = { + 'swagger': '2.0', + 'info': {'title': 'Old API', 'version': '0.1'}, + 'paths': {}, +} + +MINIMAL_SCHEMA = {} + + +# --------------------------------------------------------------------------- +# version() +# --------------------------------------------------------------------------- + +class TestVersion: + def test_openapi_3(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + assert parser.version() == '3.0.0' + + def test_swagger_2(self): + parser = OpenAPIParser(SWAGGER_2_SCHEMA) + assert parser.version() == '2.0' + + def test_no_version_returns_none(self): + parser = OpenAPIParser(MINIMAL_SCHEMA) + assert parser.version() is None + + def test_v54_version(self): + parser = OpenAPIParser_v54(OPENAPI_3_SCHEMA) + assert parser.version() == '3.0.0' + + +# --------------------------------------------------------------------------- +# info() +# --------------------------------------------------------------------------- + +class TestInfo: + def test_returns_info_block(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + info = parser.info() + assert info['title'] == 'Test API' + assert info['version'] == '1.0.0' + + def test_no_info_returns_none(self): + parser = OpenAPIParser(MINIMAL_SCHEMA) + assert parser.info() is None + + def test_v54_info(self): + parser = OpenAPIParser_v54(SWAGGER_2_SCHEMA) + assert parser.info()['title'] == 'Old API' + + +# --------------------------------------------------------------------------- +# servers() +# --------------------------------------------------------------------------- + +class TestServers: + def test_returns_all_servers(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + servers = parser.servers() + assert len(servers) == 2 + + def test_server_url_present(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + urls = [s['url'] for s in parser.servers()] + assert 'https://api.example.com' in urls + + def test_server_description_present_when_defined(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + prod = next(s for s in parser.servers() if s['url'] == 'https://api.example.com') + assert prod.get('description') == 'Production' + + def test_server_no_description_when_absent(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + staging = next(s for s in parser.servers() if s['url'] == 'https://staging.example.com') + assert 'description' not in staging + + def test_no_servers_returns_empty_list(self): + parser = OpenAPIParser(MINIMAL_SCHEMA) + assert parser.servers() == [] + + def test_v54_servers(self): + parser = OpenAPIParser_v54(OPENAPI_3_SCHEMA) + assert len(parser.servers()) == 2 + + +# --------------------------------------------------------------------------- +# paths() +# --------------------------------------------------------------------------- + +class TestPaths: + def test_returns_all_paths(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + paths = parser.paths() + path_uris = [p['path'] for p in paths] + assert '/users' in path_uris + assert '/users/{id}' in path_uris + + def test_methods_populated(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + users_path = next(p for p in parser.paths() if p['path'] == '/users') + methods = [m['method'] for m in users_path['methods']] + assert 'get' in methods + assert 'post' in methods + + def test_method_details(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + users_path = next(p for p in parser.paths() if p['path'] == '/users') + get_method = next(m for m in users_path['methods'] if m['method'] == 'get') + assert get_method['details']['summary'] == 'List users' + assert get_method['details']['operationId'] == 'listUsers' + + def test_query_parameters_parsed(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + users_path = next(p for p in parser.paths() if p['path'] == '/users') + get_method = next(m for m in users_path['methods'] if m['method'] == 'get') + assert len(get_method['parameters']) == 1 + assert get_method['parameters'][0]['name'] == 'limit' + assert get_method['parameters'][0]['in'] == 'query' + + def test_parameter_schema_parsed(self): + parser = OpenAPIParser(OPENAPI_3_SCHEMA) + users_path = next(p for p in parser.paths() if p['path'] == '/users') + get_method = next(m for m in users_path['methods'] if m['method'] == 'get') + schema = get_method['parameters'][0]['schema'] + assert schema['type'] == 'integer' + assert schema['default'] == '20' + + def test_no_paths_returns_empty_list(self): + parser = OpenAPIParser(MINIMAL_SCHEMA) + assert parser.paths() == [] + + def test_non_http_keys_ignored(self): + # 'summary' at path level is not a valid HTTP method and should be ignored + schema = { + 'paths': { + '/health': { + 'summary': 'health check path', + 'get': {'description': 'ping', 'summary': 'health', 'operationId': 'healthCheck'}, + } + } + } + parser = OpenAPIParser(schema) + paths = parser.paths() + health = next(p for p in paths if p['path'] == '/health') + methods = [m['method'] for m in health['methods']] + assert 'get' in methods + assert 'summary' not in methods + + def test_v54_paths(self): + parser = OpenAPIParser_v54(OPENAPI_3_SCHEMA) + paths = parser.paths() + assert any(p['path'] == '/users' for p in paths) diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 569605a..5df1547 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -26,5 +26,11 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts', + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['src/test/**'], + }, }, });