Newer
Older
navi-1 / tests / unit / tools / test_redact_args.py
"""Unit tests for secret masking in tool-argument logs."""

import pytest

from navi.tools._internal.redact import REDACTED, is_sensitive_key, redact_args


class TestIsSensitiveKey:
    @pytest.mark.parametrize(
        "key",
        [
            "password",
            "Password",
            "PASSWORD",
            "passwd",
            "pwd",
            "secret",
            "client_secret",
            "token",
            "access_token",
            "refresh_token",
            "api_key",
            "api-key",
            "apikey",
            "private_key",
            "passphrase",
            "authorization",
        ],
    )
    def test_credentials_are_sensitive(self, key: str):
        assert is_sensitive_key(key) is True

    @pytest.mark.parametrize(
        "key",
        [
            "db_password",
            "user_token",
            "openai_api_key",
            "ssh_private_key",
            "my-passphrase",
        ],
    )
    def test_qualified_names_are_sensitive(self, key: str):
        assert is_sensitive_key(key) is True

    @pytest.mark.parametrize(
        "key",
        [
            # Real ssh_exec parameter: a path, not a credential.
            "key_path",
            "command",
            "host",
            "username",
            # Would be caught by a careless suffix rule, and must not be.
            "max_tokens",
            "token_count",
            "token_limit",
            "tokens",
        ],
    )
    def test_ordinary_parameters_stay_readable(self, key: str):
        assert is_sensitive_key(key) is False

    def test_non_string_key_is_not_sensitive(self):
        assert is_sensitive_key(7) is False


class TestRedactArgs:
    def test_masks_a_password_and_keeps_everything_else(self):
        args = {"command": "uptime", "host": "10.0.0.1", "password": "hunter2"}
        assert redact_args(args) == {
            "command": "uptime",
            "host": "10.0.0.1",
            "password": REDACTED,
        }

    def test_masks_at_depth(self):
        """A credential nested in a tool's own arguments payload (test_mcp_tool)."""
        args = {"server_name": "x", "arguments": {"user": "a", "api_key": "sk-1"}}
        assert redact_args(args)["arguments"] == {"user": "a", "api_key": REDACTED}

    def test_masks_inside_lists(self):
        args = {"items": [{"password": "a"}, {"note": "b"}]}
        assert redact_args(args) == {"items": [{"password": REDACTED}, {"note": "b"}]}

    def test_masks_each_item_of_a_list_of_dicts(self):
        args = [{"token": "a"}, {"token": "b"}]
        assert redact_args(args) == [{"token": REDACTED}, {"token": REDACTED}]

    def test_the_key_is_spelled_out_so_the_log_stays_useful(self):
        assert redact_args({"authorization": "Bearer x"}) == {"authorization": REDACTED}

    def test_a_secret_in_a_value_under_an_ordinary_key_is_not_caught(self):
        """The documented gap: only the *key* is inspected, so this stays visible.

        Same shape as a password inside ``terminal``'s ``command`` or a token in
        an unlabelled header value. Nothing name-based can see it.
        """
        args = {"headers": [{"name": "Authorization", "value": "Bearer sekrit"}]}
        assert redact_args(args) == args

    def test_does_not_mutate_the_original(self):
        """The tool and the client still get the real value — only the log is masked."""
        args = {"password": "hunter2", "nested": {"token": "t"}}
        original = {"password": "hunter2", "nested": {"token": "t"}}
        redact_args(args)
        assert args == original
        assert args["nested"]["token"] == "t"

    def test_scalars_and_none_pass_through(self):
        assert redact_args(None) is None
        assert redact_args("plain") == "plain"
        assert redact_args(3) == 3

    def test_a_deeply_nested_structure_terminates(self):
        """The depth cap is a guard, not a feature — it just must not recurse forever."""
        node: dict = {"password": "hunter2"}
        for _ in range(30):
            node = {"nested": node}
        redact_args(node)  # must return, not blow the stack