Files
mcp-nextcloud/tests/test_cli.py
T
Chris CoutinhoandClaude Opus 4.6 29fd0486c9 refactor: change OAuth scope separator from colon to dot for IDP compatibility
Many identity providers (AWS Cognito, Okta, Azure AD) reject or mishandle
colons in OAuth scope names. This migrates all custom scopes from
`resource:action` to `resource.action` format (e.g., `notes:read` →
`notes.read`), which is universally accepted and aligns with industry
conventions (Microsoft, Google).

Includes Alembic migration 004 for stored scope strings and ADR-024
documenting the rationale and RFC references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:07:02 +02:00

288 lines
10 KiB
Python

"""Tests for CLI options using Click's testing utilities."""
import os
import pytest
from click.testing import CliRunner
from nextcloud_mcp_server.cli import run
@pytest.fixture
def runner():
"""Create a Click CLI runner."""
return CliRunner()
@pytest.fixture
def clean_env(monkeypatch):
"""Clean environment variables before each test."""
env_vars = [
"NEXTCLOUD_HOST",
"NEXTCLOUD_USERNAME",
"NEXTCLOUD_PASSWORD",
"NEXTCLOUD_OIDC_CLIENT_ID",
"NEXTCLOUD_OIDC_CLIENT_SECRET",
"NEXTCLOUD_OIDC_SCOPES",
"NEXTCLOUD_OIDC_TOKEN_TYPE",
"NEXTCLOUD_MCP_SERVER_URL",
"NEXTCLOUD_PUBLIC_ISSUER_URL",
]
for var in env_vars:
monkeypatch.delenv(var, raising=False)
def test_help_message_displays_all_options(runner):
"""Test that help message includes all new CLI options."""
result = runner.invoke(run, ["--help"])
assert result.exit_code == 0
# Check for new options
assert "--nextcloud-host" in result.output
assert "--nextcloud-username" in result.output
assert "--nextcloud-password" in result.output
assert "--oauth-scopes" in result.output
assert "--oauth-token-type" in result.output
assert "--public-issuer-url" in result.output
# Check for existing options
assert "--oauth-client-id" in result.output
assert "--oauth-client-secret" in result.output
assert "--mcp-server-url" in result.output
def test_token_type_accepts_valid_values(runner, clean_env):
"""Test that --oauth-token-type accepts bearer and jwt (case insensitive)."""
# Test lowercase bearer
result = runner.invoke(run, ["--oauth-token-type", "bearer", "--help"])
assert result.exit_code == 0
# Test lowercase jwt
result = runner.invoke(run, ["--oauth-token-type", "jwt", "--help"])
assert result.exit_code == 0
# Test uppercase (should work with case_sensitive=False)
result = runner.invoke(run, ["--oauth-token-type", "Bearer", "--help"])
assert result.exit_code == 0
result = runner.invoke(run, ["--oauth-token-type", "JWT", "--help"])
assert result.exit_code == 0
def test_token_type_rejects_invalid_values(runner, clean_env):
"""Test that --oauth-token-type rejects invalid values."""
result = runner.invoke(run, ["--oauth-token-type", "invalid"])
assert result.exit_code != 0
assert "Invalid value" in result.output
def test_cli_options_set_environment_variables(runner, clean_env, monkeypatch):
"""Test that CLI options set environment variables correctly."""
# We need to mock the actual server startup to avoid connection errors
# Store the env vars that get set
captured_env = {}
def mock_get_app(*args, **kwargs):
# Capture environment variables after they're set by CLI
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get(
"NEXTCLOUD_PUBLIC_ISSUER_URL"
),
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
}
)
# Raise an exception to stop execution before uvicorn.run
raise SystemExit(0)
# Patch get_app to capture env vars
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
_ = runner.invoke(
run,
[
"--nextcloud-host",
"https://test.example.com",
"--nextcloud-username",
"testuser",
"--nextcloud-password",
"testpass",
"--oauth-scopes",
"openid nc:read",
"--oauth-token-type",
"jwt",
"--public-issuer-url",
"https://public.example.com",
"--mcp-server-url",
"http://test:8000",
],
)
# Verify environment variables were set
assert captured_env["NEXTCLOUD_HOST"] == "https://test.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "testuser"
assert captured_env["NEXTCLOUD_PASSWORD"] == "testpass"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:read"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
assert captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public.example.com"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://test:8000"
def test_cli_options_override_environment_variables(runner, monkeypatch):
"""Test that CLI options override environment variables."""
# Set environment variables
monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com")
monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser")
monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid")
monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "bearer")
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
# Provide CLI options that should override env vars
_ = runner.invoke(
run,
[
"--nextcloud-host",
"https://from-cli.example.com",
"--nextcloud-username",
"cliuser",
"--oauth-scopes",
"openid nc:write",
"--oauth-token-type",
"jwt",
],
)
# Verify CLI options overrode env vars
assert captured_env["NEXTCLOUD_HOST"] == "https://from-cli.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "cliuser"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:write"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
def test_environment_variables_used_when_cli_not_provided(runner, monkeypatch):
"""Test that environment variables are used when CLI options not provided."""
# Set environment variables
monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com")
monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser")
monkeypatch.setenv("NEXTCLOUD_PASSWORD", "envpass")
monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid email")
monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "jwt")
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://public-env.example.com")
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get(
"NEXTCLOUD_PUBLIC_ISSUER_URL"
),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
# Don't provide any CLI options - should use env vars
_ = runner.invoke(run, [])
# Verify env vars were used
assert captured_env["NEXTCLOUD_HOST"] == "https://from-env.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "envuser"
assert captured_env["NEXTCLOUD_PASSWORD"] == "envpass"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid email"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
assert (
captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public-env.example.com"
)
def test_default_values(runner, clean_env, monkeypatch):
"""Test that default values are used when neither CLI nor env vars provided."""
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
# Don't provide CLI options or env vars - should use defaults
_ = runner.invoke(run, [])
# Verify default values
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == (
"openid profile email "
"notes.read notes.write "
"calendar.read calendar.write "
"todo.read todo.write "
"contacts.read contacts.write "
"cookbook.read cookbook.write "
"deck.read deck.write "
"tables.read tables.write "
"files.read files.write "
"sharing.read sharing.write"
)
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch):
"""Test that token type is normalized correctly regardless of input case."""
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] = os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.cli.get_app", mock_get_app)
# Test uppercase JWT
runner.invoke(run, ["--oauth-token-type", "JWT"])
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["JWT", "jwt"]
# Test mixed case Bearer
captured_env.clear()
runner.invoke(run, ["--oauth-token-type", "Bearer"])
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["Bearer", "bearer"]