Skip to content

Commit

Permalink
fix: Refactor auth flow, re-enable login command (#272)
Browse files Browse the repository at this point in the history
* feat: Refactor auth flow, re-enable `login` command

* Fix docstring phrasing

* Mock TableRenderable in tests to allow mutation
  • Loading branch information
pederhan authored Dec 11, 2024
1 parent de28ce1 commit 019475b
Show file tree
Hide file tree
Showing 3 changed files with 392 additions and 66 deletions.
241 changes: 241 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING
from typing import Optional

import pytest
from inline_snapshot import snapshot
from packaging.version import Version
from pydantic import SecretStr
from zabbix_cli import auth
from zabbix_cli._v2_compat import AUTH_FILE as AUTH_FILE_LEGACY
from zabbix_cli._v2_compat import AUTH_TOKEN_FILE as AUTH_TOKEN_FILE_LEGACY
from zabbix_cli.auth import Authenticator
from zabbix_cli.auth import Credentials
from zabbix_cli.auth import CredentialsSource
from zabbix_cli.auth import CredentialsType
from zabbix_cli.auth import get_auth_file_paths
from zabbix_cli.auth import get_auth_token_file_paths
from zabbix_cli.config.constants import AUTH_FILE
from zabbix_cli.config.constants import AUTH_TOKEN_FILE
from zabbix_cli.config.model import Config
from zabbix_cli.pyzabbix.client import ZabbixAPI

if TYPE_CHECKING:
from zabbix_cli.models import TableRenderable


def test_get_auth_file_paths_defafult(config: Config) -> None:
Expand Down Expand Up @@ -51,3 +66,229 @@ def test_get_auth_token_file_paths_override(tmp_path: Path, config: Config) -> N
AUTH_TOKEN_FILE,
AUTH_TOKEN_FILE_LEGACY,
]


@pytest.fixture
def table_renderable_mock(monkeypatch) -> type[TableRenderable]:
"""Replace TableRenderable class in zabbix_cli.models with mock class
so that tests can mutate it without affecting other tests."""
from zabbix_cli.models import TableRenderable

class MockTableRenderable(TableRenderable):
pass

# Set a default version that is different from the real class
# so that we can tell if the mock class was used
MockTableRenderable.zabbix_version = Version("0.0.1")

# Use monkeypatch to temporarily replace the real class with the mock class
monkeypatch.setattr("zabbix_cli.models.TableRenderable", MockTableRenderable)

return MockTableRenderable


@pytest.fixture(name="auth_token_file")
def _auth_token_file(tmp_path: Path) -> Path:
return tmp_path / ".zabbix-cli_auth_token"


@pytest.fixture(name="auth_file")
def _auth_file(tmp_path: Path) -> Path:
return tmp_path / ".zabbix-cli_auth"


@pytest.mark.parametrize(
"sources, expect_type, expect_source",
[
pytest.param(
[
(CredentialsType.AUTH_TOKEN, CredentialsSource.CONFIG),
(CredentialsType.AUTH_TOKEN, CredentialsSource.ENV),
(CredentialsType.AUTH_TOKEN, CredentialsSource.FILE),
(CredentialsType.PASSWORD, CredentialsSource.CONFIG),
(CredentialsType.PASSWORD, CredentialsSource.FILE),
(CredentialsType.PASSWORD, CredentialsSource.ENV),
(CredentialsType.PASSWORD, CredentialsSource.PROMPT),
],
CredentialsType.AUTH_TOKEN,
CredentialsSource.CONFIG,
id="expect_auth_token_config",
),
pytest.param(
[
(CredentialsType.AUTH_TOKEN, CredentialsSource.ENV),
(CredentialsType.AUTH_TOKEN, CredentialsSource.FILE),
(CredentialsType.PASSWORD, CredentialsSource.CONFIG),
(CredentialsType.PASSWORD, CredentialsSource.FILE),
(CredentialsType.PASSWORD, CredentialsSource.ENV),
(CredentialsType.PASSWORD, CredentialsSource.PROMPT),
],
CredentialsType.AUTH_TOKEN,
CredentialsSource.ENV,
id="expect_auth_token_env",
),
pytest.param(
[
(CredentialsType.AUTH_TOKEN, CredentialsSource.FILE),
(CredentialsType.PASSWORD, CredentialsSource.CONFIG),
(CredentialsType.PASSWORD, CredentialsSource.FILE),
(CredentialsType.PASSWORD, CredentialsSource.ENV),
(CredentialsType.PASSWORD, CredentialsSource.PROMPT),
],
CredentialsType.AUTH_TOKEN,
CredentialsSource.FILE,
id="expect_auth_token_file",
),
pytest.param(
[
(CredentialsType.PASSWORD, CredentialsSource.CONFIG),
(CredentialsType.PASSWORD, CredentialsSource.FILE),
(CredentialsType.PASSWORD, CredentialsSource.ENV),
(CredentialsType.PASSWORD, CredentialsSource.PROMPT),
],
CredentialsType.PASSWORD,
CredentialsSource.CONFIG,
id="expect_password_config",
),
pytest.param(
[
(CredentialsType.PASSWORD, CredentialsSource.FILE),
(CredentialsType.PASSWORD, CredentialsSource.ENV),
(CredentialsType.PASSWORD, CredentialsSource.PROMPT),
],
CredentialsType.PASSWORD,
CredentialsSource.FILE,
id="expect_password_file",
),
pytest.param(
[
(CredentialsType.PASSWORD, CredentialsSource.ENV),
(CredentialsType.PASSWORD, CredentialsSource.PROMPT),
],
CredentialsType.PASSWORD,
CredentialsSource.ENV,
id="expect_password_env",
),
pytest.param(
[
(CredentialsType.PASSWORD, CredentialsSource.PROMPT),
],
CredentialsType.PASSWORD,
CredentialsSource.PROMPT,
id="expect_password_prompt",
),
],
)
def test_authenticator_login_with_any(
monkeypatch: pytest.MonkeyPatch,
table_renderable_mock: type[TableRenderable],
auth_token_file: Path,
auth_file: Path,
config: Config,
sources: list[tuple[CredentialsType, CredentialsSource]],
expect_source: CredentialsSource,
expect_type: CredentialsType,
) -> None:
"""Test the authenticator login with a variety of credential sources."""
# TODO: test with other variations of sources and types
authenticator = Authenticator(config)

MOCK_USER = "Admin"
MOCK_PASSWORD = "zabbix"
MOCK_TOKEN = "abc1234567890"

# Mock certain methods that are difficult to test
# States reasons for mocking each method

# REASON: Makes HTTP calls to the Zabbix API
def mock_login(self: ZabbixAPI, *args, **kwargs):
self.auth = MOCK_TOKEN
return self.auth

monkeypatch.setattr(auth.ZabbixAPI, "login", mock_login)

# REASON: Makes HTTP calls to the Zabbix API
monkeypatch.setattr(auth.ZabbixAPI, "version", Version("1.2.3"))

# REASON: Prompts for input (could also be tested with a fake input stream)
def mock_get_username_password_prompt() -> Credentials:
return Credentials(
username=MOCK_USER,
password=MOCK_PASSWORD,
source=CredentialsSource.PROMPT,
)

monkeypatch.setattr(
authenticator,
"_get_username_password_prompt",
mock_get_username_password_prompt,
)

# REASON: Falls back on default auth token file path (which might exist on test user's system)
def mock_get_auth_token_file_paths(config: Optional[Config] = None) -> list[Path]:
return [auth_token_file]

monkeypatch.setattr(
auth, "get_auth_token_file_paths", mock_get_auth_token_file_paths
)

# REASON: Falls back on default auth file path (which might exist on test user's system)
def mock_get_auth_file_paths(config: Optional[Config] = None) -> list[Path]:
return [auth_file]

monkeypatch.setattr(auth, "get_auth_file_paths", mock_get_auth_file_paths)

# Set the credentials in the various sources
for ctype, csource in sources:
if csource == CredentialsSource.CONFIG:
if ctype == CredentialsType.AUTH_TOKEN:
config.api.auth_token = SecretStr(MOCK_TOKEN)
elif ctype == CredentialsType.PASSWORD:
config.api.username = MOCK_USER
config.api.password = SecretStr(MOCK_PASSWORD)
elif csource == CredentialsSource.ENV:
if ctype == CredentialsType.AUTH_TOKEN:
monkeypatch.setenv("ZABBIX_API_TOKEN", MOCK_TOKEN)
elif ctype == CredentialsType.PASSWORD:
monkeypatch.setenv("ZABBIX_USERNAME", MOCK_USER)
monkeypatch.setenv("ZABBIX_PASSWORD", MOCK_PASSWORD)
elif csource == CredentialsSource.FILE:
if ctype == CredentialsType.AUTH_TOKEN:
auth_token_file.write_text(f"{MOCK_USER}::{MOCK_TOKEN}")
config.app.auth_token_file = auth_token_file
config.app.use_auth_token_file = True
config.app.allow_insecure_auth_file = True
elif ctype == CredentialsType.PASSWORD:
auth_file.write_text(f"{MOCK_USER}::{MOCK_PASSWORD}")
config.app.auth_file = auth_file

client, info = authenticator.login_with_any()
assert info.credentials.source == expect_source
assert info.credentials.type == expect_type
assert info.token == MOCK_TOKEN

# Ensure the login method modified the base renderable's zabbix version attribute
# with the one we got from the mocked API response

# Check the mocked version we replace the real class with
assert table_renderable_mock.zabbix_version == Version("1.2.3")

# Try to import the real class and check that our mock changes were applied
from zabbix_cli.models import TableRenderable

assert TableRenderable.zabbix_version == Version("1.2.3")


def test_table_renderable_mock_reverted() -> None:
"""Attempt to ensure that the TableRenderable has been unchanged after
running tests that mutate it.
If those tests have used the mock class correctly, this test should pass.
TODO: Ensure this test is run _after_ the tests that mutate the class.
"""
from zabbix_cli.models import TableRenderable

# Ensure the mock changes were reverted
assert TableRenderable.zabbix_version != Version("1.2.3")
assert TableRenderable.zabbix_version.release == snapshot((7, 0, 0))
Loading

0 comments on commit 019475b

Please sign in to comment.