mirror of
https://github.com/mvt-project/mvt.git
synced 2026-09-03 00:21:07 +02:00
Add a plugins command to list installed plugins and check updates
Add a "plugins" command to the platform-neutral mvt command. "plugins list" shows every installed plugin package with its version, where it was installed from, how many forensic modules it contributes and which commands it adds. "plugins check-updates" checks for updates immediately, without waiting for the automatic check, and prints the command which upgrades a plugin instead of installing anything. It lives on mvt only. The packages it lists extend mvt-ios and mvt-android too, but auditing them is not the job of a command which analyses one platform, and the two platform CLIs should not carry commands which are not about an acquisition. The command is registered as a built-in, before any external command, so that an installed package cannot replace this audit surface.
This commit is contained in:
@@ -58,7 +58,7 @@ For alternative installation options and known issues, please refer to the [docu
|
||||
|
||||
## Usage
|
||||
|
||||
MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
|
||||
MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion`, `plugins` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
|
||||
|
||||
### Shell completion
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ pipx inject mvt my-mvt-plugin
|
||||
```
|
||||
|
||||
When MVT is installed in an active virtual environment, install the plugin with
|
||||
`pip` in that environment.
|
||||
`pip` in that environment. `mvt plugins list` shows the installed packages and
|
||||
the commands they add, see [Managing Plugins](plugins.md).
|
||||
|
||||
Command packages that need their own settings, such as an API key, should store
|
||||
them in a namespaced [plugin configuration file](plugin_configuration.md)
|
||||
@@ -69,9 +70,9 @@ rather than in MVT's own `config.yaml`.
|
||||
### Commands on `mvt`
|
||||
|
||||
The `mvt` command hosts what belongs to neither platform: `version`,
|
||||
`completion` and `download-iocs`. A plugin command which is not about the
|
||||
acquisition of one platform, such as one which configures the plugin or
|
||||
synchronizes the indicators it uses, belongs there too, in the
|
||||
`completion`, `plugins` and `download-iocs`. A plugin command which is not
|
||||
about the acquisition of one platform, such as one which configures the plugin
|
||||
or synchronizes the indicators it uses, belongs there too, in the
|
||||
`mvt.cli_plugins` group:
|
||||
|
||||
```toml
|
||||
|
||||
@@ -214,6 +214,9 @@ module came from:
|
||||
- When a command runs with an `--output` folder, the `command.log` file
|
||||
records one line per module source with the source's version or hash and
|
||||
the list of modules loaded from it.
|
||||
- `mvt plugins list` lists the installed packages, where each of them
|
||||
was installed from and how many modules it contributes, see
|
||||
[Managing Plugins](plugins.md).
|
||||
|
||||
## Profiling
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Managing Plugins
|
||||
|
||||
Plugin packages extend MVT with additional
|
||||
[forensic modules](index.md#custom-modules) and
|
||||
[CLI commands](custom_commands.md). Because installed packages load
|
||||
automatically, `mvt plugins` audits what is installed and checks whether
|
||||
updates are available. The command lives on `mvt` only, although the packages
|
||||
it lists extend `mvt-ios` and `mvt-android` too.
|
||||
|
||||
## List Installed Plugins
|
||||
|
||||
```bash
|
||||
mvt plugins list
|
||||
```
|
||||
|
||||
```
|
||||
Installed MVT plugins
|
||||
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┓
|
||||
┃ Name ┃ Version ┃ Origin ┃ Modules ┃ Commands ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━┩
|
||||
│ mvt-plugin-example │ 1.2.0 │ pypi │ 4 │ summarize │
|
||||
│ mvt-plugin-research │ 0.1.0 │ git+3f9a1c7d │ 2 │ - │
|
||||
│ mvt-plugin-local │ 0.0.1 │ local │ 1 │ triage │
|
||||
└─────────────────────┴─────────┴──────────────┴─────────┴───────────┘
|
||||
```
|
||||
|
||||
The origin records where each package was installed from: `pypi` for a package
|
||||
installed from a package index, `git+<commit>` for a package installed directly
|
||||
from a repository, and `local` for a package installed from a local folder or
|
||||
archive rather than from an index, including an editable development install.
|
||||
The last two columns show how many forensic modules the package contributes and
|
||||
which CLI commands it adds.
|
||||
|
||||
A plugin whose modules cannot be imported is listed with `error` in the
|
||||
`Modules` column rather than breaking the listing.
|
||||
|
||||
## Check for Updates
|
||||
|
||||
```bash
|
||||
mvt plugins check-updates
|
||||
```
|
||||
|
||||
```
|
||||
Plugin updates available:
|
||||
mvt-plugin-example 1.2.0 → 1.3.0
|
||||
Upgrade with: pip install -U mvt-plugin-example
|
||||
|
||||
MVT does not install plugin updates. Run the command above when you decide to
|
||||
upgrade.
|
||||
```
|
||||
|
||||
Packages installed from a package index are compared against the latest release
|
||||
published for them. A package which was never published, for example a plugin
|
||||
distributed only within an organization, is skipped silently.
|
||||
|
||||
!!! note
|
||||
|
||||
Packages shown with the `pypi` origin are compared against
|
||||
[PyPI](https://pypi.org), whichever index they were installed from. A
|
||||
plugin installed from a private index under a name which also exists on
|
||||
PyPI is therefore compared against the unrelated public package of that
|
||||
name. Give plugins published to a private index a name which is not taken
|
||||
on PyPI, and treat an unexpected update suggestion as a reason to check
|
||||
where the package would come from.
|
||||
|
||||
!!! warning
|
||||
|
||||
MVT never installs or upgrades a plugin itself, it only prints the command
|
||||
which does. Upgrading a plugin in the middle of an investigation changes
|
||||
the modules producing the results, and a plugin runs as trusted code inside
|
||||
the MVT process, so pulling in a new version is a decision for the analyst
|
||||
to make deliberately and not a side effect of running a check.
|
||||
|
||||
## Automatic Update Checks
|
||||
|
||||
MVT also reports available plugin updates in the banner printed when a command
|
||||
starts:
|
||||
|
||||
```
|
||||
MVT - Mobile Verification Toolkit
|
||||
|
||||
https://mvt.re
|
||||
Version: 2026.7.29
|
||||
|
||||
Plugin updates available:
|
||||
mvt-plugin-example 1.2.0 → 1.3.0 (pip install -U mvt-plugin-example)
|
||||
```
|
||||
|
||||
This check runs at most once every 12 hours. In between checks MVT prints the
|
||||
findings of the latest check without contacting anything, so a plugin update
|
||||
stays visible without a lookup on every command. The
|
||||
`mvt plugins check-updates` command checks immediately, regardless of when the
|
||||
last check happened.
|
||||
|
||||
The automatic check is skipped when the `--disable-update-check` option is
|
||||
used, when `NETWORK_ACCESS_ALLOWED` is disabled in the MVT configuration, and
|
||||
when no plugins are installed.
|
||||
|
||||
## Plugins Installed From a Repository
|
||||
|
||||
A plugin installed with `pip install "mvt-plugin-example @ git+<url>"` is
|
||||
checked by asking the remote repository which commit the installed revision
|
||||
points at now. MVT runs git and ssh in batch mode, so a repository which needs
|
||||
credentials MVT does not already have fails the check instead of prompting for
|
||||
them. The check is skipped silently when git is not available, when the
|
||||
repository cannot be reached, and when access to it is denied.
|
||||
|
||||
How the plugin was installed decides what an update means:
|
||||
|
||||
- A plugin installed from a branch is reported as outdated when the branch has
|
||||
moved past the installed commit.
|
||||
- A plugin installed from a specific commit or a tag is pinned. It is never
|
||||
reported as outdated, however far the branch it came from moves on.
|
||||
|
||||
Pinning a plugin to a commit or a tag is therefore the way to keep the modules
|
||||
used across an investigation stable.
|
||||
@@ -53,4 +53,5 @@ nav:
|
||||
- Development Instructions: "development/index.md"
|
||||
- Custom CLI Commands: "development/custom_commands.md"
|
||||
- Plugin Configuration: "development/plugin_configuration.md"
|
||||
- Managing Plugins: "development/plugins.md"
|
||||
- License: "license.md"
|
||||
|
||||
@@ -11,6 +11,7 @@ from mvt.common.cli_plugins import (
|
||||
load_cli_commands_option,
|
||||
register_cli_plugins,
|
||||
)
|
||||
from mvt.common.cmd_plugins import plugins
|
||||
from mvt.common.completion import completion
|
||||
from mvt.common.help import (
|
||||
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
@@ -84,6 +85,11 @@ def version():
|
||||
return
|
||||
|
||||
|
||||
# The plugins command is registered as a built-in command, before any external
|
||||
# command, so that an installed package can never replace it.
|
||||
cli.add_command(plugins)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Entry point of the mvt console script
|
||||
# ==============================================================================
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# 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 importlib.metadata
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from .cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
)
|
||||
from .config import settings
|
||||
from .help import (
|
||||
HELP_MSG_PLUGINS,
|
||||
HELP_MSG_PLUGINS_CHECK_UPDATES,
|
||||
HELP_MSG_PLUGINS_LIST,
|
||||
)
|
||||
from .module import MVTModule
|
||||
from .module_loader import MODULES_ENTRY_POINT_GROUP, distribution_direct_url
|
||||
from .updates import (
|
||||
SHORT_COMMIT_LENGTH,
|
||||
PluginUpdates,
|
||||
installed_plugin_distributions,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CLI_PLUGIN_GROUPS = (
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
)
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
def _entry_points(group: str) -> list[importlib.metadata.EntryPoint]:
|
||||
try:
|
||||
return list(importlib.metadata.entry_points(group=group))
|
||||
except Exception as exc:
|
||||
log.warning("Unable to discover the entry points in group %s: %s", group, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _entry_point_distribution(
|
||||
entry_point: importlib.metadata.EntryPoint,
|
||||
) -> Optional[str]:
|
||||
dist = getattr(entry_point, "dist", None)
|
||||
if dist is None:
|
||||
return None
|
||||
try:
|
||||
return dist.name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _distribution_version(dist: importlib.metadata.Distribution) -> str:
|
||||
try:
|
||||
return dist.version or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _distribution_origin(dist: importlib.metadata.Distribution) -> str:
|
||||
"""Describe where a plugin package was installed from."""
|
||||
direct_url = distribution_direct_url(dist)
|
||||
if direct_url is None:
|
||||
return "pypi"
|
||||
|
||||
vcs_info = direct_url.get("vcs_info")
|
||||
if isinstance(vcs_info, dict):
|
||||
commit = vcs_info.get("commit_id") or ""
|
||||
if commit:
|
||||
return f"git+{commit[:SHORT_COMMIT_LENGTH]}"
|
||||
return "git"
|
||||
|
||||
return "local"
|
||||
|
||||
|
||||
def _contributed_modules(
|
||||
entry_points: list[importlib.metadata.EntryPoint], distribution: str
|
||||
) -> str:
|
||||
"""Count the forensic modules a plugin package contributes.
|
||||
|
||||
Entry points are resolved the way MVT resolves them when it loads
|
||||
modules, but a broken entry point is reported instead of raising: listing
|
||||
the installed plugins must work even when one of them is faulty.
|
||||
"""
|
||||
count = 0
|
||||
broken = False
|
||||
|
||||
for entry_point in entry_points:
|
||||
if _entry_point_distribution(entry_point) != distribution:
|
||||
continue
|
||||
try:
|
||||
loaded = entry_point.load()
|
||||
if callable(loaded) and not isinstance(loaded, type):
|
||||
loaded = loaded()
|
||||
count += sum(
|
||||
1
|
||||
for module in loaded
|
||||
if isinstance(module, type) and issubclass(module, MVTModule)
|
||||
)
|
||||
except (Exception, SystemExit) as exc:
|
||||
log.debug(
|
||||
"Unable to load the modules of entry point %s (%s): %s",
|
||||
entry_point.name,
|
||||
entry_point.value,
|
||||
exc,
|
||||
)
|
||||
broken = True
|
||||
|
||||
if broken:
|
||||
return f"{count} (error)" if count else "error"
|
||||
|
||||
return str(count)
|
||||
|
||||
|
||||
def _contributed_commands(
|
||||
entry_points: list[importlib.metadata.EntryPoint], distribution: str
|
||||
) -> str:
|
||||
names = {
|
||||
entry_point.name
|
||||
for entry_point in entry_points
|
||||
if _entry_point_distribution(entry_point) == distribution
|
||||
}
|
||||
|
||||
return ", ".join(sorted(names)) if names else "-"
|
||||
|
||||
|
||||
@click.group("plugins", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS)
|
||||
def plugins() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@plugins.command("list", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS_LIST)
|
||||
def list_plugins() -> None:
|
||||
distributions = installed_plugin_distributions()
|
||||
if not distributions:
|
||||
click.echo("No MVT plugins are installed.")
|
||||
return
|
||||
|
||||
module_entry_points = _entry_points(MODULES_ENTRY_POINT_GROUP)
|
||||
command_entry_points = []
|
||||
for group in CLI_PLUGIN_GROUPS:
|
||||
command_entry_points.extend(_entry_points(group))
|
||||
|
||||
table = Table(title="Installed MVT plugins")
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Version")
|
||||
table.add_column("Origin")
|
||||
table.add_column("Modules", justify="right")
|
||||
table.add_column("Commands")
|
||||
|
||||
for dist in distributions:
|
||||
name = dist.name
|
||||
table.add_row(
|
||||
name,
|
||||
_distribution_version(dist),
|
||||
_distribution_origin(dist),
|
||||
_contributed_modules(module_entry_points, name),
|
||||
_contributed_commands(command_entry_points, name),
|
||||
)
|
||||
|
||||
Console().print(table)
|
||||
|
||||
|
||||
@plugins.command(
|
||||
"check-updates",
|
||||
context_settings=CONTEXT_SETTINGS,
|
||||
help=HELP_MSG_PLUGINS_CHECK_UPDATES,
|
||||
short_help="Check the installed plugins for updates",
|
||||
)
|
||||
def check_plugin_updates() -> None:
|
||||
if not settings.NETWORK_ACCESS_ALLOWED:
|
||||
click.echo(
|
||||
"Network access is disabled, cannot check for plugin updates. "
|
||||
"Enable NETWORK_ACCESS_ALLOWED in the MVT configuration to check."
|
||||
)
|
||||
return
|
||||
|
||||
if not installed_plugin_distributions():
|
||||
click.echo("No MVT plugins are installed.")
|
||||
return
|
||||
|
||||
findings = PluginUpdates().check()
|
||||
if not findings:
|
||||
click.echo("All plugins are up to date.")
|
||||
return
|
||||
|
||||
click.echo("Plugin updates available:")
|
||||
for finding in findings:
|
||||
click.echo(f" {finding['name']} {finding['installed']} → {finding['latest']}")
|
||||
click.echo(f" Upgrade with: {finding['upgrade_command']}")
|
||||
|
||||
click.echo(
|
||||
"\nMVT does not install plugin updates. Run the command above when you "
|
||||
"decide to upgrade."
|
||||
)
|
||||
@@ -24,6 +24,11 @@ HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check"
|
||||
HELP_MSG_COMPLETION = (
|
||||
"Generate or install shell completion for mvt, mvt-ios and mvt-android"
|
||||
)
|
||||
HELP_MSG_PLUGINS = "Inspect the installed MVT plugin packages"
|
||||
HELP_MSG_PLUGINS_LIST = "List the installed plugins and what they contribute to MVT"
|
||||
HELP_MSG_PLUGINS_CHECK_UPDATES = (
|
||||
"Check the installed plugins for updates without installing them"
|
||||
)
|
||||
|
||||
# IOS Specific
|
||||
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.android.cli import cli as android_cli
|
||||
from mvt.cli import cli as mvt_cli
|
||||
from mvt.common.cli_plugins import (
|
||||
ANDROID_CLI_PLUGIN_GROUP,
|
||||
IOS_CLI_PLUGIN_GROUP,
|
||||
NEUTRAL_CLI_PLUGIN_GROUP,
|
||||
)
|
||||
from mvt.common.cmd_plugins import plugins
|
||||
from mvt.common.module import MVTModule
|
||||
from mvt.common.module_loader import MODULES_ENTRY_POINT_GROUP
|
||||
from mvt.common.updates import PluginUpdates
|
||||
from mvt.ios.cli import cli as ios_cli
|
||||
|
||||
|
||||
class ExampleModule(MVTModule):
|
||||
pass
|
||||
|
||||
|
||||
class AnotherModule(MVTModule):
|
||||
pass
|
||||
|
||||
|
||||
class FakeDistribution:
|
||||
def __init__(self, name, version="1.0.0", direct_url=None):
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.direct_url = direct_url
|
||||
|
||||
def read_text(self, file_name):
|
||||
if file_name == "direct_url.json" and self.direct_url is not None:
|
||||
return json.dumps(self.direct_url)
|
||||
return None
|
||||
|
||||
|
||||
def _entry_point(name, distribution, modules=None, exception=None):
|
||||
def load():
|
||||
if exception is not None:
|
||||
raise exception
|
||||
return modules
|
||||
|
||||
return SimpleNamespace(
|
||||
name=name, value="example_plugin:modules", dist=distribution, load=load
|
||||
)
|
||||
|
||||
|
||||
def _run(command, arguments):
|
||||
# Keep rich from wrapping the table while its content is being asserted.
|
||||
return CliRunner().invoke(command, arguments, env={"COLUMNS": "200"})
|
||||
|
||||
|
||||
def _table_rows(output):
|
||||
"""Return the content of the table rows, without the header and the box."""
|
||||
return [
|
||||
[cell.strip() for cell in line.strip().strip("│").split("│")]
|
||||
for line in output.splitlines()
|
||||
if "│" in line
|
||||
]
|
||||
|
||||
|
||||
def _table_header(output):
|
||||
for line in output.splitlines():
|
||||
if "┃" in line:
|
||||
return [cell.strip() for cell in line.strip().strip("┃").split("┃")]
|
||||
return []
|
||||
|
||||
|
||||
def _install(monkeypatch, distributions, entry_points):
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cmd_plugins.installed_plugin_distributions",
|
||||
lambda: distributions,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cmd_plugins.importlib.metadata.entry_points",
|
||||
lambda *, group: entry_points.get(group, []),
|
||||
)
|
||||
|
||||
|
||||
def test_plugins_is_a_builtin_command_of_the_mvt_cli_only():
|
||||
assert mvt_cli.commands["plugins"] is plugins
|
||||
assert "plugins" not in ios_cli.commands
|
||||
assert "plugins" not in android_cli.commands
|
||||
|
||||
|
||||
def test_list_shows_what_every_plugin_contributes(monkeypatch):
|
||||
index_plugin = FakeDistribution("example-plugin", version="1.2.0")
|
||||
repository_plugin = FakeDistribution(
|
||||
"repository-plugin",
|
||||
version="0.1.0",
|
||||
direct_url={
|
||||
"url": "https://example.org/plugin.git",
|
||||
"vcs_info": {"vcs": "git", "commit_id": "b" * 40},
|
||||
},
|
||||
)
|
||||
local_plugin = FakeDistribution(
|
||||
"local-plugin",
|
||||
direct_url={"url": "file:///plugins", "dir_info": {"editable": True}},
|
||||
)
|
||||
_install(
|
||||
monkeypatch,
|
||||
[index_plugin, local_plugin, repository_plugin],
|
||||
{
|
||||
MODULES_ENTRY_POINT_GROUP: [
|
||||
_entry_point(
|
||||
"example", index_plugin, modules=[ExampleModule, AnotherModule]
|
||||
),
|
||||
_entry_point("local", local_plugin, modules=lambda: [ExampleModule]),
|
||||
],
|
||||
IOS_CLI_PLUGIN_GROUP: [_entry_point("summarize", repository_plugin)],
|
||||
ANDROID_CLI_PLUGIN_GROUP: [_entry_point("triage", local_plugin)],
|
||||
NEUTRAL_CLI_PLUGIN_GROUP: [_entry_point("report", repository_plugin)],
|
||||
},
|
||||
)
|
||||
|
||||
result = _run(plugins, ["list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Plugins are listed by name, with the modules and the commands each of
|
||||
# them contributes.
|
||||
assert _table_header(result.output) == [
|
||||
"Name",
|
||||
"Version",
|
||||
"Origin",
|
||||
"Modules",
|
||||
"Commands",
|
||||
]
|
||||
assert _table_rows(result.output) == [
|
||||
["example-plugin", "1.2.0", "pypi", "2", "-"],
|
||||
["local-plugin", "1.0.0", "local", "1", "triage"],
|
||||
["repository-plugin", "0.1.0", "git+bbbbbbbb", "0", "report, summarize"],
|
||||
]
|
||||
|
||||
|
||||
def test_list_reports_a_broken_module_entry_point(monkeypatch):
|
||||
plugin = FakeDistribution("broken-plugin")
|
||||
_install(
|
||||
monkeypatch,
|
||||
[plugin],
|
||||
{
|
||||
MODULES_ENTRY_POINT_GROUP: [
|
||||
_entry_point(
|
||||
"broken", plugin, exception=ImportError("missing dependency")
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = _run(plugins, ["list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert _table_rows(result.output) == [
|
||||
["broken-plugin", "1.0.0", "pypi", "error", "-"]
|
||||
]
|
||||
|
||||
|
||||
def test_list_without_plugins(monkeypatch):
|
||||
_install(monkeypatch, [], {})
|
||||
|
||||
result = _run(plugins, ["list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "No MVT plugins are installed.\n"
|
||||
|
||||
|
||||
def test_check_updates_prints_the_findings_and_ignores_the_throttle(monkeypatch):
|
||||
findings = [
|
||||
{
|
||||
"name": "example-plugin",
|
||||
"installed": "1.0.0",
|
||||
"latest": "1.2.0",
|
||||
"origin": "pypi",
|
||||
"upgrade_command": "pip install -U example-plugin",
|
||||
}
|
||||
]
|
||||
_install(monkeypatch, [FakeDistribution("example-plugin")], {})
|
||||
monkeypatch.setattr(PluginUpdates, "check", lambda self: findings)
|
||||
monkeypatch.setattr(
|
||||
PluginUpdates,
|
||||
"should_check",
|
||||
lambda self: pytest.fail("an explicit check must not be throttled"),
|
||||
)
|
||||
|
||||
result = _run(plugins, ["check-updates"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Plugin updates available:" in result.output
|
||||
assert "example-plugin 1.0.0 → 1.2.0" in result.output
|
||||
assert "Upgrade with: pip install -U example-plugin" in result.output
|
||||
assert "MVT does not install plugin updates." in result.output
|
||||
|
||||
|
||||
def test_check_updates_without_available_updates(monkeypatch):
|
||||
_install(monkeypatch, [FakeDistribution("example-plugin")], {})
|
||||
monkeypatch.setattr(PluginUpdates, "check", lambda self: [])
|
||||
|
||||
result = _run(plugins, ["check-updates"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "All plugins are up to date." in result.output
|
||||
|
||||
|
||||
def test_check_updates_without_plugins(monkeypatch):
|
||||
_install(monkeypatch, [], {})
|
||||
monkeypatch.setattr(
|
||||
PluginUpdates,
|
||||
"check",
|
||||
lambda self: pytest.fail("nothing must be checked without plugins"),
|
||||
)
|
||||
|
||||
result = _run(plugins, ["check-updates"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No MVT plugins are installed." in result.output
|
||||
|
||||
|
||||
def test_check_updates_without_network_access(monkeypatch):
|
||||
monkeypatch.setattr("mvt.common.cmd_plugins.settings.NETWORK_ACCESS_ALLOWED", False)
|
||||
monkeypatch.setattr(
|
||||
"mvt.common.cmd_plugins.installed_plugin_distributions",
|
||||
lambda: pytest.fail("plugins must not be listed without network access"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
PluginUpdates,
|
||||
"check",
|
||||
lambda self: pytest.fail("nothing must be checked without network access"),
|
||||
)
|
||||
|
||||
result = _run(plugins, ["check-updates"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Network access is disabled" in result.output
|
||||
Reference in New Issue
Block a user