mirror of
https://github.com/mvt-project/mvt.git
synced 2026-09-03 00:21:07 +02:00
Plugin packages need somewhere to keep their own settings, but MVT rewrites its config.yaml with only the fields it knows about, so any foreign section is dropped. Add MVTPluginSettings, a pydantic-settings base class that gives each plugin its own file under the MVT config folder and its own MVT_PLUGIN_<NAME>_ environment variable namespace. Settings resolve from constructor arguments, then the environment, then the plugin file, then the field defaults. Saving skips the values the environment currently supplies, so credentials passed as environment variables are not copied to disk, and writes through a private temporary file so a settings file is never partially written or briefly readable by other users.
304 lines
9.7 KiB
Python
304 lines
9.7 KiB
Python
# Mobile Verification Toolkit (MVT)
|
|
# Copyright (c) 2021-2026 The MVT Authors.
|
|
# Use of this software is governed by the MVT License 1.1 that can be found at
|
|
# https://license.mvt.re/1.1/
|
|
|
|
import os
|
|
import stat
|
|
import sys
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from mvt.common.plugin_config import (
|
|
MVTPluginSettings,
|
|
PluginConfigLoadError,
|
|
plugin_config_folder,
|
|
plugin_config_path,
|
|
plugin_env_prefix,
|
|
)
|
|
|
|
|
|
class ExamplePluginSettings(MVTPluginSettings):
|
|
plugin_name = "example-plugin"
|
|
|
|
API_KEY: Optional[str] = None
|
|
CACHE_FOLDER: str = "cache"
|
|
MAX_RESULTS: int = 25
|
|
|
|
|
|
class OtherPluginSettings(MVTPluginSettings):
|
|
plugin_name = "other-plugin"
|
|
|
|
API_KEY: Optional[str] = None
|
|
|
|
|
|
@pytest.fixture
|
|
def config_folder(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"mvt.common.plugin_config.user_config_dir",
|
|
lambda *args, **kwargs: str(tmp_path),
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
def _write_plugin_file(plugin_name, values):
|
|
config_path = plugin_config_path(plugin_name)
|
|
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
content = values if isinstance(values, str) else yaml.dump(values)
|
|
with open(config_path, "w") as config_file:
|
|
config_file.write(content)
|
|
return config_path
|
|
|
|
|
|
def test_plugin_paths_and_prefixes_are_namespaced(config_folder):
|
|
assert plugin_config_folder() == str(config_folder / "plugins")
|
|
assert plugin_config_path("example-plugin") == str(
|
|
config_folder / "plugins" / "example-plugin.yaml"
|
|
)
|
|
assert plugin_env_prefix("example-plugin") == "MVT_PLUGIN_EXAMPLE_PLUGIN_"
|
|
assert plugin_env_prefix("other-plugin") == "MVT_PLUGIN_OTHER_PLUGIN_"
|
|
|
|
|
|
def test_defaults_are_used_without_file_or_environment(config_folder):
|
|
settings = ExamplePluginSettings.load()
|
|
|
|
assert settings.API_KEY is None
|
|
assert settings.CACHE_FOLDER == "cache"
|
|
assert settings.MAX_RESULTS == 25
|
|
assert not os.path.exists(plugin_config_path("example-plugin"))
|
|
|
|
|
|
def test_values_are_loaded_from_the_plugin_file(config_folder):
|
|
_write_plugin_file("example-plugin", {"API_KEY": "from-file", "MAX_RESULTS": 5})
|
|
|
|
settings = ExamplePluginSettings.load()
|
|
|
|
assert settings.API_KEY == "from-file"
|
|
assert settings.MAX_RESULTS == 5
|
|
assert settings.CACHE_FOLDER == "cache"
|
|
|
|
|
|
def test_environment_overrides_the_plugin_file(config_folder, monkeypatch):
|
|
_write_plugin_file("example-plugin", {"API_KEY": "from-file", "MAX_RESULTS": 5})
|
|
monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "from-environment")
|
|
|
|
settings = ExamplePluginSettings.load()
|
|
|
|
assert settings.API_KEY == "from-environment"
|
|
assert settings.MAX_RESULTS == 5
|
|
|
|
|
|
def test_arguments_override_the_environment_and_the_plugin_file(
|
|
config_folder, monkeypatch
|
|
):
|
|
_write_plugin_file("example-plugin", {"API_KEY": "from-file", "MAX_RESULTS": 5})
|
|
monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "from-environment")
|
|
monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS", "10")
|
|
|
|
settings = ExamplePluginSettings(API_KEY="from-argument")
|
|
|
|
assert settings.API_KEY == "from-argument"
|
|
assert settings.MAX_RESULTS == 10
|
|
|
|
|
|
def test_save_and_load_round_trip(config_folder):
|
|
settings = ExamplePluginSettings.load()
|
|
settings.API_KEY = "saved-key"
|
|
settings.MAX_RESULTS = 100
|
|
|
|
settings.save()
|
|
|
|
config_path = plugin_config_path("example-plugin")
|
|
assert os.path.isfile(config_path)
|
|
with open(config_path) as config_file:
|
|
assert yaml.safe_load(config_file) == {
|
|
"API_KEY": "saved-key",
|
|
"MAX_RESULTS": 100,
|
|
}
|
|
|
|
reloaded = ExamplePluginSettings.load()
|
|
assert reloaded.API_KEY == "saved-key"
|
|
assert reloaded.MAX_RESULTS == 100
|
|
assert reloaded.CACHE_FOLDER == "cache"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32", reason="POSIX file permissions are not available"
|
|
)
|
|
def test_saved_file_is_only_readable_by_the_user(config_folder):
|
|
settings = ExamplePluginSettings.load()
|
|
settings.API_KEY = "saved-key"
|
|
|
|
settings.save()
|
|
|
|
config_path = plugin_config_path("example-plugin")
|
|
assert stat.S_IMODE(os.stat(config_path).st_mode) == 0o600
|
|
folder_mode = stat.S_IMODE(os.stat(plugin_config_folder()).st_mode)
|
|
assert folder_mode & 0o077 == 0
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32", reason="POSIX file permissions are not available"
|
|
)
|
|
def test_save_restricts_the_permissions_of_an_existing_file(config_folder):
|
|
config_path = _write_plugin_file("example-plugin", {"API_KEY": "from-file"})
|
|
os.chmod(config_path, 0o644)
|
|
|
|
settings = ExamplePluginSettings.load()
|
|
settings.MAX_RESULTS = 100
|
|
settings.save()
|
|
|
|
assert stat.S_IMODE(os.stat(config_path).st_mode) == 0o600
|
|
assert os.listdir(plugin_config_folder()) == ["example-plugin.yaml"]
|
|
|
|
|
|
def test_save_only_persists_non_default_values(config_folder):
|
|
settings = ExamplePluginSettings.load()
|
|
settings.CACHE_FOLDER = "another-cache"
|
|
|
|
settings.save()
|
|
|
|
with open(plugin_config_path("example-plugin")) as config_file:
|
|
assert yaml.safe_load(config_file) == {"CACHE_FOLDER": "another-cache"}
|
|
|
|
|
|
def test_save_does_not_persist_values_coming_from_the_environment(
|
|
config_folder, monkeypatch
|
|
):
|
|
monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "environment-secret")
|
|
|
|
settings = ExamplePluginSettings.load()
|
|
assert settings.API_KEY == "environment-secret"
|
|
settings.MAX_RESULTS = 100
|
|
settings.save()
|
|
|
|
with open(plugin_config_path("example-plugin")) as config_file:
|
|
assert yaml.safe_load(config_file) == {"MAX_RESULTS": 100}
|
|
|
|
|
|
def test_an_invalid_environment_variable_still_protects_the_other_values(
|
|
config_folder, monkeypatch
|
|
):
|
|
monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "environment-secret")
|
|
monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS", "not-an-int")
|
|
|
|
settings = ExamplePluginSettings(MAX_RESULTS=100)
|
|
assert settings.API_KEY == "environment-secret"
|
|
settings.save()
|
|
|
|
with open(plugin_config_path("example-plugin")) as config_file:
|
|
saved_values = yaml.safe_load(config_file)
|
|
assert saved_values == {"MAX_RESULTS": 100}
|
|
assert "API_KEY" not in saved_values
|
|
|
|
|
|
def test_save_persists_values_which_differ_from_the_environment(
|
|
config_folder, monkeypatch
|
|
):
|
|
monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "environment-secret")
|
|
|
|
settings = ExamplePluginSettings.load()
|
|
settings.API_KEY = "chosen-key"
|
|
settings.save()
|
|
|
|
with open(plugin_config_path("example-plugin")) as config_file:
|
|
assert yaml.safe_load(config_file) == {"API_KEY": "chosen-key"}
|
|
|
|
|
|
def test_save_does_not_write_the_mvt_configuration_file(config_folder):
|
|
settings = ExamplePluginSettings.load()
|
|
settings.API_KEY = "saved-key"
|
|
|
|
settings.save()
|
|
|
|
assert os.listdir(config_folder) == ["plugins"]
|
|
|
|
|
|
def test_unknown_keys_in_the_plugin_file_are_ignored(config_folder):
|
|
_write_plugin_file(
|
|
"example-plugin",
|
|
{"API_KEY": "from-file", "UNKNOWN_SETTING": "ignored"},
|
|
)
|
|
|
|
settings = ExamplePluginSettings.load()
|
|
|
|
assert settings.API_KEY == "from-file"
|
|
assert not hasattr(settings, "UNKNOWN_SETTING")
|
|
|
|
|
|
def test_unparsable_plugin_file_is_reported_with_its_path(config_folder):
|
|
config_path = _write_plugin_file("example-plugin", "API_KEY: [unclosed\n")
|
|
|
|
with pytest.raises(PluginConfigLoadError) as raised:
|
|
ExamplePluginSettings.load()
|
|
|
|
assert config_path in str(raised.value)
|
|
|
|
|
|
def test_plugin_file_which_is_not_a_mapping_is_reported_with_its_path(config_folder):
|
|
config_path = _write_plugin_file("example-plugin", "- one\n- two\n")
|
|
|
|
with pytest.raises(PluginConfigLoadError) as raised:
|
|
ExamplePluginSettings.load()
|
|
|
|
assert config_path in str(raised.value)
|
|
assert "mapping of setting names" in str(raised.value)
|
|
|
|
|
|
def test_plugins_do_not_interfere_with_each_other(config_folder, monkeypatch):
|
|
_write_plugin_file("other-plugin", {"API_KEY": "other-file-key"})
|
|
monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "example-environment-key")
|
|
|
|
example_settings = ExamplePluginSettings.load()
|
|
other_settings = OtherPluginSettings.load()
|
|
|
|
assert example_settings.API_KEY == "example-environment-key"
|
|
assert other_settings.API_KEY == "other-file-key"
|
|
|
|
example_settings.MAX_RESULTS = 100
|
|
example_settings.save()
|
|
assert sorted(os.listdir(plugin_config_folder())) == [
|
|
"example-plugin.yaml",
|
|
"other-plugin.yaml",
|
|
]
|
|
with open(plugin_config_path("other-plugin")) as config_file:
|
|
assert yaml.safe_load(config_file) == {"API_KEY": "other-file-key"}
|
|
|
|
|
|
def test_subclass_without_plugin_name_is_rejected():
|
|
with pytest.raises(TypeError, match="plugin_name"):
|
|
|
|
class MissingNameSettings(MVTPluginSettings):
|
|
API_KEY: Optional[str] = None
|
|
|
|
|
|
def test_subclass_with_invalid_plugin_name_is_rejected():
|
|
with pytest.raises(ValueError, match="Invalid plugin name"):
|
|
|
|
class InvalidNameSettings(MVTPluginSettings):
|
|
plugin_name = "Bad/Name"
|
|
|
|
|
|
def test_underscores_are_not_allowed_in_plugin_names():
|
|
# Underscores are replaced by dashes in the environment prefix, so allowing
|
|
# both would let two plugin names share one environment namespace.
|
|
with pytest.raises(ValueError, match="Invalid plugin name"):
|
|
|
|
class UnderscoreNameSettings(MVTPluginSettings):
|
|
plugin_name = "under_score"
|
|
|
|
with pytest.raises(ValueError, match="Invalid plugin name"):
|
|
plugin_config_path("under_score")
|
|
with pytest.raises(ValueError, match="Invalid plugin name"):
|
|
plugin_env_prefix("under_score")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin_name", ["../escape", "folder/name", "UPPER", "-dash", ""]
|
|
)
|
|
def test_unsafe_plugin_names_have_no_configuration_path(plugin_name):
|
|
with pytest.raises(ValueError, match="Invalid plugin name"):
|
|
plugin_config_path(plugin_name)
|