Files
unraid-mcp/tests/test_auth_builder.py
Jacob Magar 2b777be927 fix(security): path traversal, timing-safe auth, stale credential bindings
Security:
- Remove /mnt/ from _ALLOWED_LOG_PREFIXES to prevent Unraid share exposure
- Add early .. detection for disk/logs and live/log_tail path validation
- Add /boot/ prefix restriction for flash_backup source_path
- Use hmac.compare_digest for timing-safe API key verification in server.py
- Gate include_traceback on DEBUG log level (no tracebacks in production)

Correctness:
- Re-raise CredentialsNotConfiguredError in health check instead of swallowing
- Fix ups_device query (remove non-existent nominalPower/currentPower fields)

Best practices (BP-01, BP-05, BP-06):
- Add # noqa: ASYNC109 to timeout params in _handle_live and unraid()
- Fix start_array* → start_array in docstring (not in ARRAY_DESTRUCTIVE)
- Remove from __future__ import annotations from snapshot.py
- Replace import-time UNRAID_API_KEY/URL bindings with _settings.ATTR pattern
  in manager.py, snapshot.py, utils.py, diagnostics.py — fixes stale binding
  after apply_runtime_config() post-elicitation (BP-05)

CI/CD:
- Add .github/workflows/ci.yml (5-job pipeline: lint, typecheck, test, version-sync, audit)
- Add fail_under = 80 to [tool.coverage.report]
- Add version sync check to scripts/validate-marketplace.sh

Documentation:
- Sync plugin.json version 1.1.1 → 1.1.2 with pyproject.toml
- Update CLAUDE.md: 3 tools, system domain count 18, scripts comment fix
- Update README.md: 3 tools, security notes
- Update docs/AUTHENTICATION.md: H1 title fix
- Add UNRAID_CREDENTIALS_DIR to .env.example

Bump: 1.1.1 → 1.1.2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-23 11:37:05 -04:00

116 lines
4.0 KiB
Python

"""Tests for _build_google_auth() in server.py."""
import importlib
from unittest.mock import MagicMock, patch
from unraid_mcp.server import _build_google_auth
def test_build_google_auth_returns_none_when_unconfigured(monkeypatch):
"""Returns None when Google OAuth env vars are absent."""
# Use explicit empty values so dotenv reload cannot re-inject from ~/.unraid-mcp/.env.
monkeypatch.setenv("GOOGLE_CLIENT_ID", "")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "")
import unraid_mcp.config.settings as s
importlib.reload(s)
result = _build_google_auth()
assert result is None
def test_build_google_auth_returns_provider_when_configured(monkeypatch):
"""Returns GoogleProvider instance when all required vars are set."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
monkeypatch.setenv("UNRAID_MCP_JWT_SIGNING_KEY", "x" * 32)
import unraid_mcp.config.settings as s
importlib.reload(s)
mock_provider = MagicMock()
mock_provider_class = MagicMock(return_value=mock_provider)
with patch("unraid_mcp.server.GoogleProvider", mock_provider_class):
result = _build_google_auth()
assert result is mock_provider
mock_provider_class.assert_called_once_with(
client_id="test-id.apps.googleusercontent.com",
client_secret="GOCSPX-test-secret",
base_url="http://10.1.0.2:6970",
extra_authorize_params={"access_type": "online", "prompt": "consent"},
require_authorization_consent=False,
jwt_signing_key="x" * 32,
)
def test_build_google_auth_omits_jwt_key_when_empty(monkeypatch):
"""jwt_signing_key is omitted (not passed as empty string) when not set."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
# Use setenv("") not delenv so dotenv reload can't re-inject from ~/.unraid-mcp/.env
monkeypatch.setenv("UNRAID_MCP_JWT_SIGNING_KEY", "")
import unraid_mcp.config.settings as s
importlib.reload(s)
mock_provider_class = MagicMock(return_value=MagicMock())
with patch("unraid_mcp.server.GoogleProvider", mock_provider_class):
_build_google_auth()
call_kwargs = mock_provider_class.call_args.kwargs
assert "jwt_signing_key" not in call_kwargs
def test_build_google_auth_warns_on_stdio_transport(monkeypatch):
"""Logs a warning when Google auth is configured but transport is stdio."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
monkeypatch.setenv("UNRAID_MCP_TRANSPORT", "stdio")
import unraid_mcp.config.settings as s
importlib.reload(s)
warning_messages: list[str] = []
with (
patch("unraid_mcp.server.GoogleProvider", MagicMock(return_value=MagicMock())),
patch("unraid_mcp.server.logger") as mock_logger,
):
mock_logger.warning.side_effect = lambda msg, *a, **kw: warning_messages.append(msg)
_build_google_auth()
assert any("stdio" in m.lower() for m in warning_messages)
def test_mcp_instance_has_no_auth_by_default():
"""The FastMCP mcp instance has no auth provider when Google vars are absent."""
import os
for var in ("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "UNRAID_MCP_BASE_URL"):
os.environ[var] = ""
import importlib
import unraid_mcp.config.settings as s
importlib.reload(s)
import unraid_mcp.server as srv
importlib.reload(srv)
# FastMCP stores auth on ._auth_provider or .auth
auth = getattr(srv.mcp, "_auth_provider", None) or getattr(srv.mcp, "auth", None)
assert auth is None