From 2c486d2786a7c43b60fcdbc0aa1d586ea10fce0e Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Wed, 30 Aug 2023 20:29:47 +0000 Subject: [PATCH 01/10] initial cli env variable unit tests --- src/_nebari/stages/infrastructure/__init__.py | 2 +- tests/tests_unit/test_cli_init.py | 26 +--- tests/tests_unit/test_cli_validate.py | 125 +++++++++++++++--- 3 files changed, 105 insertions(+), 48 deletions(-) diff --git a/src/_nebari/stages/infrastructure/__init__.py b/src/_nebari/stages/infrastructure/__init__.py index d5b554e257..701c9c5c33 100644 --- a/src/_nebari/stages/infrastructure/__init__.py +++ b/src/_nebari/stages/infrastructure/__init__.py @@ -315,7 +315,7 @@ class GCPNodeGroup(schema.Base): class GoogleCloudPlatformProvider(schema.Base): - project: str = pydantic.Field(default_factory=lambda: os.environ["PROJECT_ID"]) + project: str = pydantic.Field(default_factory=lambda: os.environ.get("PROJECT_ID")) region: str = "us-central1" availability_zones: typing.Optional[typing.List[str]] = [] kubernetes_version: typing.Optional[str] diff --git a/tests/tests_unit/test_cli_init.py b/tests/tests_unit/test_cli_init.py index ce4695efff..a06e16f30f 100644 --- a/tests/tests_unit/test_cli_init.py +++ b/tests/tests_unit/test_cli_init.py @@ -14,30 +14,6 @@ TEST_KUBERNETES_VERSION = {"default": "1.20", "do": "1.20.2-do.0"} -MOCK_ENV = { - k: "test" - for k in [ - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", # aws - "GOOGLE_CREDENTIALS", - "PROJECT_ID", # gcp - "ARM_SUBSCRIPTION_ID", - "ARM_TENANT_ID", - "ARM_CLIENT_ID", - "ARM_CLIENT_SECRET", # azure - "DIGITALOCEAN_TOKEN", - "SPACES_ACCESS_KEY_ID", - "SPACES_SECRET_ACCESS_KEY", # digital ocean - "GITHUB_CLIENT_ID", - "GITHUB_CLIENT_SECRET", - "GITHUB_USERNAME", - "GITHUB_TOKEN", # github - "AUTH0_CLIENT_ID", - "AUTH0_CLIENT_SECRET", - "AUTH0_DOMAIN", # auth0 - ] -} - @pytest.mark.parametrize( "args, exit_code, content", @@ -207,7 +183,7 @@ def assert_nebari_init_args( print(f"\n>>>> Testing nebari {args} -- input {input}") result = runner.invoke( - app, args + ["--output", tmp_file.resolve()], input=input, env=MOCK_ENV + app, args + ["--output", tmp_file.resolve()], input=input ) print(f"\n>>> runner.stdout == {result.stdout}") diff --git a/tests/tests_unit/test_cli_validate.py b/tests/tests_unit/test_cli_validate.py index 1afc5cd431..48412db606 100644 --- a/tests/tests_unit/test_cli_validate.py +++ b/tests/tests_unit/test_cli_validate.py @@ -1,34 +1,19 @@ +import pytest import re +import tempfile +import yaml + from pathlib import Path -from typing import List +from typing import Any, Dict, List -import pytest from typer.testing import CliRunner from _nebari.cli import create_cli TEST_DATA_DIR = Path(__file__).resolve().parent / "cli_validate" -MOCK_ENV = { - k: "test" - for k in [ - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", # aws - "GOOGLE_CREDENTIALS", - "PROJECT_ID", # gcp - "ARM_SUBSCRIPTION_ID", - "ARM_TENANT_ID", - "ARM_CLIENT_ID", - "ARM_CLIENT_SECRET", # azure - "DIGITALOCEAN_TOKEN", - "SPACES_ACCESS_KEY_ID", - "SPACES_SECRET_ACCESS_KEY", # digital ocean - ] -} - runner = CliRunner() - @pytest.mark.parametrize( "args, exit_code, content", [ @@ -76,11 +61,107 @@ def test_validate_local_happy_path(config_yaml: str): assert test_file.exists() is True app = create_cli() - result = runner.invoke(app, ["validate", "--config", test_file], env=MOCK_ENV) + result = runner.invoke(app, ["validate", "--config", test_file]) assert not result.exception assert 0 == result.exit_code assert "Successfully validated configuration" in result.stdout +@pytest.mark.parametrize( + "key, value, provider, expected_message", + [ + ("NEBARI_SECRET__project_name", "123invalid", "local", "validation error"), + ("NEBARI_SECRET__this_is_an_error", "true", "local", "object has no field"), + ("NEBARI_SECRET__amazon_web_services__kubernetes_version", "1.0", "aws", "validation error"), + ], +) +def test_validate_error_from_env(key: str, value: str, provider: str, expected_message: str): + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + nebari_config = yaml.safe_load( + f""" +provider: {provider} +project_name: test + """ + ) + + with open(tmp_file.resolve(), "w") as f: + yaml.dump(nebari_config, f) + + assert tmp_file.exists() is True + app = create_cli() + + # confirm the file is otherwise valid without environment variable overrides + pre = runner.invoke(app, ["validate", "--config", tmp_file.resolve()]) + assert 0 == pre.exit_code + assert not pre.exception + + # run validate again with environment variables that are expected to trigger + # validation errors + result = runner.invoke(app, ["validate", "--config", tmp_file.resolve()], env = { + key: value + }) + + assert 1 == result.exit_code + assert result.exception + assert expected_message in result.stdout + + +@pytest.mark.parametrize( + "provider, addl_config", + [ + ("aws", {}), + # azure credentials are only checked if a kubernetes_version is specified + ("azure", { "azure": { "kubernetes_version": "1.0" } }), + # gcp credentials are only checked if a kubernetes_version is specified + ("gcp", { "google_cloud_platform": { "kubernetes_version": "1.0" } }), + ("do", {}), + ], +) +def test_validate_error_missing_cloud_env(monkeypatch: pytest.MonkeyPatch, provider: str, addl_config: Dict[str, Any]): + # cloud methods are all globally mocked, need to reset so the env variables will be checked + monkeypatch.undo() + for e in [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "GOOGLE_CREDENTIALS", + "ARM_SUBSCRIPTION_ID", + "ARM_TENANT_ID", + "ARM_CLIENT_ID", + "ARM_CLIENT_SECRET", + "DIGITALOCEAN_TOKEN", + "SPACES_ACCESS_KEY_ID", + "SPACES_SECRET_ACCESS_KEY", + ]: + try: + monkeypatch.delenv(e) + except Exception: + pass + + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + nebari_config = { **yaml.safe_load( + f""" +provider: {provider} +project_name: test + """ + ), **addl_config } + + with open(tmp_file.resolve(), "w") as f: + yaml.dump(nebari_config, f) + + assert tmp_file.exists() is True + app = create_cli() + + result = runner.invoke(app, ["validate", "--config", tmp_file.resolve()]) + + assert 1 == result.exit_code + assert result.exception + assert "Missing the following required environment variable" in result.stdout + def generate_test_data_test_validate_error(): """ @@ -113,7 +194,7 @@ def test_validate_error(config_yaml: str, expected_message: str): assert test_file.exists() is True app = create_cli() - result = runner.invoke(app, ["validate", "--config", test_file], env=MOCK_ENV) + result = runner.invoke(app, ["validate", "--config", test_file]) print(result.stdout) assert result.exception assert 1 == result.exit_code From 75da75484477677fce068419b094ad4b3455b87f Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Wed, 30 Aug 2023 20:31:47 +0000 Subject: [PATCH 02/10] pre-commit cleanup --- tests/tests_unit/test_cli_validate.py | 47 +++++++++++++++++---------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/tests/tests_unit/test_cli_validate.py b/tests/tests_unit/test_cli_validate.py index 48412db606..fa16e33456 100644 --- a/tests/tests_unit/test_cli_validate.py +++ b/tests/tests_unit/test_cli_validate.py @@ -1,11 +1,10 @@ -import pytest import re import tempfile -import yaml - from pathlib import Path from typing import Any, Dict, List +import pytest +import yaml from typer.testing import CliRunner from _nebari.cli import create_cli @@ -14,6 +13,7 @@ runner = CliRunner() + @pytest.mark.parametrize( "args, exit_code, content", [ @@ -66,15 +66,23 @@ def test_validate_local_happy_path(config_yaml: str): assert 0 == result.exit_code assert "Successfully validated configuration" in result.stdout + @pytest.mark.parametrize( "key, value, provider, expected_message", [ ("NEBARI_SECRET__project_name", "123invalid", "local", "validation error"), ("NEBARI_SECRET__this_is_an_error", "true", "local", "object has no field"), - ("NEBARI_SECRET__amazon_web_services__kubernetes_version", "1.0", "aws", "validation error"), + ( + "NEBARI_SECRET__amazon_web_services__kubernetes_version", + "1.0", + "aws", + "validation error", + ), ], ) -def test_validate_error_from_env(key: str, value: str, provider: str, expected_message: str): +def test_validate_error_from_env( + key: str, value: str, provider: str, expected_message: str +): with tempfile.TemporaryDirectory() as tmp: tmp_file = Path(tmp).resolve() / "nebari-config.yaml" assert tmp_file.exists() is False @@ -99,9 +107,9 @@ def test_validate_error_from_env(key: str, value: str, provider: str, expected_m # run validate again with environment variables that are expected to trigger # validation errors - result = runner.invoke(app, ["validate", "--config", tmp_file.resolve()], env = { - key: value - }) + result = runner.invoke( + app, ["validate", "--config", tmp_file.resolve()], env={key: value} + ) assert 1 == result.exit_code assert result.exception @@ -112,14 +120,16 @@ def test_validate_error_from_env(key: str, value: str, provider: str, expected_m "provider, addl_config", [ ("aws", {}), - # azure credentials are only checked if a kubernetes_version is specified - ("azure", { "azure": { "kubernetes_version": "1.0" } }), - # gcp credentials are only checked if a kubernetes_version is specified - ("gcp", { "google_cloud_platform": { "kubernetes_version": "1.0" } }), + # azure credentials are only checked if a kubernetes_version is specified + ("azure", {"azure": {"kubernetes_version": "1.0"}}), + # gcp credentials are only checked if a kubernetes_version is specified + ("gcp", {"google_cloud_platform": {"kubernetes_version": "1.0"}}), ("do", {}), ], ) -def test_validate_error_missing_cloud_env(monkeypatch: pytest.MonkeyPatch, provider: str, addl_config: Dict[str, Any]): +def test_validate_error_missing_cloud_env( + monkeypatch: pytest.MonkeyPatch, provider: str, addl_config: Dict[str, Any] +): # cloud methods are all globally mocked, need to reset so the env variables will be checked monkeypatch.undo() for e in [ @@ -138,17 +148,20 @@ def test_validate_error_missing_cloud_env(monkeypatch: pytest.MonkeyPatch, provi monkeypatch.delenv(e) except Exception: pass - + with tempfile.TemporaryDirectory() as tmp: tmp_file = Path(tmp).resolve() / "nebari-config.yaml" assert tmp_file.exists() is False - nebari_config = { **yaml.safe_load( - f""" + nebari_config = { + **yaml.safe_load( + f""" provider: {provider} project_name: test """ - ), **addl_config } + ), + **addl_config, + } with open(tmp_file.resolve(), "w") as f: yaml.dump(nebari_config, f) From a96208aeeb60ac8e55c09553a719a7f1c09a2972 Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Wed, 30 Aug 2023 21:00:40 +0000 Subject: [PATCH 03/10] adding validation env happy path test --- tests/tests_unit/test_cli_validate.py | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/tests_unit/test_cli_validate.py b/tests/tests_unit/test_cli_validate.py index fa16e33456..b7f86dce42 100644 --- a/tests/tests_unit/test_cli_validate.py +++ b/tests/tests_unit/test_cli_validate.py @@ -67,6 +67,41 @@ def test_validate_local_happy_path(config_yaml: str): assert "Successfully validated configuration" in result.stdout +def test_validate_from_env(): + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + nebari_config = yaml.safe_load( + f""" +provider: aws +project_name: test + """ + ) + + with open(tmp_file.resolve(), "w") as f: + yaml.dump(nebari_config, f) + + assert tmp_file.exists() is True + app = create_cli() + + valid_result = runner.invoke( + app, ["validate", "--config", tmp_file.resolve()], env={"NEBARI_SECRET__amazon_web_services__kubernetes_version": "1.20"} + ) + + assert 0 == valid_result.exit_code + assert not valid_result.exception + assert "Successfully validated configuration" in valid_result.stdout + + invalid_result = runner.invoke( + app, ["validate", "--config", tmp_file.resolve()], env={"NEBARI_SECRET__amazon_web_services__kubernetes_version": "1.0"} + ) + + assert 1 == invalid_result.exit_code + assert invalid_result.exception + assert "Invalid `kubernetes-version`" in invalid_result.stdout + + @pytest.mark.parametrize( "key, value, provider, expected_message", [ From 931fbf0cb1fe77c3828b6ed56deca6141e38d9a7 Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Wed, 30 Aug 2023 21:04:58 +0000 Subject: [PATCH 04/10] pre-commit... :/ --- tests/tests_unit/test_cli_validate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/tests_unit/test_cli_validate.py b/tests/tests_unit/test_cli_validate.py index b7f86dce42..58078f3669 100644 --- a/tests/tests_unit/test_cli_validate.py +++ b/tests/tests_unit/test_cli_validate.py @@ -73,7 +73,7 @@ def test_validate_from_env(): assert tmp_file.exists() is False nebari_config = yaml.safe_load( - f""" + """ provider: aws project_name: test """ @@ -86,15 +86,19 @@ def test_validate_from_env(): app = create_cli() valid_result = runner.invoke( - app, ["validate", "--config", tmp_file.resolve()], env={"NEBARI_SECRET__amazon_web_services__kubernetes_version": "1.20"} + app, + ["validate", "--config", tmp_file.resolve()], + env={"NEBARI_SECRET__amazon_web_services__kubernetes_version": "1.20"}, ) assert 0 == valid_result.exit_code assert not valid_result.exception assert "Successfully validated configuration" in valid_result.stdout - + invalid_result = runner.invoke( - app, ["validate", "--config", tmp_file.resolve()], env={"NEBARI_SECRET__amazon_web_services__kubernetes_version": "1.0"} + app, + ["validate", "--config", tmp_file.resolve()], + env={"NEBARI_SECRET__amazon_web_services__kubernetes_version": "1.0"}, ) assert 1 == invalid_result.exit_code From cde4b21a8eaeb513a67cc138d73601519bb0b9a9 Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Thu, 31 Aug 2023 18:37:57 +0000 Subject: [PATCH 05/10] initial cli support tests --- src/_nebari/subcommands/support.py | 10 +- tests/tests_unit/test_cli_init.py | 8 +- tests/tests_unit/test_cli_support.py | 213 ++++++++++++++++++++++++++ tests/tests_unit/test_cli_upgrade.py | 22 +-- tests/tests_unit/test_cli_validate.py | 20 +-- 5 files changed, 244 insertions(+), 29 deletions(-) create mode 100644 tests/tests_unit/test_cli_support.py diff --git a/src/_nebari/subcommands/support.py b/src/_nebari/subcommands/support.py index 93b185fa25..fb16513111 100644 --- a/src/_nebari/subcommands/support.py +++ b/src/_nebari/subcommands/support.py @@ -2,6 +2,7 @@ from zipfile import ZipFile import kubernetes.client +import kubernetes.client.exceptions import kubernetes.config import typer @@ -34,13 +35,13 @@ def support( """ from nebari.plugins import nebari_plugin_manager + config_schema = nebari_plugin_manager.config_schema + namespace = read_configuration(config_filename, config_schema).namespace + kubernetes.config.kube_config.load_kube_config() v1 = kubernetes.client.CoreV1Api() - config_schema = nebari_plugin_manager.config_schema - namespace = read_configuration(config_filename, config_schema).namespace - pods = v1.list_namespaced_pod(namespace=namespace) for pod in pods.items: @@ -72,9 +73,10 @@ def support( namespace=namespace, container=container, ) + + "\n" ) - except client.exceptions.ApiException as e: + except kubernetes.client.exceptions.ApiException as e: file.write("%s not available" % pod.metadata.name) raise e diff --git a/tests/tests_unit/test_cli_init.py b/tests/tests_unit/test_cli_init.py index a06e16f30f..a9dda4f7b9 100644 --- a/tests/tests_unit/test_cli_init.py +++ b/tests/tests_unit/test_cli_init.py @@ -40,7 +40,7 @@ (["-o"], 2, ["requires an argument"]), ], ) -def test_init_stdout(args: List[str], exit_code: int, content: List[str]): +def test_cli_init_stdout(args: List[str], exit_code: int, content: List[str]): app = create_cli() result = runner.invoke(app, ["init"] + args) assert result.exit_code == exit_code @@ -48,9 +48,9 @@ def test_init_stdout(args: List[str], exit_code: int, content: List[str]): assert c in result.stdout -def generate_test_data_test_all_init_happy_path(): +def generate_test_data_test_cli_init_happy_path(): """ - Generate inputs to test_all_init_happy_path representing all valid combinations of options + Generate inputs to test_cli_init_happy_path representing all valid combinations of options available to nebari init """ @@ -101,7 +101,7 @@ def generate_test_data_test_all_init_happy_path(): return {"keys": keys, "test_data": test_data} -def test_all_init_happy_path( +def test_cli_init_happy_path( provider: str, project_name: str, domain_name: str, diff --git a/tests/tests_unit/test_cli_support.py b/tests/tests_unit/test_cli_support.py new file mode 100644 index 0000000000..0c0e7f9629 --- /dev/null +++ b/tests/tests_unit/test_cli_support.py @@ -0,0 +1,213 @@ +import tempfile +from pathlib import Path +from typing import List +from unittest.mock import Mock, patch +from zipfile import ZipFile + +import kubernetes.client +import kubernetes.client.exceptions +import pytest +import yaml +from typer.testing import CliRunner + +from _nebari.cli import create_cli + +runner = CliRunner() + + +class MockPod: + name: str + containers: List[str] + ip_address: str + + def __init__(self, name: str, ip_address: str, containers: List[str]): + self.name = name + self.ip_address = ip_address + self.containers = containers + + +def mock_list_namespaced_pod(pods: List[MockPod], namespace: str): + return kubernetes.client.V1PodList( + items=[ + kubernetes.client.V1Pod( + metadata=kubernetes.client.V1ObjectMeta( + name=p.name, namespace=namespace + ), + spec=kubernetes.client.V1PodSpec( + containers=[ + kubernetes.client.V1Container(name=c) for c in p.containers + ] + ), + status=kubernetes.client.V1PodStatus(pod_ip=p.ip_address), + ) + for p in pods + ] + ) + + +def mock_read_namespaced_pod_log(name: str, namespace: str, container: str): + return f"Test log entry: {name} -- {namespace} -- {container}" + + +@pytest.mark.parametrize( + "args, exit_code, content", + [ + # --help + (["--help"], 0, ["Usage:"]), + (["-h"], 0, ["Usage:"]), + # error, missing args + ([], 2, ["Missing option"]), + (["--config"], 2, ["requires an argument"]), + (["-c"], 2, ["requires an argument"]), + (["--output"], 2, ["requires an argument"]), + (["-o"], 2, ["requires an argument"]), + ], +) +def test_cli_support_stdout(args: List[str], exit_code: int, content: List[str]): + app = create_cli() + result = runner.invoke(app, ["support"] + args) + assert result.exit_code == exit_code + for c in content: + assert c in result.stdout + + +@patch("kubernetes.config.kube_config.load_kube_config", return_value=Mock()) +@patch( + "kubernetes.client.CoreV1Api", + return_value=Mock( + list_namespaced_pod=Mock( + side_effect=lambda namespace: mock_list_namespaced_pod( + [ + MockPod( + name="pod-1", + ip_address="10.0.0.1", + containers=["container-1-1", "container-1-2"], + ), + MockPod( + name="pod-2", + ip_address="10.0.0.2", + containers=["container-2-1"], + ), + ], + namespace, + ) + ), + read_namespaced_pod_log=Mock(side_effect=mock_read_namespaced_pod_log), + ), +) +def test_cli_support_happy_path( + _mock_k8s_corev1api, _mock_config, monkeypatch: pytest.MonkeyPatch +): + with tempfile.TemporaryDirectory() as tmp: + # NOTE: The support command leaves the ./log folder behind after running, + # relative to wherever the tests were run from. + # Changing context to the tmp dir so this will be cleaned up properly. + monkeypatch.chdir(Path(tmp).resolve()) + + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + with open(tmp_file.resolve(), "w") as f: + yaml.dump({"project_name": "support", "namespace": "test-ns"}, f) + + assert tmp_file.exists() is True + + app = create_cli() + + log_zip_file = Path(tmp).resolve() / "test-support.zip" + assert log_zip_file.exists() is False + + result = runner.invoke( + app, + [ + "support", + "--config", + tmp_file.resolve(), + "--output", + log_zip_file.resolve(), + ], + ) + + # print(f"\n >>> result.stdout {result.stdout}") + # print(f"\n >>> result.exception {result.exception}") + + assert log_zip_file.exists() is True + + assert 0 == result.exit_code + assert not result.exception + assert "log/test-ns" in result.stdout + + # open the zip and check a sample file for the expected formatting + with ZipFile(log_zip_file.resolve(), "r") as log_zip: + # expect 1 log file per pod + assert 2 == len(log_zip.namelist()) + with log_zip.open("log/test-ns/pod-1.txt") as log_file: + content = str(log_file.read(), "UTF-8") + # expect formatted header + logs for each container + expected = """ +10.0.0.1\ttest-ns\tpod-1 +Container: container-1-1 +Test log entry: pod-1 -- test-ns -- container-1-1 +Container: container-1-2 +Test log entry: pod-1 -- test-ns -- container-1-2 +""" + assert expected.strip() == content.strip() + + +@patch("kubernetes.config.kube_config.load_kube_config", return_value=Mock()) +@patch( + "kubernetes.client.CoreV1Api", + return_value=Mock( + list_namespaced_pod=Mock( + side_effect=kubernetes.client.exceptions.ApiException(reason="unit testing") + ) + ), +) +def test_cli_support_error_apiexception( + _mock_k8s_corev1api, _mock_config, monkeypatch: pytest.MonkeyPatch +): + with tempfile.TemporaryDirectory() as tmp: + monkeypatch.chdir(Path(tmp).resolve()) + + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + with open(tmp_file.resolve(), "w") as f: + yaml.dump({"project_name": "support", "namespace": "test-ns"}, f) + + assert tmp_file.exists() is True + + app = create_cli() + + log_zip_file = Path(tmp).resolve() / "test-support.zip" + + result = runner.invoke( + app, + [ + "support", + "--config", + tmp_file.resolve(), + "--output", + log_zip_file.resolve(), + ], + ) + + assert log_zip_file.exists() is False + + assert 1 == result.exit_code + assert result.exception + assert "Reason: unit testing" in str(result.exception) + + +def test_cli_support_error_missing_config(): + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + app = create_cli() + + result = runner.invoke(app, ["support", "--config", tmp_file.resolve()]) + + assert 1 == result.exit_code + assert result.exception + assert "nebari-config.yaml does not exist" in str(result.exception) diff --git a/tests/tests_unit/test_cli_upgrade.py b/tests/tests_unit/test_cli_upgrade.py index d00c04b1b0..a3eb608786 100644 --- a/tests/tests_unit/test_cli_upgrade.py +++ b/tests/tests_unit/test_cli_upgrade.py @@ -57,7 +57,7 @@ class Test_Cli_Upgrade_2023_5_1(_nebari.upgrade.UpgradeStep): ), ], ) -def test_upgrade_stdout(args: List[str], exit_code: int, content: List[str]): +def test_cli_upgrade_stdout(args: List[str], exit_code: int, content: List[str]): app = create_cli() result = runner.invoke(app, ["upgrade"] + args) assert result.exit_code == exit_code @@ -65,19 +65,19 @@ def test_upgrade_stdout(args: List[str], exit_code: int, content: List[str]): assert c in result.stdout -def test_upgrade_2022_10_1_to_2022_11_1(monkeypatch: pytest.MonkeyPatch): +def test_cli_upgrade_2022_10_1_to_2022_11_1(monkeypatch: pytest.MonkeyPatch): assert_nebari_upgrade_success(monkeypatch, "2022.10.1", "2022.11.1") -def test_upgrade_2022_11_1_to_2023_1_1(monkeypatch: pytest.MonkeyPatch): +def test_cli_upgrade_2022_11_1_to_2023_1_1(monkeypatch: pytest.MonkeyPatch): assert_nebari_upgrade_success(monkeypatch, "2022.11.1", "2023.1.1") -def test_upgrade_2023_1_1_to_2023_4_1(monkeypatch: pytest.MonkeyPatch): +def test_cli_upgrade_2023_1_1_to_2023_4_1(monkeypatch: pytest.MonkeyPatch): assert_nebari_upgrade_success(monkeypatch, "2023.1.1", "2023.4.1") -def test_upgrade_2023_4_1_to_2023_5_1(monkeypatch: pytest.MonkeyPatch): +def test_cli_upgrade_2023_4_1_to_2023_5_1(monkeypatch: pytest.MonkeyPatch): assert_nebari_upgrade_success( monkeypatch, "2023.4.1", @@ -91,7 +91,7 @@ def test_upgrade_2023_4_1_to_2023_5_1(monkeypatch: pytest.MonkeyPatch): "workflows_enabled, workflow_controller_enabled", [(True, True), (True, False), (False, None), (None, None)], ) -def test_upgrade_2023_5_1_to_2023_7_2( +def test_cli_upgrade_2023_5_1_to_2023_7_2( monkeypatch: pytest.MonkeyPatch, workflows_enabled: bool, workflow_controller_enabled: bool, @@ -130,7 +130,7 @@ def test_upgrade_2023_5_1_to_2023_7_2( assert "argo_workflows" not in upgraded -def test_upgrade_image_tags(monkeypatch: pytest.MonkeyPatch): +def test_cli_upgrade_image_tags(monkeypatch: pytest.MonkeyPatch): start_version = "2023.5.1" end_version = "2023.7.2" @@ -182,7 +182,7 @@ def test_upgrade_image_tags(monkeypatch: pytest.MonkeyPatch): assert profile["image"].endswith(end_version) -def test_upgrade_fail_on_missing_file(): +def test_cli_upgrade_fail_on_missing_file(): with tempfile.TemporaryDirectory() as tmp: tmp_file = Path(tmp).resolve() / "nebari-config.yaml" assert tmp_file.exists() is False @@ -199,7 +199,7 @@ def test_upgrade_fail_on_missing_file(): ) -def test_upgrade_fail_invalid_file(): +def test_cli_upgrade_fail_invalid_file(): with tempfile.TemporaryDirectory() as tmp: tmp_file = Path(tmp).resolve() / "nebari-config.yaml" assert tmp_file.exists() is False @@ -224,7 +224,7 @@ def test_upgrade_fail_invalid_file(): assert "provider" in str(result.exception) -def test_upgrade_fail_on_downgrade(): +def test_cli_upgrade_fail_on_downgrade(): start_version = "9999.9.9" # way in the future end_version = _nebari.upgrade.__version__ @@ -262,7 +262,7 @@ def test_upgrade_fail_on_downgrade(): assert yaml.safe_load(c) == nebari_config -def test_upgrade_does_nothing_on_same_version(): +def test_cli_upgrade_does_nothing_on_same_version(): # this test only seems to work against the actual current version, any # mocked earlier versions trigger an actual update start_version = _nebari.upgrade.__version__ diff --git a/tests/tests_unit/test_cli_validate.py b/tests/tests_unit/test_cli_validate.py index 58078f3669..ef5a45f11f 100644 --- a/tests/tests_unit/test_cli_validate.py +++ b/tests/tests_unit/test_cli_validate.py @@ -31,7 +31,7 @@ ), # https://github.com/nebari-dev/nebari/issues/1937 ], ) -def test_validate_stdout(args: List[str], exit_code: int, content: List[str]): +def test_cli_validate_stdout(args: List[str], exit_code: int, content: List[str]): app = create_cli() result = runner.invoke(app, ["validate"] + args) assert result.exit_code == exit_code @@ -39,11 +39,11 @@ def test_validate_stdout(args: List[str], exit_code: int, content: List[str]): assert c in result.stdout -def generate_test_data_test_validate_local_happy_path(): +def generate_test_data_test_cli_validate_local_happy_path(): """ Search the cli_validate folder for happy path test cases and add them to the parameterized list of inputs for - test_validate_local_happy_path + test_cli_validate_local_happy_path """ test_data = [] @@ -56,7 +56,7 @@ def generate_test_data_test_validate_local_happy_path(): return {"keys": keys, "test_data": test_data} -def test_validate_local_happy_path(config_yaml: str): +def test_cli_validate_local_happy_path(config_yaml: str): test_file = TEST_DATA_DIR / config_yaml assert test_file.exists() is True @@ -67,7 +67,7 @@ def test_validate_local_happy_path(config_yaml: str): assert "Successfully validated configuration" in result.stdout -def test_validate_from_env(): +def test_cli_validate_from_env(): with tempfile.TemporaryDirectory() as tmp: tmp_file = Path(tmp).resolve() / "nebari-config.yaml" assert tmp_file.exists() is False @@ -119,7 +119,7 @@ def test_validate_from_env(): ), ], ) -def test_validate_error_from_env( +def test_cli_validate_error_from_env( key: str, value: str, provider: str, expected_message: str ): with tempfile.TemporaryDirectory() as tmp: @@ -166,7 +166,7 @@ def test_validate_error_from_env( ("do", {}), ], ) -def test_validate_error_missing_cloud_env( +def test_cli_validate_error_missing_cloud_env( monkeypatch: pytest.MonkeyPatch, provider: str, addl_config: Dict[str, Any] ): # cloud methods are all globally mocked, need to reset so the env variables will be checked @@ -215,11 +215,11 @@ def test_validate_error_missing_cloud_env( assert "Missing the following required environment variable" in result.stdout -def generate_test_data_test_validate_error(): +def generate_test_data_test_cli_validate_error(): """ Search the cli_validate folder for unhappy path test cases and add them to the parameterized list of inputs for - test_validate_error. Optionally parse an expected + test_cli_validate_error. Optionally parse an expected error message from the file name to assert is present in the validate output """ @@ -241,7 +241,7 @@ def generate_test_data_test_validate_error(): return {"keys": keys, "test_data": test_data} -def test_validate_error(config_yaml: str, expected_message: str): +def test_cli_validate_error(config_yaml: str, expected_message: str): test_file = TEST_DATA_DIR / config_yaml assert test_file.exists() is True From 4c6c9e39df0f7888cc7a6d52f65c9de865df209e Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Fri, 1 Sep 2023 14:20:33 +0000 Subject: [PATCH 06/10] initial nebari keycloak cli unit tests --- src/_nebari/keycloak.py | 4 +- tests/tests_unit/test_cli_keycloak.py | 375 ++++++++++++++++++++++++++ tests/tests_unit/test_cli_support.py | 3 - 3 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 tests/tests_unit/test_cli_keycloak.py diff --git a/src/_nebari/keycloak.py b/src/_nebari/keycloak.py index 674b7c8cab..ea8815940d 100644 --- a/src/_nebari/keycloak.py +++ b/src/_nebari/keycloak.py @@ -114,7 +114,9 @@ def get_keycloak_admin_from_config(config: schema.Main): def keycloak_rest_api_call(config: schema.Main = None, request: str = None): """Communicate directly with the Keycloak REST API by passing it a request""" - keycloak_server_url = f"https://{config.domain}/auth/" + keycloak_server_url = os.environ.get( + "KEYCLOAK_SERVER_URL", f"https://{config.domain}/auth/" + ) keycloak_admin_username = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "root") keycloak_admin_password = os.environ.get( diff --git a/tests/tests_unit/test_cli_keycloak.py b/tests/tests_unit/test_cli_keycloak.py new file mode 100644 index 0000000000..5d1aed5036 --- /dev/null +++ b/tests/tests_unit/test_cli_keycloak.py @@ -0,0 +1,375 @@ +import json +import tempfile +from pathlib import Path +from typing import Any, List +from unittest.mock import Mock, patch + +import keycloak.exceptions +import pytest +import requests.exceptions +import yaml +from typer.testing import CliRunner + +from _nebari.cli import create_cli + +TEST_KEYCLOAK_USERS = [ + {"id": "1", "username": "test-dev", "groups": ["analyst", "developer"]}, + {"id": "2", "username": "test-admin", "groups": ["admin"]}, + {"id": "3", "username": "test-nogroup", "groups": []}, +] + +MOCK_KEYCLOAK_ENV = { + "KEYCLOAK_SERVER_URL": "http://nebari.example.com/auth/", + "KEYCLOAK_ADMIN_USERNAME": "root", + "KEYCLOAK_ADMIN_PASSWORD": "super-secret-123!", +} + +TEST_ACCESS_TOKEN = "abc123" + +runner = CliRunner() + + +@pytest.mark.parametrize( + "args, exit_code, content", + [ + # --help + ([], 0, ["Usage:"]), + (["--help"], 0, ["Usage:"]), + (["-h"], 0, ["Usage:"]), + (["adduser", "--help"], 0, ["Usage:"]), + (["adduser", "-h"], 0, ["Usage:"]), + (["export-users", "--help"], 0, ["Usage:"]), + (["export-users", "-h"], 0, ["Usage:"]), + (["listusers", "--help"], 0, ["Usage:"]), + (["listusers", "-h"], 0, ["Usage:"]), + # error, missing args + (["adduser"], 2, ["Missing option"]), + (["adduser", "--config"], 2, ["requires an argument"]), + (["adduser", "-c"], 2, ["requires an argument"]), + (["adduser", "--user"], 2, ["requires 2 arguments"]), + (["export-users"], 2, ["Missing option"]), + (["export-users", "--config"], 2, ["requires an argument"]), + (["export-users", "-c"], 2, ["requires an argument"]), + (["export-users", "--realm"], 2, ["requires an argument"]), + (["listusers"], 2, ["Missing option"]), + (["listusers", "--config"], 2, ["requires an argument"]), + (["listusers", "-c"], 2, ["requires an argument"]), + ], +) +def test_cli_keycloak_stdout(args: List[str], exit_code: int, content: List[str]): + app = create_cli() + result = runner.invoke(app, ["keycloak"] + args) + assert result.exit_code == exit_code + for c in content: + assert c in result.stdout + + +@patch("keycloak.KeycloakAdmin") +def test_cli_keycloak_adduser_happy_path(_mock_keycloak_admin): + result = run_cli_keycloak_adduser() + + assert 0 == result.exit_code + assert not result.exception + assert f"Created user={TEST_KEYCLOAK_USERS[0]['username']}" in result.stdout + + +@patch( + "keycloak.KeycloakAdmin.__init__", + side_effect=keycloak.exceptions.KeycloakConnectionError( + error_message="connection test" + ), +) +def test_cli_keycloak_adduser_keycloak_connection_exception(_mock_keycloak_admin): + result = run_cli_keycloak_adduser() + + assert 1 == result.exit_code + assert result.exception + assert "Failed to connect to Keycloak server: connection test" in str( + result.exception + ) + + +@patch( + "keycloak.KeycloakAdmin.__init__", + side_effect=keycloak.exceptions.KeycloakAuthenticationError( + error_message="auth test" + ), +) +def test_cli_keycloak_adduser_keycloak_auth_exception(_mock_keycloak_admin): + result = run_cli_keycloak_adduser() + + assert 1 == result.exit_code + assert result.exception + assert "Failed to connect to Keycloak server: auth test" in str(result.exception) + + +@patch( + "keycloak.KeycloakAdmin", + return_value=Mock( + create_user=Mock( + side_effect=keycloak.exceptions.KeycloakConnectionError( + error_message="unhandled" + ) + ), + ), +) +def test_cli_keycloak_adduser_keycloak_unhandled_error(_mock_keycloak_admin): + result = run_cli_keycloak_adduser() + + assert 1 == result.exit_code + assert result.exception + assert "unhandled" == str(result.exception) + + +@patch( + "keycloak.KeycloakAdmin", + return_value=Mock( + users_count=Mock(side_effect=lambda: len(TEST_KEYCLOAK_USERS)), + get_users=Mock( + side_effect=lambda: [ + { + "id": u["id"], + "username": u["username"], + "email": f"{u['username']}@example.com", + } + for u in TEST_KEYCLOAK_USERS + ] + ), + get_user_groups=Mock( + side_effect=lambda user_id: [ + {"name": g} + for u in TEST_KEYCLOAK_USERS + if u["id"] == user_id + for g in u["groups"] + ] + ), + ), +) +def test_cli_keycloak_listusers_happy_path(_mock_keycloak_admin): + result = run_cli_keycloak_listusers() + + assert 0 == result.exit_code + assert not result.exception + + # output should start with the number of users found then + # display a table with their info + assert result.stdout.startswith(f"{len(TEST_KEYCLOAK_USERS)} Keycloak Users") + # user count + headers + separator + 3 user rows == 6 + assert 6 == len(result.stdout.strip().split("\n")) + for u in TEST_KEYCLOAK_USERS: + assert u["username"] in result.stdout + + +@patch( + "keycloak.KeycloakAdmin.__init__", + side_effect=keycloak.exceptions.KeycloakConnectionError( + error_message="connection test" + ), +) +def test_cli_keycloak_listusers_keycloak_connection_exception(_mock_keycloak_admin): + result = run_cli_keycloak_listusers() + + assert 1 == result.exit_code + assert result.exception + assert "Failed to connect to Keycloak server: connection test" in str( + result.exception + ) + + +@patch( + "keycloak.KeycloakAdmin.__init__", + side_effect=keycloak.exceptions.KeycloakAuthenticationError( + error_message="auth test" + ), +) +def test_cli_keycloak_listusers_keycloak_auth_exception(_mock_keycloak_admin): + result = run_cli_keycloak_listusers() + + assert 1 == result.exit_code + assert result.exception + assert "Failed to connect to Keycloak server: auth test" in str(result.exception) + + +@patch( + "keycloak.KeycloakAdmin", + return_value=Mock( + users_count=Mock( + side_effect=keycloak.exceptions.KeycloakConnectionError( + error_message="unhandled" + ) + ), + ), +) +def test_cli_keycloak_listusers_keycloak_unhandled_error(_mock_keycloak_admin): + result = run_cli_keycloak_listusers() + + assert 1 == result.exit_code + assert result.exception + assert "unhandled" == str(result.exception) + + +def mock_api_post(admin_password: str, url: str, headers: Any, data: Any, verify: bool): + response = Mock() + if ( + url + == f"{MOCK_KEYCLOAK_ENV['KEYCLOAK_SERVER_URL']}realms/master/protocol/openid-connect/token" + and data["password"] == admin_password + ): + response.status_code = 200 + response.content = bytes( + json.dumps({"access_token": TEST_ACCESS_TOKEN}), "UTF-8" + ) + else: + response.status_code = 403 + return response + + +def mock_api_request( + access_token: str, method: str, url: str, headers: Any, verify: bool +): + response = Mock() + if ( + method == "GET" + and url + == f"{MOCK_KEYCLOAK_ENV['KEYCLOAK_SERVER_URL']}admin/realms/test-realm/users" + and headers["Authorization"] == f"Bearer {access_token}" + ): + response.status_code = 200 + response.content = bytes(json.dumps(TEST_KEYCLOAK_USERS), "UTF-8") + else: + response.status_code = 403 + return response + + +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + MOCK_KEYCLOAK_ENV["KEYCLOAK_ADMIN_PASSWORD"], url, headers, data, verify + ), +) +@patch( + "_nebari.keycloak.requests.request", + side_effect=lambda method, url, headers, verify: mock_api_request( + TEST_ACCESS_TOKEN, method, url, headers, verify + ), +) +def test_cli_keycloak_exportusers_happy_path( + _mock_requests_post, _mock_requests_request +): + result = run_cli_keycloak_exportusers() + + assert 0 == result.exit_code + assert not result.exception + + r = json.loads(result.stdout) + assert "test-realm" == r["realm"] + assert 3 == len(r["users"]) + assert "test-dev" == r["users"][0]["username"] + + +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + "invalid_admin_password", url, headers, data, verify + ), +) +def test_cli_keycloak_exportusers_error_authentication(_mock_requests_post): + result = run_cli_keycloak_exportusers() + + assert 1 == result.exit_code + assert result.exception + assert "Unable to retrieve Keycloak API token" in str(result.exception) + assert "Status code: 403" in str(result.exception) + + +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + MOCK_KEYCLOAK_ENV["KEYCLOAK_ADMIN_PASSWORD"], url, headers, data, verify + ), +) +@patch( + "_nebari.keycloak.requests.request", + side_effect=lambda method, url, headers, verify: mock_api_request( + "invalid_access_token", method, url, headers, verify + ), +) +def test_cli_keycloak_exportusers_error_authorization( + _mock_requests_post, _mock_requests_request +): + result = run_cli_keycloak_exportusers() + + assert 1 == result.exit_code + assert result.exception + assert "Unable to communicate with Keycloak API" in str(result.exception) + assert "Status code: 403" in str(result.exception) + + +@patch( + "_nebari.keycloak.requests.post", side_effect=requests.exceptions.RequestException() +) +def test_cli_keycloak_exportusers_request_exception(_mock_requests_post): + result = run_cli_keycloak_exportusers() + + assert 1 == result.exit_code + assert result.exception + + +@patch("_nebari.keycloak.requests.post", side_effect=Exception()) +def test_cli_keycloak_exportusers_unhandled_error(_mock_requests_post): + result = run_cli_keycloak_exportusers() + + assert 1 == result.exit_code + assert result.exception + + +def run_cli_keycloak(command: str, extra_args: List[str]): + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + with open(tmp_file.resolve(), "w") as f: + yaml.dump({"project_name": "keycloak"}, f) + + assert tmp_file.exists() is True + + app = create_cli() + + args = [ + "keycloak", + command, + "--config", + tmp_file.resolve(), + ] + extra_args + + result = runner.invoke(app, args=args, env=MOCK_KEYCLOAK_ENV) + + return result + + +def run_cli_keycloak_adduser(): + username = TEST_KEYCLOAK_USERS[0]["username"] + password = "test-password-123!" + + return run_cli_keycloak( + "adduser", + [ + "--user", + username, + password, + ], + ) + + +def run_cli_keycloak_listusers(): + return run_cli_keycloak("listusers", []) + + +def run_cli_keycloak_exportusers(): + return run_cli_keycloak( + "export-users", + [ + "--realm", + "test-realm", + ], + ) diff --git a/tests/tests_unit/test_cli_support.py b/tests/tests_unit/test_cli_support.py index 0c0e7f9629..66822d165d 100644 --- a/tests/tests_unit/test_cli_support.py +++ b/tests/tests_unit/test_cli_support.py @@ -128,9 +128,6 @@ def test_cli_support_happy_path( ], ) - # print(f"\n >>> result.stdout {result.stdout}") - # print(f"\n >>> result.exception {result.exception}") - assert log_zip_file.exists() is True assert 0 == result.exit_code From 0a9e601f208555f5c24c63604ae5a2fe293616a3 Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Fri, 1 Sep 2023 14:36:38 +0000 Subject: [PATCH 07/10] additional cli keycloak coverage for config source --- tests/tests_unit/test_cli_keycloak.py | 126 +++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 15 deletions(-) diff --git a/tests/tests_unit/test_cli_keycloak.py b/tests/tests_unit/test_cli_keycloak.py index 5d1aed5036..a82c4cd044 100644 --- a/tests/tests_unit/test_cli_keycloak.py +++ b/tests/tests_unit/test_cli_keycloak.py @@ -18,8 +18,9 @@ {"id": "3", "username": "test-nogroup", "groups": []}, ] +TEST_DOMAIN = "nebari.example.com" MOCK_KEYCLOAK_ENV = { - "KEYCLOAK_SERVER_URL": "http://nebari.example.com/auth/", + "KEYCLOAK_SERVER_URL": f"https://{TEST_DOMAIN}/auth/", "KEYCLOAK_ADMIN_USERNAME": "root", "KEYCLOAK_ADMIN_PASSWORD": "super-secret-123!", } @@ -65,8 +66,17 @@ def test_cli_keycloak_stdout(args: List[str], exit_code: int, content: List[str] @patch("keycloak.KeycloakAdmin") -def test_cli_keycloak_adduser_happy_path(_mock_keycloak_admin): - result = run_cli_keycloak_adduser() +def test_cli_keycloak_adduser_happy_path_from_env(_mock_keycloak_admin): + result = run_cli_keycloak_adduser(use_env=True) + + assert 0 == result.exit_code + assert not result.exception + assert f"Created user={TEST_KEYCLOAK_USERS[0]['username']}" in result.stdout + + +@patch("keycloak.KeycloakAdmin") +def test_cli_keycloak_adduser_happy_path_from_config(_mock_keycloak_admin): + result = run_cli_keycloak_adduser(use_env=False) assert 0 == result.exit_code assert not result.exception @@ -145,8 +155,47 @@ def test_cli_keycloak_adduser_keycloak_unhandled_error(_mock_keycloak_admin): ), ), ) -def test_cli_keycloak_listusers_happy_path(_mock_keycloak_admin): - result = run_cli_keycloak_listusers() +def test_cli_keycloak_listusers_happy_path_from_env(_mock_keycloak_admin): + result = run_cli_keycloak_listusers(use_env=True) + + assert 0 == result.exit_code + assert not result.exception + + # output should start with the number of users found then + # display a table with their info + assert result.stdout.startswith(f"{len(TEST_KEYCLOAK_USERS)} Keycloak Users") + # user count + headers + separator + 3 user rows == 6 + assert 6 == len(result.stdout.strip().split("\n")) + for u in TEST_KEYCLOAK_USERS: + assert u["username"] in result.stdout + + +@patch( + "keycloak.KeycloakAdmin", + return_value=Mock( + users_count=Mock(side_effect=lambda: len(TEST_KEYCLOAK_USERS)), + get_users=Mock( + side_effect=lambda: [ + { + "id": u["id"], + "username": u["username"], + "email": f"{u['username']}@example.com", + } + for u in TEST_KEYCLOAK_USERS + ] + ), + get_user_groups=Mock( + side_effect=lambda user_id: [ + {"name": g} + for u in TEST_KEYCLOAK_USERS + if u["id"] == user_id + for g in u["groups"] + ] + ), + ), +) +def test_cli_keycloak_listusers_happy_path_from_config(_mock_keycloak_admin): + result = run_cli_keycloak_listusers(use_env=False) assert 0 == result.exit_code assert not result.exception @@ -253,7 +302,7 @@ def mock_api_request( TEST_ACCESS_TOKEN, method, url, headers, verify ), ) -def test_cli_keycloak_exportusers_happy_path( +def test_cli_keycloak_exportusers_happy_path_from_env( _mock_requests_post, _mock_requests_request ): result = run_cli_keycloak_exportusers() @@ -267,6 +316,32 @@ def test_cli_keycloak_exportusers_happy_path( assert "test-dev" == r["users"][0]["username"] +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + MOCK_KEYCLOAK_ENV["KEYCLOAK_ADMIN_PASSWORD"], url, headers, data, verify + ), +) +@patch( + "_nebari.keycloak.requests.request", + side_effect=lambda method, url, headers, verify: mock_api_request( + TEST_ACCESS_TOKEN, method, url, headers, verify + ), +) +def test_cli_keycloak_exportusers_happy_path_from_config( + _mock_requests_post, _mock_requests_request +): + result = run_cli_keycloak_exportusers(use_env=False) + + assert 0 == result.exit_code + assert not result.exception + + r = json.loads(result.stdout) + assert "test-realm" == r["realm"] + assert 3 == len(r["users"]) + assert "test-dev" == r["users"][0]["username"] + + @patch( "_nebari.keycloak.requests.post", side_effect=lambda url, headers, data, verify: mock_api_post( @@ -323,13 +398,28 @@ def test_cli_keycloak_exportusers_unhandled_error(_mock_requests_post): assert result.exception -def run_cli_keycloak(command: str, extra_args: List[str]): +def run_cli_keycloak(command: str, use_env: bool, extra_args: List[str] = []): with tempfile.TemporaryDirectory() as tmp: tmp_file = Path(tmp).resolve() / "nebari-config.yaml" assert tmp_file.exists() is False + extra_config = ( + { + "domain": TEST_DOMAIN, + "security": { + "keycloak": { + "initial_root_password": MOCK_KEYCLOAK_ENV[ + "KEYCLOAK_ADMIN_PASSWORD" + ] + } + }, + } + if not use_env + else {} + ) + config = {**{"project_name": "keycloak"}, **extra_config} with open(tmp_file.resolve(), "w") as f: - yaml.dump({"project_name": "keycloak"}, f) + yaml.dump(config, f) assert tmp_file.exists() is True @@ -342,18 +432,20 @@ def run_cli_keycloak(command: str, extra_args: List[str]): tmp_file.resolve(), ] + extra_args - result = runner.invoke(app, args=args, env=MOCK_KEYCLOAK_ENV) + env = MOCK_KEYCLOAK_ENV if use_env else {} + result = runner.invoke(app, args=args, env=env) return result -def run_cli_keycloak_adduser(): +def run_cli_keycloak_adduser(use_env: bool = True): username = TEST_KEYCLOAK_USERS[0]["username"] password = "test-password-123!" return run_cli_keycloak( "adduser", - [ + use_env=use_env, + extra_args=[ "--user", username, password, @@ -361,14 +453,18 @@ def run_cli_keycloak_adduser(): ) -def run_cli_keycloak_listusers(): - return run_cli_keycloak("listusers", []) +def run_cli_keycloak_listusers(use_env: bool = True): + return run_cli_keycloak( + "listusers", + use_env=use_env, + ) -def run_cli_keycloak_exportusers(): +def run_cli_keycloak_exportusers(use_env: bool = True): return run_cli_keycloak( "export-users", - [ + use_env=use_env, + extra_args=[ "--realm", "test-realm", ], From 9706d7b36fe0d64c6ca0caca086f2824754bad23 Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Fri, 1 Sep 2023 15:24:39 +0000 Subject: [PATCH 08/10] initial nebari dev unit tests --- src/_nebari/subcommands/dev.py | 4 +- tests/tests_unit/test_cli_dev.py | 253 +++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 tests/tests_unit/test_cli_dev.py diff --git a/src/_nebari/subcommands/dev.py b/src/_nebari/subcommands/dev.py index 59bfc77d5f..af0af99c59 100644 --- a/src/_nebari/subcommands/dev.py +++ b/src/_nebari/subcommands/dev.py @@ -50,6 +50,6 @@ def keycloak_api( config_schema = nebari_plugin_manager.config_schema - read_configuration(config_filename, config_schema=config_schema) - r = keycloak_rest_api_call(config_filename, request=request) + config = read_configuration(config_filename, config_schema=config_schema) + r = keycloak_rest_api_call(config, request=request) print(json.dumps(r, indent=4)) diff --git a/tests/tests_unit/test_cli_dev.py b/tests/tests_unit/test_cli_dev.py new file mode 100644 index 0000000000..4a4d58ef22 --- /dev/null +++ b/tests/tests_unit/test_cli_dev.py @@ -0,0 +1,253 @@ +import json +import tempfile +from pathlib import Path +from typing import Any, List +from unittest.mock import Mock, patch + +import pytest +import requests.exceptions +import yaml +from typer.testing import CliRunner + +from _nebari.cli import create_cli + +TEST_KEYCLOAKAPI_REQUEST = "GET /" # get list of realms + +TEST_DOMAIN = "nebari.example.com" +MOCK_KEYCLOAK_ENV = { + "KEYCLOAK_SERVER_URL": f"https://{TEST_DOMAIN}/auth/", + "KEYCLOAK_ADMIN_USERNAME": "root", + "KEYCLOAK_ADMIN_PASSWORD": "super-secret-123!", +} + +TEST_ACCESS_TOKEN = "abc123" + +TEST_REALMS = [ + {"id": "test-realm", "realm": "test-realm"}, + {"id": "master", "realm": "master"}, +] + +runner = CliRunner() + + +@pytest.mark.parametrize( + "args, exit_code, content", + [ + # --help + ([], 0, ["Usage:"]), + (["--help"], 0, ["Usage:"]), + (["-h"], 0, ["Usage:"]), + (["keycloak-api", "--help"], 0, ["Usage:"]), + (["keycloak-api", "-h"], 0, ["Usage:"]), + # error, missing args + (["keycloak-api"], 2, ["Missing option"]), + (["keycloak-api", "--config"], 2, ["requires an argument"]), + (["keycloak-api", "-c"], 2, ["requires an argument"]), + (["keycloak-api", "--request"], 2, ["requires an argument"]), + (["keycloak-api", "-r"], 2, ["requires an argument"]), + ], +) +def test_cli_dev_stdout(args: List[str], exit_code: int, content: List[str]): + app = create_cli() + result = runner.invoke(app, ["dev"] + args) + assert result.exit_code == exit_code + for c in content: + assert c in result.stdout + + +def mock_api_post(admin_password: str, url: str, headers: Any, data: Any, verify: bool): + response = Mock() + if ( + url + == f"{MOCK_KEYCLOAK_ENV['KEYCLOAK_SERVER_URL']}realms/master/protocol/openid-connect/token" + and data["password"] == admin_password + ): + response.status_code = 200 + response.content = bytes( + json.dumps({"access_token": TEST_ACCESS_TOKEN}), "UTF-8" + ) + else: + response.status_code = 403 + return response + + +def mock_api_request( + access_token: str, method: str, url: str, headers: Any, verify: bool +): + response = Mock() + if ( + method == "GET" + and url == f"{MOCK_KEYCLOAK_ENV['KEYCLOAK_SERVER_URL']}admin/realms/" + and headers["Authorization"] == f"Bearer {access_token}" + ): + response.status_code = 200 + response.content = bytes(json.dumps(TEST_REALMS), "UTF-8") + else: + response.status_code = 403 + return response + + +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + MOCK_KEYCLOAK_ENV["KEYCLOAK_ADMIN_PASSWORD"], url, headers, data, verify + ), +) +@patch( + "_nebari.keycloak.requests.request", + side_effect=lambda method, url, headers, verify: mock_api_request( + TEST_ACCESS_TOKEN, method, url, headers, verify + ), +) +def test_cli_dev_keycloakapi_happy_path_from_env( + _mock_requests_post, _mock_requests_request +): + result = run_cli_dev(use_env=True) + + assert 0 == result.exit_code + assert not result.exception + + r = json.loads(result.stdout) + assert 2 == len(r) + assert "test-realm" == r[0]["realm"] + + +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + MOCK_KEYCLOAK_ENV["KEYCLOAK_ADMIN_PASSWORD"], url, headers, data, verify + ), +) +@patch( + "_nebari.keycloak.requests.request", + side_effect=lambda method, url, headers, verify: mock_api_request( + TEST_ACCESS_TOKEN, method, url, headers, verify + ), +) +def test_cli_dev_keycloakapi_happy_path_from_config( + _mock_requests_post, _mock_requests_request +): + result = run_cli_dev(use_env=False) + + assert 0 == result.exit_code + assert not result.exception + + r = json.loads(result.stdout) + assert 2 == len(r) + assert "test-realm" == r[0]["realm"] + + +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + MOCK_KEYCLOAK_ENV["KEYCLOAK_ADMIN_PASSWORD"], url, headers, data, verify + ), +) +def test_cli_dev_keycloakapi_error_bad_request(_mock_requests_post): + result = run_cli_dev(request="malformed") + + assert 1 == result.exit_code + assert result.exception + assert "not enough values to unpack" in str(result.exception) + + +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + "invalid_admin_password", url, headers, data, verify + ), +) +def test_cli_dev_keycloakapi_error_authentication(_mock_requests_post): + result = run_cli_dev() + + assert 1 == result.exit_code + assert result.exception + assert "Unable to retrieve Keycloak API token" in str(result.exception) + assert "Status code: 403" in str(result.exception) + + +@patch( + "_nebari.keycloak.requests.post", + side_effect=lambda url, headers, data, verify: mock_api_post( + MOCK_KEYCLOAK_ENV["KEYCLOAK_ADMIN_PASSWORD"], url, headers, data, verify + ), +) +@patch( + "_nebari.keycloak.requests.request", + side_effect=lambda method, url, headers, verify: mock_api_request( + "invalid_access_token", method, url, headers, verify + ), +) +def test_cli_dev_keycloakapi_error_authorization( + _mock_requests_post, _mock_requests_request +): + result = run_cli_dev() + + assert 1 == result.exit_code + assert result.exception + assert "Unable to communicate with Keycloak API" in str(result.exception) + assert "Status code: 403" in str(result.exception) + + +@patch( + "_nebari.keycloak.requests.post", side_effect=requests.exceptions.RequestException() +) +def test_cli_dev_keycloakapi_request_exception(_mock_requests_post): + result = run_cli_dev() + + assert 1 == result.exit_code + assert result.exception + + +@patch("_nebari.keycloak.requests.post", side_effect=Exception()) +def test_cli_dev_keycloakapi_unhandled_error(_mock_requests_post): + result = run_cli_dev() + + assert 1 == result.exit_code + assert result.exception + + +def run_cli_dev( + request: str = TEST_KEYCLOAKAPI_REQUEST, + use_env: bool = True, + extra_args: List[str] = [], +): + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + extra_config = ( + { + "domain": TEST_DOMAIN, + "security": { + "keycloak": { + "initial_root_password": MOCK_KEYCLOAK_ENV[ + "KEYCLOAK_ADMIN_PASSWORD" + ] + } + }, + } + if not use_env + else {} + ) + config = {**{"project_name": "dev"}, **extra_config} + with open(tmp_file.resolve(), "w") as f: + yaml.dump(config, f) + + assert tmp_file.exists() is True + + app = create_cli() + + args = [ + "dev", + "keycloak-api", + "--config", + tmp_file.resolve(), + "--request", + request, + ] + extra_args + + env = MOCK_KEYCLOAK_ENV if use_env else {} + result = runner.invoke(app, args=args, env=env) + + return result From c6a03ae5f88d7a74f90cc584a1a1a58dabb81a6f Mon Sep 17 00:00:00 2001 From: Scott Blair Date: Fri, 1 Sep 2023 15:29:22 +0000 Subject: [PATCH 09/10] cleanup --- tests/tests_unit/test_cli_init.py | 4 ---- tests/tests_unit/test_cli_validate.py | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/tests_unit/test_cli_init.py b/tests/tests_unit/test_cli_init.py index a9dda4f7b9..851bd8b79f 100644 --- a/tests/tests_unit/test_cli_init.py +++ b/tests/tests_unit/test_cli_init.py @@ -177,15 +177,11 @@ def assert_nebari_init_args( """ with tempfile.TemporaryDirectory() as tmp: tmp_file = Path(tmp).resolve() / "nebari-config.yaml" - print(f"\n>>>> Using tmp file {tmp_file}") assert tmp_file.exists() is False - print(f"\n>>>> Testing nebari {args} -- input {input}") - result = runner.invoke( app, args + ["--output", tmp_file.resolve()], input=input ) - print(f"\n>>> runner.stdout == {result.stdout}") assert not result.exception assert 0 == result.exit_code diff --git a/tests/tests_unit/test_cli_validate.py b/tests/tests_unit/test_cli_validate.py index ef5a45f11f..d1a904eb58 100644 --- a/tests/tests_unit/test_cli_validate.py +++ b/tests/tests_unit/test_cli_validate.py @@ -247,7 +247,7 @@ def test_cli_validate_error(config_yaml: str, expected_message: str): app = create_cli() result = runner.invoke(app, ["validate", "--config", test_file]) - print(result.stdout) + assert result.exception assert 1 == result.exit_code assert "ERROR validating configuration" in result.stdout From 8efbb85b8a248203f73cfa22db0f2f50900b3fe3 Mon Sep 17 00:00:00 2001 From: Ken Foster Date: Fri, 1 Sep 2023 15:44:51 +0000 Subject: [PATCH 10/10] update render test to pass with plugins installed --- tests/tests_unit/test_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tests_unit/test_render.py b/tests/tests_unit/test_render.py index 06b5e5a1f1..73c4fb5ca1 100644 --- a/tests/tests_unit/test_render.py +++ b/tests/tests_unit/test_render.py @@ -20,7 +20,7 @@ def test_render_config(nebari_render): "06-kubernetes-keycloak-configuration", "04-kubernetes-ingress", "03-kubernetes-initialize", - } == set(os.listdir(output_directory / "stages")) + }.issubset(os.listdir(output_directory / "stages")) if config.provider == schema.ProviderEnum.do: assert (output_directory / "stages" / "01-terraform-state/do").is_dir()