openwebui-cli/tests/test_auth_cli.py
2025-12-01 04:24:51 +01:00

465 lines
16 KiB
Python

"""CLI-level tests for auth commands."""
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import httpx
import keyring
import pytest
from typer.testing import CliRunner
from openwebui_cli.main import app
runner = CliRunner()
@pytest.fixture(autouse=True)
def mock_config(tmp_path, monkeypatch):
"""Use a temp config dir to avoid touching the real filesystem."""
config_dir = tmp_path / "openwebui"
config_path = config_dir / "config.yaml"
monkeypatch.setattr("openwebui_cli.config.get_config_dir", lambda: config_dir)
monkeypatch.setattr("openwebui_cli.config.get_config_path", lambda: config_path)
from openwebui_cli.config import Config, save_config
save_config(Config())
return config_path
@pytest.fixture
def mock_keyring(monkeypatch):
"""Mock keyring functions."""
get_password_mock = Mock(return_value=None)
set_password_mock = Mock()
delete_password_mock = Mock()
monkeypatch.setattr("openwebui_cli.http.keyring.get_password", get_password_mock)
monkeypatch.setattr("openwebui_cli.http.keyring.set_password", set_password_mock)
monkeypatch.setattr("openwebui_cli.http.keyring.delete_password", delete_password_mock)
return {
"get_password": get_password_mock,
"set_password": set_password_mock,
"delete_password": delete_password_mock,
}
@pytest.fixture
def mock_create_client(monkeypatch):
"""Mock create_client to return a mocked httpx.Client."""
def _create_mock_client():
client_mock = MagicMock(spec=httpx.Client)
return client_mock
return _create_mock_client
# Test login success
def test_login_success(mock_keyring, monkeypatch):
"""Login command stores token when successful."""
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {"token": "test_token_123", "name": "Test User"}
client_mock = MagicMock(spec=httpx.Client)
client_mock.post.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(
app,
["auth", "login", "--username", "testuser", "--password", "testpass"],
)
assert result.exit_code == 0
assert "Successfully logged in as Test User" in result.stdout
assert mock_keyring["set_password"].called
# Test login failure
def test_login_failure_401(mock_keyring, monkeypatch):
"""Login command handles 401 error from server."""
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 401
response_mock.text = "Unauthorized"
client_mock = MagicMock(spec=httpx.Client)
client_mock.post.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(
app,
["auth", "login", "--username", "testuser", "--password", "wrongpass"],
)
assert result.exit_code != 0
# Test login with no token in response
def test_login_no_token_received(mock_keyring, monkeypatch):
"""Login command handles missing token in response."""
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {"name": "Test User"} # No token field
client_mock = MagicMock(spec=httpx.Client)
client_mock.post.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(
app,
["auth", "login", "--username", "testuser", "--password", "testpass"],
)
assert result.exit_code != 0
# Test login with env token precedence
def test_login_with_env_token_override(monkeypatch, tmp_path):
"""Verify OPENWEBUI_TOKEN env var takes precedence."""
config_dir = tmp_path / "openwebui"
config_path = config_dir / "config.yaml"
monkeypatch.setattr("openwebui_cli.config.get_config_dir", lambda: config_dir)
monkeypatch.setattr("openwebui_cli.config.get_config_path", lambda: config_path)
from openwebui_cli.config import Config, save_config
save_config(Config())
monkeypatch.setenv("OPENWEBUI_TOKEN", "env_token_value")
# Mock keyring to raise error - env token should take precedence
monkeypatch.setattr("openwebui_cli.http.keyring.get_password", Mock(side_effect=keyring.errors.KeyringError()))
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {"name": "Test User", "email": "test@example.com", "role": "user"}
client_mock = MagicMock(spec=httpx.Client)
client_mock.get.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(app, ["auth", "whoami"])
assert result.exit_code == 0
assert "User: Test User" in result.stdout
# Test login no keyring available
def test_login_no_keyring_fallback(mock_keyring, monkeypatch):
"""Login handles keyring unavailability gracefully."""
# Mock keyring to raise error
mock_keyring["set_password"].side_effect = keyring.errors.KeyringError("No backend")
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {"token": "test_token_123", "name": "Test User"}
client_mock = MagicMock(spec=httpx.Client)
client_mock.post.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(
app,
["auth", "login", "--username", "testuser", "--password", "testpass"],
)
# Should still show error about keyring
assert result.exit_code != 0
# Test logout
def test_logout_removes_token(mock_keyring, monkeypatch):
"""Logout command removes token from keyring."""
result = runner.invoke(app, ["auth", "logout"])
assert result.exit_code == 0
assert "Logged out" in result.stdout
assert mock_keyring["delete_password"].called
# Test whoami with valid token
def test_whoami_with_token(mock_keyring, monkeypatch):
"""whoami command displays user info when token is valid."""
mock_keyring["get_password"].return_value = "valid_token"
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
}
client_mock = MagicMock(spec=httpx.Client)
client_mock.get.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(app, ["auth", "whoami"])
assert result.exit_code == 0
assert "John Doe" in result.stdout
assert "john@example.com" in result.stdout
assert "admin" in result.stdout
# Test whoami with missing fields
def test_whoami_missing_fields(mock_keyring, monkeypatch):
"""whoami displays Unknown for missing fields."""
mock_keyring["get_password"].return_value = "valid_token"
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {"name": "Jane Doe"} # Missing email, role
client_mock = MagicMock(spec=httpx.Client)
client_mock.get.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(app, ["auth", "whoami"])
assert result.exit_code == 0
assert "Jane Doe" in result.stdout
assert "Unknown" in result.stdout
# Test whoami without token
def test_whoami_no_token(mock_keyring, monkeypatch):
"""whoami fails when no token is available."""
mock_keyring["get_password"].return_value = None
monkeypatch.delenv("OPENWEBUI_TOKEN", raising=False)
monkeypatch.setattr(
"openwebui_cli.commands.auth.create_client",
Mock(side_effect=Exception("No authentication token available")),
)
result = runner.invoke(app, ["auth", "whoami"])
assert result.exit_code != 0
# Test token command show flag
def test_token_show_full_token(mock_keyring, monkeypatch):
"""Token command with --show displays full token."""
mock_keyring["get_password"].return_value = "very_long_test_token_1234567890"
monkeypatch.setattr(
"openwebui_cli.config.Settings",
lambda: SimpleNamespace(
openwebui_token=None, openwebui_profile=None, openwebui_uri=None
),
)
monkeypatch.setattr("openwebui_cli.commands.auth.get_token", lambda *args, **kwargs: "very_long_test_token_1234567890")
result = runner.invoke(app, ["auth", "token", "--show"])
assert result.exit_code == 0
assert "very_long_test_token_1234567890" in result.stdout
# Test token command without show flag
def test_token_masked(mock_keyring, monkeypatch):
"""Token command masks token when --show not provided."""
test_token = "very_long_test_token_1234567890"
monkeypatch.setattr(
"openwebui_cli.config.Settings",
lambda: SimpleNamespace(
openwebui_token=None, openwebui_profile=None, openwebui_uri=None
),
)
monkeypatch.setattr("openwebui_cli.commands.auth.get_token", lambda *args, **kwargs: test_token)
result = runner.invoke(app, ["auth", "token"])
assert result.exit_code == 0
assert "7890" in result.stdout # Last 4 chars visible
assert "..." in result.stdout # Dots indicating masking
assert test_token not in result.stdout # Full token not visible
# Test token command no token
def test_token_no_token_available(mock_keyring, monkeypatch):
"""Token command shows message when no token available."""
mock_keyring["get_password"].return_value = None
monkeypatch.setattr(
"openwebui_cli.config.Settings",
lambda: SimpleNamespace(
openwebui_token=None, openwebui_profile=None, openwebui_uri=None
),
)
monkeypatch.setattr("openwebui_cli.commands.auth.get_token", lambda *args, **kwargs: None)
result = runner.invoke(app, ["auth", "token"])
assert result.exit_code == 0
assert "No token found" in result.stdout
# Test refresh token
def test_refresh_token_success(mock_keyring, monkeypatch):
"""Refresh command successfully refreshes token."""
mock_keyring["get_password"].return_value = "old_token"
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {"token": "new_refreshed_token"}
client_mock = MagicMock(spec=httpx.Client)
client_mock.post.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(app, ["auth", "refresh"])
assert result.exit_code == 0
assert "Token refreshed successfully" in result.stdout
assert mock_keyring["set_password"].called
# Test refresh token no new token
def test_refresh_token_no_new_token(mock_keyring, monkeypatch):
"""Refresh handles response without new token."""
mock_keyring["get_password"].return_value = "old_token"
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {} # No token in response
client_mock = MagicMock(spec=httpx.Client)
client_mock.post.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(app, ["auth", "refresh"])
assert result.exit_code == 0
assert "No new token received" in result.stdout
# Test token command with short token
def test_token_short_token_masked(monkeypatch, tmp_path):
"""Token command shows *** for short tokens."""
config_dir = tmp_path / "openwebui"
config_path = config_dir / "config.yaml"
monkeypatch.setattr("openwebui_cli.config.get_config_dir", lambda: config_dir)
monkeypatch.setattr("openwebui_cli.config.get_config_path", lambda: config_path)
from openwebui_cli.config import Config, save_config
save_config(Config())
# Mock Settings to return a short token
monkeypatch.setattr(
"openwebui_cli.commands.auth.Settings",
lambda: SimpleNamespace(
openwebui_token="short", openwebui_profile=None, openwebui_uri=None
),
)
monkeypatch.setattr("openwebui_cli.commands.auth.get_token", lambda *args, **kwargs: None)
result = runner.invoke(app, ["auth", "token"])
assert result.exit_code == 0
assert "***" in result.stdout
# Test login prompts for credentials
def test_login_prompts_for_credentials(mock_keyring, monkeypatch):
"""Login prompts for username and password if not provided."""
response_mock = MagicMock(spec=httpx.Response)
response_mock.status_code = 200
response_mock.json.return_value = {"token": "test_token", "name": "Test User"}
client_mock = MagicMock(spec=httpx.Client)
client_mock.post.return_value = response_mock
client_mock.__enter__ = Mock(return_value=client_mock)
client_mock.__exit__ = Mock(return_value=False)
monkeypatch.setattr("openwebui_cli.commands.auth.create_client", lambda **kwargs: client_mock)
result = runner.invoke(
app,
["auth", "login"],
input="testuser\ntestpass\n",
)
# Should complete without error (prompts are handled by typer)
assert mock_keyring["set_password"].called or result.exit_code == 0
# Test refresh token with error
def test_refresh_token_error_handling(mock_keyring, monkeypatch):
"""Refresh command handles errors gracefully."""
mock_keyring["get_password"].return_value = "old_token"
# Mock create_client to raise an exception during refresh
monkeypatch.setattr(
"openwebui_cli.commands.auth.create_client",
Mock(side_effect=httpx.ConnectError("Connection failed")),
)
result = runner.invoke(app, ["auth", "refresh"])
assert result.exit_code != 0
# Test login with network error
def test_login_network_error(mock_keyring, monkeypatch):
"""Login command handles network errors."""
monkeypatch.setattr(
"openwebui_cli.commands.auth.create_client",
Mock(side_effect=httpx.ConnectError("Could not connect to server")),
)
result = runner.invoke(
app,
["auth", "login", "--username", "testuser", "--password", "testpass"],
)
assert result.exit_code != 0
def test_auth_token_env_fallback(monkeypatch):
"""Token command should respect OPENWEBUI_TOKEN env even without keyring."""
monkeypatch.setenv("OPENWEBUI_TOKEN", "ENV_TOKEN")
monkeypatch.setattr(
"openwebui_cli.config.Settings",
lambda: SimpleNamespace(
openwebui_token="ENV_TOKEN", openwebui_profile=None, openwebui_uri=None
),
)
monkeypatch.setattr("openwebui_cli.commands.auth.get_token", lambda *args, **kwargs: None)
result = runner.invoke(app, ["auth", "token", "--show"])
assert result.exit_code == 0
assert "ENV_TOKEN" in result.stdout