diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9c20459d..c5652c87 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,15 +19,15 @@ on: lrr_repository: description: 'LANraragi repository path' required: true - default: 'difegue/LANraragi' + default: 'psilabs-dev/LANraragi' lrr_ref: description: 'LANraragi ref (branch, tag, or commit SHA)' required: true - default: 'dev' + default: 'dev-registry/backend' env: - LRR_REPOSITORY: ${{ github.event.inputs.lrr_repository || 'difegue/LANraragi' }} - LRR_REF: ${{ github.event.inputs.lrr_ref || 'dev' }} + LRR_REPOSITORY: ${{ github.event.inputs.lrr_repository || 'psilabs-dev/LANraragi' }} + LRR_REF: ${{ github.event.inputs.lrr_ref || 'dev-registry/backend' }} jobs: @@ -104,7 +104,8 @@ jobs: --server-logs "$GITHUB_WORKSPACE/server-logs" \ --playwright \ --no-rate-limit \ - --npseed 42 + --npseed 42 \ + --dev registry env: DOCKER_HOST: unix:///var/run/docker.sock @@ -264,7 +265,8 @@ jobs: --server-logs "$env:GITHUB_WORKSPACE\server-logs" ` --playwright ` --no-rate-limit ` - --npseed 42 + --npseed 42 ` + --dev registry - name: Upload server logs if: failure() diff --git a/integration_tests/src/aio_lanraragi_tests/deployment/base.py b/integration_tests/src/aio_lanraragi_tests/deployment/base.py index 944853ae..cf8d90d2 100644 --- a/integration_tests/src/aio_lanraragi_tests/deployment/base.py +++ b/integration_tests/src/aio_lanraragi_tests/deployment/base.py @@ -176,6 +176,22 @@ def redis_dir(self) -> Path: redis_dirname = self.resource_prefix + "redis" return self.staging_dir / redis_dirname + @property + def shared_dir(self) -> Path: + """ + Host directory bind-mounted into LRR, for test fixtures that LRR must be + able to read. Contents are visible to LRR at + ``lrr_mount_path``. + """ + return self.staging_dir / (self.resource_prefix + "shared") + + @abc.abstractmethod + def lrr_mount_path(self, host_path: Path) -> str: + """ + Map a host path under ``shared_dir`` to the path at which LRR reads it + (a container path under Docker, the host path itself on native runs). + """ + @property def redis_client(self) -> redis.Redis: """ diff --git a/integration_tests/src/aio_lanraragi_tests/deployment/container.py b/integration_tests/src/aio_lanraragi_tests/deployment/container.py index 866455ef..9e87c30b 100644 --- a/integration_tests/src/aio_lanraragi_tests/deployment/container.py +++ b/integration_tests/src/aio_lanraragi_tests/deployment/container.py @@ -35,6 +35,7 @@ from aio_lanraragi_tests.utils.docker import set_pdeathsig DEFAULT_LANRARAGI_DOCKER_TAG = "difegue/lanraragi" +LRR_SHARED_CONTAINER_PATH = "/srv/shared" LOGGER = logging.getLogger(__name__) @@ -223,6 +224,26 @@ def plugins_root_dir(self) -> Path: dirname = self.resource_prefix + "plugins" return self.staging_dir / dirname + @property + def plugin_managed_dir(self) -> Path: + """ + Bind mount for LRR container:/home/koyomi/lanraragi/lib/LANraragi/Plugin/Managed. + """ + dirname = self.resource_prefix + "plugin_managed" + return self.staging_dir / dirname + + @property + def plugin_sideloaded_dir(self) -> Path: + """ + Bind mount for LRR container:/home/koyomi/lanraragi/lib/LANraragi/Plugin/Sideloaded. + """ + dirname = self.resource_prefix + "plugin_sideloaded" + return self.staging_dir / dirname + + def lrr_mount_path(self, host_path: Path) -> str: + rel = Path(host_path).relative_to(self.shared_dir) + return f"{LRR_SHARED_CONTAINER_PATH}/{rel.as_posix()}" + @property def docker_client(self) -> docker.DockerClient: return self._docker_client @@ -516,6 +537,9 @@ def setup( thumb_dir = self.thumb_dir logs_dir = self.logs_dir redis_dir = self.redis_dir + plugin_managed_dir = self.plugin_managed_dir + plugin_sideloaded_dir = self.plugin_sideloaded_dir + shared_dir = self.shared_dir if contents_dir.exists(): self.logger.debug(f"Contents directory exists: {contents_dir}") else: @@ -541,6 +565,27 @@ def setup( # newly created directory before it is used as a bind mount source. if sys.platform == "darwin": time.sleep(1) + if plugin_managed_dir.exists(): + self.logger.debug(f"Plugin managed directory exists: {plugin_managed_dir}") + else: + self.logger.debug(f"Creating plugin managed dir: {plugin_managed_dir}") + plugin_managed_dir.mkdir(parents=True, exist_ok=False) + if sys.platform == "darwin": + time.sleep(1) + if plugin_sideloaded_dir.exists(): + self.logger.debug(f"Plugin sideloaded directory exists: {plugin_sideloaded_dir}") + else: + self.logger.debug(f"Creating plugin sideloaded dir: {plugin_sideloaded_dir}") + plugin_sideloaded_dir.mkdir(parents=True, exist_ok=False) + if sys.platform == "darwin": + time.sleep(1) + if shared_dir.exists(): + self.logger.debug(f"Shared directory exists: {shared_dir}") + else: + self.logger.debug(f"Creating shared dir: {shared_dir}") + shared_dir.mkdir(parents=True, exist_ok=False) + if sys.platform == "darwin": + time.sleep(1) # log the setup resource allocations for user to see # the docker image is not included, haven't decided how to classify it yet. @@ -716,6 +761,9 @@ def setup( str(contents_dir): {"bind": "/home/koyomi/lanraragi/content", "mode": "rw"}, str(thumb_dir): {"bind": "/home/koyomi/lanraragi/thumb", "mode": "rw"}, str(logs_dir): {"bind": "/home/koyomi/lanraragi/log", "mode": "rw"}, + str(plugin_managed_dir): {"bind": "/home/koyomi/lanraragi/lib/LANraragi/Plugin/Managed", "mode": "rw"}, + str(plugin_sideloaded_dir): {"bind": "/home/koyomi/lanraragi/lib/LANraragi/Plugin/Sideloaded", "mode": "rw"}, + str(self.shared_dir): {"bind": LRR_SHARED_CONTAINER_PATH, "mode": "ro"}, } lrr_volumes.update(plugin_volumes) self.lrr_container = self.docker_client.containers.create( @@ -910,6 +958,8 @@ def _reset_test_env(self, remove_data: bool=False): self.lrr_container.exec_run(["sh", "-c", 'rm -rf /home/koyomi/lanraragi/content/*'], user='root') self.lrr_container.exec_run(["sh", "-c", 'rm -rf /home/koyomi/lanraragi/thumb/*'], user='root') self.lrr_container.exec_run(["sh", "-c", 'rm -rf /home/koyomi/lanraragi/log/*'], user='root') + self.lrr_container.exec_run(["sh", "-c", 'rm -rf /home/koyomi/lanraragi/lib/LANraragi/Plugin/Managed/*'], user='root') + self.lrr_container.exec_run(["sh", "-c", 'rm -rf /home/koyomi/lanraragi/lib/LANraragi/Plugin/Sideloaded/*'], user='root') else: self.logger.info(f"Container not running with status {status} (no teardown commands run): {self.lrr_container_name}") if self.lrr_container: @@ -948,6 +998,15 @@ def _reset_test_env(self, remove_data: bool=False): if self.plugins_root_dir.exists(): shutil.rmtree(self.plugins_root_dir) self.logger.debug(f"Removed plugins directory: {self.plugins_root_dir}") + if self.plugin_managed_dir.exists(): + shutil.rmtree(self.plugin_managed_dir) + self.logger.debug(f"Removed plugin managed directory: {self.plugin_managed_dir}") + if self.plugin_sideloaded_dir.exists(): + shutil.rmtree(self.plugin_sideloaded_dir) + self.logger.debug(f"Removed plugin sideloaded directory: {self.plugin_sideloaded_dir}") + if self.shared_dir.exists(): + shutil.rmtree(self.shared_dir) + self.logger.debug(f"Removed shared directory: {self.shared_dir}") redis_conf_staging = self.staging_dir / (self.resource_prefix + "redis.conf") if redis_conf_staging.exists(): redis_conf_staging.unlink() diff --git a/integration_tests/src/aio_lanraragi_tests/deployment/windows.py b/integration_tests/src/aio_lanraragi_tests/deployment/windows.py index c6f49ffa..5e4d83d8 100644 --- a/integration_tests/src/aio_lanraragi_tests/deployment/windows.py +++ b/integration_tests/src/aio_lanraragi_tests/deployment/windows.py @@ -168,6 +168,17 @@ def lrr_lanraragi_path(self) -> Path: def lrr_plugin_dir(self) -> Path: return self.windist_dir / "lib" / "LANraragi" / "Plugin" + @property + def plugin_managed_dir(self) -> Path: + return self.lrr_plugin_dir / "Managed" + + @property + def plugin_sideloaded_dir(self) -> Path: + return self.lrr_plugin_dir / "Sideloaded" + + def lrr_mount_path(self, host_path: Path) -> str: + return str(host_path) + def __init__( self, windist_path: str, staging_directory: str, resource_prefix: str, port_offset: int, logger: logging.Logger | None=None @@ -244,6 +255,7 @@ def setup( log_dir = self.logs_dir pid_dir = self.pid_dir redis_dir = self.redis_dir + shared_dir = self.shared_dir if contents_dir.exists(): self.logger.debug(f"Contents directory exists: {contents_dir}") else: @@ -274,6 +286,11 @@ def setup( else: self.logger.debug(f"Creating Redis directory: {redis_dir}") redis_dir.mkdir(parents=True, exist_ok=False) + if shared_dir.exists(): + self.logger.debug(f"Shared directory exists: {shared_dir}") + else: + self.logger.debug(f"Creating shared directory: {shared_dir}") + shared_dir.mkdir(parents=True, exist_ok=False) # we need to handle cases where existing services are running. # Unlike docker, we have no idea whether we can skip recreation of @@ -391,6 +408,7 @@ def teardown(self, remove_data: bool=False): windist_dir = self.windist_dir redis_dir = self.redis_dir temp_dir = self.temp_dir + shared_dir = self.shared_dir self.stop() if hasattr(self, "_redis_client") and self._redis_client is not None: self._redis_client.close() @@ -419,6 +437,10 @@ def teardown(self, remove_data: bool=False): self._remove_ro(temp_dir) shutil.rmtree(temp_dir) self.logger.debug(f"Removed temp directory: {temp_dir}") + if shared_dir.exists(): + self._remove_ro(shared_dir) + shutil.rmtree(shared_dir) + self.logger.debug(f"Removed shared directory: {shared_dir}") @override def start_lrr(self): diff --git a/integration_tests/src/aio_lanraragi_tests/registries/__init__.py b/integration_tests/src/aio_lanraragi_tests/registries/__init__.py new file mode 100644 index 00000000..5e81f8a6 --- /dev/null +++ b/integration_tests/src/aio_lanraragi_tests/registries/__init__.py @@ -0,0 +1,3 @@ +""" +Module for registry data structures, APIs, and utilities. +""" diff --git a/integration_tests/src/aio_lanraragi_tests/registries/base.py b/integration_tests/src/aio_lanraragi_tests/registries/base.py new file mode 100644 index 00000000..e8ff2b8a --- /dev/null +++ b/integration_tests/src/aio_lanraragi_tests/registries/base.py @@ -0,0 +1,80 @@ + +import abc +import hashlib + +REGISTRY_SCHEMA_VERSION = 1 + + +class AbstractRegistry(abc.ABC): + + def __init__(self, generated_at: str = "2026-01-01T00:00:00Z"): + self._generated_at = generated_at + # namespace -> {"type": str, "versions": {version: version_record}} + self._plugins: dict[str, dict] = {} + # (namespace, version) -> artifact bytes staged for generate_manifest() + self._artifacts: dict[tuple[str, str], bytes] = {} + + def add_plugin( + self, + namespace: str, + plugin_type: str, + version: str, + *, + name: str, + author: str, + description: str, + artifact_content: bytes | str | None = None, + artifact_relpath: str | None = None, + published_at: str | None = None, + sha256: str | None = None, + ) -> None: + artifact = artifact_relpath or f"artifacts/{namespace}/{version}/{namespace}.pm" + if artifact_content is not None: + content = artifact_content.encode("utf-8") if isinstance(artifact_content, str) else artifact_content + self._artifacts[(namespace, version)] = content + if sha256 is None: + sha256 = hashlib.sha256(content).hexdigest() + elif sha256 is None: + raise ValueError("sha256 must be provided when artifact_content is None") + + record = self._plugins.setdefault(namespace, {"type": plugin_type, "versions": {}}) + record["type"] = plugin_type + record["versions"][version] = { + "version": version, + "name": name, + "author": author, + "description": description, + "artifact": artifact, + "sha256": sha256, + "published_at": published_at or self._generated_at, + } + + def remove_plugin(self, namespace: str, version: str | None = None) -> None: + if namespace not in self._plugins: + return + if version is None: + del self._plugins[namespace] + self._artifacts = {k: v for k, v in self._artifacts.items() if k[0] != namespace} + return + self._plugins[namespace]["versions"].pop(version, None) + self._artifacts.pop((namespace, version), None) + if not self._plugins[namespace]["versions"]: + del self._plugins[namespace] + + def manifest(self) -> dict: + return { + "version": REGISTRY_SCHEMA_VERSION, + "generated_at": self._generated_at, + "plugins": { + namespace: { + "namespace": namespace, + "type": record["type"], + "versions": record["versions"], + } + for namespace, record in self._plugins.items() + }, + } + + @abc.abstractmethod + def generate_manifest(self) -> None: + """Flush the manifest and any staged artifacts to the registry's backing store.""" diff --git a/integration_tests/src/aio_lanraragi_tests/registries/local_registry.py b/integration_tests/src/aio_lanraragi_tests/registries/local_registry.py new file mode 100644 index 00000000..274b2cf3 --- /dev/null +++ b/integration_tests/src/aio_lanraragi_tests/registries/local_registry.py @@ -0,0 +1,30 @@ + +import json +from pathlib import Path + +from aio_lanraragi_tests.registries.base import AbstractRegistry + + +class LocalRegistry(AbstractRegistry): + + def __init__(self, name: str, root: Path): + super().__init__() + self.name = name + self._root = Path(root) + + @property + def root(self) -> Path: + return self._root + + @property + def registry_json_path(self) -> Path: + return self._root / "registry.json" + + def generate_manifest(self) -> None: + self._root.mkdir(parents=True, exist_ok=True) + for (namespace, version), content in self._artifacts.items(): + relpath = self._plugins[namespace]["versions"][version]["artifact"] + artifact_path = self._root / relpath + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_bytes(content) + self.registry_json_path.write_text(json.dumps(self.manifest()), encoding="utf-8") diff --git a/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py b/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py index 4bf20773..b884a43d 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py @@ -28,7 +28,12 @@ GetCategoryResponse, RemoveArchiveFromCategoryRequest, ) -from lanraragi.models.minion import GetMinionJobStatusRequest +from lanraragi.models.minion import GetMinionJobDetailRequest, GetMinionJobStatusRequest +from lanraragi.models.misc import ( + CreateRegistryRequest, + InstallPluginRequest, + InstallPluginResponse, +) from aio_lanraragi_tests.archive_generation.archive import write_archives_to_disk from aio_lanraragi_tests.archive_generation.enums import ArchivalStrategyEnum @@ -41,6 +46,9 @@ WriteArchiveResponse, ) from aio_lanraragi_tests.common import compute_upload_checksum +from aio_lanraragi_tests.deployment.base import AbstractLRRDeploymentContext +from aio_lanraragi_tests.registries.base import AbstractRegistry +from aio_lanraragi_tests.registries.local_registry import LocalRegistry from aio_lanraragi_tests.utils.concurrency import retry_on_lock LOGGER = logging.getLogger(__name__) @@ -413,3 +421,69 @@ async def trigger_stat_rebuild(lrr_client: LRRClient, timeout_seconds: int = 60) elif state == "failed": raise AssertionError("build_stat_hashes job failed") await asyncio.sleep(0.5) + + +async def install_plugin_and_wait( + lrr_client: LRRClient, request: InstallPluginRequest, timeout_seconds: int = 60 +) -> tuple[InstallPluginResponse | None, LanraragiErrorResponse | None]: + """Enqueue a plugin install and wait for its Minion job, returning the result or an error.""" + job_id, error = await lrr_client.misc_api.install_plugin(request) + if error is not None: + return (None, error) + + start_time = time.time() + while True: + assert time.time() - start_time < timeout_seconds, f"install_plugin timed out after {timeout_seconds}s" + detail, detail_error = await lrr_client.minion_api.get_minion_job_details( + GetMinionJobDetailRequest(job_id=job_id) + ) + assert not detail_error, f"Failed to get install job details: {detail_error.error}" + state = detail.state.lower() + if state == "finished": + result = detail.result + if result and result.success: + data = result.data or {} + return (InstallPluginResponse( + name=data["name"], + namespace=data["namespace"], + version=data["version"], + registry=data["registry"], + sha256=data["sha256"], + ), None) + message = result.error if result and result.error else "install failed" + return (None, LanraragiErrorResponse(error=message, status=200)) + if state == "failed": + result = detail.result + message = result.error if result and result.error else "install job failed" + return (None, LanraragiErrorResponse(error=message, status=500)) + await asyncio.sleep(0.5) + +async def add_registry( + client: LRRClient, + deployment: AbstractLRRDeploymentContext, + registry: AbstractRegistry, + *, + refresh: bool = False, +) -> str: + """ + Register a registry with LRR and return its id. ``refresh`` is opt-in + because a registry's ``registry.json`` may not exist yet at creation time. + """ + if isinstance(registry, LocalRegistry): + create_request = CreateRegistryRequest( + name=registry.name, provider="local", path=deployment.lrr_mount_path(registry.root) + ) + else: + raise NotImplementedError(f"add_registry does not support {type(registry).__name__}") + + response, error = await client.misc_api.create_registry(create_request) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + registry_id = response.id + + if refresh: + _, refresh_error = await client.misc_api.refresh_registry(registry_id) + assert not refresh_error, ( + f"Failed to refresh registry (status {refresh_error.status}): {refresh_error.error}" + ) + + return registry_id diff --git a/integration_tests/tests/registry/__init__.py b/integration_tests/tests/registry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/integration_tests/tests/registry/conftest.py b/integration_tests/tests/registry/conftest.py new file mode 100644 index 00000000..f7f9bd00 --- /dev/null +++ b/integration_tests/tests/registry/conftest.py @@ -0,0 +1,42 @@ +import logging +from collections.abc import AsyncGenerator, Generator + +import pytest +import pytest_asyncio +from lanraragi.clients.client import LRRClient + +from aio_lanraragi_tests.common import DEFAULT_API_KEY +from aio_lanraragi_tests.deployment.base import AbstractLRRDeploymentContext +from aio_lanraragi_tests.deployment.factory import generate_deployment + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture +def resource_prefix(request: pytest.FixtureRequest) -> Generator[str, None, None]: + yield request.config.getoption("--resource-prefix") + "test_" + + +@pytest.fixture +def port_offset(request: pytest.FixtureRequest) -> Generator[int, None, None]: + yield request.config.getoption("--port-offset") + 10 + + +@pytest.fixture +def environment(request: pytest.FixtureRequest, resource_prefix: str, port_offset: int): + env: AbstractLRRDeploymentContext = generate_deployment(request, resource_prefix, port_offset, logger=LOGGER) + request.session.lrr_environments = {resource_prefix: env} + try: + yield env + finally: + env.teardown(remove_data=True) + + +@pytest_asyncio.fixture +async def lrr_client(environment: AbstractLRRDeploymentContext) -> AsyncGenerator[LRRClient, None]: + client = environment.lrr_client() + client.update_api_key(DEFAULT_API_KEY) + try: + yield client + finally: + await client.close() diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py new file mode 100644 index 00000000..3017acca --- /dev/null +++ b/integration_tests/tests/registry/test_local_registry.py @@ -0,0 +1,1438 @@ +""" +Local-registry validation, orphan, and install error paths. +""" + +import asyncio +import hashlib +import json +import logging +import tempfile +import time +from pathlib import Path + +import pytest +from lanraragi.clients.client import LRRClient +from lanraragi.models.misc import ( + CreateRegistryRequest, + GetAvailablePluginsRequest, + InstallPluginRequest, + UsePluginRequest, +) + +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) +from aio_lanraragi_tests.utils.api_wrappers import ( + create_archive_file, + install_plugin_and_wait, + upload_archive, +) + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_local_registry_install_errors( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + Test local-registry refresh validation and install error paths. + + Single environment; registry.json is rotated between stages. + + 1. Malformed plugins field: refresh 400. + 2. Missing generated_at: refresh 400. + 3. Unknown plugin field: refresh 400. + 4. Invalid published_at: refresh 400. + 5. Invalid sha256 format: refresh 400. + 6. Uppercase sha256: refresh 400 (canonical form is lowercase). + 7. Traversal path: refresh 400. + 8. Absolute path: refresh 400. + 9. Symlink escape path: install 400. + 10. Wrong sha256: install 422, target path absent. + 11. Correct sha256: install 200, file present on host. + """ + environment.setup(with_api_key=True) + + registry_json = environment.local_registry_dir / "registry.json" + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="local-test", + provider="local", + path=environment.local_registry_path, + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + dummy_sha = "00" * 32 + + # >>>>> MALFORMED PLUGINS FIELD >>>>> + registry_json.write_text(json.dumps({"version": 1, "plugins": "not-an-object"})) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail with malformed plugins field" + assert error.status == 400, f"Expected 400 for malformed plugins, got {error.status}" + # <<<<< MALFORMED PLUGINS FIELD <<<<< + + # >>>>> MISSING generated_at >>>>> + registry_json.write_text(json.dumps({"version": 1, "plugins": {}})) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail without generated_at" + assert error.status == 400, f"Expected 400 for missing generated_at, got {error.status}" + # <<<<< MISSING generated_at <<<<< + + # >>>>> UNKNOWN PLUGIN FIELD >>>>> + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "unknown-field-plugin": { + "namespace": "unknown-field-plugin", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "unknown", + "author": "test", + "description": "unknown field test plugin", + "artifact": "artifacts/unknown/1.0.0/Unknown.pm", + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, + "unexpected": "boom", + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail on unknown plugin field" + assert error.status == 400, f"Expected 400 for unknown plugin field, got {error.status}" + # <<<<< UNKNOWN PLUGIN FIELD <<<<< + + # >>>>> INVALID published_at >>>>> + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "bad-published-at": { + "namespace": "bad-published-at", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "bad-published-at", + "author": "test", + "description": "bad published_at test plugin", + "artifact": "artifacts/bad-published-at/1.0.0/BadPublishedAt.pm", + "sha256": dummy_sha, + "published_at": "2026-04-25T10:00:00+01:00", + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail on invalid published_at" + assert error.status == 400, f"Expected 400 for invalid published_at, got {error.status}" + # <<<<< INVALID published_at <<<<< + + # >>>>> INVALID SHA256 FORMAT >>>>> + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "bad-sha": { + "namespace": "bad-sha", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "bad-sha", + "author": "test", + "description": "bad sha test plugin", + "artifact": "artifacts/bad-sha/1.0.0/BadSha.pm", + "sha256": "xyz", + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail on invalid sha256 format" + assert error.status == 400, f"Expected 400 for invalid sha256 format, got {error.status}" + # <<<<< INVALID SHA256 FORMAT <<<<< + + # >>>>> UPPERCASE SHA256 REJECTED AT REFRESH >>>>> + # User expectation (publisher-facing): LRR and registry publishers agree on a + # single canonical lowercase form for SHA-256. A manifest that deviates is + # rejected at refresh with a clear error, not silently accepted into the + # cached index where it would later cause a misleading integrity-mismatch + # at install time. + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "uppercase-sha": { + "namespace": "uppercase-sha", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "uppercase-sha", + "author": "test", + "description": "uppercase sha format test", + "artifact": "artifacts/uppercase-sha/1.0.0/UppercaseSha.pm", + "sha256": "AB" * 32, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail on uppercase sha256" + assert error.status == 400, f"Expected 400 for uppercase sha256, got {error.status}" + # <<<<< UPPERCASE SHA256 REJECTED AT REFRESH <<<<< + + # >>>>> UNKNOWN ROOT FIELD REJECTED AT REFRESH >>>>> + # User expectation: registry manifests follow a strict schema. An unknown + # field at the root level (e.g. a typo or an experimental publisher + # extension) is rejected at refresh, not silently accepted. + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": {}, + "generator": "publisher-tool-v9", + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail on unknown root field" + assert error.status == 400, f"Expected 400 for unknown root field, got {error.status}" + # <<<<< UNKNOWN ROOT FIELD REJECTED AT REFRESH <<<<< + + # >>>>> VERSION KEY DIVERGES FROM INNER VERSION REJECTED AT REFRESH >>>>> + # User expectation: in a registry manifest, the version key (e.g. "1.0.0") + # must equal the inner `version` field of that record. Otherwise the + # version selected by `resolve_max_version` (which sorts on outer keys) + # would not equal the version recorded in install provenance — admins + # could not reliably reason about which version is installed. + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "version-key-mismatch": { + "namespace": "version-key-mismatch", + "type": "download", + "versions": { + "1.0.0": { + "version": "9.9.9", + "name": "version-key-mismatch", + "author": "test", + "description": "outer key vs inner version mismatch", + "artifact": "artifacts/version-key-mismatch/1.0.0/VersionKeyMismatch.pm", + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail when version key != inner version" + assert error.status == 400, f"Expected 400 for version-key/inner-version mismatch, got {error.status}" + # <<<<< VERSION KEY DIVERGES FROM INNER VERSION REJECTED AT REFRESH <<<<< + + # >>>>> TRAVERSAL PATH REJECTED >>>>> + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "traversal-plugin": { + "namespace": "traversal-plugin", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "traversal", + "author": "test", + "description": "traversal test plugin", + "artifact": "../../etc/passwd", + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail for traversal path" + assert error.status == 400, f"Expected 400 for traversal path refresh, got {error.status}" + assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for traversal path" + # <<<<< TRAVERSAL PATH REJECTED <<<<< + + # >>>>> ABSOLUTE PATH REJECTED >>>>> + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "absolute-plugin": { + "namespace": "absolute-plugin", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "absolute", + "author": "test", + "description": "absolute path test plugin", + "artifact": "/etc/passwd", + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected refresh to fail for absolute path" + assert error.status == 400, f"Expected 400 for absolute path refresh, got {error.status}" + assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for absolute path" + # <<<<< ABSOLUTE PATH REJECTED <<<<< + + # >>>>> SYMLINK ESCAPE REJECTED >>>>> + escape_target = environment.local_registry_dir.parent / "outside-plugin.pm" + escape_target.write_text("outside", encoding="utf-8") + symlink_path = environment.local_registry_dir / "artifacts" / "escape-link.pm" + symlink_path.parent.mkdir(parents=True, exist_ok=True) + if symlink_path.exists() or symlink_path.is_symlink(): + symlink_path.unlink() + symlink_path.symlink_to(escape_target) + + try: + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "symlink-plugin": { + "namespace": "symlink-plugin", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "symlink", + "author": "test", + "description": "symlink escape test plugin", + "artifact": "artifacts/escape-link.pm", + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Expected refresh to succeed with symlink artifact entry (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="symlink-plugin", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail for symlink escape" + assert "Plugin file not found" in error.error, f"Expected symlink-escape install rejected, got: {error.error!r}" + assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for symlink escape" + finally: + symlink_path.unlink(missing_ok=True) + escape_target.unlink(missing_ok=True) + # <<<<< SYMLINK ESCAPE REJECTED <<<<< + + plugin_rel_path = "artifacts/local-sample-downloader/1.0.0/LocalSample.pm" + plugin_file = environment.local_registry_dir / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_text("""\ +package LANraragi::Plugin::Managed::Download::LocalSample; + +use strict; +use warnings; +no warnings 'uninitialized'; + +sub plugin_info { + return ( + name => "local-sample-downloader", + type => "download", + namespace => "local-sample-downloader", + author => "test", + version => "1.0", + ); +} + +sub provide_url { + return; +} + +1; +""", encoding="utf-8") + real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() + + # >>>>> SHA256 MISMATCH REJECTED >>>>> + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "local-sample-downloader": { + "namespace": "local-sample-downloader", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "Local Sample", + "author": "test", + "description": "local sample downloader", + "artifact": plugin_rel_path, + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Expected refresh to succeed with wrong sha entry (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail for wrong sha256" + assert "SHA-256 mismatch" in error.error, f"Expected SHA-256 mismatch, got: {error.error!r}" + + target_pm = environment.plugin_managed_dir / "Download" / "LocalSample.pm" + assert not target_pm.exists(), f"Plugin file should not exist after sha256 mismatch: {target_pm}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert "local-sample-downloader" not in namespaces, ( + f"Plugin should not appear in list after failed install: {namespaces}" + ) + # <<<<< SHA256 MISMATCH REJECTED <<<<< + + # >>>>> SHA256 MATCH INSTALLS >>>>> + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "local-sample-downloader": { + "namespace": "local-sample-downloader", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "Local Sample", + "author": "test", + "description": "local sample downloader", + "artifact": plugin_rel_path, + "sha256": real_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Expected refresh to succeed (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id, version="1.0.0") + ) + assert not error, f"Expected install to succeed (status {error.status}): {error.error}" + assert response.namespace == "local-sample-downloader" + assert response.version == "1.0.0", f"Expected version 1.0.0, got {response.version}" + assert response.registry == reg_id, ( + f"Expected provenance {reg_id}, got {response.registry}" + ) + + assert target_pm.exists(), f"Plugin file should exist after successful install: {target_pm}" + + expect_no_error_logs(environment, LOGGER) + # <<<<< SHA256 MATCH INSTALLS <<<<< + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_install_blocked_against_default_namespace( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + A registry plugin that claims a default plugin's namespace must be rejected, + and force=true must not bypass the rejection. + + 1. Create a local registry that publishes a plugin with namespace `copytags` (a default plugin). + 2. Refresh succeeds; install fails because the namespace is owned by a default plugin. + 3. Force install fails for the same reason. + """ + environment.setup(with_api_key=True) + + plugin_rel_path = "artifacts/copytags-impostor/1.0.0/CopyTagsImpostor.pm" + plugin_file = environment.local_registry_dir / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_text("""\ +package LANraragi::Plugin::Managed::Metadata::CopyTagsImpostor; + +use strict; +use warnings; +no warnings 'uninitialized'; + +sub plugin_info { + return ( + name => "copytags-impostor", + type => "metadata", + namespace => "copytags", + author => "test", + version => "1.0", + ); +} + +sub get_tags { return (); } + +1; +""", encoding="utf-8") + real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + registry_json = environment.local_registry_dir / "registry.json" + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "copytags": { + "namespace": "copytags", + "type": "metadata", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "copytags-impostor", + "author": "test", + "description": "tries to shadow the built-in copytags plugin", + "artifact": plugin_rel_path, + "sha256": real_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="default-conflict", + provider="local", + path=environment.local_registry_path, + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="copytags", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to be rejected over a default plugin namespace" + assert "already exists as a builtin plugin" in error.error, f"Expected builtin-conflict rejection, got: {error.error!r}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="copytags", registry=reg_id, version="1.0.0", force=True) + ) + assert error is not None, "force=true must not bypass a default-plugin namespace conflict" + assert "already exists as a builtin plugin" in error.error, f"Expected builtin-conflict rejection (force), got: {error.error!r}" + + target_pm = environment.plugin_managed_dir / "Metadata" / "CopyTagsImpostor.pm" + assert not target_pm.exists(), f"Impostor plugin must not be written to disk: {target_pm}" + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_install_blocked_against_invalid_filename( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + User expectation: a registry publisher who ships an artifact whose + filename contains characters outside the safe ASCII allowlist + (spaces, exotic punctuation) is rejected at install time, before any + bytes land in `Plugin/Managed/`. This protects deployments where + spaces in plugin filenames cause subtle Perl module-load issues. + + 1. Publish a plugin file at `My Plugin.pm` (space in filename) with + a valid SHA-256 in the manifest. + 2. Refresh succeeds — manifest validation only checks for null bytes, + absolute paths, and dot segments, none of which apply here. + 3. Install fails 422 with "Invalid plugin filename". + 4. No file lands in `Plugin/Managed/Download/`. + """ + environment.setup(with_api_key=True) + + plugin_rel_path = "artifacts/filename-test/1.0.0/My Plugin.pm" + plugin_file = environment.local_registry_dir / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_text("""\ +package LANraragi::Plugin::Managed::Download::SafePackage; + +use strict; +use warnings; +no warnings 'uninitialized'; + +sub plugin_info { + return ( + name => "filename-test", + type => "download", + namespace => "filename-test", + author => "test", + version => "1.0", + ); +} + +sub provide_url { return; } + +1; +""", encoding="utf-8") + real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + registry_json = environment.local_registry_dir / "registry.json" + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "filename-test": { + "namespace": "filename-test", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "filename-test", + "author": "test", + "description": "tests filename character allowlist", + "artifact": plugin_rel_path, + "sha256": real_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="filename-test", provider="local", path=environment.local_registry_path) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Refresh should accept the manifest (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="filename-test", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail for invalid filename" + assert "Invalid plugin filename" in error.error, ( + f"Expected error message to mention 'Invalid plugin filename', got: {error.error!r}. " + f"A different rejection reason indicates the filename allowlist did not fire — install reached a later validation." + ) + + assert not list(environment.plugin_managed_dir.rglob("*.pm")), ( + "No .pm files should be written for an invalid-filename install" + ) + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_install_blocked_against_package_mismatch( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + User expectation: a plugin file whose `package` declaration does not + match the package implied by its artifact filename is rejected at + install time. This prevents namespace squatting where a publisher + ships `Foo.pm` declaring `LANraragi::Plugin::Managed::Download::Bar` + and then later legitimate `Bar` plugins collide against the + orphaned mismatched file. + + 1. Publish a plugin at `Foo.pm` whose content declares + `package LANraragi::Plugin::Managed::Download::Bar`. + 2. Refresh succeeds; sha256 verification at install time will pass. + 3. Install fails 422 with "Package mismatch". + 4. No file lands in `Plugin/Managed/Download/`. + """ + environment.setup(with_api_key=True) + + plugin_rel_path = "artifacts/package-mismatch/1.0.0/Foo.pm" + plugin_file = environment.local_registry_dir / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_text("""\ +package LANraragi::Plugin::Managed::Download::Bar; + +use strict; +use warnings; +no warnings 'uninitialized'; + +sub plugin_info { + return ( + name => "package-mismatch", + type => "download", + namespace => "package-mismatch", + author => "test", + version => "1.0", + ); +} + +sub provide_url { return; } + +1; +""", encoding="utf-8") + real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + registry_json = environment.local_registry_dir / "registry.json" + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "package-mismatch": { + "namespace": "package-mismatch", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "package-mismatch", + "author": "test", + "description": "tests package vs filename mismatch detection", + "artifact": plugin_rel_path, + "sha256": real_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="package-mismatch", provider="local", path=environment.local_registry_path) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Refresh should accept the manifest (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="package-mismatch", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail for package mismatch" + assert "Package mismatch" in error.error, ( + f"Expected error message to mention 'Package mismatch', got: {error.error!r}. " + f"A different rejection reason indicates the package check did not fire — install reached a later validation." + ) + + assert not list(environment.plugin_managed_dir.rglob("*.pm")), ( + "No .pm files should be written for a package-mismatch install" + ) + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_plugin_install_blocked_against_sideloaded( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + User expectation: installing a managed plugin from a registry over an + existing sideloaded plugin of the same namespace requires the user to + remove the sideloaded plugin first, just like for builtin plugins. + A registry install must not silently overwrite a sideloaded plugin, + and `force=true` must not bypass this protection. + + 1. Seed a sideloaded plugin (namespace `sample-downloader`) before LRR + starts so it is discovered and registered as the existing copy. + 2. Confirm the plugin is registered with a `Plugin/Sideloaded/` path + and no `installed_registry` provenance. + 3. Create a local registry that publishes namespace `sample-downloader`. + 4. Refresh succeeds; install without force is rejected with 400. + 5. Force install is rejected with the same status. + 6. Redis provenance for the sideloaded plugin is unchanged (still a + Sideloaded path, still no `installed_registry`). + 7. No `Plugin/Managed/Download/SampleDownload.pm` is written. + """ + sideloaded_body = """\ +package LANraragi::Plugin::Sideloaded::Testing::SideSample; + +use strict; +use warnings; +no warnings 'uninitialized'; + +sub plugin_info { + return ( + name => "sample-downloader-sideloaded", + type => "download", + namespace => "sample-downloader", + author => "test", + version => "0.9", + ); +} + +sub provide_url { return; } + +1; +""" + with tempfile.TemporaryDirectory() as tmpdir: + sideloaded_src = Path(tmpdir) / "SideSample.pm" + sideloaded_src.write_bytes(sideloaded_body.encode("utf-8")) + + environment.setup( + with_api_key=True, + plugin_paths={"Sideloaded": [str(sideloaded_src)]}, + ) + + sideloaded_redis_key = "LRR_PLUGIN_SAMPLE-DOWNLOADER" + environment.redis_client.select(2) + sideloaded_initial_path = environment.redis_client.hget(sideloaded_redis_key, "installed_path") + assert sideloaded_initial_path and "Plugin/Sideloaded/" in sideloaded_initial_path, ( + f"Sideloaded fixture not registered with a Sideloaded path: {sideloaded_initial_path!r}" + ) + assert environment.redis_client.hget(sideloaded_redis_key, "installed_registry") is None, ( + "Sideloaded fixture must not have installed_registry set" + ) + + plugin_rel_path = "artifacts/sample-downloader/1.0.0/SampleDownload.pm" + plugin_file = environment.local_registry_dir / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_text("""\ +package LANraragi::Plugin::Managed::Download::SampleDownload; + +use strict; +use warnings; +no warnings 'uninitialized'; + +sub plugin_info { + return ( + name => "sample-downloader", + type => "download", + namespace => "sample-downloader", + author => "test", + version => "1.0", + ); +} + +sub provide_url { return; } + +1; +""", encoding="utf-8") + real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + registry_json = environment.local_registry_dir / "registry.json" + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "sample-downloader": { + "namespace": "sample-downloader", + "type": "download", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "sample-downloader", + "author": "test", + "description": "managed sample downloader", + "artifact": plugin_rel_path, + "sha256": real_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="sideloaded-conflict", + provider="local", + path=environment.local_registry_path, + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + # >>>>> INSTALL BLOCKED AGAINST SIDELOADED >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to be rejected over a sideloaded plugin" + assert "already exists as a sideloaded plugin" in error.error, ( + f"Expected sideloaded-conflict rejection, got: {error.error!r}" + ) + # <<<<< INSTALL BLOCKED AGAINST SIDELOADED <<<<< + + # >>>>> FORCE INSTALL ALSO BLOCKED >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version="1.0.0", force=True) + ) + assert error is not None, "force=true must not bypass a sideloaded namespace conflict" + assert "already exists as a sideloaded plugin" in error.error, ( + f"Expected sideloaded-conflict rejection (force), got: {error.error!r}" + ) + # <<<<< FORCE INSTALL ALSO BLOCKED <<<<< + + # >>>>> SIDELOADED PROVENANCE UNTOUCHED, MANAGED ARTIFACT NOT WRITTEN >>>>> + environment.redis_client.select(2) + assert environment.redis_client.hget(sideloaded_redis_key, "installed_path") == sideloaded_initial_path, ( + "Sideloaded plugin's installed_path changed after rejected managed install" + ) + assert environment.redis_client.hget(sideloaded_redis_key, "installed_registry") is None, ( + "Sideloaded plugin gained installed_registry after rejected managed install" + ) + target_pm = environment.plugin_managed_dir / "Download" / "SampleDownload.pm" + assert not target_pm.exists(), ( + f"Managed plugin must not be written when sideloaded conflict is present: {target_pm}" + ) + # <<<<< SIDELOADED PROVENANCE UNTOUCHED, MANAGED ARTIFACT NOT WRITTEN <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_composite_registry( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + Test composite registry/plugin functionality. Use 3 local registries with metadata plugins. + Tests cross-registry provenance handoff via force-install, max-version SemVer resolution, and version/registry-orphan transitions. + + Registry 1: + - shared-metadata-1 + - v1.0.0 (appends " from registry 1 v1.0.0" to title) + - v2.0.0 (appends " from registry 1 v2.0.0" to title) + Registry 2: + - shared-metadata-1 + - v1.0.0 (appends " from registry 2 v1.0.0" to title) + - v1.1.0 (appends " from registry 2 v1.1.0" to title) + - v2.0.0 (appends " from registry 2 v2.0.0" to title) + Registry 3: + - shared-metadata-1 + - v1.0.0 (appends " from registry 3 v1.0.0" to title) + - v1.1.0 (appends " from registry 3 v1.1.0" to title) + - v2.0.0 (appends " from registry 3 v2.0.0" to title) + + Ougi (default registry) designation is covered separately in test_ougi.py. + + Steps: + 1. Add registry 1, registry 2, registry 3. + 2. Install shared-metadata-1 from registry 1 (max-version resolution selects v2.0.0). + 3. Expect shared-metadata-1 version is v2.0.0. + 4. Upload archive and invoke plugin, expect processed title. + 5. Reinstall same plugin/version with force, expect idempotent (provenance unchanged). + 6. Uninstall shared-metadata-1. + 7. Install shared-metadata-1:v1.0.0 from registry 2 (explicit version). + 8. Expect version: v1.0.0. + 9. Invoke plugin, expect processed title. + 10. Remove registry 2 (plugin now registry-orphaned), assert 2 registries total. + 11. Install v1.0.0 from registry 3 without force, expect provenance mismatch error. + 12. Install v1.0.0 from registry 3 with force; verify provenance updated. + 13. Test plugin run. + 14. Regenerate registry 3 without version 1.0.0 (plugin becomes version orphan). + 15. Install v2.0.0 and run plugin. + + Every installation -> assert values for provenance + configuration, and assert title after running plugin. + `use_plugin` does not persist the returned title to the archive, so the stored title remains unchanged across runs. + """ + environment.setup(with_api_key=True) + + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: str): + plugins: dict = {} + plugin_versions: dict = {} + for version, registry_n in versions: + rel_path = f"artifacts/shared-metadata-1/{version}/SharedMetadata1.pm" + plugin_file = reg_dir / rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + suffix = f" from registry {registry_n} v{version}" + plugin_file.write_text(f"""\ +package LANraragi::Plugin::Managed::Metadata::SharedMetadata1; + +use strict; +use warnings; +no warnings 'uninitialized'; + +sub plugin_info {{ + return ( + name => "shared-metadata-1", + type => "metadata", + namespace => "shared-metadata-1", + author => "test", + version => "{version}", + description => "shared-metadata-1 test plugin", + ); +}} + +sub get_tags {{ + shift; + my $lrr_info = shift; + my $title = $lrr_info->{{archive_title}} . "{suffix}"; + return (title => $title); +}} + +1; +""", encoding="utf-8") + sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() + plugin_versions[version] = { + "version": version, + "name": "shared-metadata-1", + "author": "test", + "description": f"shared metadata 1 v{version} from registry {registry_n}", + "artifact": rel_path, + "sha256": sha, + "published_at": generated_at, + } + plugins["shared-metadata-1"] = { + "namespace": "shared-metadata-1", + "type": "metadata", + "versions": plugin_versions, + } + (reg_dir / "registry.json").write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": plugins, + }), encoding="utf-8") + + reg1_dir = environment.local_registry_dir / "registry-1" + reg2_dir = environment.local_registry_dir / "registry-2" + reg3_dir = environment.local_registry_dir / "registry-3" + + reg1_dir.mkdir(parents=True, exist_ok=True) + reg2_dir.mkdir(parents=True, exist_ok=True) + reg3_dir.mkdir(parents=True, exist_ok=True) + + write_registry(reg1_dir, [("1.0.0", 1), ("2.0.0", 1)], generated_at) + write_registry(reg2_dir, [("1.0.0", 2), ("1.1.0", 2), ("2.0.0", 2)], generated_at) + write_registry(reg3_dir, [("1.0.0", 3), ("1.1.0", 3), ("2.0.0", 3)], generated_at) + + # >>>>> SETUP THREE REGISTRIES >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="registry-1", + provider="local", + path=f"{environment.local_registry_path}/registry-1", + ) + ) + assert not error, f"Failed to create registry 1 (status {error.status}): {error.error}" + reg1_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg1_id) + assert not error, f"Failed to refresh registry 1 (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="registry-2", + provider="local", + path=f"{environment.local_registry_path}/registry-2", + ) + ) + assert not error, f"Failed to create registry 2 (status {error.status}): {error.error}" + reg2_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg2_id) + assert not error, f"Failed to refresh registry 2 (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="registry-3", + provider="local", + path=f"{environment.local_registry_path}/registry-3", + ) + ) + assert not error, f"Failed to create registry 3 (status {error.status}): {error.error}" + reg3_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg3_id) + assert not error, f"Failed to refresh registry 3 (status {error.status}): {error.error}" + # <<<<< SETUP THREE REGISTRIES <<<<< + + # >>>>> UPLOAD ARCHIVE >>>>> + with tempfile.TemporaryDirectory() as tmpdir: + archive_path = create_archive_file(Path(tmpdir), "test_composite_1", num_pages=1) + response, error = await upload_archive( + lrr_client, archive_path, archive_path.name, asyncio.Semaphore(1), + title="base title", tags="test:composite", + ) + assert not error, f"Upload failed (status {error.status}): {error.error}" + arcid = response.arcid + # <<<<< UPLOAD ARCHIVE <<<<< + + # >>>>> MAX-VERSION INSTALL AND INVOKE FROM REGISTRY 1 >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="shared-metadata-1", registry=reg1_id) + ) + assert not error, f"Failed to install from registry 1 (status {error.status}): {error.error}" + assert response.version == "2.0.0", f"Expected max version 2.0.0, got {response.version}" + assert response.registry == reg1_id, ( + f"Expected registry {reg1_id}, got {response.registry}" + ) + + response, error = await lrr_client.misc_api.use_plugin( + UsePluginRequest(plugin="shared-metadata-1", arcid=arcid) + ) + assert not error, f"Plugin execution failed (status {error.status}): {error.error}" + assert response.data is not None, "Plugin response did not include data payload" + assert response.data.get("title") == "base title from registry 1 v2.0.0", ( + f"Unexpected title after registry 1 v2.0.0 run: {response.data.get('title')!r}" + ) + # <<<<< MAX-VERSION INSTALL AND INVOKE FROM REGISTRY 1 <<<<< + + # >>>>> IDEMPOTENT FORCE REINSTALL >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins before reinstall (status {error.status}): {error.error}" + pre_reinstall_version = None + pre_reinstall_sha256 = None + pre_reinstall_registry = None + for plugin in response.plugins: + if plugin.namespace == "shared-metadata-1": + pre_reinstall_version = plugin.version + pre_reinstall_sha256 = plugin.sha256 + pre_reinstall_registry = plugin.registry + break + else: + pytest.fail("shared-metadata-1 not found before force reinstall") + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="shared-metadata-1", registry=reg1_id, version="2.0.0", force=True) + ) + assert not error, f"Force reinstall failed (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after reinstall (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "shared-metadata-1": + assert plugin.version == pre_reinstall_version, "version changed after force reinstall" + assert plugin.sha256 == pre_reinstall_sha256, "sha256 changed after force reinstall" + assert plugin.registry == pre_reinstall_registry, "registry changed after force reinstall" + break + else: + pytest.fail("shared-metadata-1 not found after force reinstall") + # <<<<< IDEMPOTENT FORCE REINSTALL <<<<< + + # >>>>> UNINSTALL >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin("shared-metadata-1") + assert not error, f"Failed to uninstall plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after uninstall (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert "shared-metadata-1" not in namespaces, ( + f"Plugin still listed after uninstall: {namespaces}" + ) + # <<<<< UNINSTALL <<<<< + + # >>>>> EXPLICIT VERSION INSTALL AND INVOKE FROM REGISTRY 2 >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="shared-metadata-1", registry=reg2_id, version="1.0.0") + ) + assert not error, f"Failed to install v1.0.0 from registry 2 (status {error.status}): {error.error}" + assert response.version == "1.0.0", f"Expected version 1.0.0, got {response.version}" + assert response.registry == reg2_id, ( + f"Expected registry {reg2_id}, got {response.registry}" + ) + + # Restart so the swapped plugin code loads, then drop the now-dead keep-alive + # connection to the old container so the next request reconnects. + environment.restart() + await lrr_client.close() + + response, error = await lrr_client.misc_api.use_plugin( + UsePluginRequest(plugin="shared-metadata-1", arcid=arcid) + ) + assert not error, f"Plugin execution failed (status {error.status}): {error.error}" + assert response.data is not None, "Plugin response did not include data payload" + assert response.data.get("title") == "base title from registry 2 v1.0.0", ( + f"Unexpected title after registry 2 v1.0.0 run: {response.data.get('title')!r}" + ) + + # <<<<< EXPLICIT VERSION INSTALL AND INVOKE FROM REGISTRY 2 <<<<< + + # >>>>> REGISTRY-ORPHAN (DELETE REGISTRY 2) >>>>> + response, error = await lrr_client.misc_api.delete_registry(reg2_id) + assert not error, f"Failed to delete registry 2 (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.list_registries() + assert not error, f"Failed to list registries (status {error.status}): {error.error}" + assert len(response.registries) == 2, ( + f"Expected 2 registries after deleting registry 2, got {len(response.registries)}" + ) + + # provenance still queryable even though registry is gone + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after registry delete (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "shared-metadata-1": + assert plugin.registry == reg2_id, ( + f"Expected orphaned provenance {reg2_id}, got {plugin.registry}" + ) + break + else: + pytest.fail("shared-metadata-1 should still be listed after registry delete") + # <<<<< REGISTRY-ORPHAN (DELETE REGISTRY 2) <<<<< + + # >>>>> CROSS-REGISTRY WITHOUT FORCE (400) >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="shared-metadata-1", registry=reg3_id, version="1.0.0") + ) + assert error is not None, "Expected 400 for cross-registry install without force" + assert "already installed from" in error.error, f"Expected cross-registry conflict, got: {error.error!r}" + # <<<<< CROSS-REGISTRY WITHOUT FORCE (400) <<<<< + + # >>>>> CROSS-REGISTRY WITH FORCE AND INVOKE >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="shared-metadata-1", registry=reg3_id, version="1.0.0", force=True) + ) + assert not error, f"Failed to force install from registry 3 (status {error.status}): {error.error}" + assert response.registry == reg3_id, ( + f"Expected registry {reg3_id}, got {response.registry}" + ) + + # Restart so the swapped plugin code loads, then drop the now-dead keep-alive + # connection to the old container so the next request reconnects. + environment.restart() + await lrr_client.close() + + response, error = await lrr_client.misc_api.use_plugin( + UsePluginRequest(plugin="shared-metadata-1", arcid=arcid) + ) + assert not error, f"Plugin execution failed (status {error.status}): {error.error}" + assert response.data is not None, "Plugin response did not include data payload" + assert response.data.get("title") == "base title from registry 3 v1.0.0", ( + f"Unexpected title after registry 3 v1.0.0 run: {response.data.get('title')!r}" + ) + + # <<<<< CROSS-REGISTRY WITH FORCE AND INVOKE <<<<< + + # >>>>> VERSION-ORPHAN (DROP v1.0.0 FROM REGISTRY 3) >>>>> + # rewrite registry-3 to list only v1.1.0 and v2.0.0; v1.0.0 is gone + write_registry(reg3_dir, [("1.1.0", 3), ("2.0.0", 3)], generated_at) + + response, error = await lrr_client.misc_api.refresh_registry(reg3_id) + assert not error, f"Failed to refresh registry 3 after version drop (status {error.status}): {error.error}" + # <<<<< VERSION-ORPHAN (DROP v1.0.0 FROM REGISTRY 3) <<<<< + + # >>>>> UPGRADE FROM VERSION-ORPHAN STATE AND INVOKE >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="shared-metadata-1", registry=reg3_id, version="2.0.0", force=True) + ) + assert not error, f"Failed to install v2.0.0 from registry 3 (status {error.status}): {error.error}" + assert response.version == "2.0.0", f"Expected version 2.0.0, got {response.version}" + assert response.registry == reg3_id, ( + f"Expected registry {reg3_id}, got {response.registry}" + ) + + # Restart so the swapped plugin code loads, then drop the now-dead keep-alive + # connection to the old container so the next request reconnects. + environment.restart() + await lrr_client.close() + + response, error = await lrr_client.misc_api.use_plugin( + UsePluginRequest(plugin="shared-metadata-1", arcid=arcid) + ) + assert not error, f"Plugin execution failed (status {error.status}): {error.error}" + assert response.data is not None, "Plugin response did not include data payload" + assert response.data.get("title") == "base title from registry 3 v2.0.0", ( + f"Unexpected title after registry 3 v2.0.0 run: {response.data.get('title')!r}" + ) + + # <<<<< UPGRADE FROM VERSION-ORPHAN STATE AND INVOKE <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_install_validation_classification( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + User expectation: the load check distinguishes a bad plugin from a check + that could not run. A plugin that passes filename/package/sha checks but + fails to compile is a content error (422 "failed to load"); a plugin whose + load blocks past the load-check timeout is an operational fault + (500 "load check failed"), not a bad plugin. Neither leaves an artifact + behind. + + 1. Publish a plugin that compiles-fails; install fails 422 ("failed to load"). + 2. Publish a plugin that blocks at load past the 20s load-check timeout; + install fails 500 ("load check failed"). + 3. No .pm files land in Plugin/Managed/. + """ + environment.setup(with_api_key=True) + + registry_json = environment.local_registry_dir / "registry.json" + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="load-check-classification", provider="local", path=environment.local_registry_path) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + # >>>>> COMPILE FAILURE -> 422 (content error) >>>>> + broken_rel = "artifacts/broken-loader/1.0.0/BrokenLoader.pm" + broken_file = environment.local_registry_dir / broken_rel + broken_file.parent.mkdir(parents=True, exist_ok=True) + broken_file.write_text("""\ +package LANraragi::Plugin::Managed::Metadata::BrokenLoader; + +use strict; +use warnings; + +my $unterminated = ( + +1; +""", encoding="utf-8") + broken_sha = hashlib.sha256(broken_file.read_bytes()).hexdigest() + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "broken-loader": { + "namespace": "broken-loader", + "type": "metadata", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "broken-loader", + "author": "test", + "description": "fails to compile", + "artifact": broken_rel, + "sha256": broken_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Refresh should accept the manifest (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="broken-loader", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail for a plugin that does not compile" + assert "failed to load" in error.error, ( + f"Expected a bad-plugin 'failed to load' message, got: {error.error!r}" + ) + # <<<<< COMPILE FAILURE -> 422 <<<<< + + # >>>>> LOAD TIMEOUT -> 500 (operational fault) >>>>> + slow_rel = "artifacts/slow-loader/1.0.0/SlowLoader.pm" + slow_file = environment.local_registry_dir / slow_rel + slow_file.parent.mkdir(parents=True, exist_ok=True) + slow_file.write_text("""\ +package LANraragi::Plugin::Managed::Metadata::SlowLoader; + +use strict; +use warnings; +no warnings 'uninitialized'; + +# Block at load past the 20s load-check timeout. +sleep 25; + +sub plugin_info { + return ( + name => "slow-loader", + type => "metadata", + namespace => "slow-loader", + author => "test", + version => "1.0", + ); +} + +sub get_tags { return (); } + +1; +""", encoding="utf-8") + slow_sha = hashlib.sha256(slow_file.read_bytes()).hexdigest() + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "slow-loader": { + "namespace": "slow-loader", + "type": "metadata", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "slow-loader", + "author": "test", + "description": "blocks at load", + "artifact": slow_rel, + "sha256": slow_sha, + "published_at": generated_at, + }, + }, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Refresh should accept the manifest (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="slow-loader", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail when the load check times out" + assert "load check failed" in error.error, ( + f"Expected an operational 'load check failed' message, got: {error.error!r}" + ) + # <<<<< LOAD TIMEOUT -> 500 <<<<< + + assert not list(environment.plugin_managed_dir.rglob("*.pm")), ( + "No .pm files should be written for a failed load check" + ) diff --git a/integration_tests/tests/registry/test_ougi.py b/integration_tests/tests/registry/test_ougi.py new file mode 100644 index 00000000..be187a9b --- /dev/null +++ b/integration_tests/tests/registry/test_ougi.py @@ -0,0 +1,115 @@ +""" +Ougi (default registry) designation API integration tests. +""" + +import logging + +import pytest +from lanraragi.clients.client import LRRClient +from lanraragi.models.misc import ( + CreateRegistryRequest, +) + +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_ougi_lifecycle( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + Test the Ougi designation across set/get/clear and auto-clear on registry delete. + + 1. Get Ougi when unset, expect empty string. + 2. DELETE when unset, expect empty string returned. + 3. Set Ougi to wrong-length id, expect 400 (OpenAPI path-length validation). + 4. Set Ougi to right-length but non-REG_ id, expect 400 (model regex validation). + 5. Set Ougi to well-formed but nonexistent id, expect 404. + 6. Create a local registry, set as Ougi, get reflects it. + 7. Explicit DELETE returns the previous id and clears the designation. + 8. Re-set Ougi, then DELETE the underlying registry; Ougi auto-clears. + """ + environment.setup(with_api_key=True) + + # >>>>> GET WHEN UNSET >>>>> + response, error = await lrr_client.misc_api.get_ougi() + assert not error, f"Failed to get Ougi (status {error.status}): {error.error}" + assert response.id == "", f"Expected empty string when unset, got: {response.id!r}" + # <<<<< GET WHEN UNSET <<<<< + + # >>>>> DELETE WHEN UNSET >>>>> + response, error = await lrr_client.misc_api.remove_ougi() + assert not error, f"Failed to clear unset Ougi (status {error.status}): {error.error}" + assert response.id == "", f"Expected empty string when no Ougi was set, got: {response.id!r}" + # <<<<< DELETE WHEN UNSET <<<<< + + # >>>>> SET WRONG-LENGTH ID >>>>> + response, error = await lrr_client.misc_api.update_ougi("not-a-reg-id") + assert error is not None, "Expected error for wrong-length registry id" + assert error.status == 400, f"Expected 400 for wrong-length id, got {error.status}" + # <<<<< SET WRONG-LENGTH ID <<<<< + + # >>>>> SET RIGHT-LENGTH NON-REG ID >>>>> + response, error = await lrr_client.misc_api.update_ougi("ABCDEFGHIJKLMN") + assert error is not None, "Expected error for right-length non-REG_ registry id" + assert error.status == 400, f"Expected 400 for non-REG_ id, got {error.status}" + # <<<<< SET RIGHT-LENGTH NON-REG ID <<<<< + + # >>>>> SET NONEXISTENT ID >>>>> + response, error = await lrr_client.misc_api.update_ougi("REG_0000000001") + assert error is not None, "Expected error for nonexistent registry id" + assert error.status == 404, f"Expected 404 for nonexistent id, got {error.status}" + + response, error = await lrr_client.misc_api.get_ougi() + assert not error, f"Failed to get Ougi (status {error.status}): {error.error}" + assert response.id == "", f"Ougi must remain unset after failed PUT, got: {response.id!r}" + # <<<<< SET NONEXISTENT ID <<<<< + + # >>>>> SET VALID ID >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="ougi-test", provider="local", path=environment.local_registry_path) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.update_ougi(reg_id) + assert not error, f"Failed to set Ougi (status {error.status}): {error.error}" + assert response.id == reg_id, f"Expected Ougi {reg_id}, got: {response.id}" + + response, error = await lrr_client.misc_api.get_ougi() + assert not error, f"Failed to get Ougi (status {error.status}): {error.error}" + assert response.id == reg_id, f"Expected Ougi {reg_id}, got: {response.id}" + # <<<<< SET VALID ID <<<<< + + # >>>>> EXPLICIT DELETE >>>>> + response, error = await lrr_client.misc_api.remove_ougi() + assert not error, f"Failed to clear Ougi (status {error.status}): {error.error}" + assert response.id == reg_id, f"Expected previous id {reg_id}, got: {response.id}" + + response, error = await lrr_client.misc_api.get_ougi() + assert not error, f"Failed to get Ougi (status {error.status}): {error.error}" + assert response.id == "", f"Expected empty string after clear, got: {response.id!r}" + # <<<<< EXPLICIT DELETE <<<<< + + # >>>>> AUTO-CLEAR ON REGISTRY DELETE >>>>> + response, error = await lrr_client.misc_api.update_ougi(reg_id) + assert not error, f"Failed to re-set Ougi (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_ougi() + assert not error, f"Failed to get Ougi (status {error.status}): {error.error}" + assert response.id == "", ( + f"Ougi must auto-clear when its registry is deleted, got: {response.id!r}" + ) + # <<<<< AUTO-CLEAR ON REGISTRY DELETE <<<<< + + expect_no_error_logs(environment, LOGGER) diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py new file mode 100644 index 00000000..56545887 --- /dev/null +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -0,0 +1,558 @@ +""" +Plugin configuration (visibility, priority, execution order) integration tests. +""" + +import asyncio +import http +import logging +import tempfile +from pathlib import Path + +import pytest +from lanraragi.clients.client import LRRClient +from lanraragi.models.archive import GetArchiveMetadataRequest +from lanraragi.models.misc import ( + CreateRegistryRequest, + GetAvailablePluginsRequest, + InstallPluginRequest, + UpdateMetadataPluginConfigRequest, +) + +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) +from aio_lanraragi_tests.utils.api_wrappers import ( + create_archive_file, + install_plugin_and_wait, + upload_archive, +) + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_save_config_preserves_managed_plugin_provenance( + lrr_client: LRRClient, environment: AbstractLRRDeploymentContext +): + """ + Saving plugin configuration must not erase managed-plugin provenance fields. + + 1. Disable password protection so POST /config/plugins is reachable without a session. + 2. Create a registry, install sample-metadata (a HASH-param managed plugin). + 3. Capture provenance fields written to LRR_PLUGIN_SAMPLE-METADATA on install. + 4. POST /config/plugins (form-encoded, minimal body) to exercise save_config. + 5. Re-read the same Redis hash and assert installed_path, installed_registry, + installed_version, installed_sha256, and type survive the save. + """ + environment.setup(with_api_key=True) + environment.redis_client.select(2) + environment.redis_client.hset("LRR_CONFIG", "enablepass", "0") + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + version_key = max(refresh_response.index["plugins"]["sample-metadata"]["versions"].keys()) + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=version_key) + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + + redis_key = "LRR_PLUGIN_SAMPLE-METADATA" + expected = { + "installed_path": environment.redis_client.hget(redis_key, "installed_path"), + "installed_registry": environment.redis_client.hget(redis_key, "installed_registry"), + "installed_version": environment.redis_client.hget(redis_key, "installed_version"), + "installed_sha256": environment.redis_client.hget(redis_key, "installed_sha256"), + "type": environment.redis_client.hget(redis_key, "type"), + } + for field, value in expected.items(): + assert value, f"Install did not write {field} to {redis_key}; got {value!r}" + + status, body = await lrr_client.handle_request( + http.HTTPMethod.POST, + lrr_client.build_url("/config/plugins"), + headers={}, + data={"replacetitles": "0"}, + ) + assert status == 200, f"POST /config/plugins returned {status}: {body!r}" + + for field, value in expected.items(): + got = environment.redis_client.hget(redis_key, field) + assert got == value, ( + f"save_config wiped managed plugin {field}: expected {value!r}, got {got!r}" + ) + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("metadata-plugin") +@pytest.mark.ratelimit +async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test hiding/unhiding a plugin and config reset on uninstall/reinstall. + + 1. Install a plugin from the registry. + 2. Hide the plugin, verify hidden field is true. + 3. Unhide the plugin, verify hidden field is false. + 4. Hide again, set priority, uninstall, reinstall. + 5. Verify hidden and priority survive uninstall/reinstall. + 6. Hide a built-in plugin, verify hidden in plugin list, then unhide. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP AND INSTALL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + sample_metadata_version = max(refresh_response.index["plugins"]["sample-metadata"]["versions"].keys()) + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + # <<<<< SETUP AND INSTALL <<<<< + + # >>>>> DEFAULT FIELD VALUES >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + found_managed = False + found_default = False + for plugin in response.plugins: + if plugin.namespace == "sample-metadata": + found_managed = True + assert plugin.hidden is False, f"Fresh install expected hidden=False, got {plugin.hidden}" + assert plugin.registry == reg_id, ( + f"Managed plugin expected registry={reg_id}, got {plugin.registry}" + ) + if plugin.namespace == "copytags": + found_default = True + assert plugin.registry is None, ( + f"Default plugin expected registry=None, got {plugin.registry}" + ) + assert found_managed, "sample-metadata not found in plugin list after install" + assert found_default, "default plugin copytags not found in plugin list" + # <<<<< DEFAULT FIELD VALUES <<<<< + + # >>>>> HIDE PLUGIN >>>>> + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "sample-metadata", UpdateMetadataPluginConfigRequest(hidden=True) + ) + assert not error, f"Failed to update plugin config (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-metadata": + assert plugin.hidden is True, f"Expected hidden=True, got {plugin.hidden}" + break + else: + pytest.fail("Plugin sample-metadata not found in list after hide") + # <<<<< HIDE PLUGIN <<<<< + + # >>>>> UNHIDE PLUGIN >>>>> + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "sample-metadata", UpdateMetadataPluginConfigRequest(hidden=False) + ) + assert not error, f"Failed to update plugin config (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-metadata": + assert plugin.hidden is False, f"Expected hidden=False, got {plugin.hidden}" + break + else: + pytest.fail("Plugin sample-metadata not found in list after unhide") + # <<<<< UNHIDE PLUGIN <<<<< + + # >>>>> CONFIG SURVIVES UNINSTALL/REINSTALL >>>>> + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "sample-metadata", UpdateMetadataPluginConfigRequest(hidden=True, priority=7) + ) + assert not error, f"Failed to set hidden+priority (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.uninstall_plugin("sample-metadata") + assert not error, f"Failed to uninstall plugin (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) + ) + assert not error, f"Failed to reinstall plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after reinstall (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-metadata": + assert plugin.hidden is True, f"Expected hidden=True preserved after reinstall, got {plugin.hidden}" + assert plugin.priority == 7, f"Expected priority=7 preserved after reinstall, got {plugin.priority}" + break + else: + pytest.fail("sample-metadata not found after reinstall") + # <<<<< CONFIG SURVIVES UNINSTALL/REINSTALL <<<<< + + # >>>>> HIDE BUILT-IN PLUGIN >>>>> + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "copytags", UpdateMetadataPluginConfigRequest(hidden=True) + ) + assert not error, f"Failed to hide built-in plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "copytags": + assert plugin.hidden is True, f"Expected built-in hidden=True, got {plugin.hidden}" + break + else: + pytest.fail("Built-in plugin copytags not found in list after hide") + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "copytags", UpdateMetadataPluginConfigRequest(hidden=False) + ) + assert not error, f"Failed to unhide built-in plugin (status {error.status}): {error.error}" + # <<<<< HIDE BUILT-IN PLUGIN <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("metadata-plugin") +async def test_plugin_config_nonexistent_namespace(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """Updating config for a never-installed namespace returns 404.""" + environment.setup(with_api_key=True) + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "definitely-not-real", UpdateMetadataPluginConfigRequest(hidden=True) + ) + assert error is not None, "Expected 404 error for nonexistent namespace" + assert error.status == 404, f"Expected 404 for nonexistent namespace, got {error.status}" + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("metadata-plugin") +@pytest.mark.ratelimit +async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test plugin priority via update_metadata_plugin_config. + + 1. Create registry, refresh, install sample-metadata. + 2. Verify default priority is 0. + 3. Set priority to 5, verify it persists in plugin list. + 4. Set distinct priorities on sample-metadata and a default metadata plugin, verify both. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP AND INSTALL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + sample_metadata_version = max(refresh_response.index["plugins"]["sample-metadata"]["versions"].keys()) + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) + ) + assert not error, f"Failed to install sample-metadata (status {error.status}): {error.error}" + # <<<<< SETUP AND INSTALL <<<<< + + # >>>>> VERIFY DEFAULT PRIORITY >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-metadata": + assert plugin.priority == 0, f"Expected default priority 0, got {plugin.priority}" + break + else: + pytest.fail("sample-metadata not found in plugin list") + # <<<<< VERIFY DEFAULT PRIORITY <<<<< + + # >>>>> SET PRIORITY >>>>> + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "sample-metadata", UpdateMetadataPluginConfigRequest(priority=5) + ) + assert not error, f"Failed to set priority (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-metadata": + assert plugin.priority == 5, f"Expected priority 5, got {plugin.priority}" + break + else: + pytest.fail("sample-metadata not found in plugin list after priority set") + # <<<<< SET PRIORITY <<<<< + + # >>>>> DISTINCT PRIORITIES ON TWO METADATA PLUGINS >>>>> + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "copytags", UpdateMetadataPluginConfigRequest(priority=3) + ) + assert not error, f"Failed to set copytags priority (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + priorities = {} + for plugin in response.plugins: + if plugin.namespace in ("sample-metadata", "copytags"): + priorities[plugin.namespace] = plugin.priority + assert priorities["sample-metadata"] == 5, f"Expected sample-metadata priority 5, got {priorities.get('sample-metadata')}" + assert priorities["copytags"] == 3, f"Expected copytags priority 3, got {priorities.get('copytags')}" + # <<<<< DISTINCT PRIORITIES ON TWO METADATA PLUGINS <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("metadata-plugin") +@pytest.mark.ratelimit +async def test_plugin_config_rejects_non_metadata_fields( + lrr_client: LRRClient, environment: AbstractLRRDeploymentContext +): + """ + Test that update_metadata_plugin_config rejects metadata-only fields on non-metadata plugins. + + Per spec: enabled, priority, and hidden are properties of metadata plugins. + Non-metadata plugins (login, download, script) cannot carry these fields. + + 1. Create registry, refresh, install sample-downloader (a download plugin). + 2. Attempt to set enabled=True; expect 400. + 3. Attempt to set priority=2; expect 400. + 4. Attempt to set hidden=True; expect 400. + """ + environment.setup(with_api_key=True) + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=sample_downloader_version) + ) + assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "sample-downloader", UpdateMetadataPluginConfigRequest(enabled=True) + ) + assert error and error.status == 400, ( + f"Expected 400 rejecting enabled on download plugin, got status={error.status if error else 'no error'}" + ) + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "sample-downloader", UpdateMetadataPluginConfigRequest(priority=2) + ) + assert error and error.status == 400, ( + f"Expected 400 rejecting priority on download plugin, got status={error.status if error else 'no error'}" + ) + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "sample-downloader", UpdateMetadataPluginConfigRequest(hidden=True) + ) + assert error and error.status == 400, ( + f"Expected 400 rejecting hidden on download plugin, got status={error.status if error else 'no error'}" + ) + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("metadata-plugin") +async def test_plugin_config_returns_500_on_corrupt_type( + lrr_client: LRRClient, environment: AbstractLRRDeploymentContext +): + """ + Test that update_metadata_plugin_config returns 500 when a plugin's type is missing from Redis. + + Simulates a corrupt registration state by deleting the `type` field from a built-in metadata + plugin's Redis hash, then calling the config endpoint. The handler logs an error and returns 500. + + 1. Set up environment with a known built-in metadata plugin (copytags). + 2. Delete the `type` field from its Redis hash. + 3. Attempt to set hidden=True; expect 500. + """ + environment.setup(with_api_key=True) + + environment.redis_client.hdel("LRR_PLUGIN_COPYTAGS", "type") + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "copytags", UpdateMetadataPluginConfigRequest(hidden=True) + ) + assert error and error.status == 500, ( + f"Expected 500 on corrupt type, got status={error.status if error else 'no error'}" + ) + + +@pytest.mark.asyncio +@pytest.mark.dev("metadata-plugin") +@pytest.mark.ratelimit +async def test_plugin_priority_execution_order(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that enabled metadata plugins execute in priority order on archive upload. + + 1. Create registry, refresh, install title-suffix-1, title-suffix-2, title-suffix-3. + 2. Set priorities: suffix-2=1, suffix-1=2, suffix-3=3 (execution order: 2, 1, 3). + 3. Enable all three via Redis. + 4. Upload archive with title "test", verify final title is "test-2-1-3". + 5. Change priorities: suffix-3=1, suffix-2=2, suffix-1=3 (execution order: 3, 2, 1). + 6. Upload another archive, verify final title is "test-3-2-1". + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL ALL THREE >>>>> + for ns in ("title-suffix-1", "title-suffix-2", "title-suffix-3"): + version_key = max(refresh_response.index["plugins"][ns]["versions"].keys()) + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace=ns, registry=reg_id, version=version_key) + ) + assert not error, f"Failed to install {ns} (status {error.status}): {error.error}" + # <<<<< INSTALL ALL THREE <<<<< + + # >>>>> SET PRIORITIES: 2, 1, 3 >>>>> + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "title-suffix-2", UpdateMetadataPluginConfigRequest(priority=1) + ) + assert not error, f"Failed to set suffix-2 priority (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "title-suffix-1", UpdateMetadataPluginConfigRequest(priority=2) + ) + assert not error, f"Failed to set suffix-1 priority (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "title-suffix-3", UpdateMetadataPluginConfigRequest(priority=3) + ) + assert not error, f"Failed to set suffix-3 priority (status {error.status}): {error.error}" + # <<<<< SET PRIORITIES <<<<< + + # >>>>> ENABLE ALL THREE >>>>> + for ns in ("title-suffix-1", "title-suffix-2", "title-suffix-3"): + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + ns, UpdateMetadataPluginConfigRequest(enabled=True) + ) + assert not error, f"Failed to enable {ns} (status {error.status}): {error.error}" + # <<<<< ENABLE ALL THREE <<<<< + + # >>>>> UPLOAD AND VERIFY ORDER 2-1-3 >>>>> + with tempfile.TemporaryDirectory() as tmpdir: + archive_path = create_archive_file(Path(tmpdir), "test_priority_order_1", num_pages=1) + response, error = await upload_archive( + lrr_client, archive_path, archive_path.name, asyncio.Semaphore(1), + title="test", tags="test:priority", + ) + assert not error, f"Upload failed (status {error.status}): {error.error}" + arcid = response.arcid + + response, error = await lrr_client.archive_api.get_archive_metadata(GetArchiveMetadataRequest(arcid=arcid)) + assert not error, f"Failed to get metadata (status {error.status}): {error.error}" + assert response.title == "test-2-1-3", f"Expected 'test-2-1-3', got: {response.title!r}" + # <<<<< UPLOAD AND VERIFY ORDER 2-1-3 <<<<< + + # >>>>> CHANGE PRIORITIES: 3, 2, 1 >>>>> + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "title-suffix-3", UpdateMetadataPluginConfigRequest(priority=1) + ) + assert not error, f"Failed to set suffix-3 priority (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "title-suffix-2", UpdateMetadataPluginConfigRequest(priority=2) + ) + assert not error, f"Failed to set suffix-2 priority (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.update_metadata_plugin_config( + "title-suffix-1", UpdateMetadataPluginConfigRequest(priority=3) + ) + assert not error, f"Failed to set suffix-1 priority (status {error.status}): {error.error}" + # <<<<< CHANGE PRIORITIES <<<<< + + # >>>>> UPLOAD AND VERIFY ORDER 3-2-1 >>>>> + with tempfile.TemporaryDirectory() as tmpdir: + archive_path = create_archive_file(Path(tmpdir), "test_priority_order_2", num_pages=1) + response, error = await upload_archive( + lrr_client, archive_path, archive_path.name, asyncio.Semaphore(1), + title="test", tags="test:priority2", + ) + assert not error, f"Upload failed (status {error.status}): {error.error}" + arcid = response.arcid + + response, error = await lrr_client.archive_api.get_archive_metadata(GetArchiveMetadataRequest(arcid=arcid)) + assert not error, f"Failed to get metadata (status {error.status}): {error.error}" + assert response.title == "test-3-2-1", f"Expected 'test-3-2-1', got: {response.title!r}" + # <<<<< UPLOAD AND VERIFY ORDER 3-2-1 <<<<< + + expect_no_error_logs(environment, LOGGER) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py new file mode 100644 index 00000000..3be97282 --- /dev/null +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -0,0 +1,1778 @@ +""" +Plugin install/uninstall lifecycle integration tests. +""" + +import asyncio +import hashlib +import http +import json +import logging +import tempfile +import time +from pathlib import Path + +import pytest +from lanraragi.clients.client import LRRClient + +# from lanraragi.models.archive import GetArchiveMetadataRequest +from lanraragi.models.misc import ( + CreateRegistryRequest, + GetAvailablePluginsRequest, + InstallPluginRequest, + # UpdateMetadataPluginConfigRequest, # metadata-plugin feature removed; see test_plugin_config.py + UpdateRegistryRequest, + UsePluginRequest, +) + +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) +from aio_lanraragi_tests.registries.local_registry import LocalRegistry +from aio_lanraragi_tests.utils.api_wrappers import add_registry, install_plugin_and_wait + +LOGGER = logging.getLogger(__name__) + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test installing and uninstalling a plugin, including error paths. + + 1. Create registry and refresh index. + 2. Install sample-downloader plugin, verify provenance. + 3. Verify plugin appears in plugin list. + 4. Uninstall the plugin, verify absent. + 5. Uninstall again (no install path), expect error. + 6. Uninstall a namespace that was never installed, expect error. + 7. Uninstall a built-in plugin, expect 403 error. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL PLUGIN >>>>> + refresh_response = response + version_key = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=version_key) + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + assert response.namespace == "sample-downloader" + assert response.name == "Sample Downloader" + assert response.registry == reg_id, f"Expected provenance {reg_id}, got: {response.registry}" + # <<<<< INSTALL PLUGIN <<<<< + + # >>>>> VERIFY INSTALLED >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + sample = next((p for p in response.plugins if p.namespace == "sample-downloader"), None) + assert sample is not None, "sample-downloader missing from download plugin list after install" + assert sample.registry == reg_id, ( + f"Expected managed provenance {reg_id}, got: {sample.registry}" + ) + # <<<<< VERIFY INSTALLED <<<<< + + # >>>>> UNINSTALL PLUGIN >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin("sample-downloader") + assert not error, f"Failed to uninstall plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list plugins after uninstall (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert "sample-downloader" not in namespaces, f"Plugin still listed after uninstall: {namespaces}" + # <<<<< UNINSTALL PLUGIN <<<<< + + # >>>>> UNINSTALL AGAIN (NO INSTALL PATH) >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin("sample-downloader") + assert error is not None, "Expected error uninstalling plugin with no install path" + assert error.status == 404, f"Expected 404 for uninstall without install path, got {error.status}" + # <<<<< UNINSTALL AGAIN (NO INSTALL PATH) <<<<< + + # >>>>> UNINSTALL NEVER-INSTALLED >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin("nonexistent-plugin-xyz") + assert error is not None, "Expected error uninstalling never-installed plugin" + assert error.status == 404, f"Expected 404 for never-installed plugin, got {error.status}" + # <<<<< UNINSTALL NEVER-INSTALLED <<<<< + + # >>>>> UNINSTALL BUILT-IN BLOCKED >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + metadata_before = {p.namespace for p in response.plugins} + assert "copytags" in metadata_before, f"copytags missing from metadata plugin list before uninstall attempt: {metadata_before}" + + response, error = await lrr_client.misc_api.uninstall_plugin("copytags") + assert error is not None, "Expected error uninstalling built-in plugin" + assert error.status == 403, f"Expected 403 for built-in uninstall, got {error.status}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + metadata_after = {p.namespace for p in response.plugins} + assert metadata_after == metadata_before, ( + f"Metadata plugin list changed after blocked uninstall. " + f"Removed: {metadata_before - metadata_after}, added: {metadata_after - metadata_before}" + ) + # <<<<< UNINSTALL BUILT-IN BLOCKED <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that registry, version, and sha256 provenance fields survive restart and explicit reinstall. + + 1. Install sample-downloader, capture provenance from install response. + 2. Verify provenance fields in plugin list. + 3. Restart LRR, verify provenance fields survive. + 4. Uninstall and reinstall explicitly, verify provenance fields are preserved. + """ + environment.setup(with_api_key=True) + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + version_key = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) + version_record = refresh_response.index["plugins"]["sample-downloader"]["versions"][version_key] + expected_sha = version_record["sha256"] + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=version_key) + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + assert response.registry == reg_id, f"Expected provenance {reg_id}, got: {response.registry}" + assert response.sha256 == expected_sha, ( + f"Expected install sha256 {expected_sha}, got {response.sha256}" + ) + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + plugin = next((p for p in response.plugins if p.namespace == "sample-downloader"), None) + assert plugin is not None, "sample-downloader missing from plugin list after install" + assert plugin.registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.registry}" + assert plugin.version == version_key, ( + f"Expected version {version_key!r}, got {plugin.version!r}" + ) + assert plugin.sha256 == expected_sha, ( + f"Expected sha256 {expected_sha}, got {plugin.sha256!r}" + ) + + environment.restart() + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list plugins after restart (status {error.status}): {error.error}" + plugin = next((p for p in response.plugins if p.namespace == "sample-downloader"), None) + assert plugin is not None, "sample-downloader missing from plugin list after restart" + assert plugin.registry == reg_id, ( + f"Expected provenance {reg_id} after restart, got {plugin.registry!r}" + ) + assert plugin.version == version_key, ( + f"Expected version {version_key!r} after restart, got {plugin.version!r}" + ) + assert plugin.sha256 == expected_sha, ( + f"Expected sha256 {expected_sha} after restart, got {plugin.sha256!r}" + ) + + response, error = await lrr_client.misc_api.uninstall_plugin("sample-downloader") + assert not error, f"Failed to uninstall plugin (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=version_key) + ) + assert not error, f"Failed to reinstall plugin (status {error.status}): {error.error}" + assert response.registry == reg_id, ( + f"Expected provenance {reg_id} after reinstall, got {response.registry!r}" + ) + assert response.sha256 == expected_sha, ( + f"Expected sha256 {expected_sha} after reinstall, got {response.sha256}" + ) + assert response.version == version_key, ( + f"Expected version {version_key!r} after reinstall, got {response.version!r}" + ) + + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test install error responses for invalid registry, missing index, and unknown namespace. + + 1. Install from nonexistent registry, expect 404. + 2. Create registry without refresh, install, expect 409. + 3. Refresh, then install nonexistent namespace, expect 404. + 4. Install with empty version string, expect schema rejection. + """ + environment.setup(with_api_key=True) + + # >>>>> INSTALL FROM NONEXISTENT REGISTRY >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001", version="1.0") + ) + assert error is not None, "Expected error for nonexistent registry" + assert "doesn't exist" in error.error, f"Expected nonexistent-registry rejection, got: {error.error!r}" + # <<<<< INSTALL FROM NONEXISTENT REGISTRY <<<<< + + # >>>>> INSTALL WITHOUT REFRESH >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version="1.0") + ) + assert error is not None, "Expected error when installing without refresh" + assert "No registry index cached" in error.error, f"Expected no-cached-index rejection, got: {error.error!r}" + # <<<<< INSTALL WITHOUT REFRESH <<<<< + + # >>>>> INSTALL NONEXISTENT NAMESPACE >>>>> + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="does-not-exist-xyz", registry=reg_id, version="1.0") + ) + assert error is not None, "Expected error for nonexistent namespace" + assert "not found in registry" in error.error, f"Expected unknown-namespace rejection, got: {error.error!r}" + # <<<<< INSTALL NONEXISTENT NAMESPACE <<<<< + + # >>>>> INSTALL EMPTY VERSION >>>>> + # Pydantic does not constrain version length; send raw to confirm OpenAPI + # rejects empty string via minLength: 1 before reaching the controller. + status, content = await lrr_client.handle_request( + http.HTTPMethod.POST, + lrr_client.build_url("/api/plugins/install"), + lrr_client.headers, + json_data={ + "namespace": "sample-downloader", + "registry": reg_id, + "version": "", + }, + ) + body = json.loads(content) + assert status == 400, f"Expected 400 for empty version, got {status}: {body}" + version_error = next((e for e in body.get("errors", []) if e.get("path") == "/body/version"), None) + assert version_error is not None, f"Expected length violation on /body/version, got: {body}" + # <<<<< INSTALL EMPTY VERSION <<<<< + + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_plugin_install_failed_require_rolls_back( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + Test that a managed install rolls back on require failure, for both fresh + install and same-path upgrade. + + Fresh install: + 1. Create local registry with one broken plugin (valid Perl, BEGIN { die }). + 2. Install the broken plugin; expect a non-2xx error response. + 3. Assert file absent, Redis hash empty, namespace absent from listing. + + Upgrade (same-path): + 4. Register a second plugin with two versions sharing one package: 1.0.0 loadable, 1.1.0 BEGIN-die. + 5. Install 1.0.0; capture file bytes, Redis hash, listing entry. + 6. Install 1.1.0; expect non-2xx error. + 7. Assert prior 1.0.0 bytes preserved on disk, Redis hash unchanged, listing unchanged. + """ + environment.setup(with_api_key=True) + + broken_ns = "sample-broken-tx-1" + broken_pm_name = "SampleBrokenTx1.pm" + broken_pm_body = ( + "package LANraragi::Plugin::Managed::Metadata::SampleBrokenTx1;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + "BEGIN { die 'boom' }\n" + "sub plugin_info {\n" + " return (\n" + " name => 'sample-broken-tx-1',\n" + " type => 'metadata',\n" + f" namespace => '{broken_ns}',\n" + " author => 'test',\n" + " version => '1.0',\n" + " );\n" + "}\n" + "sub get_tags { return (); }\n" + "1;\n" + ) + broken_pm_bytes = broken_pm_body.encode("utf-8") + broken_sha = hashlib.sha256(broken_pm_bytes).hexdigest() + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + plugin_rel_path = f"artifacts/{broken_ns}/1.0.0/{broken_pm_name}" + + upgrade_ns = "sample-upgrade-tx-1" + upgrade_pm_name = "SampleUpgradeTx1.pm" + upgrade_v1_body = ( + "package LANraragi::Plugin::Managed::Metadata::SampleUpgradeTx1;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + "sub plugin_info {\n" + " return (\n" + " name => 'sample-upgrade-tx-1',\n" + " type => 'metadata',\n" + f" namespace => '{upgrade_ns}',\n" + " author => 'test',\n" + " version => '1.0.0',\n" + " );\n" + "}\n" + "sub get_tags { return (); }\n" + "1;\n" + ) + upgrade_v2_body = ( + "package LANraragi::Plugin::Managed::Metadata::SampleUpgradeTx1;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + "BEGIN { die 'upgrade boom' }\n" + "sub plugin_info {\n" + " return (\n" + " name => 'sample-upgrade-tx-1',\n" + " type => 'metadata',\n" + f" namespace => '{upgrade_ns}',\n" + " author => 'test',\n" + " version => '1.1.0',\n" + " );\n" + "}\n" + "sub get_tags { return (); }\n" + "1;\n" + ) + upgrade_v1_bytes = upgrade_v1_body.encode("utf-8") + upgrade_v2_bytes = upgrade_v2_body.encode("utf-8") + upgrade_v1_sha = hashlib.sha256(upgrade_v1_bytes).hexdigest() + upgrade_v2_sha = hashlib.sha256(upgrade_v2_bytes).hexdigest() + upgrade_v1_rel_path = f"artifacts/{upgrade_ns}/1.0.0/{upgrade_pm_name}" + upgrade_v2_rel_path = f"artifacts/{upgrade_ns}/1.1.0/{upgrade_pm_name}" + + registry_data = { + "version": 1, + "generated_at": generated_at, + "plugins": { + broken_ns: { + "namespace": broken_ns, + "type": "metadata", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "sample-broken-tx-1", + "author": "test", + "description": "broken require test plugin", + "artifact": plugin_rel_path, + "sha256": broken_sha, + "published_at": generated_at, + }, + }, + }, + upgrade_ns: { + "namespace": upgrade_ns, + "type": "metadata", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "sample-upgrade-tx-1", + "author": "test", + "description": "good baseline for upgrade rollback test", + "artifact": upgrade_v1_rel_path, + "sha256": upgrade_v1_sha, + "published_at": generated_at, + }, + "1.1.0": { + "version": "1.1.0", + "name": "sample-upgrade-tx-1", + "author": "test", + "description": "broken upgrade target for rollback test", + "artifact": upgrade_v2_rel_path, + "sha256": upgrade_v2_sha, + "published_at": generated_at, + }, + }, + }, + }, + } + + registry = LocalRegistry(name="local-broken", root=environment.shared_dir / "local-broken") + registry.root.mkdir(parents=True, exist_ok=True) + + plugin_file = registry.root / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_bytes(broken_pm_bytes) + + upgrade_v1_file = registry.root / upgrade_v1_rel_path + upgrade_v1_file.parent.mkdir(parents=True, exist_ok=True) + upgrade_v1_file.write_bytes(upgrade_v1_bytes) + upgrade_v2_file = registry.root / upgrade_v2_rel_path + upgrade_v2_file.parent.mkdir(parents=True, exist_ok=True) + upgrade_v2_file.write_bytes(upgrade_v2_bytes) + + registry_json = registry.registry_json_path + registry_json.write_text(json.dumps(registry_data), encoding="utf-8") + + # >>>>> SETUP REGISTRY >>>>> + reg_id = await add_registry(lrr_client, environment, registry) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL BROKEN PLUGIN >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected error for broken plugin install" + assert "failed to load" in error.error, f"Expected broken-plugin load failure, got: {error.error!r}" + LOGGER.debug(f"Install broken plugin: status={error.status}, error={error.error!r}") + # <<<<< INSTALL BROKEN PLUGIN <<<<< + + # >>>>> ROLLBACK ASSERTIONS >>>>> + target_pm = environment.plugin_managed_dir / "Metadata" / broken_pm_name + assert not target_pm.exists(), f"Plugin file should be absent after failed install: {target_pm}" + + environment.redis_client.select(2) + redis_key = f"LRR_PLUGIN_{broken_ns.upper()}" + redis_hash = environment.redis_client.hgetall(redis_key) + assert not redis_hash, f"Expected empty Redis hash for {broken_ns} after failed install, got: {redis_hash}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list metadata plugins (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert broken_ns not in namespaces, f"{broken_ns} must be absent after failed install, got: {namespaces}" + # <<<<< ROLLBACK ASSERTIONS <<<<< + + # >>>>> INSTALL UPGRADE BASELINE v1.0.0 AND CAPTURE STATE >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace=upgrade_ns, registry=reg_id, version="1.0.0") + ) + assert not error, f"Failed to install upgrade baseline (status {error.status}): {error.error}" + + upgrade_target_pm = environment.plugin_managed_dir / "Metadata" / upgrade_pm_name + assert upgrade_target_pm.exists(), f"Upgrade baseline file missing after install: {upgrade_target_pm}" + captured_bytes = upgrade_target_pm.read_bytes() + assert captured_bytes == upgrade_v1_bytes, "Upgrade baseline bytes do not match registry artifact" + + environment.redis_client.select(2) + upgrade_redis_key = f"LRR_PLUGIN_{upgrade_ns.upper()}" + captured_redis = environment.redis_client.hgetall(upgrade_redis_key) + assert captured_redis, f"Expected non-empty Redis hash for {upgrade_ns} after baseline install" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list metadata plugins (status {error.status}): {error.error}" + captured_listing = next((p for p in response.plugins if p.namespace == upgrade_ns), None) + assert captured_listing is not None, f"{upgrade_ns} missing from listing after baseline install" + # <<<<< INSTALL UPGRADE BASELINE v1.0.0 AND CAPTURE STATE <<<<< + + # >>>>> ATTEMPT UPGRADE TO BROKEN v1.1.0 >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace=upgrade_ns, registry=reg_id, version="1.1.0") + ) + assert error is not None, "Expected error for broken upgrade install" + assert "failed to load" in error.error, f"Expected broken-upgrade load failure, got: {error.error!r}" + LOGGER.debug(f"Upgrade install: status={error.status}, error={error.error!r}") + # <<<<< ATTEMPT UPGRADE TO BROKEN v1.1.0 <<<<< + + # >>>>> UPGRADE ROLLBACK ASSERTIONS >>>>> + assert upgrade_target_pm.exists(), f"Prior artifact must remain on disk after failed upgrade: {upgrade_target_pm}" + assert upgrade_target_pm.read_bytes() == captured_bytes, ( + "Prior artifact bytes were mutated by failed upgrade; spec requires restore to last working plugin" + ) + + environment.redis_client.select(2) + after_redis = environment.redis_client.hgetall(upgrade_redis_key) + assert after_redis == captured_redis, ( + f"Redis hash for {upgrade_ns} changed after failed upgrade.\nBefore: {captured_redis}\nAfter: {after_redis}" + ) + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list metadata plugins (status {error.status}): {error.error}" + after_listing = next((p for p in response.plugins if p.namespace == upgrade_ns), None) + assert after_listing == captured_listing, ( + f"Listing for {upgrade_ns} changed after failed upgrade.\nBefore: {captured_listing}\nAfter: {after_listing}" + ) + # <<<<< UPGRADE ROLLBACK ASSERTIONS <<<<< + + response, error = await lrr_client.misc_api.uninstall_plugin(upgrade_ns) + assert not error, f"Failed to uninstall upgrade baseline (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + # expect_no_error_logs is intentionally omitted: the install attempts against + # deliberately broken plugins cause LRR to log server-side errors describing + # the failed require/rollback. Those logs are expected, not defects. + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_install_failure_preserves_other_plugins( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + Test that a failed managed install does not disturb a previously installed plugin. + + 1. Create local registry with two plugins: sample-good (loadable metadata) and sample-broken (BEGIN die). + 2. Install sample-good; assert success and capture full state. + 3. Install sample-broken; expect a non-2xx error response. + 4. Assert rollback for sample-broken (file absent, Redis empty, not in plugin list). + 5. Re-fetch sample-good state; assert it matches the captured snapshot byte-for-byte. + """ + environment.setup(with_api_key=True) + + good_ns = "sample-good-tx-1" + good_pm_name = "SampleGoodTx1.pm" + good_pm_body = ( + "package LANraragi::Plugin::Managed::Metadata::SampleGoodTx1;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + "sub plugin_info {\n" + " return (\n" + " name => 'sample-good-tx-1',\n" + " type => 'metadata',\n" + f" namespace => '{good_ns}',\n" + " author => 'test',\n" + " version => '1.0',\n" + " );\n" + "}\n" + "sub get_tags { return (); }\n" + "1;\n" + ) + + broken_ns = "sample-broken-tx-2" + broken_pm_name = "SampleBrokenTx2.pm" + broken_pm_body = ( + "package LANraragi::Plugin::Managed::Metadata::SampleBrokenTx2;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + "BEGIN { die 'boom' }\n" + "sub plugin_info {\n" + " return (\n" + " name => 'sample-broken-tx-2',\n" + " type => 'metadata',\n" + f" namespace => '{broken_ns}',\n" + " author => 'test',\n" + " version => '1.0',\n" + " );\n" + "}\n" + "sub get_tags { return (); }\n" + "1;\n" + ) + + good_pm_bytes = good_pm_body.encode("utf-8") + broken_pm_bytes = broken_pm_body.encode("utf-8") + good_sha = hashlib.sha256(good_pm_bytes).hexdigest() + broken_sha = hashlib.sha256(broken_pm_bytes).hexdigest() + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + good_rel_path = f"artifacts/{good_ns}/1.0.0/{good_pm_name}" + broken_rel_path = f"artifacts/{broken_ns}/1.0.0/{broken_pm_name}" + + registry = LocalRegistry(name="local-two-plugins", root=environment.shared_dir / "local-two-plugins") + registry.root.mkdir(parents=True, exist_ok=True) + + good_file = registry.root / good_rel_path + good_file.parent.mkdir(parents=True, exist_ok=True) + good_file.write_bytes(good_pm_bytes) + + broken_file = registry.root / broken_rel_path + broken_file.parent.mkdir(parents=True, exist_ok=True) + broken_file.write_bytes(broken_pm_bytes) + + registry_data = { + "version": 1, + "generated_at": generated_at, + "plugins": { + good_ns: { + "namespace": good_ns, + "type": "metadata", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "sample-good-tx-1", + "author": "test", + "description": "good metadata test plugin", + "artifact": good_rel_path, + "sha256": good_sha, + "published_at": generated_at, + }, + }, + }, + broken_ns: { + "namespace": broken_ns, + "type": "metadata", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "sample-broken-tx-2", + "author": "test", + "description": "broken metadata test plugin", + "artifact": broken_rel_path, + "sha256": broken_sha, + "published_at": generated_at, + }, + }, + }, + }, + } + registry_json = registry.registry_json_path + registry_json.write_text(json.dumps(registry_data), encoding="utf-8") + + # >>>>> SETUP REGISTRY >>>>> + reg_id = await add_registry(lrr_client, environment, registry) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL GOOD PLUGIN AND CAPTURE STATE >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace=good_ns, registry=reg_id, version="1.0.0") + ) + assert not error, f"Failed to install good plugin (status {error.status}): {error.error}" + assert response.namespace == good_ns + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list metadata plugins (status {error.status}): {error.error}" + good_plugin_before = next((p for p in response.plugins if p.namespace == good_ns), None) + assert good_plugin_before is not None, f"{good_ns} missing from plugin list after install" + + environment.redis_client.select(2) + good_redis_key = f"LRR_PLUGIN_{good_ns.upper()}" + good_redis_before = environment.redis_client.hgetall(good_redis_key) + assert good_redis_before, f"Expected non-empty Redis hash for {good_ns} after install" + LOGGER.debug(f"Captured good plugin Redis state: {good_redis_before}") + # <<<<< INSTALL GOOD PLUGIN AND CAPTURE STATE <<<<< + + # >>>>> INSTALL BROKEN PLUGIN >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected error for broken plugin install" + assert "failed to load" in error.error, f"Expected broken-plugin load failure, got: {error.error!r}" + LOGGER.debug(f"Install broken plugin: status={error.status}, error={error.error!r}") + # <<<<< INSTALL BROKEN PLUGIN <<<<< + + # >>>>> ROLLBACK ASSERTIONS FOR BROKEN >>>>> + broken_target = environment.plugin_managed_dir / "Metadata" / broken_pm_name + assert not broken_target.exists(), f"Broken plugin file should be absent after failed install: {broken_target}" + + environment.redis_client.select(2) + broken_redis_key = f"LRR_PLUGIN_{broken_ns.upper()}" + broken_redis_hash = environment.redis_client.hgetall(broken_redis_key) + assert not broken_redis_hash, ( + f"Expected empty Redis hash for {broken_ns} after failed install, got: {broken_redis_hash}" + ) + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list metadata plugins (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert broken_ns not in namespaces, f"{broken_ns} must be absent after failed install, got: {namespaces}" + # <<<<< ROLLBACK ASSERTIONS FOR BROKEN <<<<< + + # >>>>> GOOD PLUGIN STATE UNCHANGED >>>>> + good_plugin_after = next((p for p in response.plugins if p.namespace == good_ns), None) + assert good_plugin_after is not None, f"{good_ns} must still be listed after broken install attempt" + assert good_plugin_after == good_plugin_before, ( + f"Good plugin API state changed after broken install attempt.\n" + f"Before: {good_plugin_before}\nAfter: {good_plugin_after}" + ) + + environment.redis_client.select(2) + good_redis_after = environment.redis_client.hgetall(good_redis_key) + assert good_redis_after == good_redis_before, ( + f"Good plugin Redis hash changed after broken install attempt.\n" + f"Before: {good_redis_before}\nAfter: {good_redis_after}" + ) + # <<<<< GOOD PLUGIN STATE UNCHANGED <<<<< + + response, error = await lrr_client.misc_api.uninstall_plugin(good_ns) + assert not error, f"Failed to uninstall good plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + # expect_no_error_logs is intentionally omitted: the broken-plugin install + # attempt causes LRR to log a server-side error describing the failed + # require/rollback. That log is expected, not a defect. + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_server_restart_status(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test the server restart-pending flag across plugin install, upgrade, and uninstall. + + 1. Fresh server reports restart_required False. + 2. First-time install keeps it False (no worker had the artifact loaded). + 3. Reinstalling the same namespace sets it True (workers may hold the prior code). + 4. Restarting the server clears it back to False. + 5. Uninstalling the plugin sets it True again. + """ + environment.setup(with_api_key=True) + + plugin_ns = "sample-restart-1" + plugin_pm_name = "SampleRestart1.pm" + plugin_pm_body = ( + "package LANraragi::Plugin::Managed::Metadata::SampleRestart1;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + "sub plugin_info {\n" + " return (\n" + " name => 'sample-restart-1',\n" + " type => 'metadata',\n" + f" namespace => '{plugin_ns}',\n" + " author => 'test',\n" + " version => '1.0.0',\n" + " );\n" + "}\n" + "sub get_tags { return (); }\n" + "1;\n" + ) + plugin_bytes = plugin_pm_body.encode("utf-8") + plugin_sha = hashlib.sha256(plugin_bytes).hexdigest() + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + plugin_rel_path = f"artifacts/{plugin_ns}/1.0.0/{plugin_pm_name}" + + registry_data = { + "version": 1, + "generated_at": generated_at, + "plugins": { + plugin_ns: { + "namespace": plugin_ns, + "type": "metadata", + "versions": { + "1.0.0": { + "version": "1.0.0", + "name": "sample-restart-1", + "author": "test", + "description": "restart-status test plugin", + "artifact": plugin_rel_path, + "sha256": plugin_sha, + "published_at": generated_at, + }, + }, + }, + }, + } + registry = LocalRegistry(name="local-restart", root=environment.shared_dir / "local-restart") + registry.root.mkdir(parents=True, exist_ok=True) + + plugin_file = registry.root / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_bytes(plugin_bytes) + registry_json = registry.registry_json_path + registry_json.write_text(json.dumps(registry_data), encoding="utf-8") + + reg_id = await add_registry(lrr_client, environment, registry) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + # >>>>> FRESH SERVER >>>>> + response, error = await lrr_client.misc_api.get_server_info() + assert not error, f"Failed to get server info (status {getattr(error, 'status', None)})" + assert response.restart_required is False, "Fresh server should not report a pending restart" + # <<<<< FRESH SERVER <<<<< + + # >>>>> FIRST INSTALL DOES NOT REQUIRE RESTART >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace=plugin_ns, registry=reg_id, version="1.0.0") + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_server_info() + assert not error, f"Failed to get server info (status {getattr(error, 'status', None)})" + assert response.restart_required is False, "First-time install should not require a restart" + # <<<<< FIRST INSTALL DOES NOT REQUIRE RESTART <<<<< + + # >>>>> REINSTALL REQUIRES RESTART >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace=plugin_ns, registry=reg_id, version="1.0.0") + ) + assert not error, f"Failed to reinstall plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_server_info() + assert not error, f"Failed to get server info (status {getattr(error, 'status', None)})" + assert response.restart_required is True, "Reinstall of an already-registered plugin should require a restart" + # <<<<< REINSTALL REQUIRES RESTART <<<<< + + # >>>>> RESTART CLEARS THE FLAG >>>>> + environment.restart() + response, error = await lrr_client.misc_api.get_server_info() + assert not error, f"Failed to get server info (status {getattr(error, 'status', None)})" + assert response.restart_required is False, "Restart should clear the pending-restart flag" + # <<<<< RESTART CLEARS THE FLAG <<<<< + + # >>>>> UNINSTALL REQUIRES RESTART >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin(plugin_ns) + assert not error, f"Failed to uninstall plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_server_info() + assert not error, f"Failed to get server info (status {getattr(error, 'status', None)})" + assert response.restart_required is True, "Uninstall should set the pending-restart flag" + # <<<<< UNINSTALL REQUIRES RESTART <<<<< + + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test uninstall/reinstall lifecycle and orphaned provenance. + + 1. Create registry and refresh index. + 2. Install title-suffix-1, verify managed provenance. + 3. Uninstall, verify plugin absent from list. + 4. Reinstall, verify managed provenance preserved. + 5. Enable plugin, upload archive, verify title mutated. + 6. Delete registry, verify plugin still listed with orphaned provenance. + 7. Upload another archive, verify orphaned plugin still auto-executes. + 8. Uninstall orphaned plugin, verify success. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + title_suffix_1_version = max(refresh_response.index["plugins"]["title-suffix-1"]["versions"].keys()) + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="title-suffix-1", registry=reg_id, version=title_suffix_1_version) + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + assert response.registry == reg_id, f"Expected provenance {reg_id}, got: {response.registry}" + # <<<<< INSTALL <<<<< + + # >>>>> VERIFY INSTALLED >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "title-suffix-1": + assert plugin.registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.registry}" + break + else: + pytest.fail("title-suffix-1 not found after install") + # <<<<< VERIFY INSTALLED <<<<< + + # >>>>> UNINSTALL >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin("title-suffix-1") + assert not error, f"Failed to uninstall plugin (status {error.status}): {error.error}" + # <<<<< UNINSTALL <<<<< + + # >>>>> VERIFY REMOVED >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after uninstall (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert "title-suffix-1" not in namespaces, f"Plugin still in list after uninstall: {namespaces}" + # <<<<< VERIFY REMOVED <<<<< + + # >>>>> REINSTALL >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="title-suffix-1", registry=reg_id, version=title_suffix_1_version) + ) + assert not error, f"Failed to reinstall plugin (status {error.status}): {error.error}" + assert response.registry == reg_id, f"Expected provenance on reinstall {reg_id}, got: {response.registry}" + # <<<<< REINSTALL <<<<< + + # >>>>> VERIFY REINSTALLED >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after reinstall (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "title-suffix-1": + assert plugin.registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.registry}" + break + else: + pytest.fail("title-suffix-1 not found after reinstall") + # <<<<< VERIFY REINSTALLED <<<<< + + # >>>>> ENABLE AND VERIFY EXECUTION >>>>> + # Commented out: depended on metadata-plugin feature (update_metadata_plugin_config) removed + # from dev-registry/backend. The reinstall lifecycle and orphaned-provenance assertions + # below still exercise registry-side behavior. + # response, error = await lrr_client.misc_api.update_metadata_plugin_config( + # "title-suffix-1", UpdateMetadataPluginConfigRequest(enabled=True) + # ) + # assert not error, f"Failed to enable plugin (status {error.status}): {error.error}" + # + # with tempfile.TemporaryDirectory() as tmpdir: + # archive_path = create_archive_file(Path(tmpdir), "test_reinstall_exec", num_pages=1) + # response, error = await upload_archive( + # lrr_client, archive_path, archive_path.name, asyncio.Semaphore(1), + # title="base", tags="test:reinstall", + # ) + # assert not error, f"Upload failed (status {error.status}): {error.error}" + # arcid = response.arcid + # + # response, error = await lrr_client.archive_api.get_archive_metadata(GetArchiveMetadataRequest(arcid=arcid)) + # assert not error, f"Failed to get metadata (status {error.status}): {error.error}" + # assert response.title == "base-1", f"Expected 'base-1' after enabled plugin execution, got: {response.title!r}" + # <<<<< ENABLE AND VERIFY EXECUTION <<<<< + + # >>>>> ORPHANED PROVENANCE >>>>> + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after registry delete (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "title-suffix-1": + assert plugin.registry == reg_id, f"Expected orphaned provenance {reg_id}, got: {plugin.registry}" + break + else: + pytest.fail("title-suffix-1 should still be listed after registry delete") + # <<<<< ORPHANED PROVENANCE <<<<< + + # >>>>> ORPHANED PLUGIN STILL EXECUTES >>>>> + # with tempfile.TemporaryDirectory() as tmpdir: + # archive_path = create_archive_file(Path(tmpdir), "test_orphan_exec", num_pages=1) + # response, error = await upload_archive( + # lrr_client, archive_path, archive_path.name, asyncio.Semaphore(1), + # title="orphan", tags="test:orphan", + # ) + # assert not error, f"Upload failed (status {error.status}): {error.error}" + # arcid = response.arcid + # + # response, error = await lrr_client.archive_api.get_archive_metadata(GetArchiveMetadataRequest(arcid=arcid)) + # assert not error, f"Failed to get metadata (status {error.status}): {error.error}" + # assert response.title == "orphan-1", f"Expected 'orphan-1' from orphaned plugin, got: {response.title!r}" + # <<<<< ORPHANED PLUGIN STILL EXECUTES <<<<< + + # >>>>> UNINSTALL ORPHANED >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin("title-suffix-1") + assert not error, f"Failed to uninstall orphaned plugin (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after orphaned uninstall (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert "title-suffix-1" not in namespaces, f"Orphaned plugin still listed after uninstall: {namespaces}" + # <<<<< UNINSTALL ORPHANED <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_install_conflict(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test plugin install conflict detection and force install. + + 1. Write a .pm file declaring the same namespace as sample-metadata. + 2. Setup environment with the conflicting plugin. + 3. Create registry and refresh index. + 4. Install sample-metadata, expect non-managed conflict (400) -- user must remove first. + 5. Force install sample-metadata, expect same non-managed conflict (400) -- force does not bypass. + 6. Install sample-downloader (no conflict), expect success with provenance. + 7. Reinstall sample-downloader (same-registry upgrade), expect success. + """ + with tempfile.TemporaryDirectory() as tmpdir: + conflict_path = Path(tmpdir) / "SampleMetadata.pm" + conflict_path.write_text( + 'package LANraragi::Plugin::Metadata::Testing::SampleMetadata;\n' + 'sub plugin_info { return ( name => "Conflict", namespace => "sample-metadata", type => "metadata" ); }\n' + 'sub get_tags { return (); }\n' + '1;\n' + ) + environment.setup( + with_api_key=True, + plugin_paths={"Metadata": [str(conflict_path)]}, + ) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + sample_metadata_version = max(refresh_response.index["plugins"]["sample-metadata"]["versions"].keys()) + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL WITH NON-MANAGED CONFLICT >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) + ) + assert error is not None, "Expected error when installing plugin with existing non-managed copy" + assert "Remove it first" in error.error, f"Expected 'Remove it first' in error, got: {error.error}" + # <<<<< INSTALL WITH NON-MANAGED CONFLICT <<<<< + + # >>>>> FORCE INSTALL STILL BLOCKED OVER NON-MANAGED >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version, force=True) + ) + assert error is not None, "Expected error: force must not bypass non-managed conflict" + assert "Remove it first" in error.error, f"Expected 'Remove it first' in error, got: {error.error}" + # <<<<< FORCE INSTALL STILL BLOCKED OVER NON-MANAGED <<<<< + + # >>>>> INSTALL WITHOUT CONFLICT >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=sample_downloader_version) + ) + assert not error, f"Failed to install non-conflicting plugin (status {error.status}): {error.error}" + assert response.namespace == "sample-downloader" + assert response.registry == reg_id, f"Expected provenance {reg_id}, got: {response.registry}" + # <<<<< INSTALL WITHOUT CONFLICT <<<<< + + # >>>>> UPGRADE (REINSTALL) >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=sample_downloader_version) + ) + assert not error, f"Failed to reinstall/upgrade plugin (status {error.status}): {error.error}" + # <<<<< UPGRADE (REINSTALL) <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that uninstalled plugin is absent from plugin list across repeated cycles. + + Worker-lottery regression check: under prefork, each request may land on a + different worker, so a single uninstall->list cycle does not exercise every + worker's module/cache state. 5 cycles raise the probability that every + worker observes both the install and the post-uninstall state. + + 1. Create registry and refresh index. + 2. Run 5 cycles of: install sample-login, uninstall, verify absent from GET /api/plugins/login. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + sample_login_version = max(refresh_response.index["plugins"]["sample-login"]["versions"].keys()) + # <<<<< SETUP REGISTRY <<<<< + + for i in range(5): + LOGGER.debug(f"Cycle {i}: installing sample-login") + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-login", registry=reg_id, version=sample_login_version) + ) + assert not error, f"Cycle {i}: install failed (status {error.status}): {error.error}" + + LOGGER.debug(f"Cycle {i}: uninstalling sample-login") + response, error = await lrr_client.misc_api.uninstall_plugin("sample-login") + assert not error, f"Cycle {i}: uninstall failed (status {error.status}): {error.error}" + + LOGGER.debug(f"Cycle {i}: verifying absent from plugin list") + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="login") + ) + assert not error, f"Cycle {i}: list failed (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert "sample-login" not in namespaces, f"Cycle {i}: sample-login still listed after uninstall: {namespaces}" + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test cross-provenance force install flow: orphan upgrade attempt, mismatch error, and forced re-attribution. + + 1. Create reg A, refresh, install sample-downloader -> 200. + 2. Delete reg A -> plugin becomes orphan (registry field still points to A_id). + 3. Install from A_id -> 404 (registry not found). + 4. Create reg B (same URL/ref), different timestamp id. + 5. Install from B without force -> provenance mismatch error. + 6. Install from B with force=True -> 200, provenance updated to B_id. + 7. GET download plugins -> sample-downloader present with registry == B_id. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REG A >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo-A", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create reg A (status {error.status}): {error.error}" + reg_a_id = response.id + + refresh_a_response, error = await lrr_client.misc_api.refresh_registry(reg_a_id) + assert not error, f"Failed to refresh reg A (status {error.status}): {error.error}" + sample_downloader_version = max(refresh_a_response.index["plugins"]["sample-downloader"]["versions"].keys()) + # <<<<< SETUP REG A <<<<< + + # >>>>> INSTALL FROM REG A >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_a_id, version=sample_downloader_version) + ) + assert not error, f"Failed to install sample-downloader from reg A (status {error.status}): {error.error}" + assert response.registry == reg_a_id, f"Expected provenance {reg_a_id}, got: {response.registry}" + # <<<<< INSTALL FROM REG A <<<<< + + # >>>>> DELETE REG A -> ORPHAN >>>>> + response, error = await lrr_client.misc_api.delete_registry(reg_a_id) + assert not error, f"Failed to delete reg A (status {error.status}): {error.error}" + # Registry IDs are REG_{unix_timestamp}. Guarantee reg B gets a distinct + # timestamp so the provenance mismatch scenario below is actually reached. + await asyncio.sleep(1.0) + # <<<<< DELETE REG A -> ORPHAN <<<<< + + # >>>>> UPGRADE WITH ORPHAN REGISTRY -> 404 >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_a_id, version=sample_downloader_version) + ) + assert error is not None, "Expected error when installing from deleted registry" + assert "doesn't exist" in error.error, f"Expected deleted-registry rejection, got: {error.error!r}" + # <<<<< UPGRADE WITH ORPHAN REGISTRY -> 404 <<<<< + + # >>>>> CREATE REG B (SAME SOURCE) >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo-B", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create reg B (status {error.status}): {error.error}" + reg_b_id = response.id + assert reg_b_id != reg_a_id, "Expected reg B to have a different id than reg A" + + refresh_b_response, error = await lrr_client.misc_api.refresh_registry(reg_b_id) + assert not error, f"Failed to refresh reg B (status {error.status}): {error.error}" + sample_downloader_version_b = max(refresh_b_response.index["plugins"]["sample-downloader"]["versions"].keys()) + # <<<<< CREATE REG B (SAME SOURCE) <<<<< + + # >>>>> INSTALL FROM REG B WITHOUT FORCE -> PROVENANCE MISMATCH >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_b_id, version=sample_downloader_version_b) + ) + assert error is not None, "Expected provenance mismatch error when installing from different registry without force" + assert "already installed from" in error.error, f"Expected cross-registry provenance mismatch, got: {error.error!r}" + # <<<<< INSTALL FROM REG B WITHOUT FORCE -> PROVENANCE MISMATCH <<<<< + + # >>>>> INSTALL FROM REG B WITH FORCE -> 200 >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_b_id, version=sample_downloader_version_b, force=True) + ) + assert not error, f"Expected force install to succeed (status {error.status}): {error.error}" + assert response.registry == reg_b_id, f"Expected provenance {reg_b_id} after force install, got: {response.registry}" + # <<<<< INSTALL FROM REG B WITH FORCE -> 200 <<<<< + + # >>>>> VERIFY PROVENANCE UPDATED >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list download plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-downloader": + assert plugin.registry == reg_b_id, f"Expected provenance {reg_b_id}, got: {plugin.registry}" + break + else: + pytest.fail("sample-downloader not found in download plugin list after force install") + # <<<<< VERIFY PROVENANCE UPDATED <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_managed_plugin_upgrade_reloads_class( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + Test that managed plugin upgrade reloads the class metadata in every prefork worker. + + Each worker forks from master with the plugin's source file cached in %INC. + Without cross-worker coherence, only the installing worker sees the new file + after upgrade; other workers report stale plugin_info() until restart. + Round-robin routing across the connection pool exposes the inconsistency. + + 1. Install sample-script v1.0; prime every worker so each loads v1.0 into its %INC. + 2. Verify every response reports v1.0. + 3. Update the registry to v1.1, refresh, force-install. + 4. Fan out get_available_plugins concurrently; assert every response reports v1.1. + """ + environment.setup(with_api_key=True) + + # >>>>> INSTALL v1.0 FROM main >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + main_refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + main_version = max(main_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) + + _, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-script", registry=reg_id, version=main_version) + ) + assert not error, f"Failed to install sample-script v1.0 (status {error.status}): {error.error}" + + # Prime every prefork worker concurrently so each loads v1.0 into its own %INC. + # Concurrent requests force the client to open multiple connections, spreading across workers. + prime_results = await asyncio.gather(*[ + lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) + for _ in range(40) + ]) + for i, (response, error) in enumerate(prime_results): + assert not error, f"Prime attempt {i} failed (status {error.status}): {error.error}" + sample = next((p for p in response.plugins if p.namespace == "sample-script"), None) + assert sample is not None, f"Prime attempt {i}: sample-script not listed" + assert sample.version == main_version, ( + f"Prime attempt {i}: expected v{main_version}, got {sample.version!r}" + ) + # <<<<< INSTALL v1.0 FROM main <<<<< + + # >>>>> SWITCH REGISTRY TO v1.1 AND UPGRADE >>>>> + _, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(ref="v1.1") + ) + assert not error, f"Failed to update registry ref (status {error.status}): {error.error}" + + v11_refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry after ref change (status {error.status}): {error.error}" + v11_version = max(v11_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) + + _, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-script", registry=reg_id, version=v11_version, force=True) + ) + assert not error, f"Failed to upgrade sample-script to v1.1 (status {error.status}): {error.error}" + # <<<<< SWITCH REGISTRY TO v1.1 AND UPGRADE <<<<< + + # >>>>> RESTART, THEN VERIFY v1.1 IN LOADED CLASS ACROSS WORKERS >>>>> + # Inter-worker hot-reload was removed in favor of a restart signal: after an upgrade + # LRR flags restart_required, and workers only load the new class once restarted. + info, error = await lrr_client.misc_api.get_server_info() + assert not error, f"Failed to get server info (status {error.status}): {error.error}" + assert info.restart_required is True, "Upgrade of an already-registered plugin should require a restart" + + # Restart so the upgraded plugin code loads, then drop the now-dead keep-alive + # connections to the old container so the fan-out reconnects fresh. + environment.restart() + await lrr_client.close() + + # After restart every prefork worker loads the upgraded class fresh from disk; + # fan out concurrent reads across workers and require v1.1 everywhere. + verify_results = await asyncio.gather(*[ + lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) + for _ in range(40) + ]) + stale_responses = [] + for i, (response, error) in enumerate(verify_results): + assert not error, f"Verify attempt {i}: list scripts failed (status {error.status}): {error.error}" + sample = next((p for p in response.plugins if p.namespace == "sample-script"), None) + assert sample is not None, f"Verify attempt {i}: sample-script not listed" + if sample.version != v11_version: + stale_responses.append((i, sample.version)) + + assert not stale_responses, ( + f"{len(stale_responses)} of 40 responses still report v{main_version} plugin_info " + f"after upgrade + restart: {stale_responses[:5]}. Worker did not reload the upgraded class." + ) + # <<<<< RESTART, THEN VERIFY v1.1 IN LOADED CLASS ACROSS WORKERS <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_managed_plugin_upgrade_reloads_across_workers( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + Test that managed plugin upgrade reloads the class in every prefork worker. + + Each worker forks from master with the plugin's source file cached in %INC. + Without cross-worker coherence, only the installing worker sees the new file + after upgrade; other workers keep running the old symbols until restart. + Round-robin routing exposes the inconsistency. + + 1. Install sample-script v1.0 under default multi-worker prefork. + 2. Upgrade to v1.1 — run_script changes to prefix its result with "v1.1:". + 3. Fire use_plugin_sync across workers; assert every response reflects v1.1. + """ + environment.setup(with_api_key=True) + + # >>>>> INSTALL v1.0 FROM main >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + main_refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + main_version = max(main_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) + + _, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-script", registry=reg_id, version=main_version) + ) + assert not error, f"Failed to install sample-script v1.0 (status {error.status}): {error.error}" + + # Prime every prefork worker concurrently so each loads v1.0 into its own + # %INC + symbol table. Concurrent requests force the client to open multiple + # connections, spreading across workers. A serial keep-alive loop would pin + # to a single worker and not reproduce the bug. + prime_results = await asyncio.gather(*[ + lrr_client.misc_api.use_plugin( + UsePluginRequest(plugin="sample-script", arg=f"prime-{i}") + ) + for i in range(40) + ]) + for i, (_, error) in enumerate(prime_results): + assert not error, f"Prime attempt {i} failed (status {error.status}): {error.error}" + # <<<<< INSTALL v1.0 FROM main <<<<< + + # >>>>> UPGRADE TO v1.1 >>>>> + _, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(ref="v1.1") + ) + assert not error, f"Failed to update registry ref (status {error.status}): {error.error}" + + v11_refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry after ref change (status {error.status}): {error.error}" + v11_version = max(v11_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) + + _, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-script", registry=reg_id, version=v11_version, force=True) + ) + assert not error, f"Failed to upgrade sample-script to v1.1 (status {error.status}): {error.error}" + # <<<<< UPGRADE TO v1.1 <<<<< + + # >>>>> RESTART, THEN VERIFY v1.1 ACROSS WORKERS >>>>> + # Inter-worker hot-reload was removed in favor of a restart signal: after an upgrade + # LRR flags restart_required, and workers only load the new class once restarted. + # v1.1 run_script prefixes its result with "v1.1:". v1.0 returns the raw arg. + info, error = await lrr_client.misc_api.get_server_info() + assert not error, f"Failed to get server info (status {error.status}): {error.error}" + assert info.restart_required is True, "Upgrade of an already-registered plugin should require a restart" + + # Restart so the upgraded plugin code loads, then drop the now-dead keep-alive + # connections to the old container so the fan-out reconnects fresh. + environment.restart() + await lrr_client.close() + + # After restart, concurrent requests spread across workers must all run v1.1 symbols. + verify_results = await asyncio.gather(*[ + lrr_client.misc_api.use_plugin( + UsePluginRequest(plugin="sample-script", arg=f"ping-{i}") + ) + for i in range(40) + ]) + v10_responses = [] + for i, (response, error) in enumerate(verify_results): + assert not error, f"Attempt {i}: use_plugin failed (status {error.status}): {error.error}" + result = response.data.get("result") if response.data else None + assert result is not None, f"Attempt {i}: use_plugin returned no result" + if not result.startswith("v1.1:"): + v10_responses.append((i, result)) + + assert not v10_responses, ( + f"{len(v10_responses)} of 40 responses still running v1.0 symbols after upgrade + restart: " + f"{v10_responses[:5]}. Worker did not reload the upgraded class." + ) + # <<<<< RESTART, THEN VERIFY v1.1 ACROSS WORKERS <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_managed_plugin_upgrade_reloads_across_workers_via_list_plugins( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + User expectation: after upgrading a plugin from a registry, the plugin + settings page (and any other UI listing plugins by type) reflects the + new version immediately on every request, regardless of which prefork + worker handles the listing call. + + This is the listing-path counterpart to + `test_managed_plugin_upgrade_reloads_across_workers`. That sibling + exercises the invocation path (`use_plugin`); this one exercises the + listing path (`list_plugins` -> `get_plugins`), which is the path the + settings page UI consumes. + + 1. Install sample-script v1.0 from the demo registry under default multi-worker prefork. + 2. Prime every prefork worker via concurrent `list_plugins` calls so each + loads the v1.0 class through the listing path. + 3. Upgrade to v1.1 (different ref publishes a new artifact bytes). + 4. Fire concurrent `list_plugins` calls; assert every worker reports v1.1. + A worker that returns v1.0 indicates the listing path short-circuited + on cached %INC without checking for an upgrade. + """ + environment.setup(with_api_key=True) + + # >>>>> INSTALL v1.0 FROM main >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + main_refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + main_version = max(main_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) + + _, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-script", registry=reg_id, version=main_version) + ) + assert not error, f"Failed to install sample-script v1.0 (status {error.status}): {error.error}" + # <<<<< INSTALL v1.0 FROM main <<<<< + + # >>>>> PRIME EVERY WORKER VIA list_plugins >>>>> + # Concurrent listing requests force the client to open multiple connections, + # spreading across workers. Each prefork worker loads the v1.0 class into + # its own %INC + symbol table the first time it serves a list_plugins call + # for the script type. + prime_results = await asyncio.gather(*[ + lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) + for _ in range(40) + ]) + for i, (response, error) in enumerate(prime_results): + assert not error, f"Prime listing {i} failed (status {error.status}): {error.error}" + sample = next((p for p in response.plugins if p.namespace == "sample-script"), None) + assert sample is not None, f"sample-script missing from prime listing {i}" + assert sample.version == main_version, ( + f"Prime listing {i}: expected v{main_version}, got {sample.version!r}" + ) + # <<<<< PRIME EVERY WORKER VIA list_plugins <<<<< + + # >>>>> UPGRADE TO v1.1 >>>>> + _, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(ref="v1.1") + ) + assert not error, f"Failed to update registry ref (status {error.status}): {error.error}" + + v11_refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry after ref change (status {error.status}): {error.error}" + v11_version = max(v11_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) + assert v11_version != main_version, ( + f"Demo registry must publish a different version on the v1.1 ref; got {v11_version!r}" + ) + + _, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-script", registry=reg_id, version=v11_version, force=True) + ) + assert not error, f"Failed to upgrade sample-script to v1.1 (status {error.status}): {error.error}" + # <<<<< UPGRADE TO v1.1 <<<<< + + # >>>>> RESTART, THEN VERIFY v1.1 IN LISTING ACROSS WORKERS >>>>> + # Inter-worker hot-reload was removed in favor of a restart signal: after an upgrade + # LRR flags restart_required, and workers only load the new class once restarted. + info, error = await lrr_client.misc_api.get_server_info() + assert not error, f"Failed to get server info (status {error.status}): {error.error}" + assert info.restart_required is True, "Upgrade of an already-registered plugin should require a restart" + + # Restart so the upgraded plugin code loads, then drop the now-dead keep-alive + # connections to the old container so the fan-out reconnects fresh. + environment.restart() + await lrr_client.close() + + verify_results = await asyncio.gather(*[ + lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) + for _ in range(40) + ]) + stale_listings = [] + for i, (response, error) in enumerate(verify_results): + assert not error, f"Verify listing {i} failed (status {error.status}): {error.error}" + sample = next((p for p in response.plugins if p.namespace == "sample-script"), None) + assert sample is not None, f"sample-script missing from verify listing {i}" + if sample.version != v11_version: + stale_listings.append((i, sample.version)) + + assert not stale_listings, ( + f"{len(stale_listings)} of 40 list_plugins responses still report the old version " + f"after upgrade + restart: {stale_listings[:5]}. Worker did not reload the upgraded class." + ) + # <<<<< RESTART, THEN VERIFY v1.1 IN LISTING ACROSS WORKERS <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that a managed plugin file persists across LRR restart and scan_plugins does not orphan it. + + Also exercises type self-heal: pre-PR Redis state lacks the `type` field; on restart, + scan_plugins repopulates it from plugin_info() discovery. + + 1. Create registry, refresh, install sample-downloader -> 200. + 2. Capture installed version and expected host path under plugin_managed_dir. + 3. Assert host path exists before restart. + 4. Delete the `type` field from Redis to simulate a pre-PR install. + 5. Restart LRR. + 6. Assert host path still exists after restart and `type` was self-healed to "download". + 7. GET download plugins -> sample-downloader present, registry provenance unchanged. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP AND INSTALL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=sample_downloader_version) + ) + assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" + installed_version = response.version + installed_sha256 = response.sha256 + # <<<<< SETUP AND INSTALL <<<<< + + # >>>>> SIMULATE PRE-PR STATE: TYPE FIELD ABSENT >>>>> + environment.redis_client.select(2) + assert environment.redis_client.hdel("LRR_PLUGIN_SAMPLE-DOWNLOADER", "type") == 1, ( + "Expected `type` field to exist before deletion" + ) + # <<<<< SIMULATE PRE-PR STATE <<<<< + + # >>>>> RESTART >>>>> + environment.restart() + # <<<<< RESTART <<<<< + + # >>>>> ASSERT FILE AND PROVENANCE SURVIVE RESTART >>>>> + plugin_file = environment.plugin_managed_dir / "Download" / "SampleDownload.pm" + assert plugin_file.exists(), f"Expected plugin file at {plugin_file} after restart" + + environment.redis_client.select(2) + healed_type = environment.redis_client.hget("LRR_PLUGIN_SAMPLE-DOWNLOADER", "type") + assert healed_type == "download", f"Expected scan_plugins to self-heal type=download, got {healed_type!r}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list download plugins after restart (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-downloader": + assert plugin.registry == reg_id, f"Expected provenance {reg_id} after restart, got: {plugin.registry}" + assert plugin.version == installed_version, ( + f"Expected version {installed_version!r} after restart, got: {plugin.version!r}" + ) + assert plugin.sha256 == installed_sha256, ( + f"Expected sha256 {installed_sha256!r} after restart, got: {plugin.sha256!r}" + ) + break + else: + pytest.fail("sample-downloader not found in download plugin list after restart") + # <<<<< ASSERT FILE AND PROVENANCE SURVIVE RESTART <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_file_deleted_under_lrr(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that a managed plugin deleted from the filesystem is orphan-cleaned by scan_plugins at restart. + + 1. Install sample-downloader from registry -> 200. + 2. Delete the plugin file directly from the host (plugin_managed_dir / "Download" / "SampleDownload.pm"). + 3. Restart LRR (triggers scan_plugins). + 4. GET download plugins -> sample-downloader absent (orphan-clean removed provenance). + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP AND INSTALL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) + + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=sample_downloader_version) + ) + assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" + # <<<<< SETUP AND INSTALL <<<<< + + # >>>>> DELETE PLUGIN FILE HOST-SIDE >>>>> + plugin_file = environment.plugin_managed_dir / "Download" / "SampleDownload.pm" + assert plugin_file.exists(), f"Expected plugin file at {plugin_file} before deletion" + plugin_file.unlink() + assert not plugin_file.exists(), "Plugin file should be gone after unlink" + # <<<<< DELETE PLUGIN FILE HOST-SIDE <<<<< + + # >>>>> RESTART TRIGGERS ORPHAN CLEANUP >>>>> + environment.restart() + # <<<<< RESTART TRIGGERS ORPHAN CLEANUP <<<<< + + # >>>>> VERIFY PLUGIN ABSENT AFTER SCAN >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list download plugins after restart (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert "sample-downloader" not in namespaces, ( + f"sample-downloader should be orphan-cleaned after file deletion and restart, got: {namespaces}" + ) + # <<<<< VERIFY PLUGIN ABSENT AFTER SCAN <<<<< + + expect_no_error_logs(environment, LOGGER) diff --git a/integration_tests/tests/registry/test_plugin_ui.py b/integration_tests/tests/registry/test_plugin_ui.py new file mode 100644 index 00000000..f49aa218 --- /dev/null +++ b/integration_tests/tests/registry/test_plugin_ui.py @@ -0,0 +1,173 @@ +""" +Plugin registry Playwright UI integration tests. +""" + +import logging + +import playwright.async_api +import playwright.async_api._generated +import pytest +from lanraragi.clients.client import LRRClient +from lanraragi.models.misc import ( + CreateRegistryRequest, + GetAvailablePluginsRequest, +) + +from aio_lanraragi_tests.common import DEFAULT_LRR_PASSWORD +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) +from aio_lanraragi_tests.utils.playwright import ( + assert_browser_responses_ok, + assert_console_logs_ok, +) + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.playwright +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test plugin install / uninstall / reinstall through the Manage tab batch UI. + + 1. Create registry, refresh index via API. + 2. Manage tab: toggle sample-metadata checkbox, click Apply, verify install. + 3. Managed badge renders in the Configure tab. + 4. Manage tab: toggle checkbox off, click Apply, verify uninstall. + 5. Card absent from Configure tab, namespace absent from API. + 6. Refresh registry, verify sample-metadata available for reinstall. + 7. Reinstall via Apply, verify managed provenance. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + # <<<<< SETUP REGISTRY <<<<< + + async with playwright.async_api.async_playwright() as p: + browser = await p.chromium.launch() + bc = await browser.new_context() + + try: + page = await bc.new_page() + + responses: list[playwright.async_api._generated.Response] = [] + console_evts: list[playwright.async_api._generated.ConsoleMessage] = [] + page.on("response", lambda response: responses.append(response)) + page.on("console", lambda console: console_evts.append(console)) + + # >>>>> LOGIN >>>>> + await page.goto(f"{lrr_client.lrr_base_url}/config/plugins#tab-manage") + await page.wait_for_load_state("networkidle") + + if "login" in page.url.lower(): + await page.fill("#pw_field", DEFAULT_LRR_PASSWORD) + await page.click("input[type='submit'][value='Login']") + await page.wait_for_load_state("networkidle") + assert "plugins" in page.url, f"Expected plugins page, got: {page.url}" + responses.clear() + console_evts.clear() + # <<<<< LOGIN <<<<< + + # >>>>> EXPAND MANAGE SECTION AND REFRESH AVAILABLE >>>>> + await page.locator("#manage-section-metadata .collapsible-title", has_text="Metadata Plugins").click() + await page.locator("#registry-refresh-btn").click() + await page.locator(".manage-install-cb[data-namespace='sample-metadata']").wait_for(state="attached") + # <<<<< EXPAND MANAGE SECTION AND REFRESH AVAILABLE <<<<< + + # >>>>> INSTALL VIA BATCH APPLY >>>>> + # Check the install checkbox — this marks the plugin for install + # without firing the request. The batch fires on #manage-apply-btn, + # which first opens a SweetAlert confirm popup (plugins.js:165). + await page.locator(".manage-install-cb[data-namespace='sample-metadata']").check() + await page.locator("#manage-apply-btn").click() + await page.locator(".swal2-confirm").wait_for(state="visible") + async with page.expect_response("**/api/plugins/install") as response_info: + await page.locator(".swal2-confirm").click() + install_response = await response_info.value + assert install_response.ok, f"Install API failed: {install_response.status}" + + # Apply reloads the page (plugins.js:553-554); wait for it to settle. + await page.wait_for_load_state("networkidle") + # <<<<< INSTALL VIA BATCH APPLY <<<<< + + # >>>>> VERIFY INSTALLED >>>>> + badge = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") + await badge.wait_for(state="attached") + assert await badge.text_content() == "managed", \ + f"Expected 'managed' badge after install, got: {await badge.text_content()}" + # <<<<< VERIFY INSTALLED <<<<< + + # >>>>> UNINSTALL VIA BATCH APPLY >>>>> + # Post-reload, the Manage tab reloads available plugins automatically + # (plugins.js:596-598 when #tab-manage hash is active on page load). + # Re-expand the section in case it collapsed, then toggle the cb off. + await page.locator("#manage-section-metadata .collapsible-title", has_text="Metadata Plugins").click() + await page.locator(".manage-install-cb[data-namespace='sample-metadata']").wait_for(state="attached") + await page.locator(".manage-install-cb[data-namespace='sample-metadata']").uncheck() + + await page.locator("#manage-apply-btn").click() + await page.locator(".swal2-confirm").wait_for(state="visible") + async with page.expect_response("**/api/plugins/installed/sample-metadata") as response_info: + await page.locator(".swal2-confirm").click() + uninstall_response = await response_info.value + assert uninstall_response.ok, f"Uninstall API failed: {uninstall_response.status}" + + await page.wait_for_load_state("networkidle") + # <<<<< UNINSTALL VIA BATCH APPLY <<<<< + + # >>>>> VERIFY REMOVED >>>>> + card_count = await page.locator(".plugin-card[data-namespace='sample-metadata']").count() + assert card_count == 0, f"sample-metadata still in DOM after uninstall (count: {card_count})" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="metadata") + ) + assert not error, f"Failed to list plugins after uninstall (status {error.status}): {error.error}" + namespaces = {p.namespace for p in response.plugins} + assert "sample-metadata" not in namespaces, f"Plugin still in API after uninstall: {namespaces}" + # <<<<< VERIFY REMOVED <<<<< + + # >>>>> REINSTALL >>>>> + await page.locator("#manage-section-metadata .collapsible-title", has_text="Metadata Plugins").click() + await page.locator(".manage-install-cb[data-namespace='sample-metadata']").wait_for(state="attached") + await page.locator(".manage-install-cb[data-namespace='sample-metadata']").check() + + await page.locator("#manage-apply-btn").click() + await page.locator(".swal2-confirm").wait_for(state="visible") + async with page.expect_response("**/api/plugins/install") as response_info: + await page.locator(".swal2-confirm").click() + reinstall_response = await response_info.value + assert reinstall_response.ok, f"Reinstall API failed: {reinstall_response.status}" + + await page.wait_for_load_state("networkidle") + + badge_after = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") + await badge_after.wait_for(state="attached") + assert await badge_after.text_content() == "managed", \ + f"Expected 'managed' after reinstall, got: {await badge_after.text_content()}" + # <<<<< REINSTALL <<<<< + + await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) + await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) + finally: + await bc.close() + await browser.close() + + expect_no_error_logs(environment, LOGGER) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py new file mode 100644 index 00000000..652ab3ee --- /dev/null +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -0,0 +1,514 @@ +""" +Plugin registry CRUD integration tests. +""" + +import http +import json +import logging + +import pytest +from lanraragi.clients.client import LRRClient +from lanraragi.models.misc import ( + CreateRegistryRequest, + GetAvailablePluginsRequest, + InstallPluginRequest, + UpdateRegistryRequest, +) + +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) +from aio_lanraragi_tests.utils.api_wrappers import install_plugin_and_wait + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test registry CRUD operations with REG_ pattern. + + 1. List registries when none configured. + 2. Create a git registry, verify ID returned. + 3. Get registry by ID, verify fields. + 4. Update registry name, verify no index cleared. + 5. Delete registry by ID, verify list is empty. + 6. Create a local registry, verify fields. + """ + environment.setup(with_api_key=True) + + # >>>>> LIST EMPTY >>>>> + response, error = await lrr_client.misc_api.list_registries() + assert not error, f"Failed to list registries (status {error.status}): {error.error}" + assert len(response.registries) == 0, f"Expected empty list, got: {response.registries}" + # <<<<< LIST EMPTY <<<<< + + # >>>>> CREATE GIT REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo plugins", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + assert reg_id.startswith("REG_"), f"Expected REG_ prefix, got: {reg_id}" + assert len(reg_id) == 14, f"Expected 14 char ID, got {len(reg_id)}: {reg_id}" + # <<<<< CREATE GIT REGISTRY <<<<< + + # >>>>> GET BY ID >>>>> + response, error = await lrr_client.misc_api.get_registry(reg_id) + assert not error, f"Failed to get registry (status {error.status}): {error.error}" + assert response.registry.id == reg_id, f"Expected registry.id {reg_id}, got: {response.registry.id}" + assert response.registry.name == "demo plugins" + assert response.registry.provider == "github" + assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" + assert response.registry.ref == "main" + + # The id lives inside the metadata object; the outer body must not duplicate it. + status, content = await lrr_client.handle_request( + http.HTTPMethod.GET, lrr_client.build_url(f"/api/registries/{reg_id}"), lrr_client.headers + ) + body = json.loads(content) + assert status == 200, f"Expected 200 from get registry, got {status}: {body}" + assert "id" not in body, f"Outer id should be absent (lives in registry.id), got: {body}" + assert body["registry"]["id"] == reg_id, f"Expected registry.id {reg_id}, got: {body['registry'].get('id')}" + # <<<<< GET BY ID <<<<< + + # >>>>> UPDATE NAME ONLY >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(name="renamed plugins") + ) + assert not error, f"Failed to update registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_registry(reg_id) + assert not error, f"Failed to get registry (status {error.status}): {error.error}" + assert response.registry.name == "renamed plugins" + # <<<<< UPDATE NAME ONLY <<<<< + + # >>>>> DELETE >>>>> + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.list_registries() + assert not error, f"Failed to list registries after delete (status {error.status}): {error.error}" + assert len(response.registries) == 0, f"Expected empty list after delete, got: {response.registries}" + # <<<<< DELETE <<<<< + + # >>>>> CREATE LOCAL REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="local plugins", provider="local", path="/home/koyomi/plugins") + ) + assert not error, f"Failed to create local registry (status {error.status}): {error.error}" + local_reg_id = response.id + + response, error = await lrr_client.misc_api.get_registry(local_reg_id) + assert not error, f"Failed to get local registry (status {error.status}): {error.error}" + assert response.registry.provider == "local" + assert response.registry.path == "/home/koyomi/plugins" + + response, error = await lrr_client.misc_api.delete_registry(local_reg_id) + assert not error, f"Failed to delete local registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.list_registries() + assert not error, f"Failed to list registries after local delete (status {error.status}): {error.error}" + assert len(response.registries) == 0, f"Expected empty list after local delete, got: {response.registries}" + # <<<<< CREATE LOCAL REGISTRY <<<<< + + # >>>>> CREATE CDN REGISTRY (https) >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="cdn plugins", provider="cdn", url="https://cdn.example.com/plugins") + ) + assert not error, f"Failed to create CDN registry (status {error.status}): {error.error}" + cdn_reg_id = response.id + assert cdn_reg_id.startswith("REG_"), f"Expected REG_ prefix, got: {cdn_reg_id}" + + response, error = await lrr_client.misc_api.get_registry(cdn_reg_id) + assert not error, f"Failed to get CDN registry (status {error.status}): {error.error}" + assert response.registry.provider == "cdn" + assert response.registry.url == "https://cdn.example.com/plugins" + assert response.registry.ref is None, "CDN registry should not carry a ref" + assert response.registry.path is None, "CDN registry should not carry a path" + + response, error = await lrr_client.misc_api.delete_registry(cdn_reg_id) + assert not error, f"Failed to delete CDN registry (status {error.status}): {error.error}" + # <<<<< CREATE CDN REGISTRY <<<<< + + # >>>>> CREATE CDN REGISTRY (http allowed) >>>>> + # Spec: CDN transport accepts http:// in addition to https://. Git remains HTTPS-only. + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="cdn http", provider="cdn", url="http://cdn.example.com/plugins") + ) + assert not error, f"Failed to create http CDN registry (status {error.status}): {error.error}" + cdn_http_reg_id = response.id + + response, error = await lrr_client.misc_api.delete_registry(cdn_http_reg_id) + assert not error, f"Failed to delete http CDN registry (status {error.status}): {error.error}" + # <<<<< CREATE CDN REGISTRY (http allowed) <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_registry_create_validation(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test registry create validation rejects invalid configurations. + + 1. Create git registry without url, expect error. + 2. Create local registry without path, expect error. + 3. Create git registry with HTTP url, expect error. + 4. Create registry without name, expect error. + 5. Create registry with invalid type enum value, expect error. + 6. Create git registry with empty ref, expect error. + """ + environment.setup(with_api_key=True) + + # >>>>> MISSING URL FOR GIT >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="bad git", provider="github", ref="main") + ) + assert error is not None, "Expected error for git registry without url" + assert error.status == 400, f"Expected 400 for git registry without url, got {error.status}" + # <<<<< MISSING URL FOR GIT <<<<< + + # >>>>> MISSING PATH FOR LOCAL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="bad local", provider="local") + ) + assert error is not None, "Expected error for local registry without path" + assert error.status == 400, f"Expected 400 for local registry without path, got {error.status}" + # <<<<< MISSING PATH FOR LOCAL <<<<< + + # >>>>> RELATIVE PATH FOR LOCAL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="relative local", provider="local", path="./plugins") + ) + assert error is not None, "Expected error for local registry with relative path" + assert error.status == 400, f"Expected 400 for local registry with relative path, got {error.status}" + # <<<<< RELATIVE PATH FOR LOCAL <<<<< + + # >>>>> NON-HTTPS URL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="http git", provider="github", url="http://github.com/owner/repo.git", ref="main") + ) + assert error is not None, "Expected error for non-HTTPS git URL" + assert error.status == 400, f"Expected 400 for non-HTTPS git URL, got {error.status}" + # <<<<< NON-HTTPS URL <<<<< + + # >>>>> MISSING URL FOR CDN >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="bad cdn", provider="cdn") + ) + assert error is not None, "Expected error for CDN registry without url" + assert error.status == 400, f"Expected 400 for CDN registry without url, got {error.status}" + # <<<<< MISSING URL FOR CDN <<<<< + + # >>>>> NON-HTTP(S) SCHEME FOR CDN >>>>> + # CDN spec allows http:// or https:// only. ftp:// must be rejected. + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="ftp cdn", provider="cdn", url="ftp://cdn.example.com/plugins") + ) + assert error is not None, "Expected error for non-http(s) CDN URL" + assert error.status == 400, f"Expected 400 for non-http(s) CDN URL, got {error.status}" + # <<<<< NON-HTTP(S) SCHEME FOR CDN <<<<< + + # >>>>> MISSING NAME >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="", provider="local", path="/tmp/plugins") + ) + assert error is not None, "Expected error for missing registry name" + assert error.status == 400, f"Expected 400 for missing registry name, got {error.status}" + # <<<<< MISSING NAME <<<<< + + # >>>>> INVALID PROVIDER ENUM >>>>> + # Pydantic Literal["github", ...] blocks case typos at the client; send raw + # to confirm OpenAPI rejects before the controller derefs $PROVIDER_FIELDS{$provider}. + status, content = await lrr_client.handle_request( + http.HTTPMethod.POST, + lrr_client.build_url("/api/registries"), + lrr_client.headers, + json_data={ + "name": "bad provider", + "provider": "Git", + "url": "https://github.com/owner/repo.git", + "ref": "main", + }, + ) + body = json.loads(content) + assert status == 400, f"Expected 400 for invalid provider enum, got {status}: {body}" + provider_error = next((e for e in body.get("errors", []) if e.get("path") == "/body/provider"), None) + assert provider_error is not None, f"Expected enum violation on /body/provider, got: {body}" + assert "enum" in provider_error.get("message", "").lower(), f"Expected enum-list message, got: {provider_error}" + # <<<<< INVALID PROVIDER ENUM <<<<< + + # >>>>> EMPTY REF >>>>> + # Pydantic ref: str | None accepts ""; send raw to assert OpenAPI rejects + # before an empty ref is stored and propagates to malformed git raw URLs. + status, content = await lrr_client.handle_request( + http.HTTPMethod.POST, + lrr_client.build_url("/api/registries"), + lrr_client.headers, + json_data={ + "name": "empty ref", + "provider": "github", + "url": "https://github.com/owner/repo.git", + "ref": "", + }, + ) + body = json.loads(content) + assert status == 400, f"Expected 400 for empty ref, got {status}: {body}" + ref_error = next((e for e in body.get("errors", []) if e.get("path") == "/body/ref"), None) + assert ref_error is not None, f"Expected length violation on /body/ref, got: {body}" + # <<<<< EMPTY REF <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_registry_error_paths(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test error responses for get, update, and delete on nonexistent registries. + + 1. Get nonexistent registry, expect 404. + 2. Update nonexistent registry, expect 404. + 3. Delete nonexistent registry, expect 404. + 4. Create registry, update with empty body, expect error. + 5. Update with non-HTTPS url, expect error. + 6. Update type to git without url+ref on a local registry, expect error. + 7. Update with mixed valid + type-invalid fields, expect error. + 8. Update with empty name, expect error. + """ + environment.setup(with_api_key=True) + + fake_id = "REG_0000000001" + + # >>>>> GET NONEXISTENT >>>>> + response, error = await lrr_client.misc_api.get_registry(fake_id) + assert error is not None, "Expected error for nonexistent registry" + assert error.status == 404, f"Expected 404, got {error.status}" + # <<<<< GET NONEXISTENT <<<<< + + # >>>>> UPDATE NONEXISTENT >>>>> + response, error = await lrr_client.misc_api.update_registry( + fake_id, UpdateRegistryRequest(name="nope") + ) + assert error is not None, "Expected error updating nonexistent registry" + assert error.status == 404, f"Expected 404 for update nonexistent, got {error.status}" + # <<<<< UPDATE NONEXISTENT <<<<< + + # >>>>> DELETE NONEXISTENT >>>>> + response, error = await lrr_client.misc_api.delete_registry(fake_id) + assert error is not None, "Expected error deleting nonexistent registry" + assert error.status == 404, f"Expected 404 for delete nonexistent, got {error.status}" + # <<<<< DELETE NONEXISTENT <<<<< + + # >>>>> EMPTY UPDATE >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="test", provider="local", path="/tmp/plugins") + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest() + ) + assert error is not None, "Expected error for empty update body" + assert error.status == 400, f"Expected 400 for empty update body, got {error.status}" + # <<<<< EMPTY UPDATE <<<<< + + # >>>>> NON-HTTPS URL ON UPDATE >>>>> + # User expectation: updating a registry must reject plaintext HTTP just like + # creation does, so plugin artifacts can't be fetched over an insecure channel. + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(provider="github", url="http://example.com/repo.git", ref="main") + ) + assert error is not None, "Expected error for non-HTTPS URL on update" + assert error.status == 400, f"Expected 400 for non-HTTPS URL on update, got {error.status}" + # <<<<< NON-HTTPS URL ON UPDATE <<<<< + + # >>>>> RELATIVE PATH ON UPDATE >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(path="./plugins") + ) + assert error is not None, "Expected error for relative path on update" + assert error.status == 400, f"Expected 400 for relative path on update, got {error.status}" + # <<<<< RELATIVE PATH ON UPDATE <<<<< + + # >>>>> UPDATE WITH FIELDS INVALID FOR LOCAL KIND >>>>> + # User expectation: switching a local registry to a git provider without providing + # url and ref fails loudly. The merge check rejects: git registry needs url+ref. + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(provider="github") + ) + assert error is not None, "Expected error for provider-invalid field on update" + assert error.status == 400, f"Expected 400 for provider-invalid field on update, got {error.status}" + # <<<<< UPDATE WITH FIELDS INVALID FOR LOCAL KIND <<<<< + + # >>>>> UPDATE WITH MIXED VALID AND KIND-INVALID FIELDS >>>>> + # Same loud-failure expectation when a valid field is bundled with an + # irrelevant one. The relevant field must not mask the irrelevant one. + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(name="renamed local", provider="github") + ) + assert error is not None, "Expected error for mixed valid + provider-invalid update" + assert error.status == 400, f"Expected 400 for mixed valid + provider-invalid update, got {error.status}" + # <<<<< UPDATE WITH MIXED VALID AND KIND-INVALID FIELDS <<<<< + + # >>>>> UPDATE WITH EMPTY NAME >>>>> + # Pydantic name: str | None accepts ""; send raw to assert OpenAPI rejects + # empty before it would silently blank the registry's display name. + status, content = await lrr_client.handle_request( + http.HTTPMethod.PUT, + lrr_client.build_url(f"/api/registries/{reg_id}"), + lrr_client.headers, + json_data={"name": ""}, + ) + body = json.loads(content) + assert status == 400, f"Expected 400 for empty name update, got {status}: {body}" + name_error = next((e for e in body.get("errors", []) if e.get("path") == "/body/name"), None) + assert name_error is not None, f"Expected length violation on /body/name, got: {body}" + # <<<<< UPDATE WITH EMPTY NAME <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_registry_update_relink(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that registry updates preserve plugin provenance and clean stale provider fields. + + 1. Create a git registry and refresh. + 2. Install a plugin from the registry. + 3. Update the URL, verify installed plugin retains provenance. + 4. Update name only. + 5. Switch type from github to local, verify stale git fields are absent. + """ + environment.setup(with_api_key=True) + + # >>>>> CREATE AND REFRESH >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + refresh_response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + assert refresh_response.index is not None, "Expected index after refresh" + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) + # <<<<< CREATE AND REFRESH <<<<< + + # >>>>> INSTALL PLUGIN BEFORE SOURCE CHANGE >>>>> + response, error = await install_plugin_and_wait(lrr_client, + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=sample_downloader_version) + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + assert response.registry == reg_id + # <<<<< INSTALL PLUGIN BEFORE SOURCE CHANGE <<<<< + + # >>>>> UPDATE URL (SOURCE CHANGE) >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(url="https://github.com/example/other-repo.git") + ) + assert not error, f"Failed to update registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list plugins after source change (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-downloader": + assert plugin.registry == reg_id, f"Expected provenance {reg_id} after source change, got: {plugin.registry}" + break + else: + pytest.fail("Installed plugin should survive registry source change") + # <<<<< UPDATE URL (SOURCE CHANGE) <<<<< + + # >>>>> UPDATE NAME ONLY >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(name="renamed") + ) + assert not error, f"Failed to update registry name (status {error.status}): {error.error}" + # <<<<< UPDATE NAME ONLY <<<<< + + # >>>>> KIND SWITCH: GITHUB -> LOCAL >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(provider="local", path="/tmp/plugins") + ) + assert not error, f"Failed to switch provider (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_registry(reg_id) + assert not error, f"Failed to get registry (status {error.status}): {error.error}" + assert response.registry.provider == "local", "Provider should be local" + assert response.registry.path == "/tmp/plugins", "Path should be set" + assert response.registry.url is None, "Stale git field 'url' should be absent" + assert response.registry.ref is None, "Stale git field 'ref' should be absent" + # <<<<< KIND SWITCH: GITHUB -> LOCAL <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test refreshing the registry index. + + 1. Refresh nonexistent registry, expect error. + 2. Create registry and refresh, verify index returned with plugins. + 3. Delete registry, verify refresh fails. + """ + environment.setup(with_api_key=True) + + # >>>>> REFRESH NONEXISTENT >>>>> + response, error = await lrr_client.misc_api.refresh_registry("REG_0000000000") + assert error is not None, "Expected error when refreshing nonexistent registry" + assert error.status == 404, f"Expected 404 for refresh nonexistent, got {error.status}" + # <<<<< REFRESH NONEXISTENT <<<<< + + # >>>>> CREATE AND REFRESH >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + assert response.index is not None, "Expected index in refresh response" + assert response.index.get("version") is not None, "Expected version in index" + plugins = response.index.get("plugins", {}) + assert len(plugins) > 0, "Expected at least one plugin in index" + assert "sample-downloader" in plugins, f"Expected sample-downloader in plugins, got: {list(plugins.keys())}" + # <<<<< CREATE AND REFRESH <<<<< + + # >>>>> DELETE CLEARS INDEX >>>>> + response, error = await lrr_client.misc_api.delete_registry(reg_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert error is not None, "Expected error refreshing after registry deleted" + assert error.status == 404, f"Expected 404 for refresh after delete, got {error.status}" + # <<<<< DELETE CLEARS INDEX <<<<< + + expect_no_error_logs(environment, LOGGER) diff --git a/integration_tests/tests/resources/plugins/scripts/SampleScript.pm b/integration_tests/tests/resources/plugins/scripts/SampleScript.pm new file mode 100644 index 00000000..ab7049e6 --- /dev/null +++ b/integration_tests/tests/resources/plugins/scripts/SampleScript.pm @@ -0,0 +1,33 @@ +package LANraragi::Plugin::Scripts::SampleScript; + +use strict; +use warnings; +no warnings 'uninitialized'; + +# Meta-information about your plugin. +sub plugin_info { + + return ( + # Standard metadata + name => "Sample Script", + type => "script", + namespace => "sample-script", + author => "koyomi", + version => "1.0", + description => "Script example", + oneshot_arg => "Value to echo back" + ); + +} + +# Mandatory function to be implemented by your script +sub run_script { + shift; + my $lrr_info = shift; + + my $arg = $lrr_info->{oneshot_param}; + + return ( result => $arg // "no argument provided" ); +} + +1; diff --git a/integration_tests/tests/test_plugins.py b/integration_tests/tests/test_plugins.py index d3d65df3..28df887a 100644 --- a/integration_tests/tests/test_plugins.py +++ b/integration_tests/tests/test_plugins.py @@ -115,6 +115,7 @@ async def test_plugin_functionality(lrr_client: LRRClient, environment: Abstract expect_no_error_logs(environment, LOGGER) @pytest.mark.asyncio +@pytest.mark.xfail(reason="requires LRR-side fix: PR #1558 omit type on plugin-not-found", strict=False) async def test_plugin_not_available(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test behavior of plugin when not available. diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index a8d32490..fb7dc4ea 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -1,5 +1,6 @@ import http import json +from typing import Any import aiohttp @@ -9,19 +10,32 @@ _process_get_server_info_response, ) from lanraragi.clients.utils import _build_err_response -from lanraragi.models.base import LanraragiErrorResponse +from lanraragi.models.base import LanraragiErrorResponse, LanraragiResponse from lanraragi.models.generics import _LRRClientResponse from lanraragi.models.misc import ( CleanTempFolderResponse, + CreateRegistryRequest, + CreateRegistryResponse, GetAvailablePluginsRequest, GetAvailablePluginsResponse, GetOpdsCatalogRequest, GetOpdsCatalogResponse, + GetOugiResponse, + GetRegistryResponse, GetServerInfoResponse, + InstallPluginRequest, + ListRegistriesResponse, QueueUrlDownloadRequest, QueueUrlDownloadResponse, + RefreshRegistryResponse, RegenerateThumbnailRequest, RegenerateThumbnailResponse, + RegistryConfig, + RemoveOugiResponse, + UpdateMetadataPluginConfigRequest, + UpdateOugiResponse, + UpdateRegistryRequest, + UpdateRegistryResponse, UsePluginAsyncRequest, UsePluginAsyncResponse, UsePluginRawResponse, @@ -149,7 +163,174 @@ async def regenerate_thumbnails(self, request: RegenerateThumbnailRequest) -> _L job = response_j.get("job") return (RegenerateThumbnailResponse(job=job), None) return (None, _build_err_response(content, status)) - pass + + async def list_registries(self) -> _LRRClientResponse[ListRegistriesResponse]: + """ + GET /api/registries + """ + url = self.api_context.build_url("/api/registries") + status, content = await self.api_context.handle_request(http.HTTPMethod.GET, url, self.headers) + if status == 200: + response_j = json.loads(content) + registries = [RegistryConfig.model_validate(r) for r in response_j.get("registries", [])] + return (ListRegistriesResponse(registries=registries), None) + return (None, _build_err_response(content, status)) + + async def create_registry(self, request: CreateRegistryRequest) -> _LRRClientResponse[CreateRegistryResponse]: + """ + POST /api/registries + """ + url = self.api_context.build_url("/api/registries") + body: dict[str, str] = {"name": request.name, "provider": request.provider} + if request.url: + body["url"] = request.url + if request.ref: + body["ref"] = request.ref + if request.path: + body["path"] = request.path + status, content = await self.api_context.handle_request( + http.HTTPMethod.POST, url, self.headers, json_data=body + ) + if status == 200: + response_j = json.loads(content) + return (CreateRegistryResponse(id=response_j["id"]), None) + return (None, _build_err_response(content, status)) + + async def get_registry(self, registry_id: str) -> _LRRClientResponse[GetRegistryResponse]: + """ + GET /api/registries/{id} + """ + url = self.api_context.build_url(f"/api/registries/{registry_id}") + status, content = await self.api_context.handle_request(http.HTTPMethod.GET, url, self.headers) + if status == 200: + response_j = json.loads(content) + registry = RegistryConfig.model_validate(response_j.get("registry")) + return (GetRegistryResponse(registry=registry), None) + return (None, _build_err_response(content, status)) + + async def update_registry(self, registry_id: str, request: UpdateRegistryRequest) -> _LRRClientResponse[UpdateRegistryResponse]: + """ + PUT /api/registries/{id} + """ + url = self.api_context.build_url(f"/api/registries/{registry_id}") + body: dict[str, str] = {} + if request.name is not None: + body["name"] = request.name + if request.provider is not None: + body["provider"] = request.provider + if request.url is not None: + body["url"] = request.url + if request.ref is not None: + body["ref"] = request.ref + if request.path is not None: + body["path"] = request.path + status, content = await self.api_context.handle_request( + http.HTTPMethod.PUT, url, self.headers, json_data=body + ) + if status == 200: + response_j = json.loads(content) + return (UpdateRegistryResponse( + id=response_j["id"], + ), None) + return (None, _build_err_response(content, status)) + + async def delete_registry(self, registry_id: str) -> _LRRClientResponse[LanraragiResponse]: + """ + DELETE /api/registries/{id} + """ + url = self.api_context.build_url(f"/api/registries/{registry_id}") + status, content = await self.api_context.handle_request(http.HTTPMethod.DELETE, url, self.headers) + if status == 200: + return (LanraragiResponse(), None) + return (None, _build_err_response(content, status)) + + async def get_ougi(self) -> _LRRClientResponse[GetOugiResponse]: + """ + GET /api/registries/ougi + """ + url = self.api_context.build_url("/api/registries/ougi") + status, content = await self.api_context.handle_request(http.HTTPMethod.GET, url, self.headers) + if status == 200: + response_j = json.loads(content) + return (GetOugiResponse(id=response_j["id"]), None) + return (None, _build_err_response(content, status)) + + async def update_ougi(self, registry_id: str) -> _LRRClientResponse[UpdateOugiResponse]: + """ + PUT /api/registries/ougi/{id} + """ + url = self.api_context.build_url(f"/api/registries/ougi/{registry_id}") + status, content = await self.api_context.handle_request(http.HTTPMethod.PUT, url, self.headers) + if status == 200: + response_j = json.loads(content) + return (UpdateOugiResponse(id=response_j["id"]), None) + return (None, _build_err_response(content, status)) + + async def remove_ougi(self) -> _LRRClientResponse[RemoveOugiResponse]: + """ + DELETE /api/registries/ougi + """ + url = self.api_context.build_url("/api/registries/ougi") + status, content = await self.api_context.handle_request(http.HTTPMethod.DELETE, url, self.headers) + if status == 200: + response_j = json.loads(content) + return (RemoveOugiResponse(id=response_j["id"]), None) + return (None, _build_err_response(content, status)) + + async def refresh_registry(self, registry_id: str) -> _LRRClientResponse[RefreshRegistryResponse]: + """ + POST /api/registries/{id}/refresh + """ + url = self.api_context.build_url(f"/api/registries/{registry_id}/refresh") + status, content = await self.api_context.handle_request(http.HTTPMethod.POST, url, self.headers) + if status == 200: + response_j = json.loads(content) + return (RefreshRegistryResponse(index=response_j.get("index")), None) + return (None, _build_err_response(content, status)) + + async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientResponse[int]: + """ + POST /api/plugins/install + """ + url = self.api_context.build_url("/api/plugins/install") + body: dict[str, Any] = {"namespace": request.namespace, "registry": request.registry} + if request.version is not None: + body["version"] = request.version + if request.force is not None: + body["force"] = request.force + status, content = await self.api_context.handle_request( + http.HTTPMethod.POST, url, self.headers, json_data=body + ) + if status == 200: + return (int(json.loads(content)["job"]), None) + return (None, _build_err_response(content, status)) + + async def uninstall_plugin(self, namespace: str) -> _LRRClientResponse[LanraragiResponse]: + """ + DELETE /api/plugins/{namespace} + """ + url = self.api_context.build_url(f"/api/plugins/installed/{namespace}") + status, content = await self.api_context.handle_request(http.HTTPMethod.DELETE, url, self.headers) + if status == 200: + return (LanraragiResponse(), None) + return (None, _build_err_response(content, status)) + + async def update_metadata_plugin_config(self, namespace: str, request: UpdateMetadataPluginConfigRequest) -> _LRRClientResponse[LanraragiResponse]: + """ + PUT /api/plugins/installed/{namespace}/metadata-config + """ + url = self.api_context.build_url(f"/api/plugins/installed/{namespace}/metadata-config") + body = {} + if request.enabled is not None: + body["enabled"] = request.enabled + if request.hidden is not None: + body["hidden"] = request.hidden + if request.priority is not None: + body["priority"] = request.priority + status, content = await self.api_context.handle_request(http.HTTPMethod.PUT, url, self.headers, json_data=body) + if status == 200: + return (LanraragiResponse(), None) + return (None, _build_err_response(content, status)) __all__ = [ "_MiscApiClient" diff --git a/src/lanraragi/models/minion.py b/src/lanraragi/models/minion.py index 1a86b942..8e866d12 100644 --- a/src/lanraragi/models/minion.py +++ b/src/lanraragi/models/minion.py @@ -49,7 +49,7 @@ class GetMinionJobDetailResponse(LanraragiResponse): state: str = Field(...) task: str = Field(...) time: str | None = Field(None) - worker: int = Field(default=0) + worker: int | None = Field(default=None) __all__ = [ "GetMinionJobStatusRequest", diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 356b2810..ff9a00a7 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -22,6 +22,7 @@ class GetServerInfoResponse(LanraragiResponse): version_desc: str = Field(...) version_name: str = Field(...) excluded_namespaces: list[str] = Field(default_factory=list) + restart_required: bool = Field(...) class GetOpdsCatalogRequest(LanraragiRequest): arcid: str | None = Field(None, min_length=40, max_length=40) @@ -49,6 +50,10 @@ class GetAvailablePluginsResponsePlugin(BaseModel): parameters: list[PluginParameter] | None = Field(None) type: Literal["login", "metadata", "script", "download", "all"] = Field(...) version: str = Field(...) + hidden: bool = Field(False) + priority: int = Field(0) + registry: str | None = Field(None) + sha256: str | None = Field(None) class GetAvailablePluginsResponse(LanraragiResponse): plugins: list[GetAvailablePluginsResponsePlugin] = Field(...) @@ -94,6 +99,72 @@ class RegenerateThumbnailRequest(LanraragiRequest): class RegenerateThumbnailResponse(LanraragiResponse): job: int = Field(...) +class RegistryConfig(BaseModel): + id: str = Field(...) + name: str = Field(...) + provider: Literal["github", "gitlab", "gitea", "cdn", "local"] = Field(...) + url: str | None = Field(None) + ref: str | None = Field(None) + path: str | None = Field(None) + created: int = Field(...) + updated: int = Field(...) + +class CreateRegistryRequest(LanraragiRequest): + name: str = Field(...) + provider: Literal["github", "gitlab", "gitea", "cdn", "local"] = Field(...) + url: str | None = Field(None) + ref: str | None = Field(None) + path: str | None = Field(None) + +class CreateRegistryResponse(LanraragiResponse): + id: str = Field(...) + +class UpdateRegistryRequest(LanraragiRequest): + name: str | None = Field(None) + provider: Literal["github", "gitlab", "gitea", "cdn", "local"] | None = Field(None) + url: str | None = Field(None) + ref: str | None = Field(None) + path: str | None = Field(None) + +class UpdateRegistryResponse(LanraragiResponse): + id: str = Field(...) + +class GetRegistryResponse(LanraragiResponse): + registry: RegistryConfig = Field(...) + +class ListRegistriesResponse(LanraragiResponse): + registries: list[RegistryConfig] = Field(...) + +class RefreshRegistryResponse(LanraragiResponse): + index: dict[str, Any] | None = Field(None) + +class GetOugiResponse(LanraragiResponse): + id: str = Field(...) + +class UpdateOugiResponse(LanraragiResponse): + id: str = Field(...) + +class RemoveOugiResponse(LanraragiResponse): + id: str = Field(...) + +class UpdateMetadataPluginConfigRequest(LanraragiRequest): + enabled: bool | None = Field(None) + hidden: bool | None = Field(None) + priority: int | None = Field(None) + +class InstallPluginRequest(LanraragiRequest): + namespace: str = Field(...) + registry: str = Field(...) + version: str | None = Field(None) + force: bool | None = Field(None) + +class InstallPluginResponse(LanraragiResponse): + name: str = Field(...) + namespace: str = Field(...) + version: str = Field(...) + registry: str = Field(...) + sha256: str = Field(...) + __all__ = [ "GetServerInfoResponse", "GetOpdsCatalogRequest", @@ -111,4 +182,18 @@ class RegenerateThumbnailResponse(LanraragiResponse): "QueueUrlDownloadResponse", "RegenerateThumbnailRequest", "RegenerateThumbnailResponse", + "RegistryConfig", + "CreateRegistryRequest", + "CreateRegistryResponse", + "UpdateRegistryRequest", + "UpdateRegistryResponse", + "GetRegistryResponse", + "ListRegistriesResponse", + "RefreshRegistryResponse", + "GetOugiResponse", + "UpdateOugiResponse", + "RemoveOugiResponse", + "UpdateMetadataPluginConfigRequest", + "InstallPluginRequest", + "InstallPluginResponse", ]