Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ The environment wins over the file, which is also why there are no profiles:
one stored key, and an environment variable for the "a different one, right
now" case.

The key and the endpoint are each looked up on their own. If `init` stored an
endpoint other than the default, a key you export in `TOC_API_KEY` is sent to
that stored endpoint unless you also set `TOC_API_URL`. Commands print a note
on stderr when that happens, naming the endpoint and the file it came from.

The key is a TrustOnCloud credential rather than a tmxcaliber one, which is
why it is named and stored this way: the same key works with `curl` or any
other client. There is deliberately no `--api-key` flag, so the secret never
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "tmxcaliber"
version = "0.5.3"
version = "0.5.4"
description = "CLI utility to filter down one or more TrustOnCloud ThreatModels and get more refined information."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
207 changes: 205 additions & 2 deletions test/test_remote_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@
import sys
from argparse import Namespace
from collections.abc import Iterator, Mapping
from typing import Any
from typing import Any, NoReturn

import pytest

from tmxcaliber import cli as cli_module
from tmxcaliber import remote_cli
from tmxcaliber.lib.remote.config import Settings
from tmxcaliber.lib.remote.contract import route_for
from tmxcaliber.lib.remote.errors import AuthenticationError
from tmxcaliber.lib.remote.resolve import resolve_source
from tmxcaliber.remote_cli import run_api_command
from tmxcaliber.remote_cli import connect, resolve_command_source, run_api_command

from .test_remote_assemble import DETAIL, DFD, PARTS, _at_release
from .test_remote_config import (
HAZARD,
NOTED_CASES,
QUIET_CASES,
STORED_URL,
EndpointCase,
)


class StubClient:
Expand Down Expand Up @@ -445,3 +455,196 @@ def test_an_unpinned_fetch_satisfies_a_later_pinned_request(

assert pinned.calls == [], "the pinned request repeated the assembly"
assert path.endswith("1611187200.json")


#: How the stored-endpoint note starts, wherever a test only needs to find it.
NOTE = "Note: the key in TOC_API_KEY will be sent to"


@pytest.fixture(autouse=True)
def fresh_notes(monkeypatch: pytest.MonkeyPatch) -> None:
"""Forget the stored-endpoint notes earlier tests wrote.

The CLI says it once per process, which is once per invocation in real
use but once per whole session under pytest.

Args:
monkeypatch: Replaces the record for this test only.
"""
monkeypatch.setattr(remote_cli, "_noted", set())


class SettingsStub(StubClient):
"""A StubClient built the way `connect` builds a real client."""

def __init__(self, settings: Settings) -> None:
"""Keep the settings `connect` resolved.

Args:
settings: The resolved settings.
"""
super().__init__()
self.settings = settings


class RefusingClient(SettingsStub):
"""Refuses every call, as an API that does not know the key would."""

def get(self, path: str, params: Mapping[str, str] | None = None) -> dict[str, Any]:
"""Refuse the call.

Args:
path: The path.
params: The query.

Raises:
AuthenticationError: Always.
"""
raise AuthenticationError("The API key was not accepted.", status=401)


def _use_environment(
monkeypatch: pytest.MonkeyPatch, env: Mapping[str, str], tmp_path: pathlib.Path
) -> None:
"""Make `env` the whole of the credential environment, for the real CLI.

Args:
monkeypatch: Sets and clears the variables.
env: The variables to set.
tmp_path: Holds the document cache and is the working directory, so
a reference cannot collide with a file of the same name.
"""
for name in ("TOC_API_KEY", "TOC_API_URL", "TOC_CONFIG_FILE"):
monkeypatch.delenv(name, raising=False)
for name, value in env.items():
monkeypatch.setenv(name, value)
monkeypatch.setenv("TMXCALIBER_CACHE_DIR", str(tmp_path / "cache"))
monkeypatch.chdir(tmp_path)


@pytest.mark.parametrize("case", NOTED_CASES)
def test_a_stored_endpoint_under_an_environment_key_is_noted_on_stderr(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
case: EndpointCase,
) -> None:
env = case.environment(tmp_path)
monkeypatch.setattr(remote_cli, "TocClient", SettingsStub)

connect(env=env)

captured = capsys.readouterr()
assert (
f"Note: the key in TOC_API_KEY will be sent to {STORED_URL}, the "
f"endpoint stored in {env['TOC_CONFIG_FILE']}. Set TOC_API_URL to "
"choose a different endpoint."
) in captured.err
assert captured.out == ""


@pytest.mark.parametrize("case", QUIET_CASES)
def test_nothing_is_said_about_an_endpoint_the_caller_chose_or_expects(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
case: EndpointCase,
) -> None:
monkeypatch.setattr(remote_cli, "TocClient", SettingsStub)

connect(env=case.environment(tmp_path))

captured = capsys.readouterr()
assert captured.err == ""
assert captured.out == ""


def test_an_api_command_notes_on_stderr_and_leaves_stdout_to_the_json(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
_use_environment(monkeypatch, HAZARD.environment(tmp_path), tmp_path)
monkeypatch.setattr(remote_cli, "TocClient", SettingsStub)
monkeypatch.setattr(sys, "argv", ["tmxcaliber", "me"])

cli_module.main()

captured = capsys.readouterr()
assert json.loads(captured.out)["tenantId"] == "t-1"
assert NOTE in captured.err


def test_the_note_is_written_even_when_the_request_then_fails(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# The caller most needs to know which API refused the key.
_use_environment(monkeypatch, HAZARD.environment(tmp_path), tmp_path)
monkeypatch.setattr(remote_cli, "TocClient", RefusingClient)
monkeypatch.setattr(sys, "argv", ["tmxcaliber", "me"])

with pytest.raises(SystemExit) as caught:
cli_module.main()

captured = capsys.readouterr()
assert caught.value.code == 1
assert NOTE in captured.err
assert NOTE not in captured.out


def test_a_reference_given_as_a_document_source_is_noted_too(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
_use_environment(monkeypatch, HAZARD.environment(tmp_path), tmp_path)
monkeypatch.setattr(remote_cli, "TocClient", SettingsStub)
argv = ["tmxcaliber", "list", "feature-classes", "aws-s3", "--format", "json"]
monkeypatch.setattr(sys, "argv", argv)

cli_module.main()

captured = capsys.readouterr()
assert isinstance(json.loads(captured.out), list)
assert NOTE in captured.err


def test_the_note_is_written_once_per_run(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# `create-change-log` resolves two references in one run.
_use_environment(monkeypatch, HAZARD.environment(tmp_path), tmp_path)
monkeypatch.setattr(remote_cli, "TocClient", SettingsStub)

resolve_command_source("aws-s3")
resolve_command_source("aws-s3")

assert capsys.readouterr().err.count(NOTE) == 1


def test_a_local_file_never_resolves_a_credential(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
# Laziness is what lets a caller work on a file with no key configured.
_use_environment(monkeypatch, HAZARD.environment(tmp_path), tmp_path)
model = tmp_path / "aws-s3"
model.write_text("{}", encoding="utf-8")
monkeypatch.setattr(remote_cli, "connect", _never_called)

assert resolve_command_source(str(model)) == str(model)
assert capsys.readouterr().err == ""


def _never_called() -> NoReturn:
"""Stand in for `connect` where a source must not reach the API.

Raises:
AssertionError: Always.
"""
raise AssertionError("a local source reached for a credential")
115 changes: 115 additions & 0 deletions test/test_remote_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import pathlib
from dataclasses import dataclass

import pytest

Expand Down Expand Up @@ -38,6 +39,72 @@ def write_config(path: pathlib.Path, body: str) -> pathlib.Path:
return path


#: An endpoint other than the default, as `init` would have stored it.
STORED_URL = "https://api.example.com"


@dataclass(frozen=True)
class EndpointCase:
"""Where the key and the endpoint come from in one test.

Attributes:
environment_key: Whether TOC_API_KEY is set.
environment_url: TOC_API_URL's value, or empty to leave it unset.
stored_url: The api_url the credentials file holds, or empty for none.
"""

environment_key: bool
environment_url: str
stored_url: str

def environment(self, tmp_path: pathlib.Path) -> dict[str, str]:
"""Write the credentials file and build the environment for this case.

The file always holds a key, and a different one from the
environment's, so a test can tell which of the two was used.

Args:
tmp_path: Where to write the credentials file.

Returns:
The environment to resolve settings from.
"""
body = f"[default]\napi_key = {OTHER}\n"
if self.stored_url:
body += f"api_url = {self.stored_url}\n"
config = write_config(tmp_path / "credentials", body)
env = {"TOC_CONFIG_FILE": str(config)}
if self.environment_key:
env["TOC_API_KEY"] = KEY
if self.environment_url:
env["TOC_API_URL"] = self.environment_url
return env


#: A key from the environment going to an endpoint from the file, which is
#: the one combination a reader is told about.
HAZARD = EndpointCase(True, "", STORED_URL)

#: The hazard, and the same with a blank TOC_API_URL, which counts as unset.
NOTED_CASES = [
pytest.param(HAZARD, id="TOC_API_URL unset"),
pytest.param(EndpointCase(True, " ", STORED_URL), id="TOC_API_URL blank"),
]

#: An endpoint the caller chose, or the default, so there is nothing to say.
QUIET_CASES = [
pytest.param(
EndpointCase(True, "https://other.example", STORED_URL), id="TOC_API_URL set"
),
pytest.param(EndpointCase(False, "", STORED_URL), id="key from the file"),
pytest.param(EndpointCase(True, "", ""), id="no endpoint stored"),
pytest.param(EndpointCase(True, "", DEFAULT_BASE_URL), id="stored default"),
pytest.param(
EndpointCase(True, "", DEFAULT_BASE_URL + "/"), id="stored default with slash"
),
]


def test_the_environment_supplies_the_key() -> None:
found = load_settings(env={"TOC_API_KEY": KEY})

Expand Down Expand Up @@ -329,3 +396,51 @@ def test_an_environment_endpoint_still_wins_over_the_stored_one(
)

assert found.base_url == "https://other.example"


@pytest.mark.parametrize("case", NOTED_CASES)
def test_an_environment_key_going_to_a_stored_endpoint_is_flagged(
tmp_path: pathlib.Path, case: EndpointCase
) -> None:
"""The hazard the CLI warns about, recorded rather than printed.

A key exported for another API goes to the endpoint `init` stored,
because the endpoint is resolved independently of the key. The
precedence stays; the settings say where the endpoint came from.
"""
env = case.environment(tmp_path)

found = load_settings(env=env)

assert found.credentials.api_key == KEY
assert found.base_url == STORED_URL
assert found.endpoint_source == env["TOC_CONFIG_FILE"]
assert found.sends_environment_key_to_stored_endpoint is True


@pytest.mark.parametrize("case", QUIET_CASES)
def test_an_endpoint_the_caller_chose_or_expects_is_not_flagged(
tmp_path: pathlib.Path, case: EndpointCase
) -> None:
found = load_settings(env=case.environment(tmp_path))

assert found.sends_environment_key_to_stored_endpoint is False


@pytest.mark.parametrize(
("case", "expected"),
[
(EndpointCase(True, "https://other.example", STORED_URL), "TOC_API_URL"),
(EndpointCase(True, "", STORED_URL), "file"),
(EndpointCase(True, "", ""), ""),
],
)
def test_the_endpoint_records_where_it_came_from(
tmp_path: pathlib.Path, case: EndpointCase, expected: str
) -> None:
env = case.environment(tmp_path)

found = load_settings(env=env)

wanted = env["TOC_CONFIG_FILE"] if expected == "file" else expected
assert found.endpoint_source == wanted
Loading
Loading