From 0e7268353a534d5b5b1fa0df27f9c4c765352906 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:45:00 -0700 Subject: [PATCH 01/72] add registry-related client and tests --- integration_tests/tests/test_registry.py | 342 ++++++++++++++++++++++ src/lanraragi/clients/api_clients/misc.py | 123 +++++++- src/lanraragi/models/misc.py | 37 +++ 3 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 integration_tests/tests/test_registry.py diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py new file mode 100644 index 00000000..49916ed4 --- /dev/null +++ b/integration_tests/tests/test_registry.py @@ -0,0 +1,342 @@ +""" +Plugin registry integration tests. +""" + +import logging +from collections.abc import AsyncGenerator, Generator + +import pytest +import pytest_asyncio +from lanraragi.clients.client import LRRClient +from lanraragi.models.misc import ( + GetAvailablePluginsRequest, + InstallPluginRequest, + SetRegistryRequest, +) + +from aio_lanraragi_tests.common import DEFAULT_API_KEY +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) +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} + yield env + 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() + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test registry configuration CRUD operations. + + 1. Get registry when none is configured. + 2. Set a git registry, verify it persists. + 3. Get the registry, verify fields match. + 4. Delete the registry, verify it is removed. + 5. Set a local registry, verify it persists. + """ + environment.setup(with_api_key=True) + + # >>>>> GET EMPTY REGISTRY >>>>> + response, error = await lrr_client.misc_api.get_registry() + assert not error, f"Failed to get registry (status {error.status}): {error.error}" + assert response.registry is None, f"Expected no registry, got: {response.registry}" + # <<<<< GET EMPTY REGISTRY <<<<< + + # >>>>> SET GIT REGISTRY >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest( + type="git", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to set registry (status {error.status}): {error.error}" + assert response.registry is not None, "Expected registry in response" + assert response.registry.type == "git" + assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" + assert response.registry.ref == "main" + # <<<<< SET GIT REGISTRY <<<<< + + # >>>>> GET GIT REGISTRY >>>>> + response, error = await lrr_client.misc_api.get_registry() + assert not error, f"Failed to get registry (status {error.status}): {error.error}" + assert response.registry is not None, "Expected registry after set" + assert response.registry.type == "git" + assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" + assert response.registry.ref == "main" + # <<<<< GET GIT REGISTRY <<<<< + + # >>>>> DELETE REGISTRY >>>>> + response, error = await lrr_client.misc_api.delete_registry() + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_registry() + assert not error, f"Failed to get registry after delete (status {error.status}): {error.error}" + assert response.registry is None, f"Expected no registry after delete, got: {response.registry}" + # <<<<< DELETE REGISTRY <<<<< + + # >>>>> SET LOCAL REGISTRY >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest(type="local", path="/home/koyomi/plugins") + ) + assert not error, f"Failed to set local registry (status {error.status}): {error.error}" + assert response.registry is not None, "Expected registry in response" + assert response.registry.type == "local" + assert response.registry.path == "/home/koyomi/plugins" + # <<<<< SET LOCAL REGISTRY <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_registry_set_validation(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test registry set validation rejects invalid configurations. + + 1. Set git registry without url, expect error. + 2. Set local registry without path, expect error. + """ + environment.setup(with_api_key=True) + + # >>>>> MISSING URL FOR GIT >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest(type="git") + ) + assert error is not None, "Expected error for git registry without url" + # <<<<< MISSING URL FOR GIT <<<<< + + # >>>>> MISSING PATH FOR LOCAL >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest(type="local") + ) + assert error is not None, "Expected error for local registry without path" + # <<<<< MISSING PATH FOR LOCAL <<<<< + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_registry_overwrite(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that setting a new registry overwrites the previous one. + + 1. Set a git registry. + 2. Set a local registry, verify git fields are gone. + """ + environment.setup(with_api_key=True) + + # >>>>> SET GIT REGISTRY >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest(type="git", url="https://github.com/example/repo.git", ref="dev") + ) + assert not error, f"Failed to set git registry (status {error.status}): {error.error}" + # <<<<< SET GIT REGISTRY <<<<< + + # >>>>> OVERWRITE WITH LOCAL >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest(type="local", path="/opt/plugins") + ) + assert not error, f"Failed to set local registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.get_registry() + assert not error, f"Failed to get registry (status {error.status}): {error.error}" + assert response.registry.type == "local" + assert response.registry.path == "/opt/plugins" + assert response.registry.url is None, f"Expected no url after overwrite, got: {response.registry.url}" + # <<<<< OVERWRITE WITH LOCAL <<<<< + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test refreshing the registry index from a remote source. + + 1. Refresh without a registry configured, expect error. + 2. Configure the lrr-plugins-demo registry. + 3. Refresh, verify the index is returned with plugins. + 4. Delete registry, verify index is also cleared. + """ + environment.setup(with_api_key=True) + + # >>>>> REFRESH WITHOUT REGISTRY >>>>> + response, error = await lrr_client.misc_api.refresh_registry() + assert error is not None, "Expected error when refreshing without a registry" + # <<<<< REFRESH WITHOUT REGISTRY <<<<< + + # >>>>> SET REGISTRY AND REFRESH >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest( + type="git", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to set registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.refresh_registry() + 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())}" + # <<<<< SET REGISTRY AND REFRESH <<<<< + + # >>>>> DELETE CLEARS INDEX >>>>> + response, error = await lrr_client.misc_api.delete_registry() + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.refresh_registry() + assert error is not None, "Expected error refreshing after registry deleted" + # <<<<< DELETE CLEARS INDEX <<<<< + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test installing and uninstalling a plugin from the registry. + + 1. Configure registry and refresh index. + 2. Install sample-downloader plugin. + 3. Verify plugin appears in plugin list. + 4. Uninstall the plugin. + 5. Verify plugin is no longer listed. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest( + type="git", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to set registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.refresh_registry() + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL PLUGIN >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader") + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + assert response.namespace == "sample-downloader" + assert response.name == "Sample Downloader" + # <<<<< 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}" + namespaces = {p.namespace for p in response.plugins} + assert "sample-downloader" in namespaces, f"Installed plugin not found in list: {namespaces}" + # <<<<< 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}" + # <<<<< UNINSTALL PLUGIN <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test hiding and unhiding a plugin. + + 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. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP AND INSTALL >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest( + type="git", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to set registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.refresh_registry() + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata") + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + # <<<<< SETUP AND INSTALL <<<<< + + # >>>>> HIDE PLUGIN >>>>> + response, error = await lrr_client.misc_api.hide_plugin("sample-metadata") + assert not error, f"Failed to hide 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 == "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.unhide_plugin("sample-metadata") + assert not error, f"Failed to unhide 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 == "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 <<<<< + + expect_no_error_logs(environment, LOGGER) diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index a8d32490..8c545f68 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -9,7 +9,7 @@ _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, @@ -17,11 +17,18 @@ GetAvailablePluginsResponse, GetOpdsCatalogRequest, GetOpdsCatalogResponse, + GetRegistryResponse, GetServerInfoResponse, + InstallPluginRequest, + InstallPluginResponse, QueueUrlDownloadRequest, QueueUrlDownloadResponse, + RefreshRegistryResponse, RegenerateThumbnailRequest, RegenerateThumbnailResponse, + RegistryConfig, + SetRegistryRequest, + SetRegistryResponse, UsePluginAsyncRequest, UsePluginAsyncResponse, UsePluginRawResponse, @@ -149,7 +156,119 @@ 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 get_registry(self) -> _LRRClientResponse[GetRegistryResponse]: + """ + GET /api/plugins/registry + """ + url = self.api_context.build_url("/api/plugins/registry") + status, content = await self.api_context.handle_request(http.HTTPMethod.GET, url, self.headers) + if status == 200: + response_j = json.loads(content) + registry_data = response_j.get("registry") + registry = RegistryConfig.model_validate(registry_data) if registry_data else None + return (GetRegistryResponse(registry=registry), None) + return (None, _build_err_response(content, status)) + + async def set_registry(self, request: SetRegistryRequest) -> _LRRClientResponse[SetRegistryResponse]: + """ + PUT /api/plugins/registry + """ + url = self.api_context.build_url("/api/plugins/registry") + body = {"type": request.type} + 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.PUT, url, self.headers, json_data=body + ) + if status == 200: + response_j = json.loads(content) + if response_j.get("success") == 0: + return (None, LanraragiErrorResponse(error=response_j.get("error", ""), status=status)) + registry_data = response_j.get("registry") + registry = RegistryConfig.model_validate(registry_data) if registry_data else None + return (SetRegistryResponse(registry=registry), None) + return (None, _build_err_response(content, status)) + + async def delete_registry(self) -> _LRRClientResponse[LanraragiResponse]: + """ + DELETE /api/plugins/registry + """ + url = self.api_context.build_url("/api/plugins/registry") + 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 refresh_registry(self) -> _LRRClientResponse[RefreshRegistryResponse]: + """ + POST /api/plugins/registry/refresh + """ + url = self.api_context.build_url("/api/plugins/registry/refresh") + status, content = await self.api_context.handle_request(http.HTTPMethod.POST, url, self.headers) + if status == 200: + response_j = json.loads(content) + if response_j.get("success") == 0: + return (None, LanraragiErrorResponse(error=response_j.get("error", ""), status=status)) + return (RefreshRegistryResponse(index=response_j.get("index")), None) + return (None, _build_err_response(content, status)) + + async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientResponse[InstallPluginResponse]: + """ + POST /api/plugins/install + """ + url = self.api_context.build_url("/api/plugins/install") + body = {"namespace": request.namespace} + 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) + if response_j.get("success") == 0: + return (None, LanraragiErrorResponse(error=response_j.get("error", ""), status=status)) + return (InstallPluginResponse( + name=response_j.get("name"), + namespace=response_j.get("namespace"), + version=response_j.get("version"), + ), 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: + response_j = json.loads(content) + if response_j.get("success") == 0: + return (None, LanraragiErrorResponse(error=response_j.get("error", ""), status=status)) + return (LanraragiResponse(), None) + return (None, _build_err_response(content, status)) + + async def hide_plugin(self, namespace: str) -> _LRRClientResponse[LanraragiResponse]: + """ + PUT /api/plugins/{namespace}/hidden + """ + url = self.api_context.build_url(f"/api/plugins/installed/{namespace}/hidden") + status, content = await self.api_context.handle_request(http.HTTPMethod.PUT, url, self.headers) + if status == 200: + return (LanraragiResponse(), None) + return (None, _build_err_response(content, status)) + + async def unhide_plugin(self, namespace: str) -> _LRRClientResponse[LanraragiResponse]: + """ + DELETE /api/plugins/{namespace}/hidden + """ + url = self.api_context.build_url(f"/api/plugins/installed/{namespace}/hidden") + 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)) __all__ = [ "_MiscApiClient" diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 356b2810..46978946 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -49,6 +49,7 @@ class GetAvailablePluginsResponsePlugin(BaseModel): parameters: list[PluginParameter] | None = Field(None) type: Literal["login", "metadata", "script", "download", "all"] = Field(...) version: str = Field(...) + hidden: bool = Field(False) class GetAvailablePluginsResponse(LanraragiResponse): plugins: list[GetAvailablePluginsResponsePlugin] = Field(...) @@ -94,6 +95,35 @@ class RegenerateThumbnailRequest(LanraragiRequest): class RegenerateThumbnailResponse(LanraragiResponse): job: int = Field(...) +class RegistryConfig(BaseModel): + type: Literal["git", "local"] = Field(...) + url: str | None = Field(None) + ref: str | None = Field(None) + path: str | None = Field(None) + +class SetRegistryRequest(LanraragiRequest): + type: Literal["git", "local"] = Field(...) + url: str | None = Field(None) + ref: str | None = Field(None) + path: str | None = Field(None) + +class GetRegistryResponse(LanraragiResponse): + registry: RegistryConfig | None = Field(None) + +class SetRegistryResponse(LanraragiResponse): + registry: RegistryConfig | None = Field(None) + +class RefreshRegistryResponse(LanraragiResponse): + index: dict[str, Any] | None = Field(None) + +class InstallPluginRequest(LanraragiRequest): + namespace: str = Field(...) + +class InstallPluginResponse(LanraragiResponse): + name: str = Field(...) + namespace: str = Field(...) + version: str = Field(...) + __all__ = [ "GetServerInfoResponse", "GetOpdsCatalogRequest", @@ -111,4 +141,11 @@ class RegenerateThumbnailResponse(LanraragiResponse): "QueueUrlDownloadResponse", "RegenerateThumbnailRequest", "RegenerateThumbnailResponse", + "RegistryConfig", + "SetRegistryRequest", + "GetRegistryResponse", + "SetRegistryResponse", + "RefreshRegistryResponse", + "InstallPluginRequest", + "InstallPluginResponse", ] From 87b33470514a81f26a845f01e39f066a2cd5a134 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:46:19 -0700 Subject: [PATCH 02/72] switch to git provider --- integration_tests/tests/test_registry.py | 6 +++++- src/lanraragi/clients/api_clients/misc.py | 2 ++ src/lanraragi/models/misc.py | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 49916ed4..95ecd582 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -76,6 +76,7 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl response, error = await lrr_client.misc_api.set_registry( SetRegistryRequest( type="git", + provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) @@ -157,7 +158,7 @@ async def test_registry_overwrite(lrr_client: LRRClient, environment: AbstractLR # >>>>> SET GIT REGISTRY >>>>> response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest(type="git", url="https://github.com/example/repo.git", ref="dev") + SetRegistryRequest(type="git", provider="github", url="https://github.com/example/repo.git", ref="dev") ) assert not error, f"Failed to set git registry (status {error.status}): {error.error}" # <<<<< SET GIT REGISTRY <<<<< @@ -198,6 +199,7 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD response, error = await lrr_client.misc_api.set_registry( SetRegistryRequest( type="git", + provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) @@ -240,6 +242,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: response, error = await lrr_client.misc_api.set_registry( SetRegistryRequest( type="git", + provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) @@ -292,6 +295,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR response, error = await lrr_client.misc_api.set_registry( SetRegistryRequest( type="git", + provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 8c545f68..386ae35b 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -176,6 +176,8 @@ async def set_registry(self, request: SetRegistryRequest) -> _LRRClientResponse[ """ url = self.api_context.build_url("/api/plugins/registry") body = {"type": request.type} + if request.provider: + body["provider"] = request.provider if request.url: body["url"] = request.url if request.ref: diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 46978946..b6d7d4ea 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -97,12 +97,14 @@ class RegenerateThumbnailResponse(LanraragiResponse): class RegistryConfig(BaseModel): type: Literal["git", "local"] = Field(...) + provider: Literal["github", "gitlab", "gitea"] | None = Field(None) url: str | None = Field(None) ref: str | None = Field(None) path: str | None = Field(None) class SetRegistryRequest(LanraragiRequest): type: Literal["git", "local"] = Field(...) + provider: Literal["github", "gitlab", "gitea"] | None = Field(None) url: str | None = Field(None) ref: str | None = Field(None) path: str | None = Field(None) From f8156d19984009a269956f0d866590c44903ceed Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:30:20 -0700 Subject: [PATCH 03/72] add test_plugin_install_conflict --- integration_tests/tests/test_registry.py | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 95ecd582..632d0881 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -3,7 +3,9 @@ """ import logging +import tempfile from collections.abc import AsyncGenerator, Generator +from pathlib import Path import pytest import pytest_asyncio @@ -344,3 +346,70 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR # <<<<< UNHIDE PLUGIN <<<<< expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_plugin_install_conflict(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that installing a plugin with a conflicting package name is rejected, + while non-conflicting installs and upgrades succeed. + + 1. Write a .pm file declaring the same package as sample-metadata. + 2. Setup environment with the conflicting plugin via plugin_paths. + 3. Configure registry and refresh index. + 4. Install sample-metadata, expect conflict error. + 5. Install sample-downloader (no conflict), expect success. + 6. Reinstall sample-downloader (upgrade), expect success. + """ + with tempfile.TemporaryDirectory() as tmpdir: + conflict_path = Path(tmpdir) / "SampleMetadata.pm" + conflict_path.write_text( + 'package LANraragi::Plugin::Metadata::SampleMetadata;\n' + 'sub plugin_info { return ( name => "Conflict" ); }\n' + '1;\n' + ) + environment.setup( + with_api_key=True, + plugin_paths={"Metadata": [str(conflict_path)]}, + ) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.set_registry( + SetRegistryRequest( + type="git", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to set registry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.refresh_registry() + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL WITH CONFLICT >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata") + ) + assert error is not None, "Expected error when installing plugin with package conflict" + assert "already declared" in error.error, f"Expected 'already declared' in error, got: {error.error}" + # <<<<< INSTALL WITH CONFLICT <<<<< + + # >>>>> INSTALL WITHOUT CONFLICT >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader") + ) + assert not error, f"Failed to install non-conflicting plugin (status {error.status}): {error.error}" + assert response.namespace == "sample-downloader" + # <<<<< INSTALL WITHOUT CONFLICT <<<<< + + # >>>>> UPGRADE (REINSTALL) >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader") + ) + assert not error, f"Failed to reinstall/upgrade plugin (status {error.status}): {error.error}" + # <<<<< UPGRADE (REINSTALL) <<<<< + + expect_no_error_logs(environment, LOGGER) From 0fca8bc882bf66c749f88af84e62e95e8dcb5e24 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:49:12 -0700 Subject: [PATCH 04/72] update plugin tests --- integration_tests/tests/test_registry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 632d0881..d0f73be1 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -355,18 +355,18 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr Test that installing a plugin with a conflicting package name is rejected, while non-conflicting installs and upgrades succeed. - 1. Write a .pm file declaring the same package as sample-metadata. + 1. Write a .pm file declaring the same namespace as sample-metadata. 2. Setup environment with the conflicting plugin via plugin_paths. 3. Configure registry and refresh index. - 4. Install sample-metadata, expect conflict error. + 4. Install sample-metadata, expect namespace conflict error. 5. Install sample-downloader (no conflict), expect success. 6. Reinstall sample-downloader (upgrade), expect success. """ with tempfile.TemporaryDirectory() as tmpdir: conflict_path = Path(tmpdir) / "SampleMetadata.pm" conflict_path.write_text( - 'package LANraragi::Plugin::Metadata::SampleMetadata;\n' - 'sub plugin_info { return ( name => "Conflict" ); }\n' + 'package LANraragi::Plugin::Metadata::Testing::SampleMetadata;\n' + 'sub plugin_info { return ( name => "Conflict", namespace => "sample-metadata" ); }\n' '1;\n' ) environment.setup( From d74153cf34b17cfabc2080c4817b827287ce4e04 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:21:23 -0700 Subject: [PATCH 05/72] use plugin update structure --- integration_tests/tests/test_registry.py | 13 +++++++++---- src/lanraragi/clients/api_clients/misc.py | 22 ++++++++-------------- src/lanraragi/models/misc.py | 4 ++++ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index d0f73be1..d9fd3547 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -14,6 +14,7 @@ GetAvailablePluginsRequest, InstallPluginRequest, SetRegistryRequest, + UpdatePluginConfigRequest, ) from aio_lanraragi_tests.common import DEFAULT_API_KEY @@ -314,8 +315,10 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR # <<<<< SETUP AND INSTALL <<<<< # >>>>> HIDE PLUGIN >>>>> - response, error = await lrr_client.misc_api.hide_plugin("sample-metadata") - assert not error, f"Failed to hide plugin (status {error.status}): {error.error}" + response, error = await lrr_client.misc_api.update_plugin_config( + "sample-metadata", UpdatePluginConfigRequest(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") @@ -330,8 +333,10 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR # <<<<< HIDE PLUGIN <<<<< # >>>>> UNHIDE PLUGIN >>>>> - response, error = await lrr_client.misc_api.unhide_plugin("sample-metadata") - assert not error, f"Failed to unhide plugin (status {error.status}): {error.error}" + response, error = await lrr_client.misc_api.update_plugin_config( + "sample-metadata", UpdatePluginConfigRequest(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") diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 386ae35b..2ad50f70 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -29,6 +29,7 @@ RegistryConfig, SetRegistryRequest, SetRegistryResponse, + UpdatePluginConfigRequest, UsePluginAsyncRequest, UsePluginAsyncResponse, UsePluginRawResponse, @@ -252,22 +253,15 @@ async def uninstall_plugin(self, namespace: str) -> _LRRClientResponse[Lanraragi return (LanraragiResponse(), None) return (None, _build_err_response(content, status)) - async def hide_plugin(self, namespace: str) -> _LRRClientResponse[LanraragiResponse]: + async def update_plugin_config(self, namespace: str, request: UpdatePluginConfigRequest) -> _LRRClientResponse[LanraragiResponse]: """ - PUT /api/plugins/{namespace}/hidden + PUT /api/plugins/installed/{namespace}/config """ - url = self.api_context.build_url(f"/api/plugins/installed/{namespace}/hidden") - status, content = await self.api_context.handle_request(http.HTTPMethod.PUT, url, self.headers) - if status == 200: - return (LanraragiResponse(), None) - return (None, _build_err_response(content, status)) - - async def unhide_plugin(self, namespace: str) -> _LRRClientResponse[LanraragiResponse]: - """ - DELETE /api/plugins/{namespace}/hidden - """ - url = self.api_context.build_url(f"/api/plugins/installed/{namespace}/hidden") - status, content = await self.api_context.handle_request(http.HTTPMethod.DELETE, url, self.headers) + url = self.api_context.build_url(f"/api/plugins/installed/{namespace}/config") + body = {} + if request.hidden is not None: + body["hidden"] = request.hidden + 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)) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index b6d7d4ea..dc959e3f 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -118,6 +118,9 @@ class SetRegistryResponse(LanraragiResponse): class RefreshRegistryResponse(LanraragiResponse): index: dict[str, Any] | None = Field(None) +class UpdatePluginConfigRequest(LanraragiRequest): + hidden: bool | None = Field(None) + class InstallPluginRequest(LanraragiRequest): namespace: str = Field(...) @@ -148,6 +151,7 @@ class InstallPluginResponse(LanraragiResponse): "GetRegistryResponse", "SetRegistryResponse", "RefreshRegistryResponse", + "UpdatePluginConfigRequest", "InstallPluginRequest", "InstallPluginResponse", ] From 8ed21f13d77447a658ab03a5eff423eda015bd9b Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:55:45 -0700 Subject: [PATCH 06/72] update registry endpoint --- integration_tests/tests/test_registry.py | 265 +++++++++++++--------- src/lanraragi/clients/api_clients/misc.py | 108 ++++++--- src/lanraragi/models/misc.py | 39 +++- 3 files changed, 270 insertions(+), 142 deletions(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index d9fd3547..5e4c0c00 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -11,10 +11,11 @@ import pytest_asyncio from lanraragi.clients.client import LRRClient from lanraragi.models.misc import ( + CreateRegistryRequest, GetAvailablePluginsRequest, InstallPluginRequest, - SetRegistryRequest, UpdatePluginConfigRequest, + UpdateRegistryRequest, ) from aio_lanraragi_tests.common import DEFAULT_API_KEY @@ -59,170 +60,210 @@ async def lrr_client(environment: AbstractLRRDeploymentContext) -> AsyncGenerato @pytest.mark.dev("registry") async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test registry configuration CRUD operations. - - 1. Get registry when none is configured. - 2. Set a git registry, verify it persists. - 3. Get the registry, verify fields match. - 4. Delete the registry, verify it is removed. - 5. Set a local registry, verify it persists. + 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) - # >>>>> GET EMPTY REGISTRY >>>>> - response, error = await lrr_client.misc_api.get_registry() - assert not error, f"Failed to get registry (status {error.status}): {error.error}" - assert response.registry is None, f"Expected no registry, got: {response.registry}" - # <<<<< GET EMPTY REGISTRY <<<<< + # >>>>> 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 <<<<< - # >>>>> SET GIT REGISTRY >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest( + # >>>>> CREATE GIT REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo plugins", type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) ) - assert not error, f"Failed to set registry (status {error.status}): {error.error}" - assert response.registry is not None, "Expected registry in response" + 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}" + assert response.registry.name == "demo plugins" assert response.registry.type == "git" assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" - assert response.registry.ref == "main" - # <<<<< SET GIT REGISTRY <<<<< + # <<<<< CREATE GIT REGISTRY <<<<< - # >>>>> GET GIT REGISTRY >>>>> - response, error = await lrr_client.misc_api.get_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 is not None, "Expected registry after set" + assert response.registry.name == "demo plugins" assert response.registry.type == "git" assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" assert response.registry.ref == "main" - # <<<<< GET GIT REGISTRY <<<<< + # <<<<< GET BY ID <<<<< - # >>>>> DELETE REGISTRY >>>>> - response, error = await lrr_client.misc_api.delete_registry() + # >>>>> 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}" + assert response.registry.name == "renamed plugins" + assert response.index_cleared is False, "Name-only update should not clear index" + # <<<<< 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.get_registry() - assert not error, f"Failed to get registry after delete (status {error.status}): {error.error}" - assert response.registry is None, f"Expected no registry after delete, got: {response.registry}" - # <<<<< DELETE REGISTRY <<<<< + 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 <<<<< - # >>>>> SET LOCAL REGISTRY >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest(type="local", path="/home/koyomi/plugins") + # >>>>> CREATE LOCAL REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="local plugins", type="local", path="/home/koyomi/plugins") ) - assert not error, f"Failed to set local registry (status {error.status}): {error.error}" - assert response.registry is not None, "Expected registry in response" + assert not error, f"Failed to create local registry (status {error.status}): {error.error}" assert response.registry.type == "local" assert response.registry.path == "/home/koyomi/plugins" - # <<<<< SET LOCAL REGISTRY <<<<< + local_reg_id = response.id + + 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 <<<<< expect_no_error_logs(environment, LOGGER) @pytest.mark.asyncio @pytest.mark.dev("registry") -async def test_registry_set_validation(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): +async def test_registry_create_validation(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test registry set validation rejects invalid configurations. + Test registry create validation rejects invalid configurations. - 1. Set git registry without url, expect error. - 2. Set local registry without path, expect error. + 1. Create git registry without url, expect error. + 2. Create local registry without path, expect error. """ environment.setup(with_api_key=True) # >>>>> MISSING URL FOR GIT >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest(type="git") + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="bad git", type="git") ) assert error is not None, "Expected error for git registry without url" # <<<<< MISSING URL FOR GIT <<<<< # >>>>> MISSING PATH FOR LOCAL >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest(type="local") + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="bad local", type="local") ) assert error is not None, "Expected error for local registry without path" # <<<<< MISSING PATH FOR LOCAL <<<<< + expect_no_error_logs(environment, LOGGER) + @pytest.mark.asyncio @pytest.mark.dev("registry") -async def test_registry_overwrite(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): +async def test_registry_update_relink(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test that setting a new registry overwrites the previous one. + Test that updating source fields clears the cached index. - 1. Set a git registry. - 2. Set a local registry, verify git fields are gone. + 1. Create a git registry and refresh. + 2. Update the URL, verify index_cleared is true. + 3. Update name only, verify index_cleared is false. """ environment.setup(with_api_key=True) - # >>>>> SET GIT REGISTRY >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest(type="git", provider="github", url="https://github.com/example/repo.git", ref="dev") + # >>>>> CREATE AND REFRESH >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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 after refresh" + # <<<<< CREATE AND REFRESH <<<<< + + # >>>>> 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 set git registry (status {error.status}): {error.error}" - # <<<<< SET GIT REGISTRY <<<<< + assert not error, f"Failed to update registry (status {error.status}): {error.error}" + assert response.index_cleared is True, "URL change should clear index" + # <<<<< UPDATE URL (SOURCE CHANGE) <<<<< - # >>>>> OVERWRITE WITH LOCAL >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest(type="local", path="/opt/plugins") + # >>>>> UPDATE NAME ONLY >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(name="renamed") ) - assert not error, f"Failed to set local registry (status {error.status}): {error.error}" + assert not error, f"Failed to update registry name (status {error.status}): {error.error}" + assert response.index_cleared is False, "Name change should not clear index" + # <<<<< UPDATE NAME ONLY <<<<< - response, error = await lrr_client.misc_api.get_registry() - assert not error, f"Failed to get registry (status {error.status}): {error.error}" - assert response.registry.type == "local" - assert response.registry.path == "/opt/plugins" - assert response.registry.url is None, f"Expected no url after overwrite, got: {response.registry.url}" - # <<<<< OVERWRITE WITH LOCAL <<<<< + expect_no_error_logs(environment, LOGGER) @pytest.mark.asyncio @pytest.mark.dev("registry") async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test refreshing the registry index from a remote source. + Test refreshing the registry index. - 1. Refresh without a registry configured, expect error. - 2. Configure the lrr-plugins-demo registry. - 3. Refresh, verify the index is returned with plugins. - 4. Delete registry, verify index is also cleared. + 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 WITHOUT REGISTRY >>>>> - response, error = await lrr_client.misc_api.refresh_registry() - assert error is not None, "Expected error when refreshing without a registry" - # <<<<< REFRESH WITHOUT REGISTRY <<<<< + # >>>>> REFRESH NONEXISTENT >>>>> + response, error = await lrr_client.misc_api.refresh_registry("REG_0000000000") + assert error is not None, "Expected error when refreshing nonexistent registry" + # <<<<< REFRESH NONEXISTENT <<<<< - # >>>>> SET REGISTRY AND REFRESH >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest( + # >>>>> CREATE AND REFRESH >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) ) - assert not error, f"Failed to set registry (status {error.status}): {error.error}" + 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() + 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())}" - # <<<<< SET REGISTRY AND REFRESH <<<<< + # <<<<< CREATE AND REFRESH <<<<< # >>>>> DELETE CLEARS INDEX >>>>> - response, error = await lrr_client.misc_api.delete_registry() + 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() + response, error = await lrr_client.misc_api.refresh_registry(reg_id) assert error is not None, "Expected error refreshing after registry deleted" # <<<<< DELETE CLEARS INDEX <<<<< @@ -233,8 +274,8 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: """ Test installing and uninstalling a plugin from the registry. - 1. Configure registry and refresh index. - 2. Install sample-downloader plugin. + 1. Create registry and refresh index. + 2. Install sample-downloader plugin (sole registry fallback). 3. Verify plugin appears in plugin list. 4. Uninstall the plugin. 5. Verify plugin is no longer listed. @@ -242,17 +283,19 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: environment.setup(with_api_key=True) # >>>>> SETUP REGISTRY >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest( + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) ) - assert not error, f"Failed to set registry (status {error.status}): {error.error}" + 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() + 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 <<<<< @@ -263,6 +306,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: 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 >>>>> @@ -295,17 +339,19 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR environment.setup(with_api_key=True) # >>>>> SETUP AND INSTALL >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest( + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) ) - assert not error, f"Failed to set registry (status {error.status}): {error.error}" + 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() + 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 lrr_client.misc_api.install_plugin( @@ -357,15 +403,15 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR @pytest.mark.dev("registry") async def test_plugin_install_conflict(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test that installing a plugin with a conflicting package name is rejected, - while non-conflicting installs and upgrades succeed. + Test plugin install conflict detection and upgrade behavior. 1. Write a .pm file declaring the same namespace as sample-metadata. - 2. Setup environment with the conflicting plugin via plugin_paths. - 3. Configure registry and refresh index. - 4. Install sample-metadata, expect namespace conflict error. - 5. Install sample-downloader (no conflict), expect success. - 6. Reinstall sample-downloader (upgrade), expect success. + 2. Setup environment with the conflicting plugin. + 3. Create registry and refresh index. + 4. Install sample-metadata, expect no-provenance error. + 5. Force install sample-metadata, expect namespace conflict error (model-layer). + 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" @@ -380,27 +426,37 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr ) # >>>>> SETUP REGISTRY >>>>> - response, error = await lrr_client.misc_api.set_registry( - SetRegistryRequest( + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", ) ) - assert not error, f"Failed to set registry (status {error.status}): {error.error}" + 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() + 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 WITH CONFLICT >>>>> + # >>>>> INSTALL WITH CONFLICT (NO PROVENANCE) >>>>> response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-metadata") ) - assert error is not None, "Expected error when installing plugin with package conflict" + assert error is not None, "Expected error when installing plugin with existing sideloaded copy" + assert "no provenance" in error.error, f"Expected 'no provenance' in error, got: {error.error}" + # <<<<< INSTALL WITH CONFLICT (NO PROVENANCE) <<<<< + + # >>>>> FORCE INSTALL (NAMESPACE CONFLICT) >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", force=True) + ) + assert error is not None, "Expected namespace conflict error on force install" assert "already declared" in error.error, f"Expected 'already declared' in error, got: {error.error}" - # <<<<< INSTALL WITH CONFLICT <<<<< + # <<<<< FORCE INSTALL (NAMESPACE CONFLICT) <<<<< # >>>>> INSTALL WITHOUT CONFLICT >>>>> response, error = await lrr_client.misc_api.install_plugin( @@ -408,6 +464,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr ) 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) >>>>> diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 2ad50f70..3aaebb05 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 @@ -13,6 +14,8 @@ from lanraragi.models.generics import _LRRClientResponse from lanraragi.models.misc import ( CleanTempFolderResponse, + CreateRegistryRequest, + CreateRegistryResponse, GetAvailablePluginsRequest, GetAvailablePluginsResponse, GetOpdsCatalogRequest, @@ -21,15 +24,16 @@ GetServerInfoResponse, InstallPluginRequest, InstallPluginResponse, + ListRegistriesResponse, QueueUrlDownloadRequest, QueueUrlDownloadResponse, RefreshRegistryResponse, RegenerateThumbnailRequest, RegenerateThumbnailResponse, RegistryConfig, - SetRegistryRequest, - SetRegistryResponse, UpdatePluginConfigRequest, + UpdateRegistryRequest, + UpdateRegistryResponse, UsePluginAsyncRequest, UsePluginAsyncResponse, UsePluginRawResponse, @@ -158,25 +162,24 @@ async def regenerate_thumbnails(self, request: RegenerateThumbnailRequest) -> _L return (RegenerateThumbnailResponse(job=job), None) return (None, _build_err_response(content, status)) - async def get_registry(self) -> _LRRClientResponse[GetRegistryResponse]: + async def list_registries(self) -> _LRRClientResponse[ListRegistriesResponse]: """ - GET /api/plugins/registry + GET /api/registries """ - url = self.api_context.build_url("/api/plugins/registry") + 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) - registry_data = response_j.get("registry") - registry = RegistryConfig.model_validate(registry_data) if registry_data else None - return (GetRegistryResponse(registry=registry), None) + registries = [RegistryConfig.model_validate(r) for r in response_j["registries"]] + return (ListRegistriesResponse(registries=registries), None) return (None, _build_err_response(content, status)) - async def set_registry(self, request: SetRegistryRequest) -> _LRRClientResponse[SetRegistryResponse]: + async def create_registry(self, request: CreateRegistryRequest) -> _LRRClientResponse[CreateRegistryResponse]: """ - PUT /api/plugins/registry + POST /api/registries """ - url = self.api_context.build_url("/api/plugins/registry") - body = {"type": request.type} + url = self.api_context.build_url("/api/registries") + body: dict[str, str] = {"name": request.name, "type": request.type} if request.provider: body["provider"] = request.provider if request.url: @@ -185,39 +188,77 @@ async def set_registry(self, request: SetRegistryRequest) -> _LRRClientResponse[ 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) + registry = RegistryConfig.model_validate(response_j["registry"]) + return (CreateRegistryResponse(id=response_j["id"], registry=registry), 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["registry"]) + return (GetRegistryResponse(id=response_j["id"], 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.type is not None: + body["type"] = request.type + 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) - if response_j.get("success") == 0: - return (None, LanraragiErrorResponse(error=response_j.get("error", ""), status=status)) - registry_data = response_j.get("registry") - registry = RegistryConfig.model_validate(registry_data) if registry_data else None - return (SetRegistryResponse(registry=registry), None) + registry = RegistryConfig.model_validate(response_j["registry"]) + return (UpdateRegistryResponse( + id=response_j["id"], + registry=registry, + index_cleared=response_j["index_cleared"], + ), None) return (None, _build_err_response(content, status)) - async def delete_registry(self) -> _LRRClientResponse[LanraragiResponse]: + async def delete_registry(self, registry_id: str) -> _LRRClientResponse[LanraragiResponse]: """ - DELETE /api/plugins/registry + DELETE /api/registries/{id} """ - url = self.api_context.build_url("/api/plugins/registry") + 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 refresh_registry(self) -> _LRRClientResponse[RefreshRegistryResponse]: + async def refresh_registry(self, registry_id: str) -> _LRRClientResponse[RefreshRegistryResponse]: """ - POST /api/plugins/registry/refresh + POST /api/registries/{id}/refresh """ - url = self.api_context.build_url("/api/plugins/registry/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) - if response_j.get("success") == 0: - return (None, LanraragiErrorResponse(error=response_j.get("error", ""), status=status)) - return (RefreshRegistryResponse(index=response_j.get("index")), None) + return (RefreshRegistryResponse(index=response_j["index"]), None) return (None, _build_err_response(content, status)) async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientResponse[InstallPluginResponse]: @@ -225,18 +266,21 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo POST /api/plugins/install """ url = self.api_context.build_url("/api/plugins/install") - body = {"namespace": request.namespace} + body: dict[str, Any] = {"namespace": request.namespace} + if request.registry is not None: + body["registry"] = request.registry + 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: response_j = json.loads(content) - if response_j.get("success") == 0: - return (None, LanraragiErrorResponse(error=response_j.get("error", ""), status=status)) return (InstallPluginResponse( - name=response_j.get("name"), - namespace=response_j.get("namespace"), - version=response_j.get("version"), + name=response_j["name"], + namespace=response_j["namespace"], + version=response_j["version"], + registry=response_j["registry"], ), None) return (None, _build_err_response(content, status)) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index dc959e3f..13674c03 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -96,24 +96,45 @@ class RegenerateThumbnailResponse(LanraragiResponse): job: int = Field(...) class RegistryConfig(BaseModel): + id: str = Field(...) + name: str = Field(...) type: Literal["git", "local"] = Field(...) provider: Literal["github", "gitlab", "gitea"] | None = Field(None) url: str | None = Field(None) ref: str | None = Field(None) path: str | None = Field(None) -class SetRegistryRequest(LanraragiRequest): +class CreateRegistryRequest(LanraragiRequest): + name: str = Field(...) type: Literal["git", "local"] = Field(...) provider: Literal["github", "gitlab", "gitea"] | None = Field(None) url: str | None = Field(None) ref: str | None = Field(None) path: str | None = Field(None) +class CreateRegistryResponse(LanraragiResponse): + id: str = Field(...) + registry: RegistryConfig = Field(...) + +class UpdateRegistryRequest(LanraragiRequest): + name: str | None = Field(None) + type: Literal["git", "local"] | None = Field(None) + provider: Literal["github", "gitlab", "gitea"] | None = Field(None) + url: str | None = Field(None) + ref: str | None = Field(None) + path: str | None = Field(None) + +class UpdateRegistryResponse(LanraragiResponse): + id: str = Field(...) + registry: RegistryConfig = Field(...) + index_cleared: bool = Field(...) + class GetRegistryResponse(LanraragiResponse): - registry: RegistryConfig | None = Field(None) + id: str = Field(...) + registry: RegistryConfig = Field(...) -class SetRegistryResponse(LanraragiResponse): - registry: RegistryConfig | None = Field(None) +class ListRegistriesResponse(LanraragiResponse): + registries: list[RegistryConfig] = Field(...) class RefreshRegistryResponse(LanraragiResponse): index: dict[str, Any] | None = Field(None) @@ -123,11 +144,14 @@ class UpdatePluginConfigRequest(LanraragiRequest): class InstallPluginRequest(LanraragiRequest): namespace: str = Field(...) + registry: str | None = Field(None) + force: bool | None = Field(None) class InstallPluginResponse(LanraragiResponse): name: str = Field(...) namespace: str = Field(...) version: str = Field(...) + registry: str = Field(...) __all__ = [ "GetServerInfoResponse", @@ -147,9 +171,12 @@ class InstallPluginResponse(LanraragiResponse): "RegenerateThumbnailRequest", "RegenerateThumbnailResponse", "RegistryConfig", - "SetRegistryRequest", + "CreateRegistryRequest", + "CreateRegistryResponse", + "UpdateRegistryRequest", + "UpdateRegistryResponse", "GetRegistryResponse", - "SetRegistryResponse", + "ListRegistriesResponse", "RefreshRegistryResponse", "UpdatePluginConfigRequest", "InstallPluginRequest", From 95c08461f18a00335c35f2eba9e399c68118b99f Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:55:55 -0700 Subject: [PATCH 07/72] update test target --- .github/workflows/tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9c20459d..82c53044 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/main' 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/main' }} jobs: From ec2a751f5e6990c5fb0daff4be2d7022b2e27980 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:00:37 -0700 Subject: [PATCH 08/72] apply ratelimit flags to registry tests --- integration_tests/tests/test_registry.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 5e4c0c00..de2f967c 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -173,6 +173,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab @pytest.mark.asyncio @pytest.mark.dev("registry") +@pytest.mark.ratelimit async def test_registry_update_relink(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test that updating source fields clears the cached index. @@ -222,6 +223,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra @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. @@ -270,6 +272,7 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD @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 from the registry. @@ -328,6 +331,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: @pytest.mark.asyncio @pytest.mark.dev("registry") +@pytest.mark.ratelimit async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test hiding and unhiding a plugin. @@ -401,6 +405,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR @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 upgrade behavior. From c6bcb177f6cad38c15f7f89b9ba95432eb335f04 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:28:07 -0700 Subject: [PATCH 09/72] add ratelimit to registry tests --- integration_tests/tests/test_registry.py | 30 ++++++----------------- src/lanraragi/clients/api_clients/misc.py | 16 ++++++------ src/lanraragi/models/misc.py | 2 +- 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index de2f967c..0d1126d2 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -173,7 +173,6 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab @pytest.mark.asyncio @pytest.mark.dev("registry") -@pytest.mark.ratelimit async def test_registry_update_relink(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test that updating source fields clears the cached index. @@ -223,7 +222,6 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra @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. @@ -272,7 +270,6 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD @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 from the registry. @@ -304,7 +301,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: # >>>>> INSTALL PLUGIN >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader") + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) ) assert not error, f"Failed to install plugin (status {error.status}): {error.error}" assert response.namespace == "sample-downloader" @@ -331,7 +328,6 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: @pytest.mark.asyncio @pytest.mark.dev("registry") -@pytest.mark.ratelimit async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test hiding and unhiding a plugin. @@ -359,7 +355,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata") + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) ) assert not error, f"Failed to install plugin (status {error.status}): {error.error}" # <<<<< SETUP AND INSTALL <<<<< @@ -405,7 +401,6 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR @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 upgrade behavior. @@ -413,10 +408,9 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr 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 no-provenance error. - 5. Force install sample-metadata, expect namespace conflict error (model-layer). - 6. Install sample-downloader (no conflict), expect success with provenance. - 7. Reinstall sample-downloader (same-registry upgrade), expect success. + 4. Install sample-metadata, expect namespace conflict error. + 5. Install sample-downloader (no conflict), expect success with provenance. + 6. Reinstall sample-downloader (same-registry upgrade), expect success. """ with tempfile.TemporaryDirectory() as tmpdir: conflict_path = Path(tmpdir) / "SampleMetadata.pm" @@ -449,23 +443,15 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # >>>>> INSTALL WITH CONFLICT (NO PROVENANCE) >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata") + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) ) assert error is not None, "Expected error when installing plugin with existing sideloaded copy" assert "no provenance" in error.error, f"Expected 'no provenance' in error, got: {error.error}" # <<<<< INSTALL WITH CONFLICT (NO PROVENANCE) <<<<< - # >>>>> FORCE INSTALL (NAMESPACE CONFLICT) >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", force=True) - ) - assert error is not None, "Expected namespace conflict error on force install" - assert "already declared" in error.error, f"Expected 'already declared' in error, got: {error.error}" - # <<<<< FORCE INSTALL (NAMESPACE CONFLICT) <<<<< - # >>>>> INSTALL WITHOUT CONFLICT >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader") + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) ) assert not error, f"Failed to install non-conflicting plugin (status {error.status}): {error.error}" assert response.namespace == "sample-downloader" @@ -474,7 +460,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # >>>>> UPGRADE (REINSTALL) >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader") + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) ) assert not error, f"Failed to reinstall/upgrade plugin (status {error.status}): {error.error}" # <<<<< UPGRADE (REINSTALL) <<<<< diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 3aaebb05..70e5eafa 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -170,7 +170,7 @@ async def list_registries(self) -> _LRRClientResponse[ListRegistriesResponse]: 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["registries"]] + registries = [RegistryConfig.model_validate(r) for r in response_j.get("registries", [])] return (ListRegistriesResponse(registries=registries), None) return (None, _build_err_response(content, status)) @@ -193,7 +193,7 @@ async def create_registry(self, request: CreateRegistryRequest) -> _LRRClientRes ) if status == 200: response_j = json.loads(content) - registry = RegistryConfig.model_validate(response_j["registry"]) + registry = RegistryConfig.model_validate(response_j.get("registry")) return (CreateRegistryResponse(id=response_j["id"], registry=registry), None) return (None, _build_err_response(content, status)) @@ -205,7 +205,7 @@ async def get_registry(self, registry_id: str) -> _LRRClientResponse[GetRegistry 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["registry"]) + registry = RegistryConfig.model_validate(response_j.get("registry")) return (GetRegistryResponse(id=response_j["id"], registry=registry), None) return (None, _build_err_response(content, status)) @@ -232,11 +232,11 @@ async def update_registry(self, registry_id: str, request: UpdateRegistryRequest ) if status == 200: response_j = json.loads(content) - registry = RegistryConfig.model_validate(response_j["registry"]) + registry = RegistryConfig.model_validate(response_j.get("registry")) return (UpdateRegistryResponse( id=response_j["id"], registry=registry, - index_cleared=response_j["index_cleared"], + index_cleared=response_j.get("index_cleared", False), ), None) return (None, _build_err_response(content, status)) @@ -258,7 +258,7 @@ async def refresh_registry(self, registry_id: str) -> _LRRClientResponse[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["index"]), None) + return (RefreshRegistryResponse(index=response_j.get("index")), None) return (None, _build_err_response(content, status)) async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientResponse[InstallPluginResponse]: @@ -266,9 +266,7 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo POST /api/plugins/install """ url = self.api_context.build_url("/api/plugins/install") - body: dict[str, Any] = {"namespace": request.namespace} - if request.registry is not None: - body["registry"] = request.registry + body: dict[str, Any] = {"namespace": request.namespace, "registry": request.registry} if request.force is not None: body["force"] = request.force status, content = await self.api_context.handle_request( diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 13674c03..7f11a1e0 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -144,7 +144,7 @@ class UpdatePluginConfigRequest(LanraragiRequest): class InstallPluginRequest(LanraragiRequest): namespace: str = Field(...) - registry: str | None = Field(None) + registry: str = Field(...) force: bool | None = Field(None) class InstallPluginResponse(LanraragiResponse): From 2e3b3e9a1a7c49b2cc96ec849687eca9acf68307 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:05:53 -0700 Subject: [PATCH 10/72] add optional registry field for GetAvailablePluginsResponsePlugin --- src/lanraragi/models/misc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 7f11a1e0..2341c1d1 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -50,6 +50,7 @@ class GetAvailablePluginsResponsePlugin(BaseModel): type: Literal["login", "metadata", "script", "download", "all"] = Field(...) version: str = Field(...) hidden: bool = Field(False) + registry: str | None = Field(None) class GetAvailablePluginsResponse(LanraragiResponse): plugins: list[GetAvailablePluginsResponsePlugin] = Field(...) From 45975393f171fd5c699116b519fabc41b8460a29 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:06:20 -0700 Subject: [PATCH 11/72] add some commented test cases --- integration_tests/tests/test_registry.py | 98 ++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 0d1126d2..06096a4d 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -466,3 +466,101 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # <<<<< UPGRADE (REINSTALL) <<<<< expect_no_error_logs(environment, LOGGER) + + +# # TODO: not needed, served its purpose. +# @pytest.mark.asyncio +# @pytest.mark.dev("registry") +# async def test_plugin_config_nonexistent(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): +# """ +# Test that updating config for a nonexistent plugin returns 404. + +# 1. Setup environment with API key. +# 2. Call update_plugin_config on a namespace that was never installed. +# 3. Verify the server returns an error (404). +# """ +# environment.setup(with_api_key=True) + +# # >>>>> UPDATE NONEXISTENT PLUGIN >>>>> +# response, error = await lrr_client.misc_api.update_plugin_config( +# "nonexistent-plugin-xyz", UpdatePluginConfigRequest(hidden=True) +# ) +# assert error is not None, "Expected error when updating config for nonexistent plugin" +# assert error.status == 404, f"Expected 404 status, got: {error.status}" +# # <<<<< UPDATE NONEXISTENT PLUGIN <<<<< + +# expect_no_error_logs(environment, LOGGER) + + +# # TODO: not needed, served its purpose. +# @pytest.mark.asyncio +# @pytest.mark.dev("registry") +# async def test_plugin_config_survives_restart(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): +# """ +# Test that plugin configuration persists across server restart. + +# 1. Create registry, refresh, install a plugin. +# 2. Hide the plugin via update_plugin_config. +# 3. Restart the server. +# 4. Verify the plugin is still hidden after restart. +# """ +# environment.setup(with_api_key=True) + +# # >>>>> SETUP AND INSTALL >>>>> +# response, error = await lrr_client.misc_api.create_registry( +# CreateRegistryRequest( +# name="demo", +# type="git", +# 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}" + +# response, error = await lrr_client.misc_api.install_plugin( +# InstallPluginRequest(namespace="sample-metadata", registry=reg_id) +# ) +# assert not error, f"Failed to install plugin (status {error.status}): {error.error}" +# # <<<<< SETUP AND INSTALL <<<<< + +# # >>>>> HIDE PLUGIN >>>>> +# response, error = await lrr_client.misc_api.update_plugin_config( +# "sample-metadata", UpdatePluginConfigRequest(hidden=True) +# ) +# assert not error, f"Failed to hide 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 == "sample-metadata": +# assert plugin.hidden is True, f"Expected hidden=True before restart, got {plugin.hidden}" +# break +# else: +# pytest.fail("Plugin sample-metadata not found before restart") +# # <<<<< HIDE PLUGIN <<<<< + +# # >>>>> RESTART >>>>> +# environment.restart() +# # <<<<< RESTART <<<<< + +# # >>>>> VERIFY AFTER RESTART >>>>> +# response, error = await lrr_client.misc_api.get_available_plugins( +# GetAvailablePluginsRequest(type="metadata") +# ) +# assert not error, f"Failed to list plugins after restart (status {error.status}): {error.error}" +# for plugin in response.plugins: +# if plugin.namespace == "sample-metadata": +# assert plugin.hidden is True, f"Expected hidden=True after restart, got {plugin.hidden}" +# break +# else: +# pytest.fail("Plugin sample-metadata not found after restart") +# # <<<<< VERIFY AFTER RESTART <<<<< + +# expect_no_error_logs(environment, LOGGER) From 8c230d0713c60bda8d88cb5b822f100a942e5aea Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:16:25 -0700 Subject: [PATCH 12/72] add plugin priority-related tests --- integration_tests/tests/test_registry.py | 111 ++++++++++++++++++++++ src/lanraragi/clients/api_clients/misc.py | 2 + src/lanraragi/models/misc.py | 2 + 3 files changed, 115 insertions(+) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 06096a4d..99adbcf2 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -399,6 +399,117 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR expect_no_error_logs(environment, LOGGER) +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test plugin priority via update_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. + 5. Set priority on a non-metadata plugin, verify it is stored. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP AND INSTALL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + ) + 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_plugin_config( + "sample-metadata", UpdatePluginConfigRequest(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_plugin_config( + "copytags", UpdatePluginConfigRequest(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 <<<<< + + # >>>>> PRIORITY ON NON-METADATA PLUGIN >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.update_plugin_config( + "sample-downloader", UpdatePluginConfigRequest(priority=2) + ) + assert not error, f"Failed to set sample-downloader priority (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 download plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-downloader": + assert plugin.priority == 2, f"Expected sample-downloader priority 2, got {plugin.priority}" + break + else: + pytest.fail("sample-downloader not found in download plugin list") + # <<<<< PRIORITY ON NON-METADATA PLUGIN <<<<< + + expect_no_error_logs(environment, LOGGER) + + @pytest.mark.asyncio @pytest.mark.dev("registry") async def test_plugin_install_conflict(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 70e5eafa..acaea95c 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -303,6 +303,8 @@ async def update_plugin_config(self, namespace: str, request: UpdatePluginConfig body = {} 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) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 2341c1d1..f0119254 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -50,6 +50,7 @@ class GetAvailablePluginsResponsePlugin(BaseModel): type: Literal["login", "metadata", "script", "download", "all"] = Field(...) version: str = Field(...) hidden: bool = Field(False) + priority: int = Field(0) registry: str | None = Field(None) class GetAvailablePluginsResponse(LanraragiResponse): @@ -142,6 +143,7 @@ class RefreshRegistryResponse(LanraragiResponse): class UpdatePluginConfigRequest(LanraragiRequest): hidden: bool | None = Field(None) + priority: int | None = Field(None) class InstallPluginRequest(LanraragiRequest): namespace: str = Field(...) From e5457f76abed930381edb0d33e783accba2ae1e3 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:32:18 -0700 Subject: [PATCH 13/72] add more plugin priority-related tests --- integration_tests/tests/test_registry.py | 243 ++++++++++++++++++++++- 1 file changed, 242 insertions(+), 1 deletion(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 99adbcf2..b1291789 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -7,6 +7,8 @@ from collections.abc import AsyncGenerator, Generator from pathlib import Path +import playwright.async_api +import playwright.async_api._generated import pytest import pytest_asyncio from lanraragi.clients.client import LRRClient @@ -18,12 +20,16 @@ UpdateRegistryRequest, ) -from aio_lanraragi_tests.common import DEFAULT_API_KEY +from aio_lanraragi_tests.common import DEFAULT_API_KEY, DEFAULT_LRR_PASSWORD from aio_lanraragi_tests.deployment.base import ( AbstractLRRDeploymentContext, expect_no_error_logs, ) from aio_lanraragi_tests.deployment.factory import generate_deployment +from aio_lanraragi_tests.utils.playwright import ( + assert_browser_responses_ok, + assert_console_logs_ok, +) LOGGER = logging.getLogger(__name__) @@ -326,6 +332,95 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: expect_no_error_logs(environment, LOGGER) +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that uninstalling a plugin fully removes it and allows reinstallation. + + 1. Create registry and refresh index. + 2. Install sample-metadata, verify managed provenance. + 3. Uninstall, verify plugin absent from list. + 4. Reinstall, verify managed provenance preserved. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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 >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + ) + 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 == "sample-metadata": + assert plugin.registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.registry}" + break + else: + pytest.fail("sample-metadata not found after install") + # <<<<< VERIFY INSTALLED <<<<< + + # >>>>> UNINSTALL >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin("sample-metadata") + 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 "sample-metadata" not in namespaces, f"Plugin still in list after uninstall: {namespaces}" + # <<<<< VERIFY REMOVED <<<<< + + # >>>>> REINSTALL >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + ) + 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 == "sample-metadata": + assert plugin.registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.registry}" + break + else: + pytest.fail("sample-metadata not found after reinstall") + # <<<<< VERIFY REINSTALLED <<<<< + + expect_no_error_logs(environment, LOGGER) + + @pytest.mark.asyncio @pytest.mark.dev("registry") async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): @@ -675,3 +770,149 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # # <<<<< VERIFY AFTER RESTART <<<<< # expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.playwright +@pytest.mark.dev("registry") +async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test plugin install, enable, uninstall, and reinstall through the UI. + + 1. Create registry, refresh index via API. + 2. Navigate to plugin page, install sample-metadata from registry. + 3. Move sample-metadata to enabled pool, save configuration. + 4. Uninstall sample-metadata, verify absent from page and API. + 5. Refresh registry, verify sample-metadata available for reinstall. + 6. Reinstall, verify managed provenance. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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") + 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 <<<<< + + # >>>>> INSTALL >>>>> + await page.get_by_role("button", name="Refresh Index").click() + await page.wait_for_load_state("networkidle") + + sample_metadata_row = page.locator(".registry-plugin-row").filter( + has=page.locator("h2", has_text="Sample Metadata") + ) + await sample_metadata_row.locator("input[type='button']").click() + await page.wait_for_load_state("networkidle") + # <<<<< INSTALL <<<<< + + # >>>>> VERIFY INSTALLED >>>>> + badge = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") + assert await badge.text_content() == "managed", f"Expected 'managed' badge after install, got: {await badge.text_content()}" + # <<<<< VERIFY INSTALLED <<<<< + + # >>>>> ENABLE AND SAVE >>>>> + # native drag does not trigger SortableJS; move via DOM + moved = await page.evaluate("""() => { + const card = document.querySelector('.plugin-card[data-namespace="sample-metadata"]'); + const enabledPool = document.getElementById('metadata-enabled'); + if (!card || !enabledPool) return false; + + const emptyMsg = enabledPool.querySelector('.pool-empty-msg'); + if (emptyMsg) emptyMsg.remove(); + + enabledPool.appendChild(card); + if (typeof Plugins !== 'undefined' && Plugins.renumberEnabled) { + Plugins.renumberEnabled(); + } + return card.closest('#metadata-enabled') !== null; + }""") + assert moved, "Failed to move sample-metadata to enabled pool" + + await page.get_by_role("button", name="Save Plugin Configuration").click() + await page.wait_for_load_state("networkidle") + await page.wait_for_timeout(2000) + # <<<<< ENABLE AND SAVE <<<<< + + # >>>>> UNINSTALL >>>>> + await page.locator(".plugin-uninstall-btn[data-namespace='sample-metadata']").click() + + # confirm uninstall dialog + await page.wait_for_selector(".swal2-confirm", state="visible") + await page.click(".swal2-confirm") + await page.wait_for_load_state("networkidle") + # <<<<< UNINSTALL <<<<< + + # >>>>> 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})" + + # verify via API + 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 <<<<< + + # >>>>> REFRESH AND VERIFY AVAILABLE >>>>> + await page.get_by_role("button", name="Refresh Index").click() + await page.wait_for_load_state("networkidle") + + reinstall_row = page.locator(".registry-plugin-row").filter( + has=page.locator("h2", has_text="Sample Metadata") + ) + await reinstall_row.wait_for(state="visible") + # <<<<< REFRESH AND VERIFY AVAILABLE <<<<< + + # >>>>> REINSTALL >>>>> + await reinstall_row.locator("input[type='button']").click() + await page.wait_for_load_state("networkidle") + + badge_after = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") + 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) From df73ac2d5ccf8d8515063357ec62315732a60689 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:32:03 -0700 Subject: [PATCH 14/72] fix registry tests --- integration_tests/tests/test_registry.py | 33 ++++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index b1291789..5385a526 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -831,18 +831,25 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL # <<<<< LOGIN <<<<< # >>>>> INSTALL >>>>> - await page.get_by_role("button", name="Refresh Index").click() + # Expand the Metadata Plugins collapsible (hidden by allcollapsible on load) + await page.locator(".collapsible-title", has_text="Metadata Plugins").click() + await page.wait_for_timeout(500) + + await page.locator("#registry-refresh-btn").click() await page.wait_for_load_state("networkidle") sample_metadata_row = page.locator(".registry-plugin-row").filter( has=page.locator("h2", has_text="Sample Metadata") ) - await sample_metadata_row.locator("input[type='button']").click() - await page.wait_for_load_state("networkidle") + async with page.expect_response("**/api/plugins/install") as response_info: + await sample_metadata_row.locator("input[type='button']").click() + install_response = await response_info.value + assert install_response.ok, f"Install API failed: {install_response.status}" # <<<<< INSTALL <<<<< # >>>>> VERIFY INSTALLED >>>>> badge = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") + await page.wait_for_timeout(500) assert await badge.text_content() == "managed", f"Expected 'managed' badge after install, got: {await badge.text_content()}" # <<<<< VERIFY INSTALLED <<<<< @@ -872,9 +879,12 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL # >>>>> UNINSTALL >>>>> await page.locator(".plugin-uninstall-btn[data-namespace='sample-metadata']").click() - # confirm uninstall dialog + # confirm uninstall dialog; wait for DELETE response then page reload await page.wait_for_selector(".swal2-confirm", state="visible") - await page.click(".swal2-confirm") + async with page.expect_response("**/api/plugins/installed/**") as response_info: + await page.click(".swal2-confirm") + 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 <<<<< @@ -892,7 +902,11 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL # <<<<< VERIFY REMOVED <<<<< # >>>>> REFRESH AND VERIFY AVAILABLE >>>>> - await page.get_by_role("button", name="Refresh Index").click() + # Re-expand collapsible (page reloaded after uninstall) + await page.locator(".collapsible-title", has_text="Metadata Plugins").click() + await page.wait_for_timeout(500) + + await page.locator("#registry-refresh-btn").click() await page.wait_for_load_state("networkidle") reinstall_row = page.locator(".registry-plugin-row").filter( @@ -902,10 +916,13 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL # <<<<< REFRESH AND VERIFY AVAILABLE <<<<< # >>>>> REINSTALL >>>>> - await reinstall_row.locator("input[type='button']").click() - await page.wait_for_load_state("networkidle") + async with page.expect_response("**/api/plugins/install") as response_info: + await reinstall_row.locator("input[type='button']").click() + reinstall_response = await response_info.value + assert reinstall_response.ok, f"Reinstall API failed: {reinstall_response.status}" badge_after = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") + await page.wait_for_timeout(500) assert await badge_after.text_content() == "managed", f"Expected 'managed' after reinstall, got: {await badge_after.text_content()}" # <<<<< REINSTALL <<<<< From 345f1fbb30e6fc731df345f80f2875e4d0d1fd7e Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:06:01 -0700 Subject: [PATCH 15/72] add test_plugin_uninstall_not_listed --- integration_tests/tests/test_registry.py | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 5385a526..4963c16a 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -933,3 +933,53 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL await browser.close() expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that uninstalled plugin is absent from plugin list across repeated cycles. + + 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", + type="git", + 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 <<<<< + + for i in range(5): + LOGGER.info(f"Cycle {i}: installing sample-login") + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-login", registry=reg_id) + ) + assert not error, f"Cycle {i}: install failed (status {error.status}): {error.error}" + + LOGGER.info(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.info(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) From a95141e77419614624f8d4c145277de5b1028cb5 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:14:39 -0700 Subject: [PATCH 16/72] add ratelimit flags --- integration_tests/tests/test_registry.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 4963c16a..b185a324 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -64,6 +64,7 @@ async def lrr_client(environment: AbstractLRRDeploymentContext) -> AsyncGenerato @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. @@ -179,6 +180,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab @pytest.mark.asyncio @pytest.mark.dev("registry") +@pytest.mark.ratelimit async def test_registry_update_relink(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test that updating source fields clears the cached index. @@ -228,6 +230,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra @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. @@ -276,6 +279,7 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD @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 from the registry. @@ -334,6 +338,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: @pytest.mark.asyncio @pytest.mark.dev("registry") +@pytest.mark.ratelimit async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test that uninstalling a plugin fully removes it and allows reinstallation. @@ -423,6 +428,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab @pytest.mark.asyncio @pytest.mark.dev("registry") +@pytest.mark.ratelimit async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test hiding and unhiding a plugin. @@ -496,6 +502,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR @pytest.mark.asyncio @pytest.mark.dev("registry") +@pytest.mark.ratelimit async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ Test plugin priority via update_plugin_config. @@ -607,6 +614,7 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe @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 upgrade behavior. @@ -775,6 +783,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr @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, enable, uninstall, and reinstall through the UI. @@ -937,6 +946,7 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL @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. From b317a87f0c7bfac1a685229e54c3c8d4863d2d52 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:25:13 -0700 Subject: [PATCH 17/72] wording --- integration_tests/tests/test_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index b185a324..9c7c3bb2 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -660,7 +660,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr InstallPluginRequest(namespace="sample-metadata", registry=reg_id) ) assert error is not None, "Expected error when installing plugin with existing sideloaded copy" - assert "no provenance" in error.error, f"Expected 'no provenance' in error, got: {error.error}" + assert "without provenance" in error.error, f"Expected 'without provenance' in error, got: {error.error}" # <<<<< INSTALL WITH CONFLICT (NO PROVENANCE) <<<<< # >>>>> INSTALL WITHOUT CONFLICT >>>>> From ab4009b615ca9d1c00be7d74aa56d1f00f50bbdf Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:59:58 -0700 Subject: [PATCH 18/72] add more registry integration tests --- integration_tests/tests/test_registry.py | 259 ++++++++++++++++++++++- 1 file changed, 250 insertions(+), 9 deletions(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 9c7c3bb2..dbd88d45 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -158,6 +158,9 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab 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 a valid registry, then create a second, expect single-registry limit error. """ environment.setup(with_api_key=True) @@ -175,6 +178,120 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab assert error is not None, "Expected error for local registry without path" # <<<<< MISSING PATH FOR LOCAL <<<<< + # >>>>> NON-HTTPS URL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="http git", type="git", provider="github", url="http://github.com/owner/repo.git") + ) + assert error is not None, "Expected error for non-HTTPS git URL" + # <<<<< NON-HTTPS URL <<<<< + + # >>>>> MISSING NAME >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="", type="local", path="/tmp/plugins") + ) + assert error is not None, "Expected error for missing registry name" + # <<<<< MISSING NAME <<<<< + + # >>>>> SINGLE-REGISTRY LIMIT >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="first", type="local", path="/tmp/plugins") + ) + assert not error, f"Failed to create first registry (status {error.status}): {error.error}" + first_id = response.id + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="second", type="local", path="/tmp/other") + ) + assert error is not None, "Expected error for single-registry limit" + + response, error = await lrr_client.misc_api.delete_registry(first_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + # <<<<< SINGLE-REGISTRY LIMIT <<<<< + + 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 ref field, verify index_cleared. + """ + 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" + # <<<<< 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" + # <<<<< DELETE NONEXISTENT <<<<< + + # >>>>> EMPTY UPDATE >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="test", type="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" + # <<<<< EMPTY UPDATE <<<<< + + # >>>>> NON-HTTPS URL ON UPDATE >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(type="git", url="http://example.com/repo.git") + ) + assert error is not None, "Expected error for non-HTTPS URL on update" + # <<<<< NON-HTTPS URL ON UPDATE <<<<< + + # >>>>> UPDATE REF 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.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create git registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(ref="dev") + ) + assert not error, f"Failed to update ref (status {error.status}): {error.error}" + assert response.index_cleared is True, "Ref change should clear 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}" + # <<<<< UPDATE REF CLEARS INDEX <<<<< + expect_no_error_logs(environment, LOGGER) @@ -282,13 +399,14 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD @pytest.mark.ratelimit async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test installing and uninstalling a plugin from the registry. + Test installing and uninstalling a plugin, including error paths. 1. Create registry and refresh index. - 2. Install sample-downloader plugin (sole registry fallback). + 2. Install sample-downloader plugin, verify provenance. 3. Verify plugin appears in plugin list. - 4. Uninstall the plugin. - 5. Verify plugin is no longer listed. + 4. Uninstall the plugin, verify absent. + 5. Uninstall again (no install path), expect error. + 6. Uninstall a namespace that was never installed, expect error. """ environment.setup(with_api_key=True) @@ -331,8 +449,25 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: # >>>>> 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" + # <<<<< 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" + # <<<<< UNINSTALL NEVER-INSTALLED <<<<< + expect_no_error_logs(environment, LOGGER) @@ -341,12 +476,14 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: @pytest.mark.ratelimit async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test that uninstalling a plugin fully removes it and allows reinstallation. + Test uninstall/reinstall lifecycle and orphaned provenance. 1. Create registry and refresh index. 2. Install sample-metadata, verify managed provenance. 3. Uninstall, verify plugin absent from list. 4. Reinstall, verify managed provenance preserved. + 5. Delete registry, verify plugin still listed with orphaned provenance. + 6. Uninstall orphaned plugin, verify success. """ environment.setup(with_api_key=True) @@ -423,6 +560,34 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab pytest.fail("sample-metadata not found after reinstall") # <<<<< VERIFY REINSTALLED <<<<< + # >>>>> 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 == "sample-metadata": + assert plugin.registry == reg_id, f"Expected orphaned provenance {reg_id}, got: {plugin.registry}" + break + else: + pytest.fail("sample-metadata should still be listed after registry delete") + # <<<<< ORPHANED PROVENANCE <<<<< + + # >>>>> UNINSTALL ORPHANED >>>>> + response, error = await lrr_client.misc_api.uninstall_plugin("sample-metadata") + 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 "sample-metadata" not in namespaces, f"Orphaned plugin still listed after uninstall: {namespaces}" + # <<<<< UNINSTALL ORPHANED <<<<< + expect_no_error_logs(environment, LOGGER) @@ -431,11 +596,13 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab @pytest.mark.ratelimit async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test hiding and unhiding a plugin. + 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. """ environment.setup(with_api_key=True) @@ -497,6 +664,33 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR 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_plugin_config( + "sample-metadata", UpdatePluginConfigRequest(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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + ) + 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 <<<<< + expect_no_error_logs(environment, LOGGER) @@ -617,14 +811,18 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe @pytest.mark.ratelimit async def test_plugin_install_conflict(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test plugin install conflict detection and upgrade behavior. + Test plugin install conflict detection, force install, and install error paths. 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 namespace conflict error. - 5. Install sample-downloader (no conflict), expect success with provenance. - 6. Reinstall sample-downloader (same-registry upgrade), expect success. + 5. Force install sample-metadata, expect namespace conflict (filesystem-level block). + 6. Install sample-downloader (no conflict), expect success with provenance. + 7. Reinstall sample-downloader (same-registry upgrade), expect success. + 8. Install nonexistent namespace, expect error. + 9. Install from nonexistent registry, expect error. + 10. Install before refresh (no cached index), expect error. """ with tempfile.TemporaryDirectory() as tmpdir: conflict_path = Path(tmpdir) / "SampleMetadata.pm" @@ -663,6 +861,13 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr assert "without provenance" in error.error, f"Expected 'without provenance' in error, got: {error.error}" # <<<<< INSTALL WITH CONFLICT (NO PROVENANCE) <<<<< + # >>>>> FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, force=True) + ) + assert error is not None, "Expected error: force bypasses provenance but not filesystem namespace conflict" + # <<<<< FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT <<<<< + # >>>>> INSTALL WITHOUT CONFLICT >>>>> response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-downloader", registry=reg_id) @@ -679,6 +884,42 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr assert not error, f"Failed to reinstall/upgrade plugin (status {error.status}): {error.error}" # <<<<< UPGRADE (REINSTALL) <<<<< + # >>>>> INSTALL NONEXISTENT NAMESPACE >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="does-not-exist", registry=reg_id) + ) + assert error is not None, "Expected error installing nonexistent namespace" + # <<<<< INSTALL NONEXISTENT NAMESPACE <<<<< + + # >>>>> INSTALL FROM NONEXISTENT REGISTRY >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001") + ) + assert error is not None, "Expected error installing from nonexistent registry" + # <<<<< INSTALL FROM NONEXISTENT REGISTRY <<<<< + + # >>>>> INSTALL BEFORE REFRESH (NO CACHED 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.create_registry( + CreateRegistryRequest( + name="unrefreshed", + type="git", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create unrefreshed registry (status {error.status}): {error.error}" + unreffed_id = response.id + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=unreffed_id) + ) + assert error is not None, "Expected error installing from registry without cached index" + # <<<<< INSTALL BEFORE REFRESH (NO CACHED INDEX) <<<<< + expect_no_error_logs(environment, LOGGER) From e9034fa2c435a5260aa178b65b6fa9a597aa4049 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:19:30 -0700 Subject: [PATCH 19/72] extend test_registry_update_relink --- integration_tests/tests/test_registry.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index dbd88d45..9b96804d 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -305,6 +305,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra 1. Create a git registry and refresh. 2. Update the URL, verify index_cleared is true. 3. Update name only, verify index_cleared is false. + 4. Switch type from git to local, verify stale git fields are absent. """ environment.setup(with_api_key=True) @@ -342,6 +343,19 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra assert response.index_cleared is False, "Name change should not clear index" # <<<<< UPDATE NAME ONLY <<<<< + # >>>>> TYPE SWITCH: GIT -> LOCAL >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(type="local", path="/tmp/plugins") + ) + assert not error, f"Failed to switch type (status {error.status}): {error.error}" + assert response.index_cleared is True, "Type change should clear index" + assert response.registry.type == "local", "Type 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.provider is None, "Stale git field 'provider' should be absent" + assert response.registry.ref is None, "Stale git field 'ref' should be absent" + # <<<<< TYPE SWITCH: GIT -> LOCAL <<<<< + expect_no_error_logs(environment, LOGGER) From 05adca225f99573e8126ae06e05ce65ab04173f3 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:16:31 -0700 Subject: [PATCH 20/72] add enabled to UpdatePluginConfigRequest --- src/lanraragi/clients/api_clients/misc.py | 2 ++ src/lanraragi/models/misc.py | 1 + 2 files changed, 3 insertions(+) diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index acaea95c..35e281b2 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -301,6 +301,8 @@ async def update_plugin_config(self, namespace: str, request: UpdatePluginConfig """ url = self.api_context.build_url(f"/api/plugins/installed/{namespace}/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: diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index f0119254..da820379 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -142,6 +142,7 @@ class RefreshRegistryResponse(LanraragiResponse): index: dict[str, Any] | None = Field(None) class UpdatePluginConfigRequest(LanraragiRequest): + enabled: bool | None = Field(None) hidden: bool | None = Field(None) priority: int | None = Field(None) From 08f75362c3a24e9a8e7644b9631faea9a4e3f303 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:16:44 -0700 Subject: [PATCH 21/72] add registry and plugin integration tests --- integration_tests/tests/test_registry.py | 251 +++++++++++++++++++++-- 1 file changed, 233 insertions(+), 18 deletions(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index 9b96804d..b3e69f77 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -2,6 +2,7 @@ Plugin registry integration tests. """ +import asyncio import logging import tempfile from collections.abc import AsyncGenerator, Generator @@ -12,6 +13,7 @@ import pytest import pytest_asyncio from lanraragi.clients.client import LRRClient +from lanraragi.models.archive import GetArchiveMetadataRequest from lanraragi.models.misc import ( CreateRegistryRequest, GetAvailablePluginsRequest, @@ -26,6 +28,7 @@ expect_no_error_logs, ) from aio_lanraragi_tests.deployment.factory import generate_deployment +from aio_lanraragi_tests.utils.api_wrappers import create_archive_file, upload_archive from aio_lanraragi_tests.utils.playwright import ( assert_browser_responses_ok, assert_console_logs_ok, @@ -303,9 +306,11 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra Test that updating source fields clears the cached index. 1. Create a git registry and refresh. - 2. Update the URL, verify index_cleared is true. - 3. Update name only, verify index_cleared is false. - 4. Switch type from git to local, verify stale git fields are absent. + 2. Install a plugin from the registry. + 3. Update the URL, verify index_cleared is true. + 4. Verify installed plugin retains provenance despite index clear. + 5. Update name only, verify index_cleared is false. + 6. Switch type from git to local, verify stale git fields are absent. """ environment.setup(with_api_key=True) @@ -327,12 +332,31 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra assert response.index is not None, "Expected index after refresh" # <<<<< CREATE AND REFRESH <<<<< + # >>>>> INSTALL PLUGIN BEFORE SOURCE CHANGE >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + 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}" assert response.index_cleared is True, "URL change should clear index" + + 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 >>>>> @@ -421,6 +445,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: 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) @@ -482,6 +507,19 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: assert error is not None, "Expected error uninstalling never-installed plugin" # <<<<< UNINSTALL NEVER-INSTALLED <<<<< + # >>>>> UNINSTALL BUILT-IN BLOCKED >>>>> + 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}" + namespaces = {p.namespace for p in response.plugins} + assert "copytags" in namespaces, "Built-in plugin should still be listed after blocked uninstall" + # <<<<< UNINSTALL BUILT-IN BLOCKED <<<<< + expect_no_error_logs(environment, LOGGER) @@ -493,11 +531,13 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab Test uninstall/reinstall lifecycle and orphaned provenance. 1. Create registry and refresh index. - 2. Install sample-metadata, verify managed provenance. + 2. Install title-suffix-1, verify managed provenance. 3. Uninstall, verify plugin absent from list. 4. Reinstall, verify managed provenance preserved. - 5. Delete registry, verify plugin still listed with orphaned provenance. - 6. Uninstall orphaned plugin, verify success. + 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) @@ -520,7 +560,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab # >>>>> INSTALL >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + InstallPluginRequest(namespace="title-suffix-1", registry=reg_id) ) 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}" @@ -532,15 +572,15 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab ) assert not error, f"Failed to list plugins (status {error.status}): {error.error}" for plugin in response.plugins: - if plugin.namespace == "sample-metadata": + if plugin.namespace == "title-suffix-1": assert plugin.registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.registry}" break else: - pytest.fail("sample-metadata not found after install") + pytest.fail("title-suffix-1 not found after install") # <<<<< VERIFY INSTALLED <<<<< # >>>>> UNINSTALL >>>>> - response, error = await lrr_client.misc_api.uninstall_plugin("sample-metadata") + 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 <<<<< @@ -550,12 +590,12 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab ) 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 list after uninstall: {namespaces}" + assert "title-suffix-1" not in namespaces, f"Plugin still in list after uninstall: {namespaces}" # <<<<< VERIFY REMOVED <<<<< # >>>>> REINSTALL >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + InstallPluginRequest(namespace="title-suffix-1", registry=reg_id) ) 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}" @@ -567,13 +607,33 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab ) 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": + if plugin.namespace == "title-suffix-1": assert plugin.registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.registry}" break else: - pytest.fail("sample-metadata not found after reinstall") + pytest.fail("title-suffix-1 not found after reinstall") # <<<<< VERIFY REINSTALLED <<<<< + # >>>>> ENABLE AND VERIFY EXECUTION >>>>> + response, error = await lrr_client.misc_api.update_plugin_config( + "title-suffix-1", UpdatePluginConfigRequest(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}" @@ -583,15 +643,30 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab ) assert not error, f"Failed to list plugins after registry delete (status {error.status}): {error.error}" for plugin in response.plugins: - if plugin.namespace == "sample-metadata": + if plugin.namespace == "title-suffix-1": assert plugin.registry == reg_id, f"Expected orphaned provenance {reg_id}, got: {plugin.registry}" break else: - pytest.fail("sample-metadata should still be listed after registry delete") + 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("sample-metadata") + 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( @@ -599,7 +674,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab ) 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 "sample-metadata" not in namespaces, f"Orphaned plugin still listed after uninstall: {namespaces}" + assert "title-suffix-1" not in namespaces, f"Orphaned plugin still listed after uninstall: {namespaces}" # <<<<< UNINSTALL ORPHANED <<<<< expect_no_error_logs(environment, LOGGER) @@ -617,6 +692,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR 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) @@ -705,6 +781,29 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR pytest.fail("sample-metadata not found after reinstall") # <<<<< CONFIG SURVIVES UNINSTALL/REINSTALL <<<<< + # >>>>> HIDE BUILT-IN PLUGIN >>>>> + response, error = await lrr_client.misc_api.update_plugin_config( + "copytags", UpdatePluginConfigRequest(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_plugin_config( + "copytags", UpdatePluginConfigRequest(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) @@ -820,6 +919,122 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe expect_no_error_logs(environment, LOGGER) +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@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", + type="git", + 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 ALL THREE >>>>> + for ns in ("title-suffix-1", "title-suffix-2", "title-suffix-3"): + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace=ns, registry=reg_id) + ) + 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_plugin_config( + "title-suffix-2", UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-1", UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-3", UpdatePluginConfigRequest(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_plugin_config( + ns, UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-3", UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-2", UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-1", UpdatePluginConfigRequest(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) + + @pytest.mark.asyncio @pytest.mark.dev("registry") @pytest.mark.ratelimit From 7dd0efe19b2da65d5026d9719ae24f57d1a8368b Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:39:56 -0700 Subject: [PATCH 22/72] update registry tests and add status code expectations --- integration_tests/tests/test_registry.py | 118 +++++++++++++++-------- 1 file changed, 76 insertions(+), 42 deletions(-) diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py index b3e69f77..509b2152 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -172,6 +172,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab CreateRegistryRequest(name="bad git", type="git") ) 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 >>>>> @@ -179,6 +180,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab CreateRegistryRequest(name="bad local", type="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 <<<<< # >>>>> NON-HTTPS URL >>>>> @@ -186,6 +188,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab CreateRegistryRequest(name="http git", type="git", provider="github", url="http://github.com/owner/repo.git") ) 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 NAME >>>>> @@ -193,6 +196,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab CreateRegistryRequest(name="", type="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 <<<<< # >>>>> SINGLE-REGISTRY LIMIT >>>>> @@ -206,6 +210,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab CreateRegistryRequest(name="second", type="local", path="/tmp/other") ) assert error is not None, "Expected error for single-registry limit" + assert error.status == 400, f"Expected 400 for single-registry limit, got {error.status}" response, error = await lrr_client.misc_api.delete_registry(first_id) assert not error, f"Failed to delete registry (status {error.status}): {error.error}" @@ -242,11 +247,13 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract 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 >>>>> @@ -260,6 +267,7 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract 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 >>>>> @@ -267,6 +275,7 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract reg_id, UpdateRegistryRequest(type="git", url="http://example.com/repo.git") ) 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 <<<<< # >>>>> UPDATE REF CLEARS INDEX >>>>> @@ -399,6 +408,7 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD # >>>>> 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 >>>>> @@ -429,6 +439,7 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD 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 <<<<< @@ -500,11 +511,13 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: # >>>>> 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 >>>>> @@ -523,6 +536,64 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: 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. + """ + environment.setup(with_api_key=True) + + # >>>>> INSTALL FROM NONEXISTENT REGISTRY >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001") + ) + assert error is not None, "Expected error for nonexistent registry" + assert error.status == 404, f"Expected 404 for nonexistent registry, got {error.status}" + # <<<<< INSTALL FROM NONEXISTENT REGISTRY <<<<< + + # >>>>> INSTALL WITHOUT REFRESH >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + assert error is not None, "Expected error when installing without refresh" + assert error.status == 409, f"Expected 409 for no cached index, got {error.status}" + # <<<<< 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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="does-not-exist-xyz", registry=reg_id) + ) + assert error is not None, "Expected error for nonexistent namespace" + assert error.status == 404, f"Expected 404 for unknown namespace, got {error.status}" + # <<<<< INSTALL NONEXISTENT NAMESPACE <<<<< + + 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 @@ -1040,18 +1111,15 @@ async def test_plugin_priority_execution_order(lrr_client: LRRClient, environmen @pytest.mark.ratelimit async def test_plugin_install_conflict(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test plugin install conflict detection, force install, and install error paths. + 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 namespace conflict error. - 5. Force install sample-metadata, expect namespace conflict (filesystem-level block). + 4. Install sample-metadata, expect provenance conflict (400). + 5. Force install sample-metadata, expect namespace conflict (422). 6. Install sample-downloader (no conflict), expect success with provenance. 7. Reinstall sample-downloader (same-registry upgrade), expect success. - 8. Install nonexistent namespace, expect error. - 9. Install from nonexistent registry, expect error. - 10. Install before refresh (no cached index), expect error. """ with tempfile.TemporaryDirectory() as tmpdir: conflict_path = Path(tmpdir) / "SampleMetadata.pm" @@ -1087,6 +1155,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr InstallPluginRequest(namespace="sample-metadata", registry=reg_id) ) assert error is not None, "Expected error when installing plugin with existing sideloaded copy" + assert error.status == 400, f"Expected 400 for provenance conflict, got {error.status}" assert "without provenance" in error.error, f"Expected 'without provenance' in error, got: {error.error}" # <<<<< INSTALL WITH CONFLICT (NO PROVENANCE) <<<<< @@ -1095,6 +1164,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr InstallPluginRequest(namespace="sample-metadata", registry=reg_id, force=True) ) assert error is not None, "Expected error: force bypasses provenance but not filesystem namespace conflict" + assert error.status == 422, f"Expected 422 for namespace conflict, got {error.status}" # <<<<< FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT <<<<< # >>>>> INSTALL WITHOUT CONFLICT >>>>> @@ -1113,42 +1183,6 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr assert not error, f"Failed to reinstall/upgrade plugin (status {error.status}): {error.error}" # <<<<< UPGRADE (REINSTALL) <<<<< - # >>>>> INSTALL NONEXISTENT NAMESPACE >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="does-not-exist", registry=reg_id) - ) - assert error is not None, "Expected error installing nonexistent namespace" - # <<<<< INSTALL NONEXISTENT NAMESPACE <<<<< - - # >>>>> INSTALL FROM NONEXISTENT REGISTRY >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001") - ) - assert error is not None, "Expected error installing from nonexistent registry" - # <<<<< INSTALL FROM NONEXISTENT REGISTRY <<<<< - - # >>>>> INSTALL BEFORE REFRESH (NO CACHED 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.create_registry( - CreateRegistryRequest( - name="unrefreshed", - type="git", - provider="github", - url="https://github.com/psilabs-dev/lrr-plugins-demo.git", - ref="main", - ) - ) - assert not error, f"Failed to create unrefreshed registry (status {error.status}): {error.error}" - unreffed_id = response.id - - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=unreffed_id) - ) - assert error is not None, "Expected error installing from registry without cached index" - # <<<<< INSTALL BEFORE REFRESH (NO CACHED INDEX) <<<<< - expect_no_error_logs(environment, LOGGER) From 475c0248e9ac0519515ac1baefd2c32435796334 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:42:52 -0700 Subject: [PATCH 23/72] add test_sideloaded_script_replaces_managed_duplicate_without_duplicate_api_entries --- .../resources/plugins/scripts/SampleScript.pm | 33 ++++++ integration_tests/tests/test_registry.py | 109 ++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 integration_tests/tests/resources/plugins/scripts/SampleScript.pm 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_registry.py b/integration_tests/tests/test_registry.py index 509b2152..ac541afe 100644 --- a/integration_tests/tests/test_registry.py +++ b/integration_tests/tests/test_registry.py @@ -8,6 +8,7 @@ from collections.abc import AsyncGenerator, Generator from pathlib import Path +import aiohttp import playwright.async_api import playwright.async_api._generated import pytest @@ -1497,3 +1498,111 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A assert "sample-login" not in namespaces, f"Cycle {i}: sample-login still listed after uninstall: {namespaces}" expect_no_error_logs(environment, LOGGER) + + +# # TODO: this needs improvement. +# @pytest.mark.asyncio +# @pytest.mark.dev("registry") +# @pytest.mark.ratelimit +# async def test_sideloaded_script_replaces_managed_duplicate_without_duplicate_api_entries( +# lrr_client: LRRClient, +# environment: AbstractLRRDeploymentContext, +# ): +# """ +# Test that replacing a managed script with a sideloaded duplicate does not duplicate script API entries. + +# 1. Create registry, refresh index, and install sample-script. +# 2. Attempt to upload the sideloaded SampleScript.pm while managed copy exists, expect failure. +# 3. Uninstall the managed sample-script. +# 4. Upload the sideloaded SampleScript.pm, expect success. +# 5. Verify GET /api/plugins/script returns one sample-script entry. +# """ +# plugin_path = Path(__file__).parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" +# assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" + +# environment.setup(with_api_key=True) + +# # >>>>> SETUP REGISTRY AND INSTALL MANAGED SCRIPT >>>>> +# response, error = await lrr_client.misc_api.create_registry( +# CreateRegistryRequest( +# name="demo", +# type="git", +# 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}" + +# response, error = await lrr_client.misc_api.install_plugin( +# InstallPluginRequest(namespace="sample-script", registry=reg_id) +# ) +# assert not error, f"Failed to install sample-script (status {error.status}): {error.error}" +# # <<<<< SETUP REGISTRY AND INSTALL MANAGED SCRIPT <<<<< + +# # >>>>> DUPLICATE SIDELOAD UPLOAD FAILS >>>>> +# login_url = lrr_client.misc_api.api_context.build_url("/login") +# upload_url = lrr_client.misc_api.api_context.build_url("/config/plugins/upload") +# async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: +# login_form = aiohttp.FormData(quote_fields=False) +# login_form.add_field("password", DEFAULT_LRR_PASSWORD) +# login_form.add_field("redirect", "index") +# async with session.post(login_url, data=login_form) as response: +# content = await response.text() +# assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" +# assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" + +# with plugin_path.open("rb") as file_handle: +# form_data = aiohttp.FormData(quote_fields=False) +# form_data.add_field("file", file_handle, filename=plugin_path.name) +# async with session.post(upload_url, data=form_data) as response: +# content = await response.text() +# assert response.status == 200, f"Expected 200 upload status, got {response.status}: {content}" +# assert '"success":0' in content, f"Expected failed duplicate upload, got: {content}" + +# response, error = await lrr_client.misc_api.get_available_plugins( +# GetAvailablePluginsRequest(type="script") +# ) +# assert not error, f"Failed to list scripts after duplicate upload (status {error.status}): {error.error}" +# sample_scripts = [plugin for plugin in response.plugins if plugin.namespace == "sample-script"] +# assert len(sample_scripts) == 1, f"Expected one sample-script before uninstall, got {len(sample_scripts)}" +# # <<<<< DUPLICATE SIDELOAD UPLOAD FAILS <<<<< + +# # >>>>> UNINSTALL MANAGED SCRIPT >>>>> +# response, error = await lrr_client.misc_api.uninstall_plugin("sample-script") +# assert not error, f"Failed to uninstall sample-script (status {error.status}): {error.error}" +# # <<<<< UNINSTALL MANAGED SCRIPT <<<<< + +# # >>>>> SIDELOAD UPLOAD SUCCEEDS >>>>> +# async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: +# login_form = aiohttp.FormData(quote_fields=False) +# login_form.add_field("password", DEFAULT_LRR_PASSWORD) +# login_form.add_field("redirect", "index") +# async with session.post(login_url, data=login_form) as response: +# content = await response.text() +# assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" +# assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" + +# with plugin_path.open("rb") as file_handle: +# form_data = aiohttp.FormData(quote_fields=False) +# form_data.add_field("file", file_handle, filename=plugin_path.name) +# async with session.post(upload_url, data=form_data) as response: +# content = await response.text() +# assert response.status == 200, f"Expected 200 upload status, got {response.status}: {content}" +# assert '"success":1' in content, f"Expected successful sideload upload, got: {content}" +# # <<<<< SIDELOAD UPLOAD SUCCEEDS <<<<< + +# # >>>>> VERIFY SINGLE API ENTRY >>>>> +# response, error = await lrr_client.misc_api.get_available_plugins( +# GetAvailablePluginsRequest(type="script") +# ) +# assert not error, f"Failed to list scripts after sideload upload (status {error.status}): {error.error}" +# sample_scripts = [plugin for plugin in response.plugins if plugin.namespace == "sample-script"] +# assert len(sample_scripts) == 1, f"Expected one sample-script after sideload replacement, got {len(sample_scripts)}" +# # <<<<< VERIFY SINGLE API ENTRY <<<<< + +# expect_no_error_logs(environment, LOGGER) From d6a26504f11576ff70d55fd9089b5f87130536f6 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:30:37 -0700 Subject: [PATCH 24/72] split registry integration tests --- integration_tests/tests/registry/__init__.py | 0 integration_tests/tests/registry/conftest.py | 40 + .../tests/registry/test_plugin_config.py | 381 ++++ .../tests/registry/test_plugin_lifecycle.py | 573 ++++++ .../tests/registry/test_plugin_ui.py | 190 ++ .../tests/registry/test_registry_crud.py | 399 ++++ integration_tests/tests/test_registry.py | 1608 ----------------- 7 files changed, 1583 insertions(+), 1608 deletions(-) create mode 100644 integration_tests/tests/registry/__init__.py create mode 100644 integration_tests/tests/registry/conftest.py create mode 100644 integration_tests/tests/registry/test_plugin_config.py create mode 100644 integration_tests/tests/registry/test_plugin_lifecycle.py create mode 100644 integration_tests/tests/registry/test_plugin_ui.py create mode 100644 integration_tests/tests/registry/test_registry_crud.py delete mode 100644 integration_tests/tests/test_registry.py 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..af0472e1 --- /dev/null +++ b/integration_tests/tests/registry/conftest.py @@ -0,0 +1,40 @@ +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} + yield env + 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_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py new file mode 100644 index 00000000..29bf30cf --- /dev/null +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -0,0 +1,381 @@ +""" +Plugin configuration (visibility, priority, execution order) integration tests. +""" + +import asyncio +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, + UpdatePluginConfigRequest, +) + +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) +from aio_lanraragi_tests.utils.api_wrappers import create_archive_file, upload_archive + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@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", + type="git", + 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}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + ) + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" + # <<<<< SETUP AND INSTALL <<<<< + + # >>>>> HIDE PLUGIN >>>>> + response, error = await lrr_client.misc_api.update_plugin_config( + "sample-metadata", UpdatePluginConfigRequest(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_plugin_config( + "sample-metadata", UpdatePluginConfigRequest(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_plugin_config( + "sample-metadata", UpdatePluginConfigRequest(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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + ) + 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_plugin_config( + "copytags", UpdatePluginConfigRequest(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_plugin_config( + "copytags", UpdatePluginConfigRequest(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("registry") +@pytest.mark.ratelimit +async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test plugin priority via update_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. + 5. Set priority on a non-metadata plugin, verify it is stored. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP AND INSTALL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + ) + 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_plugin_config( + "sample-metadata", UpdatePluginConfigRequest(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_plugin_config( + "copytags", UpdatePluginConfigRequest(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 <<<<< + + # >>>>> PRIORITY ON NON-METADATA PLUGIN >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.update_plugin_config( + "sample-downloader", UpdatePluginConfigRequest(priority=2) + ) + assert not error, f"Failed to set sample-downloader priority (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 download plugins (status {error.status}): {error.error}" + for plugin in response.plugins: + if plugin.namespace == "sample-downloader": + assert plugin.priority == 2, f"Expected sample-downloader priority 2, got {plugin.priority}" + break + else: + pytest.fail("sample-downloader not found in download plugin list") + # <<<<< PRIORITY ON NON-METADATA PLUGIN <<<<< + + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@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", + type="git", + 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 ALL THREE >>>>> + for ns in ("title-suffix-1", "title-suffix-2", "title-suffix-3"): + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace=ns, registry=reg_id) + ) + 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_plugin_config( + "title-suffix-2", UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-1", UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-3", UpdatePluginConfigRequest(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_plugin_config( + ns, UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-3", UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-2", UpdatePluginConfigRequest(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_plugin_config( + "title-suffix-1", UpdatePluginConfigRequest(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..29b99357 --- /dev/null +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -0,0 +1,573 @@ +""" +Plugin install/uninstall lifecycle integration tests. +""" + +import asyncio +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, + UpdatePluginConfigRequest, +) + +from aio_lanraragi_tests.deployment.base import ( + AbstractLRRDeploymentContext, + expect_no_error_logs, +) +from aio_lanraragi_tests.utils.api_wrappers import create_archive_file, upload_archive + +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", + type="git", + 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 >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + 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}" + namespaces = {p.namespace for p in response.plugins} + assert "sample-downloader" in namespaces, f"Installed plugin not found in list: {namespaces}" + # <<<<< 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.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}" + namespaces = {p.namespace for p in response.plugins} + assert "copytags" in namespaces, "Built-in plugin should still be listed after blocked uninstall" + # <<<<< 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_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. + """ + environment.setup(with_api_key=True) + + # >>>>> INSTALL FROM NONEXISTENT REGISTRY >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001") + ) + assert error is not None, "Expected error for nonexistent registry" + assert error.status == 404, f"Expected 404 for nonexistent registry, got {error.status}" + # <<<<< INSTALL FROM NONEXISTENT REGISTRY <<<<< + + # >>>>> INSTALL WITHOUT REFRESH >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + assert error is not None, "Expected error when installing without refresh" + assert error.status == 409, f"Expected 409 for no cached index, got {error.status}" + # <<<<< 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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="does-not-exist-xyz", registry=reg_id) + ) + assert error is not None, "Expected error for nonexistent namespace" + assert error.status == 404, f"Expected 404 for unknown namespace, got {error.status}" + # <<<<< INSTALL NONEXISTENT NAMESPACE <<<<< + + 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", + type="git", + 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 >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="title-suffix-1", registry=reg_id) + ) + 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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="title-suffix-1", registry=reg_id) + ) + 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 >>>>> + response, error = await lrr_client.misc_api.update_plugin_config( + "title-suffix-1", UpdatePluginConfigRequest(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 provenance conflict (400). + 5. Force install sample-metadata, expect namespace conflict (422). + 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" ); }\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", + type="git", + 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 WITH CONFLICT (NO PROVENANCE) >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + ) + assert error is not None, "Expected error when installing plugin with existing sideloaded copy" + assert error.status == 400, f"Expected 400 for provenance conflict, got {error.status}" + assert "without provenance" in error.error, f"Expected 'without provenance' in error, got: {error.error}" + # <<<<< INSTALL WITH CONFLICT (NO PROVENANCE) <<<<< + + # >>>>> FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, force=True) + ) + assert error is not None, "Expected error: force bypasses provenance but not filesystem namespace conflict" + assert error.status == 422, f"Expected 422 for namespace conflict, got {error.status}" + # <<<<< FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT <<<<< + + # >>>>> INSTALL WITHOUT CONFLICT >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + 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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + 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. + + 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", + type="git", + 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 <<<<< + + for i in range(5): + LOGGER.info(f"Cycle {i}: installing sample-login") + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-login", registry=reg_id) + ) + assert not error, f"Cycle {i}: install failed (status {error.status}): {error.error}" + + LOGGER.info(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.info(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) + + +# # TODO: this needs improvement. +# @pytest.mark.asyncio +# @pytest.mark.dev("registry") +# @pytest.mark.ratelimit +# async def test_sideloaded_script_replaces_managed_duplicate_without_duplicate_api_entries( +# lrr_client: LRRClient, +# environment: AbstractLRRDeploymentContext, +# ): +# """ +# Test that replacing a managed script with a sideloaded duplicate does not duplicate script API entries. +# +# 1. Create registry, refresh index, and install sample-script. +# 2. Attempt to upload the sideloaded SampleScript.pm while managed copy exists, expect failure. +# 3. Uninstall the managed sample-script. +# 4. Upload the sideloaded SampleScript.pm, expect success. +# 5. Verify GET /api/plugins/script returns one sample-script entry. +# """ +# plugin_path = Path(__file__).parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" +# assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" +# +# environment.setup(with_api_key=True) +# +# # >>>>> SETUP REGISTRY AND INSTALL MANAGED SCRIPT >>>>> +# response, error = await lrr_client.misc_api.create_registry( +# CreateRegistryRequest( +# name="demo", +# type="git", +# 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}" +# +# response, error = await lrr_client.misc_api.install_plugin( +# InstallPluginRequest(namespace="sample-script", registry=reg_id) +# ) +# assert not error, f"Failed to install sample-script (status {error.status}): {error.error}" +# # <<<<< SETUP REGISTRY AND INSTALL MANAGED SCRIPT <<<<< +# +# # >>>>> DUPLICATE SIDELOAD UPLOAD FAILS >>>>> +# login_url = lrr_client.misc_api.api_context.build_url("/login") +# upload_url = lrr_client.misc_api.api_context.build_url("/config/plugins/upload") +# async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: +# login_form = aiohttp.FormData(quote_fields=False) +# login_form.add_field("password", DEFAULT_LRR_PASSWORD) +# login_form.add_field("redirect", "index") +# async with session.post(login_url, data=login_form) as response: +# content = await response.text() +# assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" +# assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" +# +# with plugin_path.open("rb") as file_handle: +# form_data = aiohttp.FormData(quote_fields=False) +# form_data.add_field("file", file_handle, filename=plugin_path.name) +# async with session.post(upload_url, data=form_data) as response: +# content = await response.text() +# assert response.status == 200, f"Expected 200 upload status, got {response.status}: {content}" +# assert '"success":0' in content, f"Expected failed duplicate upload, got: {content}" +# +# response, error = await lrr_client.misc_api.get_available_plugins( +# GetAvailablePluginsRequest(type="script") +# ) +# assert not error, f"Failed to list scripts after duplicate upload (status {error.status}): {error.error}" +# sample_scripts = [plugin for plugin in response.plugins if plugin.namespace == "sample-script"] +# assert len(sample_scripts) == 1, f"Expected one sample-script before uninstall, got {len(sample_scripts)}" +# # <<<<< DUPLICATE SIDELOAD UPLOAD FAILS <<<<< +# +# # >>>>> UNINSTALL MANAGED SCRIPT >>>>> +# response, error = await lrr_client.misc_api.uninstall_plugin("sample-script") +# assert not error, f"Failed to uninstall sample-script (status {error.status}): {error.error}" +# # <<<<< UNINSTALL MANAGED SCRIPT <<<<< +# +# # >>>>> SIDELOAD UPLOAD SUCCEEDS >>>>> +# async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: +# login_form = aiohttp.FormData(quote_fields=False) +# login_form.add_field("password", DEFAULT_LRR_PASSWORD) +# login_form.add_field("redirect", "index") +# async with session.post(login_url, data=login_form) as response: +# content = await response.text() +# assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" +# assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" +# +# with plugin_path.open("rb") as file_handle: +# form_data = aiohttp.FormData(quote_fields=False) +# form_data.add_field("file", file_handle, filename=plugin_path.name) +# async with session.post(upload_url, data=form_data) as response: +# content = await response.text() +# assert response.status == 200, f"Expected 200 upload status, got {response.status}: {content}" +# assert '"success":1' in content, f"Expected successful sideload upload, got: {content}" +# # <<<<< SIDELOAD UPLOAD SUCCEEDS <<<<< +# +# # >>>>> VERIFY SINGLE API ENTRY >>>>> +# response, error = await lrr_client.misc_api.get_available_plugins( +# GetAvailablePluginsRequest(type="script") +# ) +# assert not error, f"Failed to list scripts after sideload upload (status {error.status}): {error.error}" +# sample_scripts = [plugin for plugin in response.plugins if plugin.namespace == "sample-script"] +# assert len(sample_scripts) == 1, f"Expected one sample-script after sideload replacement, got {len(sample_scripts)}" +# # <<<<< VERIFY SINGLE API ENTRY <<<<< +# +# 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..bb1bc00b --- /dev/null +++ b/integration_tests/tests/registry/test_plugin_ui.py @@ -0,0 +1,190 @@ +""" +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, enable, uninstall, and reinstall through the UI. + + 1. Create registry, refresh index via API. + 2. Navigate to plugin page, install sample-metadata from registry. + 3. Move sample-metadata to enabled pool, save configuration. + 4. Uninstall sample-metadata, verify absent from page and API. + 5. Refresh registry, verify sample-metadata available for reinstall. + 6. Reinstall, verify managed provenance. + """ + environment.setup(with_api_key=True) + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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") + 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 <<<<< + + # >>>>> INSTALL >>>>> + # Expand the Metadata Plugins collapsible (hidden by allcollapsible on load) + await page.locator(".collapsible-title", has_text="Metadata Plugins").click() + await page.wait_for_timeout(500) + + await page.locator("#registry-refresh-btn").click() + await page.wait_for_load_state("networkidle") + + sample_metadata_row = page.locator(".registry-plugin-row").filter( + has=page.locator("h2", has_text="Sample Metadata") + ) + async with page.expect_response("**/api/plugins/install") as response_info: + await sample_metadata_row.locator("input[type='button']").click() + install_response = await response_info.value + assert install_response.ok, f"Install API failed: {install_response.status}" + # <<<<< INSTALL <<<<< + + # >>>>> VERIFY INSTALLED >>>>> + badge = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") + await page.wait_for_timeout(500) + assert await badge.text_content() == "managed", f"Expected 'managed' badge after install, got: {await badge.text_content()}" + # <<<<< VERIFY INSTALLED <<<<< + + # >>>>> ENABLE AND SAVE >>>>> + # native drag does not trigger SortableJS; move via DOM + moved = await page.evaluate("""() => { + const card = document.querySelector('.plugin-card[data-namespace="sample-metadata"]'); + const enabledPool = document.getElementById('metadata-enabled'); + if (!card || !enabledPool) return false; + + const emptyMsg = enabledPool.querySelector('.pool-empty-msg'); + if (emptyMsg) emptyMsg.remove(); + + enabledPool.appendChild(card); + if (typeof Plugins !== 'undefined' && Plugins.renumberEnabled) { + Plugins.renumberEnabled(); + } + return card.closest('#metadata-enabled') !== null; + }""") + assert moved, "Failed to move sample-metadata to enabled pool" + + await page.get_by_role("button", name="Save Plugin Configuration").click() + await page.wait_for_load_state("networkidle") + await page.wait_for_timeout(2000) + # <<<<< ENABLE AND SAVE <<<<< + + # >>>>> UNINSTALL >>>>> + await page.locator(".plugin-uninstall-btn[data-namespace='sample-metadata']").click() + + # confirm uninstall dialog; wait for DELETE response then page reload + await page.wait_for_selector(".swal2-confirm", state="visible") + async with page.expect_response("**/api/plugins/installed/**") as response_info: + await page.click(".swal2-confirm") + 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 <<<<< + + # >>>>> 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})" + + # verify via API + 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 <<<<< + + # >>>>> REFRESH AND VERIFY AVAILABLE >>>>> + # Re-expand collapsible (page reloaded after uninstall) + await page.locator(".collapsible-title", has_text="Metadata Plugins").click() + await page.wait_for_timeout(500) + + await page.locator("#registry-refresh-btn").click() + await page.wait_for_load_state("networkidle") + + reinstall_row = page.locator(".registry-plugin-row").filter( + has=page.locator("h2", has_text="Sample Metadata") + ) + await reinstall_row.wait_for(state="visible") + # <<<<< REFRESH AND VERIFY AVAILABLE <<<<< + + # >>>>> REINSTALL >>>>> + async with page.expect_response("**/api/plugins/install") as response_info: + await reinstall_row.locator("input[type='button']").click() + reinstall_response = await response_info.value + assert reinstall_response.ok, f"Reinstall API failed: {reinstall_response.status}" + + badge_after = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") + await page.wait_for_timeout(500) + 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..27aaf246 --- /dev/null +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -0,0 +1,399 @@ +""" +Plugin registry CRUD integration tests. +""" + +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, +) + +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", + type="git", + 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}" + assert response.registry.name == "demo plugins" + assert response.registry.type == "git" + assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" + # <<<<< 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.name == "demo plugins" + assert response.registry.type == "git" + assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" + assert response.registry.ref == "main" + # <<<<< 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}" + assert response.registry.name == "renamed plugins" + assert response.index_cleared is False, "Name-only update should not clear index" + # <<<<< 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", type="local", path="/home/koyomi/plugins") + ) + assert not error, f"Failed to create local registry (status {error.status}): {error.error}" + assert response.registry.type == "local" + assert response.registry.path == "/home/koyomi/plugins" + local_reg_id = response.id + + 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 <<<<< + + 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 a valid registry, then create a second, expect single-registry limit error. + """ + environment.setup(with_api_key=True) + + # >>>>> MISSING URL FOR GIT >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="bad git", type="git") + ) + 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", type="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 <<<<< + + # >>>>> NON-HTTPS URL >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="http git", type="git", provider="github", url="http://github.com/owner/repo.git") + ) + 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 NAME >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="", type="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 <<<<< + + # >>>>> SINGLE-REGISTRY LIMIT >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="first", type="local", path="/tmp/plugins") + ) + assert not error, f"Failed to create first registry (status {error.status}): {error.error}" + first_id = response.id + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="second", type="local", path="/tmp/other") + ) + assert error is not None, "Expected error for single-registry limit" + assert error.status == 400, f"Expected 400 for single-registry limit, got {error.status}" + + response, error = await lrr_client.misc_api.delete_registry(first_id) + assert not error, f"Failed to delete registry (status {error.status}): {error.error}" + # <<<<< SINGLE-REGISTRY LIMIT <<<<< + + 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 ref field, verify index_cleared. + """ + 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", type="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 >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(type="git", url="http://example.com/repo.git") + ) + 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 <<<<< + + # >>>>> UPDATE REF 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.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + provider="github", + url="https://github.com/psilabs-dev/lrr-plugins-demo.git", + ref="main", + ) + ) + assert not error, f"Failed to create git registry (status {error.status}): {error.error}" + reg_id = response.id + + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(ref="dev") + ) + assert not error, f"Failed to update ref (status {error.status}): {error.error}" + assert response.index_cleared is True, "Ref change should clear 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}" + # <<<<< UPDATE REF CLEARS INDEX <<<<< + + 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 updating source fields clears the cached index. + + 1. Create a git registry and refresh. + 2. Install a plugin from the registry. + 3. Update the URL, verify index_cleared is true. + 4. Verify installed plugin retains provenance despite index clear. + 5. Update name only, verify index_cleared is false. + 6. Switch type from git 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", + type="git", + 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 after refresh" + # <<<<< CREATE AND REFRESH <<<<< + + # >>>>> INSTALL PLUGIN BEFORE SOURCE CHANGE >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + 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}" + assert response.index_cleared is True, "URL change should clear index" + + 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}" + assert response.index_cleared is False, "Name change should not clear index" + # <<<<< UPDATE NAME ONLY <<<<< + + # >>>>> TYPE SWITCH: GIT -> LOCAL >>>>> + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(type="local", path="/tmp/plugins") + ) + assert not error, f"Failed to switch type (status {error.status}): {error.error}" + assert response.index_cleared is True, "Type change should clear index" + assert response.registry.type == "local", "Type 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.provider is None, "Stale git field 'provider' should be absent" + assert response.registry.ref is None, "Stale git field 'ref' should be absent" + # <<<<< TYPE SWITCH: GIT -> 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", + type="git", + 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 <<<<< diff --git a/integration_tests/tests/test_registry.py b/integration_tests/tests/test_registry.py deleted file mode 100644 index ac541afe..00000000 --- a/integration_tests/tests/test_registry.py +++ /dev/null @@ -1,1608 +0,0 @@ -""" -Plugin registry integration tests. -""" - -import asyncio -import logging -import tempfile -from collections.abc import AsyncGenerator, Generator -from pathlib import Path - -import aiohttp -import playwright.async_api -import playwright.async_api._generated -import pytest -import pytest_asyncio -from lanraragi.clients.client import LRRClient -from lanraragi.models.archive import GetArchiveMetadataRequest -from lanraragi.models.misc import ( - CreateRegistryRequest, - GetAvailablePluginsRequest, - InstallPluginRequest, - UpdatePluginConfigRequest, - UpdateRegistryRequest, -) - -from aio_lanraragi_tests.common import DEFAULT_API_KEY, DEFAULT_LRR_PASSWORD -from aio_lanraragi_tests.deployment.base import ( - AbstractLRRDeploymentContext, - expect_no_error_logs, -) -from aio_lanraragi_tests.deployment.factory import generate_deployment -from aio_lanraragi_tests.utils.api_wrappers import create_archive_file, upload_archive -from aio_lanraragi_tests.utils.playwright import ( - assert_browser_responses_ok, - assert_console_logs_ok, -) - -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} - yield env - 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() - - -@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", - type="git", - 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}" - assert response.registry.name == "demo plugins" - assert response.registry.type == "git" - assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" - # <<<<< 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.name == "demo plugins" - assert response.registry.type == "git" - assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" - assert response.registry.ref == "main" - # <<<<< 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}" - assert response.registry.name == "renamed plugins" - assert response.index_cleared is False, "Name-only update should not clear index" - # <<<<< 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", type="local", path="/home/koyomi/plugins") - ) - assert not error, f"Failed to create local registry (status {error.status}): {error.error}" - assert response.registry.type == "local" - assert response.registry.path == "/home/koyomi/plugins" - local_reg_id = response.id - - 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 <<<<< - - 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 a valid registry, then create a second, expect single-registry limit error. - """ - environment.setup(with_api_key=True) - - # >>>>> MISSING URL FOR GIT >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="bad git", type="git") - ) - 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", type="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 <<<<< - - # >>>>> NON-HTTPS URL >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="http git", type="git", provider="github", url="http://github.com/owner/repo.git") - ) - 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 NAME >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="", type="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 <<<<< - - # >>>>> SINGLE-REGISTRY LIMIT >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="first", type="local", path="/tmp/plugins") - ) - assert not error, f"Failed to create first registry (status {error.status}): {error.error}" - first_id = response.id - - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="second", type="local", path="/tmp/other") - ) - assert error is not None, "Expected error for single-registry limit" - assert error.status == 400, f"Expected 400 for single-registry limit, got {error.status}" - - response, error = await lrr_client.misc_api.delete_registry(first_id) - assert not error, f"Failed to delete registry (status {error.status}): {error.error}" - # <<<<< SINGLE-REGISTRY LIMIT <<<<< - - 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 ref field, verify index_cleared. - """ - 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", type="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 >>>>> - response, error = await lrr_client.misc_api.update_registry( - reg_id, UpdateRegistryRequest(type="git", url="http://example.com/repo.git") - ) - 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 <<<<< - - # >>>>> UPDATE REF 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.create_registry( - CreateRegistryRequest( - name="demo", - type="git", - provider="github", - url="https://github.com/psilabs-dev/lrr-plugins-demo.git", - ref="main", - ) - ) - assert not error, f"Failed to create git registry (status {error.status}): {error.error}" - reg_id = response.id - - response, error = await lrr_client.misc_api.update_registry( - reg_id, UpdateRegistryRequest(ref="dev") - ) - assert not error, f"Failed to update ref (status {error.status}): {error.error}" - assert response.index_cleared is True, "Ref change should clear 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}" - # <<<<< UPDATE REF CLEARS INDEX <<<<< - - 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 updating source fields clears the cached index. - - 1. Create a git registry and refresh. - 2. Install a plugin from the registry. - 3. Update the URL, verify index_cleared is true. - 4. Verify installed plugin retains provenance despite index clear. - 5. Update name only, verify index_cleared is false. - 6. Switch type from git 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", - type="git", - 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 after refresh" - # <<<<< CREATE AND REFRESH <<<<< - - # >>>>> INSTALL PLUGIN BEFORE SOURCE CHANGE >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) - ) - 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}" - assert response.index_cleared is True, "URL change should clear index" - - 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}" - assert response.index_cleared is False, "Name change should not clear index" - # <<<<< UPDATE NAME ONLY <<<<< - - # >>>>> TYPE SWITCH: GIT -> LOCAL >>>>> - response, error = await lrr_client.misc_api.update_registry( - reg_id, UpdateRegistryRequest(type="local", path="/tmp/plugins") - ) - assert not error, f"Failed to switch type (status {error.status}): {error.error}" - assert response.index_cleared is True, "Type change should clear index" - assert response.registry.type == "local", "Type 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.provider is None, "Stale git field 'provider' should be absent" - assert response.registry.ref is None, "Stale git field 'ref' should be absent" - # <<<<< TYPE SWITCH: GIT -> 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", - type="git", - 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 <<<<< - - -@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", - type="git", - 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 >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) - ) - 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}" - namespaces = {p.namespace for p in response.plugins} - assert "sample-downloader" in namespaces, f"Installed plugin not found in list: {namespaces}" - # <<<<< 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.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}" - namespaces = {p.namespace for p in response.plugins} - assert "copytags" in namespaces, "Built-in plugin should still be listed after blocked uninstall" - # <<<<< 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_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. - """ - environment.setup(with_api_key=True) - - # >>>>> INSTALL FROM NONEXISTENT REGISTRY >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001") - ) - assert error is not None, "Expected error for nonexistent registry" - assert error.status == 404, f"Expected 404 for nonexistent registry, got {error.status}" - # <<<<< INSTALL FROM NONEXISTENT REGISTRY <<<<< - - # >>>>> INSTALL WITHOUT REFRESH >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest( - name="demo", - type="git", - 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.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) - ) - assert error is not None, "Expected error when installing without refresh" - assert error.status == 409, f"Expected 409 for no cached index, got {error.status}" - # <<<<< 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 lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="does-not-exist-xyz", registry=reg_id) - ) - assert error is not None, "Expected error for nonexistent namespace" - assert error.status == 404, f"Expected 404 for unknown namespace, got {error.status}" - # <<<<< INSTALL NONEXISTENT NAMESPACE <<<<< - - 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", - type="git", - 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 >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="title-suffix-1", registry=reg_id) - ) - 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 lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="title-suffix-1", registry=reg_id) - ) - 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 >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "title-suffix-1", UpdatePluginConfigRequest(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_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", - type="git", - 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}" - - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) - ) - assert not error, f"Failed to install plugin (status {error.status}): {error.error}" - # <<<<< SETUP AND INSTALL <<<<< - - # >>>>> HIDE PLUGIN >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "sample-metadata", UpdatePluginConfigRequest(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_plugin_config( - "sample-metadata", UpdatePluginConfigRequest(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_plugin_config( - "sample-metadata", UpdatePluginConfigRequest(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 lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) - ) - 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_plugin_config( - "copytags", UpdatePluginConfigRequest(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_plugin_config( - "copytags", UpdatePluginConfigRequest(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("registry") -@pytest.mark.ratelimit -async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): - """ - Test plugin priority via update_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. - 5. Set priority on a non-metadata plugin, verify it is stored. - """ - environment.setup(with_api_key=True) - - # >>>>> SETUP AND INSTALL >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest( - name="demo", - type="git", - 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}" - - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) - ) - 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_plugin_config( - "sample-metadata", UpdatePluginConfigRequest(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_plugin_config( - "copytags", UpdatePluginConfigRequest(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 <<<<< - - # >>>>> PRIORITY ON NON-METADATA PLUGIN >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) - ) - assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" - - response, error = await lrr_client.misc_api.update_plugin_config( - "sample-downloader", UpdatePluginConfigRequest(priority=2) - ) - assert not error, f"Failed to set sample-downloader priority (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 download plugins (status {error.status}): {error.error}" - for plugin in response.plugins: - if plugin.namespace == "sample-downloader": - assert plugin.priority == 2, f"Expected sample-downloader priority 2, got {plugin.priority}" - break - else: - pytest.fail("sample-downloader not found in download plugin list") - # <<<<< PRIORITY ON NON-METADATA PLUGIN <<<<< - - expect_no_error_logs(environment, LOGGER) - - -@pytest.mark.asyncio -@pytest.mark.dev("registry") -@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", - type="git", - 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 ALL THREE >>>>> - for ns in ("title-suffix-1", "title-suffix-2", "title-suffix-3"): - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace=ns, registry=reg_id) - ) - 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_plugin_config( - "title-suffix-2", UpdatePluginConfigRequest(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_plugin_config( - "title-suffix-1", UpdatePluginConfigRequest(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_plugin_config( - "title-suffix-3", UpdatePluginConfigRequest(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_plugin_config( - ns, UpdatePluginConfigRequest(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_plugin_config( - "title-suffix-3", UpdatePluginConfigRequest(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_plugin_config( - "title-suffix-2", UpdatePluginConfigRequest(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_plugin_config( - "title-suffix-1", UpdatePluginConfigRequest(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) - - -@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 provenance conflict (400). - 5. Force install sample-metadata, expect namespace conflict (422). - 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" ); }\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", - type="git", - 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 WITH CONFLICT (NO PROVENANCE) >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) - ) - assert error is not None, "Expected error when installing plugin with existing sideloaded copy" - assert error.status == 400, f"Expected 400 for provenance conflict, got {error.status}" - assert "without provenance" in error.error, f"Expected 'without provenance' in error, got: {error.error}" - # <<<<< INSTALL WITH CONFLICT (NO PROVENANCE) <<<<< - - # >>>>> FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id, force=True) - ) - assert error is not None, "Expected error: force bypasses provenance but not filesystem namespace conflict" - assert error.status == 422, f"Expected 422 for namespace conflict, got {error.status}" - # <<<<< FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT <<<<< - - # >>>>> INSTALL WITHOUT CONFLICT >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) - ) - 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 lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) - ) - assert not error, f"Failed to reinstall/upgrade plugin (status {error.status}): {error.error}" - # <<<<< UPGRADE (REINSTALL) <<<<< - - expect_no_error_logs(environment, LOGGER) - - -# # TODO: not needed, served its purpose. -# @pytest.mark.asyncio -# @pytest.mark.dev("registry") -# async def test_plugin_config_nonexistent(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): -# """ -# Test that updating config for a nonexistent plugin returns 404. - -# 1. Setup environment with API key. -# 2. Call update_plugin_config on a namespace that was never installed. -# 3. Verify the server returns an error (404). -# """ -# environment.setup(with_api_key=True) - -# # >>>>> UPDATE NONEXISTENT PLUGIN >>>>> -# response, error = await lrr_client.misc_api.update_plugin_config( -# "nonexistent-plugin-xyz", UpdatePluginConfigRequest(hidden=True) -# ) -# assert error is not None, "Expected error when updating config for nonexistent plugin" -# assert error.status == 404, f"Expected 404 status, got: {error.status}" -# # <<<<< UPDATE NONEXISTENT PLUGIN <<<<< - -# expect_no_error_logs(environment, LOGGER) - - -# # TODO: not needed, served its purpose. -# @pytest.mark.asyncio -# @pytest.mark.dev("registry") -# async def test_plugin_config_survives_restart(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): -# """ -# Test that plugin configuration persists across server restart. - -# 1. Create registry, refresh, install a plugin. -# 2. Hide the plugin via update_plugin_config. -# 3. Restart the server. -# 4. Verify the plugin is still hidden after restart. -# """ -# environment.setup(with_api_key=True) - -# # >>>>> SETUP AND INSTALL >>>>> -# response, error = await lrr_client.misc_api.create_registry( -# CreateRegistryRequest( -# name="demo", -# type="git", -# 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}" - -# response, error = await lrr_client.misc_api.install_plugin( -# InstallPluginRequest(namespace="sample-metadata", registry=reg_id) -# ) -# assert not error, f"Failed to install plugin (status {error.status}): {error.error}" -# # <<<<< SETUP AND INSTALL <<<<< - -# # >>>>> HIDE PLUGIN >>>>> -# response, error = await lrr_client.misc_api.update_plugin_config( -# "sample-metadata", UpdatePluginConfigRequest(hidden=True) -# ) -# assert not error, f"Failed to hide 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 == "sample-metadata": -# assert plugin.hidden is True, f"Expected hidden=True before restart, got {plugin.hidden}" -# break -# else: -# pytest.fail("Plugin sample-metadata not found before restart") -# # <<<<< HIDE PLUGIN <<<<< - -# # >>>>> RESTART >>>>> -# environment.restart() -# # <<<<< RESTART <<<<< - -# # >>>>> VERIFY AFTER RESTART >>>>> -# response, error = await lrr_client.misc_api.get_available_plugins( -# GetAvailablePluginsRequest(type="metadata") -# ) -# assert not error, f"Failed to list plugins after restart (status {error.status}): {error.error}" -# for plugin in response.plugins: -# if plugin.namespace == "sample-metadata": -# assert plugin.hidden is True, f"Expected hidden=True after restart, got {plugin.hidden}" -# break -# else: -# pytest.fail("Plugin sample-metadata not found after restart") -# # <<<<< VERIFY AFTER RESTART <<<<< - -# expect_no_error_logs(environment, LOGGER) - - -@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, enable, uninstall, and reinstall through the UI. - - 1. Create registry, refresh index via API. - 2. Navigate to plugin page, install sample-metadata from registry. - 3. Move sample-metadata to enabled pool, save configuration. - 4. Uninstall sample-metadata, verify absent from page and API. - 5. Refresh registry, verify sample-metadata available for reinstall. - 6. Reinstall, verify managed provenance. - """ - environment.setup(with_api_key=True) - - # >>>>> SETUP REGISTRY >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest( - name="demo", - type="git", - 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") - 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 <<<<< - - # >>>>> INSTALL >>>>> - # Expand the Metadata Plugins collapsible (hidden by allcollapsible on load) - await page.locator(".collapsible-title", has_text="Metadata Plugins").click() - await page.wait_for_timeout(500) - - await page.locator("#registry-refresh-btn").click() - await page.wait_for_load_state("networkidle") - - sample_metadata_row = page.locator(".registry-plugin-row").filter( - has=page.locator("h2", has_text="Sample Metadata") - ) - async with page.expect_response("**/api/plugins/install") as response_info: - await sample_metadata_row.locator("input[type='button']").click() - install_response = await response_info.value - assert install_response.ok, f"Install API failed: {install_response.status}" - # <<<<< INSTALL <<<<< - - # >>>>> VERIFY INSTALLED >>>>> - badge = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") - await page.wait_for_timeout(500) - assert await badge.text_content() == "managed", f"Expected 'managed' badge after install, got: {await badge.text_content()}" - # <<<<< VERIFY INSTALLED <<<<< - - # >>>>> ENABLE AND SAVE >>>>> - # native drag does not trigger SortableJS; move via DOM - moved = await page.evaluate("""() => { - const card = document.querySelector('.plugin-card[data-namespace="sample-metadata"]'); - const enabledPool = document.getElementById('metadata-enabled'); - if (!card || !enabledPool) return false; - - const emptyMsg = enabledPool.querySelector('.pool-empty-msg'); - if (emptyMsg) emptyMsg.remove(); - - enabledPool.appendChild(card); - if (typeof Plugins !== 'undefined' && Plugins.renumberEnabled) { - Plugins.renumberEnabled(); - } - return card.closest('#metadata-enabled') !== null; - }""") - assert moved, "Failed to move sample-metadata to enabled pool" - - await page.get_by_role("button", name="Save Plugin Configuration").click() - await page.wait_for_load_state("networkidle") - await page.wait_for_timeout(2000) - # <<<<< ENABLE AND SAVE <<<<< - - # >>>>> UNINSTALL >>>>> - await page.locator(".plugin-uninstall-btn[data-namespace='sample-metadata']").click() - - # confirm uninstall dialog; wait for DELETE response then page reload - await page.wait_for_selector(".swal2-confirm", state="visible") - async with page.expect_response("**/api/plugins/installed/**") as response_info: - await page.click(".swal2-confirm") - 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 <<<<< - - # >>>>> 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})" - - # verify via API - 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 <<<<< - - # >>>>> REFRESH AND VERIFY AVAILABLE >>>>> - # Re-expand collapsible (page reloaded after uninstall) - await page.locator(".collapsible-title", has_text="Metadata Plugins").click() - await page.wait_for_timeout(500) - - await page.locator("#registry-refresh-btn").click() - await page.wait_for_load_state("networkidle") - - reinstall_row = page.locator(".registry-plugin-row").filter( - has=page.locator("h2", has_text="Sample Metadata") - ) - await reinstall_row.wait_for(state="visible") - # <<<<< REFRESH AND VERIFY AVAILABLE <<<<< - - # >>>>> REINSTALL >>>>> - async with page.expect_response("**/api/plugins/install") as response_info: - await reinstall_row.locator("input[type='button']").click() - reinstall_response = await response_info.value - assert reinstall_response.ok, f"Reinstall API failed: {reinstall_response.status}" - - badge_after = page.locator(".plugin-card[data-namespace='sample-metadata'] .plugin-badge") - await page.wait_for_timeout(500) - 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) - - -@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. - - 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", - type="git", - 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 <<<<< - - for i in range(5): - LOGGER.info(f"Cycle {i}: installing sample-login") - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-login", registry=reg_id) - ) - assert not error, f"Cycle {i}: install failed (status {error.status}): {error.error}" - - LOGGER.info(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.info(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) - - -# # TODO: this needs improvement. -# @pytest.mark.asyncio -# @pytest.mark.dev("registry") -# @pytest.mark.ratelimit -# async def test_sideloaded_script_replaces_managed_duplicate_without_duplicate_api_entries( -# lrr_client: LRRClient, -# environment: AbstractLRRDeploymentContext, -# ): -# """ -# Test that replacing a managed script with a sideloaded duplicate does not duplicate script API entries. - -# 1. Create registry, refresh index, and install sample-script. -# 2. Attempt to upload the sideloaded SampleScript.pm while managed copy exists, expect failure. -# 3. Uninstall the managed sample-script. -# 4. Upload the sideloaded SampleScript.pm, expect success. -# 5. Verify GET /api/plugins/script returns one sample-script entry. -# """ -# plugin_path = Path(__file__).parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" -# assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" - -# environment.setup(with_api_key=True) - -# # >>>>> SETUP REGISTRY AND INSTALL MANAGED SCRIPT >>>>> -# response, error = await lrr_client.misc_api.create_registry( -# CreateRegistryRequest( -# name="demo", -# type="git", -# 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}" - -# response, error = await lrr_client.misc_api.install_plugin( -# InstallPluginRequest(namespace="sample-script", registry=reg_id) -# ) -# assert not error, f"Failed to install sample-script (status {error.status}): {error.error}" -# # <<<<< SETUP REGISTRY AND INSTALL MANAGED SCRIPT <<<<< - -# # >>>>> DUPLICATE SIDELOAD UPLOAD FAILS >>>>> -# login_url = lrr_client.misc_api.api_context.build_url("/login") -# upload_url = lrr_client.misc_api.api_context.build_url("/config/plugins/upload") -# async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: -# login_form = aiohttp.FormData(quote_fields=False) -# login_form.add_field("password", DEFAULT_LRR_PASSWORD) -# login_form.add_field("redirect", "index") -# async with session.post(login_url, data=login_form) as response: -# content = await response.text() -# assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" -# assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" - -# with plugin_path.open("rb") as file_handle: -# form_data = aiohttp.FormData(quote_fields=False) -# form_data.add_field("file", file_handle, filename=plugin_path.name) -# async with session.post(upload_url, data=form_data) as response: -# content = await response.text() -# assert response.status == 200, f"Expected 200 upload status, got {response.status}: {content}" -# assert '"success":0' in content, f"Expected failed duplicate upload, got: {content}" - -# response, error = await lrr_client.misc_api.get_available_plugins( -# GetAvailablePluginsRequest(type="script") -# ) -# assert not error, f"Failed to list scripts after duplicate upload (status {error.status}): {error.error}" -# sample_scripts = [plugin for plugin in response.plugins if plugin.namespace == "sample-script"] -# assert len(sample_scripts) == 1, f"Expected one sample-script before uninstall, got {len(sample_scripts)}" -# # <<<<< DUPLICATE SIDELOAD UPLOAD FAILS <<<<< - -# # >>>>> UNINSTALL MANAGED SCRIPT >>>>> -# response, error = await lrr_client.misc_api.uninstall_plugin("sample-script") -# assert not error, f"Failed to uninstall sample-script (status {error.status}): {error.error}" -# # <<<<< UNINSTALL MANAGED SCRIPT <<<<< - -# # >>>>> SIDELOAD UPLOAD SUCCEEDS >>>>> -# async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: -# login_form = aiohttp.FormData(quote_fields=False) -# login_form.add_field("password", DEFAULT_LRR_PASSWORD) -# login_form.add_field("redirect", "index") -# async with session.post(login_url, data=login_form) as response: -# content = await response.text() -# assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" -# assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" - -# with plugin_path.open("rb") as file_handle: -# form_data = aiohttp.FormData(quote_fields=False) -# form_data.add_field("file", file_handle, filename=plugin_path.name) -# async with session.post(upload_url, data=form_data) as response: -# content = await response.text() -# assert response.status == 200, f"Expected 200 upload status, got {response.status}: {content}" -# assert '"success":1' in content, f"Expected successful sideload upload, got: {content}" -# # <<<<< SIDELOAD UPLOAD SUCCEEDS <<<<< - -# # >>>>> VERIFY SINGLE API ENTRY >>>>> -# response, error = await lrr_client.misc_api.get_available_plugins( -# GetAvailablePluginsRequest(type="script") -# ) -# assert not error, f"Failed to list scripts after sideload upload (status {error.status}): {error.error}" -# sample_scripts = [plugin for plugin in response.plugins if plugin.namespace == "sample-script"] -# assert len(sample_scripts) == 1, f"Expected one sample-script after sideload replacement, got {len(sample_scripts)}" -# # <<<<< VERIFY SINGLE API ENTRY <<<<< - -# expect_no_error_logs(environment, LOGGER) From 17a3e573bfe737fd972e959411a51d23a86c0e97 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:52:25 -0700 Subject: [PATCH 25/72] add sideloaded test --- .../aio_lanraragi_tests/utils/api_wrappers.py | 20 ++ .../tests/registry/test_plugin_lifecycle.py | 257 ++++++++++-------- 2 files changed, 169 insertions(+), 108 deletions(-) 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..e83bbba2 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py @@ -413,3 +413,23 @@ 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 sideload_plugin(client: LRRClient, plugin_path: Path, password: str) -> tuple[int, str]: + """Upload a plugin file via the browser-style login + multipart upload flow.""" + login_url = client.misc_api.api_context.build_url("/login") + upload_url = client.misc_api.api_context.build_url("/config/plugins/upload") + async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: + login_form = aiohttp.FormData(quote_fields=False) + login_form.add_field("password", password) + login_form.add_field("redirect", "index") + async with session.post(login_url, data=login_form) as response: + content = await response.text() + assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" + assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" + + with plugin_path.open("rb") as file_handle: + form_data = aiohttp.FormData(quote_fields=False) + form_data.add_field("file", file_handle, filename=plugin_path.name) + async with session.post(upload_url, data=form_data) as response: + content = await response.text() + return response.status, content diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 29b99357..f0626444 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -7,6 +7,8 @@ import tempfile from pathlib import Path +import playwright.async_api +import playwright.async_api._generated import pytest from lanraragi.clients.client import LRRClient from lanraragi.models.archive import GetArchiveMetadataRequest @@ -17,15 +19,23 @@ UpdatePluginConfigRequest, ) +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.api_wrappers import create_archive_file, upload_archive +from aio_lanraragi_tests.utils.api_wrappers import ( + create_archive_file, + sideload_plugin, + upload_archive, +) +from aio_lanraragi_tests.utils.playwright import ( + assert_browser_responses_ok, + assert_console_logs_ok, +) LOGGER = logging.getLogger(__name__) - @pytest.mark.asyncio @pytest.mark.dev("registry") @pytest.mark.ratelimit @@ -464,110 +474,141 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A expect_no_error_logs(environment, LOGGER) +@pytest.mark.asyncio +@pytest.mark.playwright +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_sideloaded_script_lifecycle( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + Test the end-to-end lifecycle of a sideloaded script plugin. + + 1. Install sample-script from a registry as a managed plugin. + 2. Sideloading the same namespace while managed copy exists is rejected. + 3. Uninstall the managed sample-script. + 4. Sideload sample-script via UI upload. + 5. The plugin is recorded with a path relative to lib/, listed exactly once + via API, rendered exactly once in the Manage tab with a sideloaded badge, + and remains so after a server restart. + 6. Uninstall the sideloaded plugin via the API; provenance and on-disk file + are cleaned up. + """ + plugin_path = Path(__file__).parent.parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" + assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" + + environment.setup(with_api_key=True) -# # TODO: this needs improvement. -# @pytest.mark.asyncio -# @pytest.mark.dev("registry") -# @pytest.mark.ratelimit -# async def test_sideloaded_script_replaces_managed_duplicate_without_duplicate_api_entries( -# lrr_client: LRRClient, -# environment: AbstractLRRDeploymentContext, -# ): -# """ -# Test that replacing a managed script with a sideloaded duplicate does not duplicate script API entries. -# -# 1. Create registry, refresh index, and install sample-script. -# 2. Attempt to upload the sideloaded SampleScript.pm while managed copy exists, expect failure. -# 3. Uninstall the managed sample-script. -# 4. Upload the sideloaded SampleScript.pm, expect success. -# 5. Verify GET /api/plugins/script returns one sample-script entry. -# """ -# plugin_path = Path(__file__).parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" -# assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" -# -# environment.setup(with_api_key=True) -# -# # >>>>> SETUP REGISTRY AND INSTALL MANAGED SCRIPT >>>>> -# response, error = await lrr_client.misc_api.create_registry( -# CreateRegistryRequest( -# name="demo", -# type="git", -# 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}" -# -# response, error = await lrr_client.misc_api.install_plugin( -# InstallPluginRequest(namespace="sample-script", registry=reg_id) -# ) -# assert not error, f"Failed to install sample-script (status {error.status}): {error.error}" -# # <<<<< SETUP REGISTRY AND INSTALL MANAGED SCRIPT <<<<< -# -# # >>>>> DUPLICATE SIDELOAD UPLOAD FAILS >>>>> -# login_url = lrr_client.misc_api.api_context.build_url("/login") -# upload_url = lrr_client.misc_api.api_context.build_url("/config/plugins/upload") -# async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: -# login_form = aiohttp.FormData(quote_fields=False) -# login_form.add_field("password", DEFAULT_LRR_PASSWORD) -# login_form.add_field("redirect", "index") -# async with session.post(login_url, data=login_form) as response: -# content = await response.text() -# assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" -# assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" -# -# with plugin_path.open("rb") as file_handle: -# form_data = aiohttp.FormData(quote_fields=False) -# form_data.add_field("file", file_handle, filename=plugin_path.name) -# async with session.post(upload_url, data=form_data) as response: -# content = await response.text() -# assert response.status == 200, f"Expected 200 upload status, got {response.status}: {content}" -# assert '"success":0' in content, f"Expected failed duplicate upload, got: {content}" -# -# response, error = await lrr_client.misc_api.get_available_plugins( -# GetAvailablePluginsRequest(type="script") -# ) -# assert not error, f"Failed to list scripts after duplicate upload (status {error.status}): {error.error}" -# sample_scripts = [plugin for plugin in response.plugins if plugin.namespace == "sample-script"] -# assert len(sample_scripts) == 1, f"Expected one sample-script before uninstall, got {len(sample_scripts)}" -# # <<<<< DUPLICATE SIDELOAD UPLOAD FAILS <<<<< -# -# # >>>>> UNINSTALL MANAGED SCRIPT >>>>> -# response, error = await lrr_client.misc_api.uninstall_plugin("sample-script") -# assert not error, f"Failed to uninstall sample-script (status {error.status}): {error.error}" -# # <<<<< UNINSTALL MANAGED SCRIPT <<<<< -# -# # >>>>> SIDELOAD UPLOAD SUCCEEDS >>>>> -# async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: -# login_form = aiohttp.FormData(quote_fields=False) -# login_form.add_field("password", DEFAULT_LRR_PASSWORD) -# login_form.add_field("redirect", "index") -# async with session.post(login_url, data=login_form) as response: -# content = await response.text() -# assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" -# assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" -# -# with plugin_path.open("rb") as file_handle: -# form_data = aiohttp.FormData(quote_fields=False) -# form_data.add_field("file", file_handle, filename=plugin_path.name) -# async with session.post(upload_url, data=form_data) as response: -# content = await response.text() -# assert response.status == 200, f"Expected 200 upload status, got {response.status}: {content}" -# assert '"success":1' in content, f"Expected successful sideload upload, got: {content}" -# # <<<<< SIDELOAD UPLOAD SUCCEEDS <<<<< -# -# # >>>>> VERIFY SINGLE API ENTRY >>>>> -# response, error = await lrr_client.misc_api.get_available_plugins( -# GetAvailablePluginsRequest(type="script") -# ) -# assert not error, f"Failed to list scripts after sideload upload (status {error.status}): {error.error}" -# sample_scripts = [plugin for plugin in response.plugins if plugin.namespace == "sample-script"] -# assert len(sample_scripts) == 1, f"Expected one sample-script after sideload replacement, got {len(sample_scripts)}" -# # <<<<< VERIFY SINGLE API ENTRY <<<<< -# -# expect_no_error_logs(environment, LOGGER) + # >>>>> INSTALL MANAGED SAMPLE-SCRIPT >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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 + + _, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + _, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-script", registry=reg_id) + ) + assert not error, f"Failed to install sample-script (status {error.status}): {error.error}" + # <<<<< INSTALL MANAGED SAMPLE-SCRIPT <<<<< + + # >>>>> SIDELOAD WHILE MANAGED COPY EXISTS IS REJECTED >>>>> + status, content = await sideload_plugin(lrr_client, plugin_path, DEFAULT_LRR_PASSWORD) + assert status == 200, f"Expected 200 upload status, got {status}: {content}" + assert '"success":0' in content, f"Expected sideload to be rejected while managed copy exists, got: {content}" + + response, error = await lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) + assert not error, f"Failed to list scripts (status {error.status}): {error.error}" + sample_scripts = [p for p in response.plugins if p.namespace == "sample-script"] + assert len(sample_scripts) == 1, f"Expected one sample-script while managed copy exists, got {len(sample_scripts)}" + # <<<<< SIDELOAD WHILE MANAGED COPY EXISTS IS REJECTED <<<<< + + # >>>>> SIDELOAD REPLACES MANAGED COPY >>>>> + _, error = await lrr_client.misc_api.uninstall_plugin("sample-script") + assert not error, f"Failed to uninstall managed sample-script (status {error.status}): {error.error}" + + status, content = await sideload_plugin(lrr_client, plugin_path, DEFAULT_LRR_PASSWORD) + assert status == 200, f"Expected 200 upload status, got {status}: {content}" + assert '"success":1' in content, f"Expected sideload to succeed after managed uninstall, got: {content}" + # <<<<< SIDELOAD REPLACES MANAGED COPY <<<<< + + # >>>>> SIDELOAD PROVENANCE IS PORTABLE AND PERSISTS ACROSS RESTART >>>>> + sideloaded_script_path = "LANraragi/Plugin/Sideloaded/SampleScript.pm" + environment.redis_client.select(2) + recorded_path = environment.redis_client.hget("LRR_PLUGIN_SAMPLE-SCRIPT", "installed_path") + assert recorded_path == sideloaded_script_path, \ + f"Expected installed_path={sideloaded_script_path!r} after upload, got {recorded_path!r}" + + environment.restart() + + environment.redis_client.select(2) + recorded_path = environment.redis_client.hget("LRR_PLUGIN_SAMPLE-SCRIPT", "installed_path") + assert recorded_path == sideloaded_script_path, \ + f"Expected installed_path={sideloaded_script_path!r} after restart, got {recorded_path!r}" + + response, error = await lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) + assert not error, f"Failed to list scripts after restart (status {error.status}): {error.error}" + sample_scripts = [p for p in response.plugins if p.namespace == "sample-script"] + assert len(sample_scripts) == 1, f"Expected one sample-script after restart, got {len(sample_scripts)}" + # <<<<< SIDELOAD PROVENANCE IS PORTABLE AND PERSISTS ACROSS RESTART <<<<< + + # >>>>> MANAGE TAB RENDERS ONE SIDELOADED ROW >>>>> + 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)) + + await page.goto(f"{lrr_client.lrr_base_url}/config/plugins") + 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") + responses.clear() + console_evts.clear() + + await page.goto(f"{lrr_client.lrr_base_url}/config/plugins#tab-manage") + await page.wait_for_load_state("networkidle") + + sample_rows = page.locator( + '.manage-installed[data-type="script"] .manage-plugin-row[data-namespace="sample-script"]' + ) + row_count = await sample_rows.count() + assert row_count == 1, f"Expected one sample-script row in Scripts section, got {row_count}" + + badge_text = await sample_rows.locator(".plugin-badge").text_content() + assert badge_text == "sideloaded", f"Expected 'sideloaded' badge, got: {badge_text!r}" + + 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() + # <<<<< MANAGE TAB RENDERS ONE SIDELOADED ROW <<<<< + + # >>>>> UNINSTALL CLEARS PROVENANCE AND FILE >>>>> + _, error = await lrr_client.misc_api.uninstall_plugin("sample-script") + assert not error, f"Failed to uninstall sideloaded sample-script (status {error.status}): {error.error}" + + environment.redis_client.select(2) + assert not environment.redis_client.hexists("LRR_PLUGIN_SAMPLE-SCRIPT", "installed_path"), \ + "Expected installed_path to be cleared after uninstall" + # <<<<< UNINSTALL CLEARS PROVENANCE AND FILE <<<<< + + expect_no_error_logs(environment, LOGGER) From 4db6572f73b9227c5df18fc6837fce947486d57f Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:11:06 -0700 Subject: [PATCH 26/72] update registry tests --- .../tests/registry/test_plugin_lifecycle.py | 134 +++++++++++++++--- 1 file changed, 111 insertions(+), 23 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index f0626444..2cf2a056 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -474,6 +474,86 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A expect_no_error_logs(environment, LOGGER) + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_sideload_after_managed_uninstall_no_duplicate_rows( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + Reproduce: install managed → uninstall → sideload → server renders 2 rows. + + Single worker so all operations hit the same Perl process. No restart + between the cycle and the check — the bug is per-worker symbol table + state that a restart would clear. + + 1. Install managed sample-script from registry. + 2. Uninstall the managed sample-script (file deleted, class stays in symbol table). + 3. Sideload SampleScript.pm (new class loaded in same worker). + 4. Fetch /config/plugins raw HTML and count sample-script rows. + """ + plugin_path = Path(__file__).parent.parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" + assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" + + environment.setup(with_api_key=True) + + # >>>>> INSTALL MANAGED >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", type="git", 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 + + _, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + _, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-script", registry=reg_id) + ) + assert not error, f"Failed to install managed sample-script (status {error.status}): {error.error}" + # <<<<< INSTALL MANAGED <<<<< + + # >>>>> UNINSTALL MANAGED >>>>> + _, error = await lrr_client.misc_api.uninstall_plugin("sample-script") + assert not error, f"Failed to uninstall managed sample-script (status {error.status}): {error.error}" + # <<<<< UNINSTALL MANAGED <<<<< + + # >>>>> SIDELOAD >>>>> + status, content = await sideload_plugin(lrr_client, plugin_path, DEFAULT_LRR_PASSWORD) + assert status == 200, f"Expected 200 upload status, got {status}: {content}" + assert '"success":1' in content, f"Expected sideload to succeed, got: {content}" + # <<<<< SIDELOAD <<<<< + + # >>>>> RAW HTML CHECK — NO RESTART, SAME WORKER >>>>> + import aiohttp + login_url = lrr_client.misc_api.api_context.build_url("/login") + plugins_url = lrr_client.misc_api.api_context.build_url("/config/plugins") + + async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: + login_form = aiohttp.FormData(quote_fields=False) + login_form.add_field("password", DEFAULT_LRR_PASSWORD) + login_form.add_field("redirect", "index") + async with session.post(login_url, data=login_form) as resp: + assert resp.status == 200 + + # Fetch raw HTML 20 times across default workers (typically 4). + # The affected worker returns 2 rows; others return 1. With 4 workers + # and 20 fetches, P(never hitting the affected worker) < 0.3%. + for i in range(20): + async with session.get(plugins_url) as resp: + html = await resp.text() + matches = html.count('data-namespace="sample-script" data-source=') + LOGGER.info(f"Fetch {i}: {matches} sample-script row(s) in server HTML") + assert matches <= 1, \ + f"Fetch {i}: expected at most 1 sample-script row in server HTML, got {matches}" + # <<<<< RAW HTML CHECK <<<<< + + @pytest.mark.asyncio @pytest.mark.playwright @pytest.mark.dev("registry") @@ -498,7 +578,9 @@ async def test_sideloaded_script_lifecycle( plugin_path = Path(__file__).parent.parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" - environment.setup(with_api_key=True) + # Single worker ensures all requests hit the same process, making per-worker + # state bugs (stale symbol table after managed install/uninstall) deterministic. + environment.setup(with_api_key=True, environment={"MOJO_WORKERS": "1"}) # >>>>> INSTALL MANAGED SAMPLE-SCRIPT >>>>> response, error = await lrr_client.misc_api.create_registry( @@ -549,20 +631,10 @@ async def test_sideloaded_script_lifecycle( assert recorded_path == sideloaded_script_path, \ f"Expected installed_path={sideloaded_script_path!r} after upload, got {recorded_path!r}" - environment.restart() - - environment.redis_client.select(2) - recorded_path = environment.redis_client.hget("LRR_PLUGIN_SAMPLE-SCRIPT", "installed_path") - assert recorded_path == sideloaded_script_path, \ - f"Expected installed_path={sideloaded_script_path!r} after restart, got {recorded_path!r}" - - response, error = await lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) - assert not error, f"Failed to list scripts after restart (status {error.status}): {error.error}" - sample_scripts = [p for p in response.plugins if p.namespace == "sample-script"] - assert len(sample_scripts) == 1, f"Expected one sample-script after restart, got {len(sample_scripts)}" - # <<<<< SIDELOAD PROVENANCE IS PORTABLE AND PERSISTS ACROSS RESTART <<<<< - # >>>>> MANAGE TAB RENDERS ONE SIDELOADED ROW >>>>> + # UI check runs BEFORE restart: the workers that handled install->uninstall->sideload + # still have the managed class in their symbol table. This catches per-worker state + # bugs (e.g. stale %INC entries causing the managed class to pass through get_plugins). async with playwright.async_api.async_playwright() as p: browser = await p.chromium.launch() bc = await browser.new_context() @@ -583,17 +655,19 @@ async def test_sideloaded_script_lifecycle( responses.clear() console_evts.clear() - await page.goto(f"{lrr_client.lrr_base_url}/config/plugins#tab-manage") - await page.wait_for_load_state("networkidle") + # Fetch multiple times to exercise different Hypnotoad workers. + for i in range(3): + await page.goto(f"{lrr_client.lrr_base_url}/config/plugins#tab-manage") + await page.wait_for_load_state("networkidle") - sample_rows = page.locator( - '.manage-installed[data-type="script"] .manage-plugin-row[data-namespace="sample-script"]' - ) - row_count = await sample_rows.count() - assert row_count == 1, f"Expected one sample-script row in Scripts section, got {row_count}" + sample_rows = page.locator( + '.manage-installed[data-type="script"] .manage-plugin-row[data-namespace="sample-script"]' + ) + row_count = await sample_rows.count() + assert row_count == 1, f"Fetch {i}: expected one sample-script row in Scripts section, got {row_count}" - badge_text = await sample_rows.locator(".plugin-badge").text_content() - assert badge_text == "sideloaded", f"Expected 'sideloaded' badge, got: {badge_text!r}" + badge_text = await sample_rows.locator(".plugin-badge").text_content() + assert badge_text == "sideloaded", f"Fetch {i}: expected 'sideloaded' badge, got: {badge_text!r}" await assert_browser_responses_ok(responses, lrr_client, logger=LOGGER) await assert_console_logs_ok(console_evts, lrr_client.lrr_base_url) @@ -602,6 +676,20 @@ async def test_sideloaded_script_lifecycle( await browser.close() # <<<<< MANAGE TAB RENDERS ONE SIDELOADED ROW <<<<< + # >>>>> SIDELOAD PROVENANCE PERSISTS ACROSS RESTART >>>>> + environment.restart() + + environment.redis_client.select(2) + recorded_path = environment.redis_client.hget("LRR_PLUGIN_SAMPLE-SCRIPT", "installed_path") + assert recorded_path == sideloaded_script_path, \ + f"Expected installed_path={sideloaded_script_path!r} after restart, got {recorded_path!r}" + + response, error = await lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) + assert not error, f"Failed to list scripts after restart (status {error.status}): {error.error}" + sample_scripts = [p for p in response.plugins if p.namespace == "sample-script"] + assert len(sample_scripts) == 1, f"Expected one sample-script after restart, got {len(sample_scripts)}" + # <<<<< SIDELOAD PROVENANCE PERSISTS ACROSS RESTART <<<<< + # >>>>> UNINSTALL CLEARS PROVENANCE AND FILE >>>>> _, error = await lrr_client.misc_api.uninstall_plugin("sample-script") assert not error, f"Failed to uninstall sideloaded sample-script (status {error.status}): {error.error}" From af2fe3eebfbccb79685d7123cb2a3d45163fc941 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:50:02 -0700 Subject: [PATCH 27/72] add a registry upgrade test case --- .../tests/registry/test_plugin_lifecycle.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 2cf2a056..9ced5eb3 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -17,6 +17,7 @@ GetAvailablePluginsRequest, InstallPluginRequest, UpdatePluginConfigRequest, + UpdateRegistryRequest, ) from aio_lanraragi_tests.common import DEFAULT_LRR_PASSWORD @@ -700,3 +701,94 @@ async def test_sideloaded_script_lifecycle( # <<<<< UNINSTALL CLEARS PROVENANCE AND FILE <<<<< 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 in the installing worker. + + The uploading worker has the plugin's source file cached in %INC. Without an + explicit delete, require short-circuits and the new file contents are not + loaded into the worker interpreter until server restart. + + 1. Install sample-script from the main ref (version 1.0). + 2. Verify plugin_info returns version "1.0". + 3. Update the registry to the v1.1 ref (same namespace, version "1.1"). + 4. Refresh and force-install sample-script. + 5. Verify plugin_info returns version "1.1" across multiple requests. + """ + # Single worker deterministically routes the verification request to the + # same process that handled the install/upgrade, where %INC is populated. + environment.setup(with_api_key=True, environment={"MOJO_WORKERS": "1"}) + + # >>>>> INSTALL v1.0 FROM main >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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 + + _, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + _, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-script", registry=reg_id) + ) + assert not error, f"Failed to install sample-script v1.0 (status {error.status}): {error.error}" + # <<<<< INSTALL v1.0 FROM main <<<<< + + # >>>>> VERIFY v1.0 IN LOADED CLASS >>>>> + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="script") + ) + assert not error, f"Failed to list scripts (status {error.status}): {error.error}" + sample = next((p for p in response.plugins if p.namespace == "sample-script"), None) + assert sample is not None, "sample-script not listed after install" + assert sample.version == "1.0", f"Expected v1.0 after initial install, got {sample.version!r}" + # <<<<< VERIFY v1.0 IN LOADED CLASS <<<<< + + # >>>>> 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}" + + _, 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}" + + _, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-script", registry=reg_id, 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 <<<<< + + # >>>>> VERIFY v1.1 IN LOADED CLASS >>>>> + # Fire several reads to cover any transient scheduling; every one must see v1.1 + # because plugin_info() returns data from the in-memory class, which should have + # been re-required against the new file contents. + for attempt in range(5): + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="script") + ) + assert not error, f"Failed to list scripts (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 not listed on attempt {attempt}" + assert sample.version == "1.1", ( + f"Attempt {attempt}: loaded class still reports version {sample.version!r} " + f"after upgrade; %INC short-circuited require so the new file was not re-read" + ) + # <<<<< VERIFY v1.1 IN LOADED CLASS <<<<< + + expect_no_error_logs(environment, LOGGER) From 0f576e7aef3e702d7ed4543391a935418d64bb6d Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:06:49 -0700 Subject: [PATCH 28/72] update registry tests --- .../tests/registry/test_plugin_lifecycle.py | 98 ++++++++++++++ .../tests/registry/test_plugin_ui.py | 124 ++++++++---------- 2 files changed, 152 insertions(+), 70 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 9ced5eb3..956c3e11 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -18,6 +18,7 @@ InstallPluginRequest, UpdatePluginConfigRequest, UpdateRegistryRequest, + UsePluginRequest, ) from aio_lanraragi_tests.common import DEFAULT_LRR_PASSWORD @@ -792,3 +793,100 @@ async def test_managed_plugin_upgrade_reloads_class( # <<<<< VERIFY v1.1 IN LOADED CLASS <<<<< 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", + type="git", + 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 + + _, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" + + _, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-script", registry=reg_id) + ) + 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}" + + _, 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}" + + _, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-script", registry=reg_id, force=True) + ) + assert not error, f"Failed to upgrade sample-script to v1.1 (status {error.status}): {error.error}" + # <<<<< UPGRADE TO v1.1 <<<<< + + # >>>>> VERIFY v1.1 ACROSS WORKERS >>>>> + # v1.1 run_script prefixes its result with "v1.1:". v1.0 returns the raw arg. + # Concurrent requests spread across workers via the connection pool. + 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 from stale workers still running v1.0 symbols: " + f"{v10_responses[:5]}. Cross-worker coherence not converging after upgrade." + ) + # <<<<< VERIFY v1.1 ACROSS WORKERS <<<<< + + 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 index bb1bc00b..bff476bb 100644 --- a/integration_tests/tests/registry/test_plugin_ui.py +++ b/integration_tests/tests/registry/test_plugin_ui.py @@ -32,14 +32,15 @@ @pytest.mark.ratelimit async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test plugin install, enable, uninstall, and reinstall through the UI. + Test plugin install / uninstall / reinstall through the Manage tab batch UI. 1. Create registry, refresh index via API. - 2. Navigate to plugin page, install sample-metadata from registry. - 3. Move sample-metadata to enabled pool, save configuration. - 4. Uninstall sample-metadata, verify absent from page and API. - 5. Refresh registry, verify sample-metadata available for reinstall. - 6. Reinstall, verify managed provenance. + 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) @@ -73,7 +74,7 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL page.on("console", lambda console: console_evts.append(console)) # >>>>> LOGIN >>>>> - await page.goto(f"{lrr_client.lrr_base_url}/config/plugins") + 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(): @@ -85,69 +86,57 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL console_evts.clear() # <<<<< LOGIN <<<<< - # >>>>> INSTALL >>>>> - # Expand the Metadata Plugins collapsible (hidden by allcollapsible on load) - await page.locator(".collapsible-title", has_text="Metadata Plugins").click() - await page.wait_for_timeout(500) - + # >>>>> 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.wait_for_load_state("networkidle") - - sample_metadata_row = page.locator(".registry-plugin-row").filter( - has=page.locator("h2", has_text="Sample Metadata") - ) + 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 sample_metadata_row.locator("input[type='button']").click() + await page.locator(".swal2-confirm").click() install_response = await response_info.value assert install_response.ok, f"Install API failed: {install_response.status}" - # <<<<< INSTALL <<<<< + + # 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 page.wait_for_timeout(500) - assert await badge.text_content() == "managed", f"Expected 'managed' badge after install, got: {await badge.text_content()}" + 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 <<<<< - # >>>>> ENABLE AND SAVE >>>>> - # native drag does not trigger SortableJS; move via DOM - moved = await page.evaluate("""() => { - const card = document.querySelector('.plugin-card[data-namespace="sample-metadata"]'); - const enabledPool = document.getElementById('metadata-enabled'); - if (!card || !enabledPool) return false; - - const emptyMsg = enabledPool.querySelector('.pool-empty-msg'); - if (emptyMsg) emptyMsg.remove(); - - enabledPool.appendChild(card); - if (typeof Plugins !== 'undefined' && Plugins.renumberEnabled) { - Plugins.renumberEnabled(); - } - return card.closest('#metadata-enabled') !== null; - }""") - assert moved, "Failed to move sample-metadata to enabled pool" - - await page.get_by_role("button", name="Save Plugin Configuration").click() - await page.wait_for_load_state("networkidle") - await page.wait_for_timeout(2000) - # <<<<< ENABLE AND SAVE <<<<< - - # >>>>> UNINSTALL >>>>> - await page.locator(".plugin-uninstall-btn[data-namespace='sample-metadata']").click() - - # confirm uninstall dialog; wait for DELETE response then page reload - await page.wait_for_selector(".swal2-confirm", state="visible") - async with page.expect_response("**/api/plugins/installed/**") as response_info: - await page.click(".swal2-confirm") + # >>>>> 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 <<<<< + # <<<<< 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})" - # verify via API response, error = await lrr_client.misc_api.get_available_plugins( GetAvailablePluginsRequest(type="metadata") ) @@ -156,29 +145,24 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL assert "sample-metadata" not in namespaces, f"Plugin still in API after uninstall: {namespaces}" # <<<<< VERIFY REMOVED <<<<< - # >>>>> REFRESH AND VERIFY AVAILABLE >>>>> - # Re-expand collapsible (page reloaded after uninstall) - await page.locator(".collapsible-title", has_text="Metadata Plugins").click() - await page.wait_for_timeout(500) - - await page.locator("#registry-refresh-btn").click() - await page.wait_for_load_state("networkidle") - - reinstall_row = page.locator(".registry-plugin-row").filter( - has=page.locator("h2", has_text="Sample Metadata") - ) - await reinstall_row.wait_for(state="visible") - # <<<<< REFRESH AND VERIFY AVAILABLE <<<<< - # >>>>> 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 reinstall_row.locator("input[type='button']").click() + 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 page.wait_for_timeout(500) - assert await badge_after.text_content() == "managed", f"Expected 'managed' after reinstall, got: {await badge_after.text_content()}" + 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) From ed427077d65deeb6d630d92986efab3e659ff26a Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:32:44 -0700 Subject: [PATCH 29/72] increase registry test coverage --- .../deployment/container.py | 70 +++++ .../aio_lanraragi_tests/deployment/windows.py | 41 +++ .../tests/registry/test_local_registry.py | 197 ++++++++++++++ .../tests/registry/test_plugin_config.py | 13 + .../tests/registry/test_plugin_lifecycle.py | 256 +++++++++++++++++- 5 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 integration_tests/tests/registry/test_local_registry.py diff --git a/integration_tests/src/aio_lanraragi_tests/deployment/container.py b/integration_tests/src/aio_lanraragi_tests/deployment/container.py index cf6ca4db..fc65e819 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" +LOCAL_REGISTRY_CONTAINER_PATH = "/srv/test-registry" LOGGER = logging.getLogger(__name__) @@ -216,6 +217,37 @@ 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 + + @property + def local_registry_dir(self) -> Path: + """ + Host path bind-mounted at ``local_registry_path`` for local-registry tests. + """ + dirname = self.resource_prefix + "local_registry" + return self.staging_dir / dirname + + @property + def local_registry_path(self) -> str: + """ + Path at which LRR reads the local registry. Pass this to ``CreateRegistryRequest(path=...)``. + """ + return LOCAL_REGISTRY_CONTAINER_PATH + @property def docker_client(self) -> docker.DockerClient: return self._docker_client @@ -509,6 +541,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 + local_registry_dir = self.local_registry_dir if contents_dir.exists(): self.logger.debug(f"Contents directory exists: {contents_dir}") else: @@ -534,6 +569,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 local_registry_dir.exists(): + self.logger.debug(f"Local registry directory exists: {local_registry_dir}") + else: + self.logger.debug(f"Creating local registry dir: {local_registry_dir}") + local_registry_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. @@ -709,6 +765,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(local_registry_dir): {"bind": LOCAL_REGISTRY_CONTAINER_PATH, "mode": "ro"}, } lrr_volumes.update(plugin_volumes) self.lrr_container = self.docker_client.containers.create( @@ -903,6 +962,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: @@ -941,6 +1002,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.local_registry_dir.exists(): + shutil.rmtree(self.local_registry_dir) + self.logger.debug(f"Removed local registry directory: {self.local_registry_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..8f5a340f 100644 --- a/integration_tests/src/aio_lanraragi_tests/deployment/windows.py +++ b/integration_tests/src/aio_lanraragi_tests/deployment/windows.py @@ -168,6 +168,23 @@ 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" + + @property + def local_registry_dir(self) -> Path: + dirname = self.resource_prefix + "local_registry" + return self.staging_dir / dirname + + @property + def local_registry_path(self) -> str: + return str(self.local_registry_dir) + def __init__( self, windist_path: str, staging_directory: str, resource_prefix: str, port_offset: int, logger: logging.Logger | None=None @@ -244,6 +261,7 @@ def setup( log_dir = self.logs_dir pid_dir = self.pid_dir redis_dir = self.redis_dir + local_registry_dir = self.local_registry_dir if contents_dir.exists(): self.logger.debug(f"Contents directory exists: {contents_dir}") else: @@ -274,6 +292,11 @@ def setup( else: self.logger.debug(f"Creating Redis directory: {redis_dir}") redis_dir.mkdir(parents=True, exist_ok=False) + if local_registry_dir.exists(): + self.logger.debug(f"Local registry directory exists: {local_registry_dir}") + else: + self.logger.debug(f"Creating local registry directory: {local_registry_dir}") + local_registry_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 @@ -365,6 +388,19 @@ def stop(self): self.logger.debug("Stopped LRR.") self.stop_redis() self.logger.debug("Stopped Redis.") + # Clear managed and sideloaded plugins between tests, matching the + # docker context. plugin_managed_dir/plugin_sideloaded_dir live inside + # the Windows lib tree, so files installed by one test would otherwise + # persist into the next. + for plugin_dir in (self.plugin_managed_dir, self.plugin_sideloaded_dir): + if not plugin_dir.exists(): + continue + for entry in plugin_dir.iterdir(): + if entry.is_dir(): + self._remove_ro(entry) + shutil.rmtree(entry) + else: + entry.unlink() @override def restart(self): @@ -391,6 +427,7 @@ def teardown(self, remove_data: bool=False): windist_dir = self.windist_dir redis_dir = self.redis_dir temp_dir = self.temp_dir + local_registry_dir = self.local_registry_dir self.stop() if hasattr(self, "_redis_client") and self._redis_client is not None: self._redis_client.close() @@ -419,6 +456,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 local_registry_dir.exists(): + self._remove_ro(local_registry_dir) + shutil.rmtree(local_registry_dir) + self.logger.debug(f"Removed local registry directory: {local_registry_dir}") @override def start_lrr(self): 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..12bbf622 --- /dev/null +++ b/integration_tests/tests/registry/test_local_registry.py @@ -0,0 +1,197 @@ +""" +Local-registry install error paths. +""" + +import hashlib +import json +import logging +import time + +import pytest +from lanraragi.clients.client import LRRClient +from lanraragi.models.misc import ( + CreateRegistryRequest, + GetAvailablePluginsRequest, + InstallPluginRequest, +) + +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_local_registry_install_errors( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + Test local-registry install error paths: malformed index, unsafe paths, sha256 mismatch, success. + + Single environment; registry.json is rotated between stages. + + 1. Malformed plugins field: refresh 400. + 2. Traversal path: install 400, no file written. + 3. Absolute path: install 400, no file written. + 4. Wrong sha256: install 422, target path absent. + 5. 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", + type="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 <<<<< + + # >>>>> TRAVERSAL PATH REJECTED >>>>> + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "traversal-plugin": { + "name": "traversal", "type": "download", "author": "test", "version": "1.0", + "path": "../../etc/passwd", "sha256": dummy_sha, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Expected refresh to succeed with traversal path entry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="traversal-plugin", registry=reg_id) + ) + assert error is not None, "Expected install to fail for traversal path" + assert error.status == 400, f"Expected 400 for traversal path install, 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": { + "name": "absolute", "type": "download", "author": "test", "version": "1.0", + "path": "/etc/passwd", "sha256": dummy_sha, + }, + }, + })) + + response, error = await lrr_client.misc_api.refresh_registry(reg_id) + assert not error, f"Expected refresh to succeed with absolute path entry (status {error.status}): {error.error}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="absolute-plugin", registry=reg_id) + ) + assert error is not None, "Expected install to fail for absolute path" + assert error.status == 400, f"Expected 400 for absolute path install, got {error.status}" + assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for absolute path" + # <<<<< ABSOLUTE PATH REJECTED <<<<< + + plugin_rel_path = "Plugin/Managed/Download/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", + ); +} + +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": { + "name": "Local Sample", "type": "download", "author": "test", "version": "1.0", + "path": plugin_rel_path, "sha256": dummy_sha, + }, + }, + })) + + 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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id) + ) + assert error is not None, "Expected install to fail for wrong sha256" + assert error.status == 422, f"Expected 422 for wrong sha256 install, got {error.status}" + + 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": { + "name": "Local Sample", "type": "download", "author": "test", "version": "1.0", + "path": plugin_rel_path, "sha256": real_sha, + }, + }, + })) + + 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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id) + ) + assert not error, f"Expected install to succeed (status {error.status}): {error.error}" + assert response.namespace == "local-sample-downloader" + assert response.version == "1.0", f"Expected version 1.0, got {response.version}" + + assert target_pm.exists(), f"Plugin file should exist after successful install: {target_pm}" + + expect_no_error_logs(environment, LOGGER) + # <<<<< SHA256 MATCH INSTALLS <<<<< diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 29bf30cf..1f0b1183 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -153,6 +153,19 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR expect_no_error_logs(environment, LOGGER) +@pytest.mark.asyncio +@pytest.mark.dev("registry") +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_plugin_config( + "definitely-not-real", UpdatePluginConfigRequest(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}" + + @pytest.mark.asyncio @pytest.mark.dev("registry") @pytest.mark.ratelimit diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 956c3e11..4a282537 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -7,6 +7,7 @@ import tempfile from pathlib import Path +import aiohttp import playwright.async_api import playwright.async_api._generated import pytest @@ -88,7 +89,9 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: ) assert not error, f"Failed to list plugins (status {error.status}): {error.error}" namespaces = {p.namespace for p in response.plugins} - assert "sample-downloader" in namespaces, f"Installed plugin not found in list: {namespaces}" + # LRR ships no built-in download plugins on dev-registry-backend, so the + # only download plugin present after install is the one we just installed. + assert namespaces == {"sample-downloader"}, f"Expected only sample-downloader in download list, got: {namespaces}" # <<<<< VERIFY INSTALLED <<<<< # >>>>> UNINSTALL PLUGIN >>>>> @@ -116,6 +119,13 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: # <<<<< 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}" @@ -124,8 +134,11 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: GetAvailablePluginsRequest(type="metadata") ) assert not error, f"Failed to list plugins (status {error.status}): {error.error}" - namespaces = {p.namespace for p in response.plugins} - assert "copytags" in namespaces, "Built-in plugin should still be listed after blocked uninstall" + 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) @@ -477,6 +490,114 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A 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", + type="git", + 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 + + 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}" + # <<<<< SETUP REG A <<<<< + + # >>>>> INSTALL FROM REG A >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_a_id) + ) + 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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_a_id) + ) + assert error is not None, "Expected error when installing from deleted registry" + assert error.status == 404, f"Expected 404 for deleted registry, got {error.status}" + # <<<<< UPGRADE WITH ORPHAN REGISTRY -> 404 <<<<< + + # >>>>> CREATE REG B (SAME SOURCE) >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo-B", + type="git", + 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" + + 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}" + # <<<<< CREATE REG B (SAME SOURCE) <<<<< + + # >>>>> INSTALL FROM REG B WITHOUT FORCE -> PROVENANCE MISMATCH >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_b_id) + ) + assert error is not None, "Expected provenance mismatch error when installing from different registry without force" + assert error.status == 400, f"Expected 400 for cross-registry provenance mismatch, got {error.status}" + # <<<<< INSTALL FROM REG B WITHOUT FORCE -> PROVENANCE MISMATCH <<<<< + + # >>>>> INSTALL FROM REG B WITH FORCE -> 200 >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_b_id, 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 @@ -485,7 +606,7 @@ async def test_sideload_after_managed_uninstall_no_duplicate_rows( environment: AbstractLRRDeploymentContext, ): """ - Reproduce: install managed → uninstall → sideload → server renders 2 rows. + Reproduce: install managed -> uninstall -> sideload -> server renders 2 rows. Single worker so all operations hit the same Perl process. No restart between the cycle and the check — the bug is per-worker symbol table @@ -532,7 +653,6 @@ async def test_sideload_after_managed_uninstall_no_duplicate_rows( # <<<<< SIDELOAD <<<<< # >>>>> RAW HTML CHECK — NO RESTART, SAME WORKER >>>>> - import aiohttp login_url = lrr_client.misc_api.api_context.build_url("/login") plugins_url = lrr_client.misc_api.api_context.build_url("/config/plugins") @@ -890,3 +1010,129 @@ async def test_managed_plugin_upgrade_reloads_across_workers( # <<<<< 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_survives_restart(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test that a managed plugin file persists across LRR restart and scan_plugins does not orphan it. + + 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. Restart LRR. + 5. Assert host path still exists after restart. + 6. 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", + type="git", + 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}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" + installed_version = response.version + # <<<<< SETUP AND INSTALL <<<<< + + # >>>>> 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" + + 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}" + ) + 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", + type="git", + 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}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + ) + 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) From 32ff239b0efa4ea60e953b8138988864d295fbac Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:05:44 -0700 Subject: [PATCH 30/72] update tests to spec for existing logic --- .github/workflows/tests.yml | 6 +- .../tests/registry/test_local_registry.py | 74 ++++++++++-- .../tests/registry/test_plugin_config.py | 20 +-- .../tests/registry/test_plugin_lifecycle.py | 114 ++++++++++-------- .../tests/registry/test_registry_crud.py | 11 +- src/lanraragi/clients/api_clients/misc.py | 4 +- src/lanraragi/models/misc.py | 5 +- 7 files changed, 153 insertions(+), 81 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 82c53044..0f371eeb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 12bbf622..855a874f 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -71,8 +71,20 @@ async def test_local_registry_install_errors( "generated_at": generated_at, "plugins": { "traversal-plugin": { - "name": "traversal", "type": "download", "author": "test", "version": "1.0", - "path": "../../etc/passwd", "sha256": dummy_sha, + "namespace": "traversal-plugin", + "type": "download", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.0", + "name": "traversal", + "author": "test", + "description": "traversal test plugin", + "artifact": "../../etc/passwd", + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, }, }, })) @@ -81,7 +93,7 @@ async def test_local_registry_install_errors( assert not error, f"Expected refresh to succeed with traversal path entry (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="traversal-plugin", registry=reg_id) + InstallPluginRequest(namespace="traversal-plugin", registry=reg_id, version="1.0") ) assert error is not None, "Expected install to fail for traversal path" assert error.status == 400, f"Expected 400 for traversal path install, got {error.status}" @@ -94,8 +106,20 @@ async def test_local_registry_install_errors( "generated_at": generated_at, "plugins": { "absolute-plugin": { - "name": "absolute", "type": "download", "author": "test", "version": "1.0", - "path": "/etc/passwd", "sha256": dummy_sha, + "namespace": "absolute-plugin", + "type": "download", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.0", + "name": "absolute", + "author": "test", + "description": "absolute path test plugin", + "artifact": "/etc/passwd", + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, }, }, })) @@ -104,14 +128,14 @@ async def test_local_registry_install_errors( assert not error, f"Expected refresh to succeed with absolute path entry (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="absolute-plugin", registry=reg_id) + InstallPluginRequest(namespace="absolute-plugin", registry=reg_id, version="1.0") ) assert error is not None, "Expected install to fail for absolute path" assert error.status == 400, f"Expected 400 for absolute path install, got {error.status}" assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for absolute path" # <<<<< ABSOLUTE PATH REJECTED <<<<< - plugin_rel_path = "Plugin/Managed/Download/LocalSample.pm" + plugin_rel_path = "artifacts/local-sample-downloader/1.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("""\ @@ -141,8 +165,20 @@ async def test_local_registry_install_errors( "generated_at": generated_at, "plugins": { "local-sample-downloader": { - "name": "Local Sample", "type": "download", "author": "test", "version": "1.0", - "path": plugin_rel_path, "sha256": dummy_sha, + "namespace": "local-sample-downloader", + "type": "download", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.0", + "name": "Local Sample", + "author": "test", + "description": "local sample downloader", + "artifact": plugin_rel_path, + "sha256": dummy_sha, + "published_at": generated_at, + }, + }, }, }, })) @@ -151,7 +187,7 @@ async def test_local_registry_install_errors( assert not error, f"Expected refresh to succeed with wrong sha entry (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id) + InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id, version="1.0") ) assert error is not None, "Expected install to fail for wrong sha256" assert error.status == 422, f"Expected 422 for wrong sha256 install, got {error.status}" @@ -175,8 +211,20 @@ async def test_local_registry_install_errors( "generated_at": generated_at, "plugins": { "local-sample-downloader": { - "name": "Local Sample", "type": "download", "author": "test", "version": "1.0", - "path": plugin_rel_path, "sha256": real_sha, + "namespace": "local-sample-downloader", + "type": "download", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.0", + "name": "Local Sample", + "author": "test", + "description": "local sample downloader", + "artifact": plugin_rel_path, + "sha256": real_sha, + "published_at": generated_at, + }, + }, }, }, })) @@ -185,7 +233,7 @@ async def test_local_registry_install_errors( assert not error, f"Expected refresh to succeed (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id) + InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id, version="1.0") ) assert not error, f"Expected install to succeed (status {error.status}): {error.error}" assert response.namespace == "local-sample-downloader" diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 1f0b1183..a5844006 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -55,11 +55,12 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR 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) + 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 = refresh_response.index["plugins"]["sample-metadata"]["channels"]["latest"] response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + 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 <<<<< @@ -110,7 +111,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR assert not error, f"Failed to uninstall plugin (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) ) assert not error, f"Failed to reinstall plugin (status {error.status}): {error.error}" @@ -194,11 +195,13 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe 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) + 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 = refresh_response.index["plugins"]["sample-metadata"]["channels"]["latest"] + sample_downloader_version = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + 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 <<<<< @@ -254,7 +257,7 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe # >>>>> PRIORITY ON NON-METADATA PLUGIN >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + 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}" @@ -307,14 +310,15 @@ async def test_plugin_priority_execution_order(lrr_client: LRRClient, environmen 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) + 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 = refresh_response.index["plugins"][ns]["channels"]["latest"] response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace=ns, registry=reg_id) + 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 <<<<< diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 4a282537..77063ad2 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -74,13 +74,15 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: # <<<<< SETUP REGISTRY <<<<< # >>>>> INSTALL PLUGIN >>>>> + refresh_response = response + version_key = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + 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}" + assert response.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" # <<<<< INSTALL PLUGIN <<<<< # >>>>> VERIFY INSTALLED >>>>> @@ -159,7 +161,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab # >>>>> INSTALL FROM NONEXISTENT REGISTRY >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001") + InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001", version="1.0") ) assert error is not None, "Expected error for nonexistent registry" assert error.status == 404, f"Expected 404 for nonexistent registry, got {error.status}" @@ -179,7 +181,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab reg_id = response.id response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version="1.0") ) assert error is not None, "Expected error when installing without refresh" assert error.status == 409, f"Expected 409 for no cached index, got {error.status}" @@ -190,7 +192,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="does-not-exist-xyz", registry=reg_id) + InstallPluginRequest(namespace="does-not-exist-xyz", registry=reg_id, version="1.0") ) assert error is not None, "Expected error for nonexistent namespace" assert error.status == 404, f"Expected 404 for unknown namespace, got {error.status}" @@ -233,16 +235,17 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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) + 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 = refresh_response.index["plugins"]["title-suffix-1"]["channels"]["latest"] # <<<<< SETUP REGISTRY <<<<< # >>>>> INSTALL >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="title-suffix-1", registry=reg_id) + 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}" + assert response.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" # <<<<< INSTALL <<<<< # >>>>> VERIFY INSTALLED >>>>> @@ -252,7 +255,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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}" + assert plugin.installed_registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.installed_registry}" break else: pytest.fail("title-suffix-1 not found after install") @@ -274,10 +277,10 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab # >>>>> REINSTALL >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="title-suffix-1", registry=reg_id) + 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}" + assert response.installed_registry == reg_id, f"Expected provenance on reinstall {reg_id}, got: {response.installed_registry}" # <<<<< REINSTALL <<<<< # >>>>> VERIFY REINSTALLED >>>>> @@ -287,7 +290,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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}" + assert plugin.installed_registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.installed_registry}" break else: pytest.fail("title-suffix-1 not found after reinstall") @@ -323,7 +326,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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}" + assert plugin.installed_registry == reg_id, f"Expected orphaned provenance {reg_id}, got: {plugin.installed_registry}" break else: pytest.fail("title-suffix-1 should still be listed after registry delete") @@ -399,13 +402,15 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr 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) + 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 = refresh_response.index["plugins"]["sample-metadata"]["channels"]["latest"] + sample_downloader_version = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] # <<<<< SETUP REGISTRY <<<<< # >>>>> INSTALL WITH CONFLICT (NO PROVENANCE) >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id) + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) ) assert error is not None, "Expected error when installing plugin with existing sideloaded copy" assert error.status == 400, f"Expected 400 for provenance conflict, got {error.status}" @@ -414,7 +419,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # >>>>> FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id, force=True) + InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version, force=True) ) assert error is not None, "Expected error: force bypasses provenance but not filesystem namespace conflict" assert error.status == 422, f"Expected 422 for namespace conflict, got {error.status}" @@ -422,16 +427,16 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # >>>>> INSTALL WITHOUT CONFLICT >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + 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}" + assert response.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" # <<<<< INSTALL WITHOUT CONFLICT <<<<< # >>>>> UPGRADE (REINSTALL) >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + 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) <<<<< @@ -464,14 +469,15 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A 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) + 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 = refresh_response.index["plugins"]["sample-login"]["channels"]["latest"] # <<<<< SETUP REGISTRY <<<<< for i in range(5): LOGGER.info(f"Cycle {i}: installing sample-login") response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-login", registry=reg_id) + InstallPluginRequest(namespace="sample-login", registry=reg_id, version=sample_login_version) ) assert not error, f"Cycle {i}: install failed (status {error.status}): {error.error}" @@ -520,16 +526,17 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: assert not error, f"Failed to create reg A (status {error.status}): {error.error}" reg_a_id = response.id - response, error = await lrr_client.misc_api.refresh_registry(reg_a_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 = refresh_a_response.index["plugins"]["sample-downloader"]["channels"]["latest"] # <<<<< SETUP REG A <<<<< # >>>>> INSTALL FROM REG A >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_a_id) + 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}" + assert response.installed_registry == reg_a_id, f"Expected provenance {reg_a_id}, got: {response.installed_registry}" # <<<<< INSTALL FROM REG A <<<<< # >>>>> DELETE REG A -> ORPHAN >>>>> @@ -542,7 +549,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: # >>>>> UPGRADE WITH ORPHAN REGISTRY -> 404 >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_a_id) + 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 error.status == 404, f"Expected 404 for deleted registry, got {error.status}" @@ -562,13 +569,14 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: reg_b_id = response.id assert reg_b_id != reg_a_id, "Expected reg B to have a different id than reg A" - response, error = await lrr_client.misc_api.refresh_registry(reg_b_id) + 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 = refresh_b_response.index["plugins"]["sample-downloader"]["channels"]["latest"] # <<<<< CREATE REG B (SAME SOURCE) <<<<< # >>>>> INSTALL FROM REG B WITHOUT FORCE -> PROVENANCE MISMATCH >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_b_id) + 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 error.status == 400, f"Expected 400 for cross-registry provenance mismatch, got {error.status}" @@ -576,10 +584,10 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: # >>>>> INSTALL FROM REG B WITH FORCE -> 200 >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_b_id, force=True) + 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}" + assert response.installed_registry == reg_b_id, f"Expected provenance {reg_b_id} after force install, got: {response.installed_registry}" # <<<<< INSTALL FROM REG B WITH FORCE -> 200 <<<<< # >>>>> VERIFY PROVENANCE UPDATED >>>>> @@ -589,7 +597,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: 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}" + assert plugin.installed_registry == reg_b_id, f"Expected provenance {reg_b_id}, got: {plugin.installed_registry}" break else: pytest.fail("sample-downloader not found in download plugin list after force install") @@ -632,11 +640,12 @@ async def test_sideload_after_managed_uninstall_no_duplicate_rows( assert not error, f"Failed to create registry (status {error.status}): {error.error}" reg_id = response.id - _, error = await lrr_client.misc_api.refresh_registry(reg_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_script_version = refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] _, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-script", registry=reg_id) + InstallPluginRequest(namespace="sample-script", registry=reg_id, version=sample_script_version) ) assert not error, f"Failed to install managed sample-script (status {error.status}): {error.error}" # <<<<< INSTALL MANAGED <<<<< @@ -717,11 +726,12 @@ async def test_sideloaded_script_lifecycle( assert not error, f"Failed to create registry (status {error.status}): {error.error}" reg_id = response.id - _, error = await lrr_client.misc_api.refresh_registry(reg_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_script_version = refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] _, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-script", registry=reg_id) + InstallPluginRequest(namespace="sample-script", registry=reg_id, version=sample_script_version) ) assert not error, f"Failed to install sample-script (status {error.status}): {error.error}" # <<<<< INSTALL MANAGED SAMPLE-SCRIPT <<<<< @@ -861,11 +871,12 @@ async def test_managed_plugin_upgrade_reloads_class( assert not error, f"Failed to create registry (status {error.status}): {error.error}" reg_id = response.id - _, error = await lrr_client.misc_api.refresh_registry(reg_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 = main_refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] _, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-script", registry=reg_id) + 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 <<<<< @@ -877,7 +888,7 @@ async def test_managed_plugin_upgrade_reloads_class( assert not error, f"Failed to list scripts (status {error.status}): {error.error}" sample = next((p for p in response.plugins if p.namespace == "sample-script"), None) assert sample is not None, "sample-script not listed after install" - assert sample.version == "1.0", f"Expected v1.0 after initial install, got {sample.version!r}" + assert sample.version == main_version, f"Expected v{main_version} after initial install, got {sample.version!r}" # <<<<< VERIFY v1.0 IN LOADED CLASS <<<<< # >>>>> SWITCH REGISTRY TO v1.1 AND UPGRADE >>>>> @@ -886,11 +897,12 @@ async def test_managed_plugin_upgrade_reloads_class( ) assert not error, f"Failed to update registry ref (status {error.status}): {error.error}" - _, error = await lrr_client.misc_api.refresh_registry(reg_id) + 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 = v11_refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] _, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-script", registry=reg_id, force=True) + 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 <<<<< @@ -906,7 +918,7 @@ async def test_managed_plugin_upgrade_reloads_class( assert not error, f"Failed to list scripts (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 not listed on attempt {attempt}" - assert sample.version == "1.1", ( + assert sample.version == v11_version, ( f"Attempt {attempt}: loaded class still reports version {sample.version!r} " f"after upgrade; %INC short-circuited require so the new file was not re-read" ) @@ -949,11 +961,12 @@ async def test_managed_plugin_upgrade_reloads_across_workers( assert not error, f"Failed to create registry (status {error.status}): {error.error}" reg_id = response.id - _, error = await lrr_client.misc_api.refresh_registry(reg_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 = main_refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] _, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-script", registry=reg_id) + 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}" @@ -977,11 +990,12 @@ async def test_managed_plugin_upgrade_reloads_across_workers( ) assert not error, f"Failed to update registry ref (status {error.status}): {error.error}" - _, error = await lrr_client.misc_api.refresh_registry(reg_id) + 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 = v11_refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] _, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-script", registry=reg_id, force=True) + 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 <<<<< @@ -1041,11 +1055,12 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen 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) + 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 = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + 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 @@ -1065,7 +1080,7 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen 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.installed_registry == reg_id, f"Expected provenance {reg_id} after restart, got: {plugin.installed_registry}" assert plugin.version == installed_version, ( f"Expected version {installed_version!r} after restart, got: {plugin.version!r}" ) @@ -1104,11 +1119,12 @@ async def test_plugin_file_deleted_under_lrr(lrr_client: LRRClient, environment: 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) + 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 = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + 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 <<<<< diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 27aaf246..27bbee93 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -292,17 +292,18 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra 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) + 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 response.index is not None, "Expected index after refresh" + assert refresh_response.index is not None, "Expected index after refresh" + sample_downloader_version = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] # <<<<< CREATE AND REFRESH <<<<< # >>>>> INSTALL PLUGIN BEFORE SOURCE CHANGE >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-downloader", registry=reg_id) + 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 + assert response.installed_registry == reg_id # <<<<< INSTALL PLUGIN BEFORE SOURCE CHANGE <<<<< # >>>>> UPDATE URL (SOURCE CHANGE) >>>>> @@ -318,7 +319,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra 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}" + assert plugin.installed_registry == reg_id, f"Expected provenance {reg_id} after source change, got: {plugin.installed_registry}" break else: pytest.fail("Installed plugin should survive registry source change") diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 35e281b2..353a2e07 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -266,7 +266,7 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo POST /api/plugins/install """ url = self.api_context.build_url("/api/plugins/install") - body: dict[str, Any] = {"namespace": request.namespace, "registry": request.registry} + body: dict[str, Any] = {"namespace": request.namespace, "registry": request.registry, "version": request.version} if request.force is not None: body["force"] = request.force status, content = await self.api_context.handle_request( @@ -278,7 +278,7 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo name=response_j["name"], namespace=response_j["namespace"], version=response_j["version"], - registry=response_j["registry"], + installed_registry=response_j["installed_registry"], ), None) return (None, _build_err_response(content, status)) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index da820379..1c4193f7 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -51,7 +51,7 @@ class GetAvailablePluginsResponsePlugin(BaseModel): version: str = Field(...) hidden: bool = Field(False) priority: int = Field(0) - registry: str | None = Field(None) + installed_registry: str | None = Field(None) class GetAvailablePluginsResponse(LanraragiResponse): plugins: list[GetAvailablePluginsResponsePlugin] = Field(...) @@ -149,13 +149,14 @@ class UpdatePluginConfigRequest(LanraragiRequest): class InstallPluginRequest(LanraragiRequest): namespace: str = Field(...) registry: str = Field(...) + version: str = Field(...) force: bool | None = Field(None) class InstallPluginResponse(LanraragiResponse): name: str = Field(...) namespace: str = Field(...) version: str = Field(...) - registry: str = Field(...) + installed_registry: str = Field(...) __all__ = [ "GetServerInfoResponse", From 3288a9b40bd6ce37426c6942eea690fc20b62405 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:19:19 -0700 Subject: [PATCH 31/72] update tests --- .../tests/registry/test_local_registry.py | 277 ++++++++++++++++-- .../tests/registry/test_plugin_config.py | 25 ++ .../tests/registry/test_plugin_lifecycle.py | 238 +++++++++++---- src/lanraragi/clients/api_clients/misc.py | 7 +- src/lanraragi/models/misc.py | 6 + 5 files changed, 475 insertions(+), 78 deletions(-) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 855a874f..d935e3e8 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -1,5 +1,5 @@ """ -Local-registry install error paths. +Local-registry validation, orphan, and install error paths. """ import hashlib @@ -30,15 +30,20 @@ async def test_local_registry_install_errors( lrr_client: LRRClient, ): """ - Test local-registry install error paths: malformed index, unsafe paths, sha256 mismatch, success. + Test local-registry refresh validation and install error paths. Single environment; registry.json is rotated between stages. 1. Malformed plugins field: refresh 400. - 2. Traversal path: install 400, no file written. - 3. Absolute path: install 400, no file written. - 4. Wrong sha256: install 422, target path absent. - 5. Correct sha256: install 200, file present on host. + 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. Traversal path: refresh 400. + 7. Absolute path: refresh 400. + 8. Symlink escape path: install 400. + 9. Wrong sha256: install 422, target path absent. + 10. Correct sha256: install 200, file present on host. """ environment.setup(with_api_key=True) @@ -65,6 +70,102 @@ async def test_local_registry_install_errors( 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", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.0", + "name": "unknown", + "author": "test", + "description": "unknown field test plugin", + "artifact": "artifacts/unknown/1.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", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.0", + "name": "bad-published-at", + "author": "test", + "description": "bad published_at test plugin", + "artifact": "artifacts/bad-published-at/1.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", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.0", + "name": "bad-sha", + "author": "test", + "description": "bad sha test plugin", + "artifact": "artifacts/bad-sha/1.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 <<<<< + # >>>>> TRAVERSAL PATH REJECTED >>>>> registry_json.write_text(json.dumps({ "version": 1, @@ -90,13 +191,8 @@ async def test_local_registry_install_errors( })) response, error = await lrr_client.misc_api.refresh_registry(reg_id) - assert not error, f"Expected refresh to succeed with traversal path entry (status {error.status}): {error.error}" - - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="traversal-plugin", registry=reg_id, version="1.0") - ) - assert error is not None, "Expected install to fail for traversal path" - assert error.status == 400, f"Expected 400 for traversal path install, got {error.status}" + 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 <<<<< @@ -125,15 +221,53 @@ async def test_local_registry_install_errors( })) response, error = await lrr_client.misc_api.refresh_registry(reg_id) - assert not error, f"Expected refresh to succeed with absolute path entry (status {error.status}): {error.error}" + 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) + + registry_json.write_text(json.dumps({ + "version": 1, + "generated_at": generated_at, + "plugins": { + "symlink-plugin": { + "namespace": "symlink-plugin", + "type": "download", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.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 lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="absolute-plugin", registry=reg_id, version="1.0") + InstallPluginRequest(namespace="symlink-plugin", registry=reg_id, version="1.0") ) - assert error is not None, "Expected install to fail for absolute path" - assert error.status == 400, f"Expected 400 for absolute path install, got {error.status}" - assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for absolute path" - # <<<<< ABSOLUTE PATH REJECTED <<<<< + assert error is not None, "Expected install to fail for symlink escape" + assert error.status == 400, f"Expected 400 for symlink escape install, got {error.status}" + assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for symlink escape" + # <<<<< SYMLINK ESCAPE REJECTED <<<<< plugin_rel_path = "artifacts/local-sample-downloader/1.0/LocalSample.pm" plugin_file = environment.local_registry_dir / plugin_rel_path @@ -155,6 +289,10 @@ async def test_local_registry_install_errors( ); } +sub provide_url { + return; +} + 1; """, encoding="utf-8") real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() @@ -238,8 +376,109 @@ async def test_local_registry_install_errors( assert not error, f"Expected install to succeed (status {error.status}): {error.error}" assert response.namespace == "local-sample-downloader" assert response.version == "1.0", f"Expected version 1.0, got {response.version}" + assert response.installed_registry == reg_id, ( + f"Expected provenance {reg_id}, got {response.installed_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/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", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.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", + type="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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="copytags", registry=reg_id, version="1.0") + ) + assert error is not None, "Expected install to be rejected over a default plugin namespace" + assert error.status == 400, f"Expected 400 for default-namespace conflict, got {error.status}" + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="copytags", registry=reg_id, version="1.0", force=True) + ) + assert error is not None, "force=true must not bypass a default-plugin namespace conflict" + assert error.status == 400, f"Expected 400 for default-namespace conflict (force), got {error.status}" + + 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) diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index a5844006..77da5bfc 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -65,6 +65,29 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR 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.installed_registry == reg_id, ( + f"Managed plugin expected installed_registry={reg_id}, got {plugin.installed_registry}" + ) + if plugin.namespace == "copytags": + found_default = True + assert plugin.installed_registry is None, ( + f"Default plugin expected installed_registry=None, got {plugin.installed_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_plugin_config( "sample-metadata", UpdatePluginConfigRequest(hidden=True) @@ -166,6 +189,8 @@ async def test_plugin_config_nonexistent_namespace(lrr_client: LRRClient, enviro 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("registry") diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 77063ad2..2145ebbc 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -90,10 +90,11 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: GetAvailablePluginsRequest(type="download") ) assert not error, f"Failed to list plugins (status {error.status}): {error.error}" - namespaces = {p.namespace for p in response.plugins} - # LRR ships no built-in download plugins on dev-registry-backend, so the - # only download plugin present after install is the one we just installed. - assert namespaces == {"sample-downloader"}, f"Expected only sample-downloader in download list, got: {namespaces}" + 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.installed_registry == reg_id, ( + f"Expected managed provenance {reg_id}, got: {sample.installed_registry}" + ) # <<<<< VERIFY INSTALLED <<<<< # >>>>> UNINSTALL PLUGIN >>>>> @@ -146,6 +147,113 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: expect_no_error_logs(environment, LOGGER) +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@pytest.mark.ratelimit +async def test_plugin_install_provenance_fields(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): + """ + Test provenance fields for channel-tracked and explicit-version installs. + + 1. Install sample-downloader with installed_channel="latest". + 2. Assert install response and plugin list include required sha256/channel provenance. + 3. Reinstall explicitly after uninstalling. + 4. Assert installed_channel is cleared while sha256 remains. + """ + environment.setup(with_api_key=True) + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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 = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + version_record = refresh_response.index["plugins"]["sample-downloader"]["versions"][version_key] + expected_sha = version_record["sha256"] + + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest( + namespace="sample-downloader", + registry=reg_id, + version=version_key, + installed_channel="latest", + ) + ) + assert not error, f"Failed to install channel-tracked plugin (status {error.status}): {error.error}" + assert response.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" + assert response.installed_sha256 == expected_sha, ( + f"Expected install sha256 {expected_sha}, got {response.installed_sha256}" + ) + assert response.installed_channel == "latest", ( + f"Expected install channel 'latest', got {response.installed_channel!r}" + ) + 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.installed_registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.installed_registry}" + assert plugin.installed_version == version_key, ( + f"Expected installed_version {version_key!r}, got {plugin.installed_version!r}" + ) + assert plugin.installed_sha256 == expected_sha, ( + f"Expected installed_sha256 {expected_sha}, got {plugin.installed_sha256!r}" + ) + assert plugin.installed_channel == "latest", ( + f"Expected installed_channel 'latest', got {plugin.installed_channel!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.installed_channel == "latest", ( + f"Expected installed_channel 'latest' after restart, got {plugin.installed_channel!r}" + ) + assert plugin.installed_sha256 == expected_sha, ( + f"Expected installed_sha256 {expected_sha} after restart, got {plugin.installed_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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=version_key) + ) + assert not error, f"Failed to install explicit-version plugin (status {error.status}): {error.error}" + assert response.installed_channel is None, ( + f"Expected no installed_channel for explicit install, got {response.installed_channel!r}" + ) + assert response.installed_sha256 == expected_sha, ( + f"Expected install sha256 {expected_sha}, got {response.installed_sha256}" + ) + response, error = await lrr_client.misc_api.get_available_plugins( + GetAvailablePluginsRequest(type="download") + ) + assert not error, f"Failed to list plugins after explicit install (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 after explicit reinstall" + assert plugin.installed_channel is None, ( + f"Expected installed_channel to clear after explicit install, got {plugin.installed_channel!r}" + ) + assert plugin.installed_sha256 == expected_sha, ( + f"Expected installed_sha256 {expected_sha}, got {plugin.installed_sha256!r}" + ) + expect_no_error_logs(environment, LOGGER) + + @pytest.mark.asyncio @pytest.mark.dev("registry") @pytest.mark.ratelimit @@ -372,8 +480,8 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr 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 provenance conflict (400). - 5. Force install sample-metadata, expect namespace conflict (422). + 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. """ @@ -389,59 +497,60 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr plugin_paths={"Metadata": [str(conflict_path)]}, ) - # >>>>> SETUP REGISTRY >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest( - name="demo", - type="git", - provider="github", - url="https://github.com/psilabs-dev/lrr-plugins-demo.git", - ref="main", + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="demo", + type="git", + 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 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 = refresh_response.index["plugins"]["sample-metadata"]["channels"]["latest"] - sample_downloader_version = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] - # <<<<< SETUP REGISTRY <<<<< + 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 = refresh_response.index["plugins"]["sample-metadata"]["channels"]["latest"] + sample_downloader_version = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + # <<<<< SETUP REGISTRY <<<<< - # >>>>> INSTALL WITH CONFLICT (NO PROVENANCE) >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) - ) - assert error is not None, "Expected error when installing plugin with existing sideloaded copy" - assert error.status == 400, f"Expected 400 for provenance conflict, got {error.status}" - assert "without provenance" in error.error, f"Expected 'without provenance' in error, got: {error.error}" - # <<<<< INSTALL WITH CONFLICT (NO PROVENANCE) <<<<< + # >>>>> INSTALL WITH NON-MANAGED CONFLICT >>>>> + response, error = await lrr_client.misc_api.install_plugin( + 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 error.status == 400, f"Expected 400 for non-managed conflict, got {error.status}" + 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 BY FILESYSTEM CONFLICT >>>>> - response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version, force=True) - ) - assert error is not None, "Expected error: force bypasses provenance but not filesystem namespace conflict" - assert error.status == 422, f"Expected 422 for namespace conflict, got {error.status}" - # <<<<< FORCE INSTALL STILL BLOCKED BY FILESYSTEM CONFLICT <<<<< + # >>>>> FORCE INSTALL STILL BLOCKED OVER NON-MANAGED >>>>> + response, error = await lrr_client.misc_api.install_plugin( + 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 error.status == 400, f"Expected 400 for non-managed conflict (force), got {error.status}" + 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 lrr_client.misc_api.install_plugin( - 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.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" - # <<<<< INSTALL WITHOUT CONFLICT <<<<< + # >>>>> INSTALL WITHOUT CONFLICT >>>>> + response, error = await lrr_client.misc_api.install_plugin( + 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.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" + # <<<<< INSTALL WITHOUT CONFLICT <<<<< - # >>>>> UPGRADE (REINSTALL) >>>>> - response, error = await lrr_client.misc_api.install_plugin( - 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) <<<<< + # >>>>> UPGRADE (REINSTALL) >>>>> + response, error = await lrr_client.misc_api.install_plugin( + 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) + expect_no_error_logs(environment, LOGGER) @pytest.mark.asyncio @@ -451,6 +560,11 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A """ 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. """ @@ -475,17 +589,17 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A # <<<<< SETUP REGISTRY <<<<< for i in range(5): - LOGGER.info(f"Cycle {i}: installing sample-login") + LOGGER.debug(f"Cycle {i}: installing sample-login") response, error = await lrr_client.misc_api.install_plugin( 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.info(f"Cycle {i}: uninstalling sample-login") + 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.info(f"Cycle {i}: verifying absent from plugin list") + LOGGER.debug(f"Cycle {i}: verifying absent from plugin list") response, error = await lrr_client.misc_api.get_available_plugins( GetAvailablePluginsRequest(type="login") ) @@ -679,11 +793,13 @@ async def test_sideload_after_managed_uninstall_no_duplicate_rows( async with session.get(plugins_url) as resp: html = await resp.text() matches = html.count('data-namespace="sample-script" data-source=') - LOGGER.info(f"Fetch {i}: {matches} sample-script row(s) in server HTML") + LOGGER.debug(f"Fetch {i}: {matches} sample-script row(s) in server HTML") assert matches <= 1, \ f"Fetch {i}: expected at most 1 sample-script row in server HTML, got {matches}" # <<<<< RAW HTML CHECK <<<<< + expect_no_error_logs(environment, LOGGER) + @pytest.mark.asyncio @pytest.mark.playwright @@ -1064,6 +1180,7 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen ) assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" installed_version = response.version + installed_sha256 = response.installed_sha256 # <<<<< SETUP AND INSTALL <<<<< # >>>>> RESTART >>>>> @@ -1084,6 +1201,15 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen assert plugin.version == installed_version, ( f"Expected version {installed_version!r} after restart, got: {plugin.version!r}" ) + assert plugin.installed_version == installed_version, ( + f"Expected installed_version {installed_version!r} after restart, got: {plugin.installed_version!r}" + ) + assert plugin.installed_sha256 == installed_sha256, ( + f"Expected installed_sha256 {installed_sha256!r} after restart, got: {plugin.installed_sha256!r}" + ) + assert plugin.installed_channel is None, ( + f"Expected no installed_channel after explicit install, got: {plugin.installed_channel!r}" + ) break else: pytest.fail("sample-downloader not found in download plugin list after restart") diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 353a2e07..6f992208 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -267,6 +267,8 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo """ url = self.api_context.build_url("/api/plugins/install") body: dict[str, Any] = {"namespace": request.namespace, "registry": request.registry, "version": request.version} + if request.installed_channel is not None: + body["installed_channel"] = request.installed_channel if request.force is not None: body["force"] = request.force status, content = await self.api_context.handle_request( @@ -279,6 +281,8 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo namespace=response_j["namespace"], version=response_j["version"], installed_registry=response_j["installed_registry"], + installed_sha256=response_j["installed_sha256"], + installed_channel=response_j.get("installed_channel"), ), None) return (None, _build_err_response(content, status)) @@ -289,9 +293,6 @@ async def uninstall_plugin(self, namespace: str) -> _LRRClientResponse[Lanraragi 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: - response_j = json.loads(content) - if response_j.get("success") == 0: - return (None, LanraragiErrorResponse(error=response_j.get("error", ""), status=status)) return (LanraragiResponse(), None) return (None, _build_err_response(content, status)) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 1c4193f7..037c35ff 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -52,6 +52,9 @@ class GetAvailablePluginsResponsePlugin(BaseModel): hidden: bool = Field(False) priority: int = Field(0) installed_registry: str | None = Field(None) + installed_version: str | None = Field(None) + installed_sha256: str | None = Field(None) + installed_channel: str | None = Field(None) class GetAvailablePluginsResponse(LanraragiResponse): plugins: list[GetAvailablePluginsResponsePlugin] = Field(...) @@ -150,6 +153,7 @@ class InstallPluginRequest(LanraragiRequest): namespace: str = Field(...) registry: str = Field(...) version: str = Field(...) + installed_channel: str | None = Field(None) force: bool | None = Field(None) class InstallPluginResponse(LanraragiResponse): @@ -157,6 +161,8 @@ class InstallPluginResponse(LanraragiResponse): namespace: str = Field(...) version: str = Field(...) installed_registry: str = Field(...) + installed_sha256: str = Field(...) + installed_channel: str | None = Field(None) __all__ = [ "GetServerInfoResponse", From 1b3b712216baa6be67aeecf333be5a3e3e24d48f Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:55:33 -0700 Subject: [PATCH 32/72] stop should not be clearing data --- .../src/aio_lanraragi_tests/deployment/windows.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/integration_tests/src/aio_lanraragi_tests/deployment/windows.py b/integration_tests/src/aio_lanraragi_tests/deployment/windows.py index 8f5a340f..9845fa79 100644 --- a/integration_tests/src/aio_lanraragi_tests/deployment/windows.py +++ b/integration_tests/src/aio_lanraragi_tests/deployment/windows.py @@ -388,19 +388,6 @@ def stop(self): self.logger.debug("Stopped LRR.") self.stop_redis() self.logger.debug("Stopped Redis.") - # Clear managed and sideloaded plugins between tests, matching the - # docker context. plugin_managed_dir/plugin_sideloaded_dir live inside - # the Windows lib tree, so files installed by one test would otherwise - # persist into the next. - for plugin_dir in (self.plugin_managed_dir, self.plugin_sideloaded_dir): - if not plugin_dir.exists(): - continue - for entry in plugin_dir.iterdir(): - if entry.is_dir(): - self._remove_ro(entry) - shutil.rmtree(entry) - else: - entry.unlink() @override def restart(self): From bc2b825505c120e4e331531d53ba03482f9a2ef7 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:09:16 -0700 Subject: [PATCH 33/72] add transactional tests, extend registry --- .../tests/registry/test_plugin_lifecycle.py | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 2145ebbc..25620cbd 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -3,8 +3,11 @@ """ import asyncio +import hashlib +import json import logging import tempfile +import time from pathlib import Path import aiohttp @@ -251,6 +254,62 @@ async def test_plugin_install_provenance_fields(lrr_client: LRRClient, environme assert plugin.installed_sha256 == expected_sha, ( f"Expected installed_sha256 {expected_sha}, got {plugin.installed_sha256!r}" ) + + # >>>>> SIDELOAD COUNTERPART PROVENANCE >>>>> + # Sideload a download plugin under a sister namespace and assert that only + # installed_path is written to Redis; the four managed provenance fields + # (installed_registry/version/sha256/channel) must be absent from the hash. + # The assertion reads Redis directly because a freshly sideloaded plugin + # may not appear in get_available_plugins until plugin discovery refreshes, + # and the parity claim is about Redis state, not list visibility. + sideload_ns = "test-sideload-provenance-1" + sideload_pm_name = "TestSideloadProvenance1.pm" + sideload_pm_body = ( + "package LANraragi::Plugin::Download::TestSideloadProvenance1;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + "sub plugin_info {\n" + " return (\n" + " name => 'Test Sideload Provenance 1',\n" + " type => 'download',\n" + f" namespace => '{sideload_ns}',\n" + " author => 'test',\n" + " version => '1.0',\n" + " );\n" + "}\n" + "sub provide_url { return; }\n" + "1;\n" + ) + with tempfile.TemporaryDirectory() as tmpdir: + pm_path = Path(tmpdir) / sideload_pm_name + pm_path.write_text(sideload_pm_body, encoding="utf-8") + status, content = await sideload_plugin(lrr_client, pm_path, DEFAULT_LRR_PASSWORD) + assert status == 200, f"Expected 200 for sideload, got {status}: {content}" + assert '"success":1' in content, f"Expected sideload to succeed, got: {content}" + + environment.redis_client.select(2) + sideload_redis_key = f"LRR_PLUGIN_{sideload_ns.upper()}" + sideload_hash = environment.redis_client.hgetall(sideload_redis_key) + assert sideload_hash.get("installed_path") == f"LANraragi/Plugin/Sideloaded/{sideload_pm_name}", ( + f"Expected installed_path LANraragi/Plugin/Sideloaded/{sideload_pm_name!r} in Redis, " + f"got hash: {sideload_hash}" + ) + for provenance_field in ("installed_registry", "installed_version", "installed_sha256", "installed_channel"): + assert provenance_field not in sideload_hash, ( + f"Expected {provenance_field} absent from sideload Redis hash, got: {sideload_hash}" + ) + + _, error = await lrr_client.misc_api.uninstall_plugin(sideload_ns) + assert not error, f"Failed to uninstall sideloaded plugin (status {error.status}): {error.error}" + # <<<<< SIDELOAD COUNTERPART PROVENANCE <<<<< + + _, error = await lrr_client.misc_api.uninstall_plugin("sample-downloader") + assert not error, f"Failed to uninstall sample-downloader (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(environment, LOGGER) @@ -312,6 +371,334 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab expect_no_error_logs(environment, LOGGER) +@pytest.mark.asyncio +@pytest.mark.dev("registry-tx") # transactional (tx) +async def test_plugin_install_failed_require_rolls_back( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + Test that a managed install rolls back when the plugin file fails to require. + + 1. Create local registry with one broken plugin (valid Perl, BEGIN { die }). + 2. Refresh registry. + 3. Install the broken plugin; expect a non-2xx error response. + 4. Assert plugin file is absent on the host. + 5. Assert Redis hash for the namespace is empty. + 6. Assert namespace is absent from GET /api/plugins/metadata. + """ + 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/{broken_pm_name}" + + registry_data = { + "version": 1, + "generated_at": generated_at, + "plugins": { + broken_ns: { + "namespace": broken_ns, + "type": "metadata", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.0", + "name": "sample-broken-tx-1", + "author": "test", + "description": "broken require test plugin", + "artifact": plugin_rel_path, + "sha256": broken_sha, + "published_at": generated_at, + }, + }, + }, + }, + } + + plugin_file = environment.local_registry_dir / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_bytes(broken_pm_bytes) + + registry_json = environment.local_registry_dir / "registry.json" + registry_json.write_text(json.dumps(registry_data), encoding="utf-8") + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="local-broken", + type="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}" + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL BROKEN PLUGIN >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0") + ) + assert error is not None, "Expected error for broken plugin install" + assert error.status >= 400, f"Expected non-2xx status for broken plugin install, got {error.status}" + 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 <<<<< + + 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 attempt against + # a deliberately broken plugin 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-tx") # transactional (tx) +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/{good_pm_name}" + broken_rel_path = f"artifacts/{broken_ns}/1.0/{broken_pm_name}" + + good_file = environment.local_registry_dir / good_rel_path + good_file.parent.mkdir(parents=True, exist_ok=True) + good_file.write_bytes(good_pm_bytes) + + broken_file = environment.local_registry_dir / 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", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.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", + "channels": {"latest": "1.0"}, + "versions": { + "1.0": { + "version": "1.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 = environment.local_registry_dir / "registry.json" + registry_json.write_text(json.dumps(registry_data), encoding="utf-8") + + # >>>>> SETUP REGISTRY >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest( + name="local-two-plugins", + type="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}" + # <<<<< SETUP REGISTRY <<<<< + + # >>>>> INSTALL GOOD PLUGIN AND CAPTURE STATE >>>>> + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace=good_ns, registry=reg_id, version="1.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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0") + ) + assert error is not None, "Expected error for broken plugin install" + assert error.status >= 400, f"Expected non-2xx status for broken plugin install, got {error.status}" + 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") @pytest.mark.ratelimit @@ -801,6 +1188,119 @@ async def test_sideload_after_managed_uninstall_no_duplicate_rows( expect_no_error_logs(environment, LOGGER) +@pytest.mark.asyncio +@pytest.mark.dev("registry") +async def test_sideload_failure_rolls_back( + lrr_client: LRRClient, + environment: AbstractLRRDeploymentContext, +): + """ + Test that sideload rolls back on require failure and namespace mismatch. + + 1. Sideload a plugin whose body has BEGIN { die }; expect success=0. + Assert file absent on container, Redis hash empty, namespace absent from plugin list. + 2. Sideload a plugin where file text declares namespace A but plugin_info returns B. + Assert same rollback shape as subsection 1. + """ + environment.setup(with_api_key=True) + + # >>>>> BROKEN REQUIRE >>>>> + broken_ns = "test-sideload-broken-1" + broken_pm_name = "TestSideloadBroken1.pm" + broken_pm_body = ( + "package LANraragi::Plugin::Metadata::TestSideloadBroken1;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + "BEGIN { die 'boom' }\n" + # namespace must be present for the pre-lock regex to extract it + f"sub plugin_info {{ return ( name => 'test-broken', type => 'metadata', namespace => '{broken_ns}', version => '1.0' ); }}\n" + "1;\n" + ) + with tempfile.TemporaryDirectory() as tmpdir: + pm_path = Path(tmpdir) / broken_pm_name + pm_path.write_text(broken_pm_body, encoding="utf-8") + status, content = await sideload_plugin(lrr_client, pm_path, DEFAULT_LRR_PASSWORD) + assert status == 200, f"Expected HTTP 200 for failed sideload, got {status}" + assert '"success":0' in content, f"Expected success=0 for broken sideload, got: {content}" + + broken_sideload_file = environment.plugin_sideloaded_dir / broken_pm_name + assert not broken_sideload_file.exists(), ( + f"File should be absent after failed sideload: {broken_sideload_file}" + ) + 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 sideload, 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 sideload, got: {namespaces}" + # <<<<< BROKEN REQUIRE <<<<< + + # >>>>> NAMESPACE MISMATCH >>>>> + # file text declares mismatch-decl-ns but plugin_info returns the actual value below + declared_ns = "test-sideload-mismatch-decl-1" + actual_ns = "test-sideload-mismatch-actual-1" + mismatch_pm_name = "TestSideloadMismatch1.pm" + mismatch_pm_body = ( + "package LANraragi::Plugin::Metadata::TestSideloadMismatch1;\n" + "use strict;\n" + "use warnings;\n" + "no warnings 'uninitialized';\n" + # The pre-lock regex (Controller/Plugins.pm) is unanchored and matches + # the first 'namespace => "..."' literal in the file text. This comment + # makes the regex extract declared_ns; plugin_info() then returns + # actual_ns, triggering the post-load coherence check. + f"# namespace => '{declared_ns}'\n" + "sub plugin_info {\n" + " return (\n" + " name => 'test-mismatch',\n" + " type => 'metadata',\n" + f" namespace => '{actual_ns}',\n" + " version => '1.0',\n" + " );\n" + "}\n" + "sub get_tags { return (); }\n" + "1;\n" + ) + with tempfile.TemporaryDirectory() as tmpdir: + pm_path = Path(tmpdir) / mismatch_pm_name + pm_path.write_text(mismatch_pm_body, encoding="utf-8") + status, content = await sideload_plugin(lrr_client, pm_path, DEFAULT_LRR_PASSWORD) + assert status == 200, f"Expected HTTP 200 for failed sideload, got {status}" + assert '"success":0' in content, f"Expected success=0 for mismatch sideload, got: {content}" + + mismatch_sideload_file = environment.plugin_sideloaded_dir / mismatch_pm_name + assert not mismatch_sideload_file.exists(), ( + f"File should be absent after namespace-mismatch sideload: {mismatch_sideload_file}" + ) + environment.redis_client.select(2) + for ns_check in (declared_ns, actual_ns): + ns_hash = environment.redis_client.hgetall(f"LRR_PLUGIN_{ns_check.upper()}") + assert not ns_hash, ( + f"Expected empty Redis hash for {ns_check} after mismatch sideload, got: {ns_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} + for ns_check in (declared_ns, actual_ns): + assert ns_check not in namespaces, ( + f"{ns_check} must be absent after namespace-mismatch sideload, got: {namespaces}" + ) + # <<<<< NAMESPACE MISMATCH <<<<< + + # expect_no_error_logs is intentionally omitted: the sideload failure paths + # emit LRR-side error logs by design (broken require, namespace mismatch). + # Those logs are the expected diagnostic output, not a defect to flag. + + @pytest.mark.asyncio @pytest.mark.playwright @pytest.mark.dev("registry") From 78bfffdeda658a567abf745dee7edc9dcd440bdc Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:07:24 -0700 Subject: [PATCH 34/72] remove sideloaded tests --- .../tests/registry/test_plugin_lifecycle.py | 165 ------------------ 1 file changed, 165 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 25620cbd..d25b3a67 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -255,58 +255,6 @@ async def test_plugin_install_provenance_fields(lrr_client: LRRClient, environme f"Expected installed_sha256 {expected_sha}, got {plugin.installed_sha256!r}" ) - # >>>>> SIDELOAD COUNTERPART PROVENANCE >>>>> - # Sideload a download plugin under a sister namespace and assert that only - # installed_path is written to Redis; the four managed provenance fields - # (installed_registry/version/sha256/channel) must be absent from the hash. - # The assertion reads Redis directly because a freshly sideloaded plugin - # may not appear in get_available_plugins until plugin discovery refreshes, - # and the parity claim is about Redis state, not list visibility. - sideload_ns = "test-sideload-provenance-1" - sideload_pm_name = "TestSideloadProvenance1.pm" - sideload_pm_body = ( - "package LANraragi::Plugin::Download::TestSideloadProvenance1;\n" - "use strict;\n" - "use warnings;\n" - "no warnings 'uninitialized';\n" - "sub plugin_info {\n" - " return (\n" - " name => 'Test Sideload Provenance 1',\n" - " type => 'download',\n" - f" namespace => '{sideload_ns}',\n" - " author => 'test',\n" - " version => '1.0',\n" - " );\n" - "}\n" - "sub provide_url { return; }\n" - "1;\n" - ) - with tempfile.TemporaryDirectory() as tmpdir: - pm_path = Path(tmpdir) / sideload_pm_name - pm_path.write_text(sideload_pm_body, encoding="utf-8") - status, content = await sideload_plugin(lrr_client, pm_path, DEFAULT_LRR_PASSWORD) - assert status == 200, f"Expected 200 for sideload, got {status}: {content}" - assert '"success":1' in content, f"Expected sideload to succeed, got: {content}" - - environment.redis_client.select(2) - sideload_redis_key = f"LRR_PLUGIN_{sideload_ns.upper()}" - sideload_hash = environment.redis_client.hgetall(sideload_redis_key) - assert sideload_hash.get("installed_path") == f"LANraragi/Plugin/Sideloaded/{sideload_pm_name}", ( - f"Expected installed_path LANraragi/Plugin/Sideloaded/{sideload_pm_name!r} in Redis, " - f"got hash: {sideload_hash}" - ) - for provenance_field in ("installed_registry", "installed_version", "installed_sha256", "installed_channel"): - assert provenance_field not in sideload_hash, ( - f"Expected {provenance_field} absent from sideload Redis hash, got: {sideload_hash}" - ) - - _, error = await lrr_client.misc_api.uninstall_plugin(sideload_ns) - assert not error, f"Failed to uninstall sideloaded plugin (status {error.status}): {error.error}" - # <<<<< SIDELOAD COUNTERPART PROVENANCE <<<<< - - _, error = await lrr_client.misc_api.uninstall_plugin("sample-downloader") - assert not error, f"Failed to uninstall sample-downloader (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}" @@ -1188,119 +1136,6 @@ async def test_sideload_after_managed_uninstall_no_duplicate_rows( expect_no_error_logs(environment, LOGGER) -@pytest.mark.asyncio -@pytest.mark.dev("registry") -async def test_sideload_failure_rolls_back( - lrr_client: LRRClient, - environment: AbstractLRRDeploymentContext, -): - """ - Test that sideload rolls back on require failure and namespace mismatch. - - 1. Sideload a plugin whose body has BEGIN { die }; expect success=0. - Assert file absent on container, Redis hash empty, namespace absent from plugin list. - 2. Sideload a plugin where file text declares namespace A but plugin_info returns B. - Assert same rollback shape as subsection 1. - """ - environment.setup(with_api_key=True) - - # >>>>> BROKEN REQUIRE >>>>> - broken_ns = "test-sideload-broken-1" - broken_pm_name = "TestSideloadBroken1.pm" - broken_pm_body = ( - "package LANraragi::Plugin::Metadata::TestSideloadBroken1;\n" - "use strict;\n" - "use warnings;\n" - "no warnings 'uninitialized';\n" - "BEGIN { die 'boom' }\n" - # namespace must be present for the pre-lock regex to extract it - f"sub plugin_info {{ return ( name => 'test-broken', type => 'metadata', namespace => '{broken_ns}', version => '1.0' ); }}\n" - "1;\n" - ) - with tempfile.TemporaryDirectory() as tmpdir: - pm_path = Path(tmpdir) / broken_pm_name - pm_path.write_text(broken_pm_body, encoding="utf-8") - status, content = await sideload_plugin(lrr_client, pm_path, DEFAULT_LRR_PASSWORD) - assert status == 200, f"Expected HTTP 200 for failed sideload, got {status}" - assert '"success":0' in content, f"Expected success=0 for broken sideload, got: {content}" - - broken_sideload_file = environment.plugin_sideloaded_dir / broken_pm_name - assert not broken_sideload_file.exists(), ( - f"File should be absent after failed sideload: {broken_sideload_file}" - ) - 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 sideload, 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 sideload, got: {namespaces}" - # <<<<< BROKEN REQUIRE <<<<< - - # >>>>> NAMESPACE MISMATCH >>>>> - # file text declares mismatch-decl-ns but plugin_info returns the actual value below - declared_ns = "test-sideload-mismatch-decl-1" - actual_ns = "test-sideload-mismatch-actual-1" - mismatch_pm_name = "TestSideloadMismatch1.pm" - mismatch_pm_body = ( - "package LANraragi::Plugin::Metadata::TestSideloadMismatch1;\n" - "use strict;\n" - "use warnings;\n" - "no warnings 'uninitialized';\n" - # The pre-lock regex (Controller/Plugins.pm) is unanchored and matches - # the first 'namespace => "..."' literal in the file text. This comment - # makes the regex extract declared_ns; plugin_info() then returns - # actual_ns, triggering the post-load coherence check. - f"# namespace => '{declared_ns}'\n" - "sub plugin_info {\n" - " return (\n" - " name => 'test-mismatch',\n" - " type => 'metadata',\n" - f" namespace => '{actual_ns}',\n" - " version => '1.0',\n" - " );\n" - "}\n" - "sub get_tags { return (); }\n" - "1;\n" - ) - with tempfile.TemporaryDirectory() as tmpdir: - pm_path = Path(tmpdir) / mismatch_pm_name - pm_path.write_text(mismatch_pm_body, encoding="utf-8") - status, content = await sideload_plugin(lrr_client, pm_path, DEFAULT_LRR_PASSWORD) - assert status == 200, f"Expected HTTP 200 for failed sideload, got {status}" - assert '"success":0' in content, f"Expected success=0 for mismatch sideload, got: {content}" - - mismatch_sideload_file = environment.plugin_sideloaded_dir / mismatch_pm_name - assert not mismatch_sideload_file.exists(), ( - f"File should be absent after namespace-mismatch sideload: {mismatch_sideload_file}" - ) - environment.redis_client.select(2) - for ns_check in (declared_ns, actual_ns): - ns_hash = environment.redis_client.hgetall(f"LRR_PLUGIN_{ns_check.upper()}") - assert not ns_hash, ( - f"Expected empty Redis hash for {ns_check} after mismatch sideload, got: {ns_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} - for ns_check in (declared_ns, actual_ns): - assert ns_check not in namespaces, ( - f"{ns_check} must be absent after namespace-mismatch sideload, got: {namespaces}" - ) - # <<<<< NAMESPACE MISMATCH <<<<< - - # expect_no_error_logs is intentionally omitted: the sideload failure paths - # emit LRR-side error logs by design (broken require, namespace mismatch). - # Those logs are the expected diagnostic output, not a defect to flag. - - @pytest.mark.asyncio @pytest.mark.playwright @pytest.mark.dev("registry") From 8cef1724b53e3b4202c5881f7ffb52375f7c868e Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Fri, 1 May 2026 17:14:26 -0700 Subject: [PATCH 35/72] clean up sideloaded hacks --- .../aio_lanraragi_tests/utils/api_wrappers.py | 20 -- .../tests/registry/test_plugin_lifecycle.py | 239 ------------------ 2 files changed, 259 deletions(-) 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 e83bbba2..4bf20773 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py @@ -413,23 +413,3 @@ 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 sideload_plugin(client: LRRClient, plugin_path: Path, password: str) -> tuple[int, str]: - """Upload a plugin file via the browser-style login + multipart upload flow.""" - login_url = client.misc_api.api_context.build_url("/login") - upload_url = client.misc_api.api_context.build_url("/config/plugins/upload") - async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: - login_form = aiohttp.FormData(quote_fields=False) - login_form.add_field("password", password) - login_form.add_field("redirect", "index") - async with session.post(login_url, data=login_form) as response: - content = await response.text() - assert response.status == 200, f"Expected login redirect target to resolve with 200, got {response.status}" - assert "LANraragi" in content, f"Expected login flow to land on app page, got: {content}" - - with plugin_path.open("rb") as file_handle: - form_data = aiohttp.FormData(quote_fields=False) - form_data.add_field("file", file_handle, filename=plugin_path.name) - async with session.post(upload_url, data=form_data) as response: - content = await response.text() - return response.status, content diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index d25b3a67..b43e79b1 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -10,9 +10,6 @@ import time from pathlib import Path -import aiohttp -import playwright.async_api -import playwright.async_api._generated import pytest from lanraragi.clients.client import LRRClient from lanraragi.models.archive import GetArchiveMetadataRequest @@ -25,20 +22,14 @@ UsePluginRequest, ) -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.api_wrappers import ( create_archive_file, - sideload_plugin, upload_archive, ) -from aio_lanraragi_tests.utils.playwright import ( - assert_browser_responses_ok, - assert_console_logs_ok, -) LOGGER = logging.getLogger(__name__) @@ -1055,236 +1046,6 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: expect_no_error_logs(environment, LOGGER) -@pytest.mark.asyncio -@pytest.mark.dev("registry") -@pytest.mark.ratelimit -async def test_sideload_after_managed_uninstall_no_duplicate_rows( - lrr_client: LRRClient, - environment: AbstractLRRDeploymentContext, -): - """ - Reproduce: install managed -> uninstall -> sideload -> server renders 2 rows. - - Single worker so all operations hit the same Perl process. No restart - between the cycle and the check — the bug is per-worker symbol table - state that a restart would clear. - - 1. Install managed sample-script from registry. - 2. Uninstall the managed sample-script (file deleted, class stays in symbol table). - 3. Sideload SampleScript.pm (new class loaded in same worker). - 4. Fetch /config/plugins raw HTML and count sample-script rows. - """ - plugin_path = Path(__file__).parent.parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" - assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" - - environment.setup(with_api_key=True) - - # >>>>> INSTALL MANAGED >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest( - name="demo", type="git", 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_script_version = refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] - - _, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-script", registry=reg_id, version=sample_script_version) - ) - assert not error, f"Failed to install managed sample-script (status {error.status}): {error.error}" - # <<<<< INSTALL MANAGED <<<<< - - # >>>>> UNINSTALL MANAGED >>>>> - _, error = await lrr_client.misc_api.uninstall_plugin("sample-script") - assert not error, f"Failed to uninstall managed sample-script (status {error.status}): {error.error}" - # <<<<< UNINSTALL MANAGED <<<<< - - # >>>>> SIDELOAD >>>>> - status, content = await sideload_plugin(lrr_client, plugin_path, DEFAULT_LRR_PASSWORD) - assert status == 200, f"Expected 200 upload status, got {status}: {content}" - assert '"success":1' in content, f"Expected sideload to succeed, got: {content}" - # <<<<< SIDELOAD <<<<< - - # >>>>> RAW HTML CHECK — NO RESTART, SAME WORKER >>>>> - login_url = lrr_client.misc_api.api_context.build_url("/login") - plugins_url = lrr_client.misc_api.api_context.build_url("/config/plugins") - - async with aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session: - login_form = aiohttp.FormData(quote_fields=False) - login_form.add_field("password", DEFAULT_LRR_PASSWORD) - login_form.add_field("redirect", "index") - async with session.post(login_url, data=login_form) as resp: - assert resp.status == 200 - - # Fetch raw HTML 20 times across default workers (typically 4). - # The affected worker returns 2 rows; others return 1. With 4 workers - # and 20 fetches, P(never hitting the affected worker) < 0.3%. - for i in range(20): - async with session.get(plugins_url) as resp: - html = await resp.text() - matches = html.count('data-namespace="sample-script" data-source=') - LOGGER.debug(f"Fetch {i}: {matches} sample-script row(s) in server HTML") - assert matches <= 1, \ - f"Fetch {i}: expected at most 1 sample-script row in server HTML, got {matches}" - # <<<<< RAW HTML CHECK <<<<< - - expect_no_error_logs(environment, LOGGER) - - -@pytest.mark.asyncio -@pytest.mark.playwright -@pytest.mark.dev("registry") -@pytest.mark.ratelimit -async def test_sideloaded_script_lifecycle( - lrr_client: LRRClient, - environment: AbstractLRRDeploymentContext, -): - """ - Test the end-to-end lifecycle of a sideloaded script plugin. - - 1. Install sample-script from a registry as a managed plugin. - 2. Sideloading the same namespace while managed copy exists is rejected. - 3. Uninstall the managed sample-script. - 4. Sideload sample-script via UI upload. - 5. The plugin is recorded with a path relative to lib/, listed exactly once - via API, rendered exactly once in the Manage tab with a sideloaded badge, - and remains so after a server restart. - 6. Uninstall the sideloaded plugin via the API; provenance and on-disk file - are cleaned up. - """ - plugin_path = Path(__file__).parent.parent / "resources" / "plugins" / "scripts" / "SampleScript.pm" - assert plugin_path.exists(), f"Test plugin file not found: {plugin_path}" - - # Single worker ensures all requests hit the same process, making per-worker - # state bugs (stale symbol table after managed install/uninstall) deterministic. - environment.setup(with_api_key=True, environment={"MOJO_WORKERS": "1"}) - - # >>>>> INSTALL MANAGED SAMPLE-SCRIPT >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest( - name="demo", - type="git", - 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_script_version = refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] - - _, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="sample-script", registry=reg_id, version=sample_script_version) - ) - assert not error, f"Failed to install sample-script (status {error.status}): {error.error}" - # <<<<< INSTALL MANAGED SAMPLE-SCRIPT <<<<< - - # >>>>> SIDELOAD WHILE MANAGED COPY EXISTS IS REJECTED >>>>> - status, content = await sideload_plugin(lrr_client, plugin_path, DEFAULT_LRR_PASSWORD) - assert status == 200, f"Expected 200 upload status, got {status}: {content}" - assert '"success":0' in content, f"Expected sideload to be rejected while managed copy exists, got: {content}" - - response, error = await lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) - assert not error, f"Failed to list scripts (status {error.status}): {error.error}" - sample_scripts = [p for p in response.plugins if p.namespace == "sample-script"] - assert len(sample_scripts) == 1, f"Expected one sample-script while managed copy exists, got {len(sample_scripts)}" - # <<<<< SIDELOAD WHILE MANAGED COPY EXISTS IS REJECTED <<<<< - - # >>>>> SIDELOAD REPLACES MANAGED COPY >>>>> - _, error = await lrr_client.misc_api.uninstall_plugin("sample-script") - assert not error, f"Failed to uninstall managed sample-script (status {error.status}): {error.error}" - - status, content = await sideload_plugin(lrr_client, plugin_path, DEFAULT_LRR_PASSWORD) - assert status == 200, f"Expected 200 upload status, got {status}: {content}" - assert '"success":1' in content, f"Expected sideload to succeed after managed uninstall, got: {content}" - # <<<<< SIDELOAD REPLACES MANAGED COPY <<<<< - - # >>>>> SIDELOAD PROVENANCE IS PORTABLE AND PERSISTS ACROSS RESTART >>>>> - sideloaded_script_path = "LANraragi/Plugin/Sideloaded/SampleScript.pm" - environment.redis_client.select(2) - recorded_path = environment.redis_client.hget("LRR_PLUGIN_SAMPLE-SCRIPT", "installed_path") - assert recorded_path == sideloaded_script_path, \ - f"Expected installed_path={sideloaded_script_path!r} after upload, got {recorded_path!r}" - - # >>>>> MANAGE TAB RENDERS ONE SIDELOADED ROW >>>>> - # UI check runs BEFORE restart: the workers that handled install->uninstall->sideload - # still have the managed class in their symbol table. This catches per-worker state - # bugs (e.g. stale %INC entries causing the managed class to pass through get_plugins). - 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)) - - await page.goto(f"{lrr_client.lrr_base_url}/config/plugins") - 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") - responses.clear() - console_evts.clear() - - # Fetch multiple times to exercise different Hypnotoad workers. - for i in range(3): - await page.goto(f"{lrr_client.lrr_base_url}/config/plugins#tab-manage") - await page.wait_for_load_state("networkidle") - - sample_rows = page.locator( - '.manage-installed[data-type="script"] .manage-plugin-row[data-namespace="sample-script"]' - ) - row_count = await sample_rows.count() - assert row_count == 1, f"Fetch {i}: expected one sample-script row in Scripts section, got {row_count}" - - badge_text = await sample_rows.locator(".plugin-badge").text_content() - assert badge_text == "sideloaded", f"Fetch {i}: expected 'sideloaded' badge, got: {badge_text!r}" - - 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() - # <<<<< MANAGE TAB RENDERS ONE SIDELOADED ROW <<<<< - - # >>>>> SIDELOAD PROVENANCE PERSISTS ACROSS RESTART >>>>> - environment.restart() - - environment.redis_client.select(2) - recorded_path = environment.redis_client.hget("LRR_PLUGIN_SAMPLE-SCRIPT", "installed_path") - assert recorded_path == sideloaded_script_path, \ - f"Expected installed_path={sideloaded_script_path!r} after restart, got {recorded_path!r}" - - response, error = await lrr_client.misc_api.get_available_plugins(GetAvailablePluginsRequest(type="script")) - assert not error, f"Failed to list scripts after restart (status {error.status}): {error.error}" - sample_scripts = [p for p in response.plugins if p.namespace == "sample-script"] - assert len(sample_scripts) == 1, f"Expected one sample-script after restart, got {len(sample_scripts)}" - # <<<<< SIDELOAD PROVENANCE PERSISTS ACROSS RESTART <<<<< - - # >>>>> UNINSTALL CLEARS PROVENANCE AND FILE >>>>> - _, error = await lrr_client.misc_api.uninstall_plugin("sample-script") - assert not error, f"Failed to uninstall sideloaded sample-script (status {error.status}): {error.error}" - - environment.redis_client.select(2) - assert not environment.redis_client.hexists("LRR_PLUGIN_SAMPLE-SCRIPT", "installed_path"), \ - "Expected installed_path to be cleared after uninstall" - # <<<<< UNINSTALL CLEARS PROVENANCE AND FILE <<<<< - - expect_no_error_logs(environment, LOGGER) - - @pytest.mark.asyncio @pytest.mark.dev("registry") @pytest.mark.ratelimit From bca427dccb89851b3b0005c6f81a84186157c4da Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 5 May 2026 15:38:05 -0700 Subject: [PATCH 36/72] remove channel (and add a stub) --- .../tests/registry/test_local_registry.py | 116 ++++++++++++------ .../tests/registry/test_plugin_config.py | 8 +- .../tests/registry/test_plugin_lifecycle.py | 116 ++++++++---------- .../tests/registry/test_registry_crud.py | 2 +- src/lanraragi/clients/api_clients/misc.py | 7 +- src/lanraragi/models/misc.py | 5 +- 6 files changed, 135 insertions(+), 119 deletions(-) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index d935e3e8..2effa358 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -86,14 +86,13 @@ async def test_local_registry_install_errors( "unknown-field-plugin": { "namespace": "unknown-field-plugin", "type": "download", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "unknown", "author": "test", "description": "unknown field test plugin", - "artifact": "artifacts/unknown/1.0/Unknown.pm", + "artifact": "artifacts/unknown/1.0.0/Unknown.pm", "sha256": dummy_sha, "published_at": generated_at, }, @@ -116,14 +115,13 @@ async def test_local_registry_install_errors( "bad-published-at": { "namespace": "bad-published-at", "type": "download", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "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/BadPublishedAt.pm", + "artifact": "artifacts/bad-published-at/1.0.0/BadPublishedAt.pm", "sha256": dummy_sha, "published_at": "2026-04-25T10:00:00+01:00", }, @@ -145,14 +143,13 @@ async def test_local_registry_install_errors( "bad-sha": { "namespace": "bad-sha", "type": "download", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "bad-sha", "author": "test", "description": "bad sha test plugin", - "artifact": "artifacts/bad-sha/1.0/BadSha.pm", + "artifact": "artifacts/bad-sha/1.0.0/BadSha.pm", "sha256": "xyz", "published_at": generated_at, }, @@ -174,10 +171,9 @@ async def test_local_registry_install_errors( "traversal-plugin": { "namespace": "traversal-plugin", "type": "download", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "traversal", "author": "test", "description": "traversal test plugin", @@ -204,10 +200,9 @@ async def test_local_registry_install_errors( "absolute-plugin": { "namespace": "absolute-plugin", "type": "download", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "absolute", "author": "test", "description": "absolute path test plugin", @@ -242,10 +237,9 @@ async def test_local_registry_install_errors( "symlink-plugin": { "namespace": "symlink-plugin", "type": "download", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "symlink", "author": "test", "description": "symlink escape test plugin", @@ -262,14 +256,14 @@ async def test_local_registry_install_errors( assert not error, f"Expected refresh to succeed with symlink artifact entry (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="symlink-plugin", registry=reg_id, version="1.0") + InstallPluginRequest(namespace="symlink-plugin", registry=reg_id, version="1.0.0") ) assert error is not None, "Expected install to fail for symlink escape" assert error.status == 400, f"Expected 400 for symlink escape install, got {error.status}" assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for symlink escape" # <<<<< SYMLINK ESCAPE REJECTED <<<<< - plugin_rel_path = "artifacts/local-sample-downloader/1.0/LocalSample.pm" + 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("""\ @@ -305,10 +299,9 @@ async def test_local_registry_install_errors( "local-sample-downloader": { "namespace": "local-sample-downloader", "type": "download", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "Local Sample", "author": "test", "description": "local sample downloader", @@ -325,7 +318,7 @@ async def test_local_registry_install_errors( assert not error, f"Expected refresh to succeed with wrong sha entry (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id, version="1.0") + 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 error.status == 422, f"Expected 422 for wrong sha256 install, got {error.status}" @@ -351,10 +344,9 @@ async def test_local_registry_install_errors( "local-sample-downloader": { "namespace": "local-sample-downloader", "type": "download", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "Local Sample", "author": "test", "description": "local sample downloader", @@ -371,11 +363,11 @@ async def test_local_registry_install_errors( assert not error, f"Expected refresh to succeed (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="local-sample-downloader", registry=reg_id, version="1.0") + 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", f"Expected version 1.0, got {response.version}" + assert response.version == "1.0.0", f"Expected version 1.0.0, got {response.version}" assert response.installed_registry == reg_id, ( f"Expected provenance {reg_id}, got {response.installed_registry}" ) @@ -402,7 +394,7 @@ async def test_install_blocked_against_default_namespace( """ environment.setup(with_api_key=True) - plugin_rel_path = "artifacts/copytags-impostor/1.0/CopyTagsImpostor.pm" + 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("""\ @@ -437,10 +429,9 @@ async def test_install_blocked_against_default_namespace( "copytags": { "namespace": "copytags", "type": "metadata", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "copytags-impostor", "author": "test", "description": "tries to shadow the built-in copytags plugin", @@ -467,13 +458,13 @@ async def test_install_blocked_against_default_namespace( assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="copytags", registry=reg_id, version="1.0") + 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 error.status == 400, f"Expected 400 for default-namespace conflict, got {error.status}" response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="copytags", registry=reg_id, version="1.0", force=True) + 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 error.status == 400, f"Expected 400 for default-namespace conflict (force), got {error.status}" @@ -482,3 +473,52 @@ async def test_install_blocked_against_default_namespace( 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_composite_registry( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + Test composite registry/plugin functionality. Use 3 local registries with metadata plugins. + Also tests duplicates and cross-registry/plugin. + + 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) + + Steps: + 1. Add registry 1 (default), registry 2, registry 3. + 2. Set default to nonexistent registry, expect 404. + 3. Install shared-metadata-1 from registry 1 (max-version resolution selects v2.0.0). + 4. Expect shared-metadata-1 version is v2.0.0. + 5. Upload archive and invoke plugin, expect processed title. + 6. Reinstall same plugin/version with force, expect idempotent (provenance unchanged). + 7. Uninstall shared-metadata-1. + 8. Set registry 2 as default, install shared-metadata-1:v1.0.0 (explicit version). + 9. Expect version: v1.0.0. + 10. Invoke plugin, expect processed title. + 11. Remove registry 2 (plugin now registry-orphaned), assert 2 registries total. + 12. Install v1.0.0 from registry 3 without force, expect provenance mismatch error. + 13. Set registry 3 as default, install v1.0.0 with force; verify provenance updated. + 14. Test plugin run. + 15. Regenerate registry 3 without version 1.0.0 (plugin becomes version orphan). + 16. Install v2.0.0 and run plugin. + + Every installation -> assert values for provenance + configuration, and assert title after running plugin. + Also reset the title after every plugin call. + """ + pass diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 77da5bfc..b882839c 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -57,7 +57,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR 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 = refresh_response.index["plugins"]["sample-metadata"]["channels"]["latest"] + sample_metadata_version = max(refresh_response.index["plugins"]["sample-metadata"]["versions"].keys()) response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) @@ -222,8 +222,8 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe 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 = refresh_response.index["plugins"]["sample-metadata"]["channels"]["latest"] - sample_downloader_version = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + sample_metadata_version = max(refresh_response.index["plugins"]["sample-metadata"]["versions"].keys()) + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) @@ -341,7 +341,7 @@ async def test_plugin_priority_execution_order(lrr_client: LRRClient, environmen # >>>>> INSTALL ALL THREE >>>>> for ns in ("title-suffix-1", "title-suffix-2", "title-suffix-3"): - version_key = refresh_response.index["plugins"][ns]["channels"]["latest"] + version_key = max(refresh_response.index["plugins"][ns]["versions"].keys()) response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace=ns, registry=reg_id, version=version_key) ) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index b43e79b1..0dee2b20 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -69,7 +69,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: # >>>>> INSTALL PLUGIN >>>>> refresh_response = response - version_key = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + version_key = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=version_key) ) @@ -144,14 +144,14 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: @pytest.mark.asyncio @pytest.mark.dev("registry") @pytest.mark.ratelimit -async def test_plugin_install_provenance_fields(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): +async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test provenance fields for channel-tracked and explicit-version installs. + Test that installed_registry, installed_version, and installed_sha256 survive restart and explicit reinstall. - 1. Install sample-downloader with installed_channel="latest". - 2. Assert install response and plugin list include required sha256/channel provenance. - 3. Reinstall explicitly after uninstalling. - 4. Assert installed_channel is cleared while sha256 remains. + 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) @@ -170,26 +170,19 @@ async def test_plugin_install_provenance_fields(lrr_client: LRRClient, environme 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 = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + 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 lrr_client.misc_api.install_plugin( - InstallPluginRequest( - namespace="sample-downloader", - registry=reg_id, - version=version_key, - installed_channel="latest", - ) + InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=version_key) ) - assert not error, f"Failed to install channel-tracked plugin (status {error.status}): {error.error}" + assert not error, f"Failed to install plugin (status {error.status}): {error.error}" assert response.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" assert response.installed_sha256 == expected_sha, ( f"Expected install sha256 {expected_sha}, got {response.installed_sha256}" ) - assert response.installed_channel == "latest", ( - f"Expected install channel 'latest', got {response.installed_channel!r}" - ) + response, error = await lrr_client.misc_api.get_available_plugins( GetAvailablePluginsRequest(type="download") ) @@ -203,9 +196,7 @@ async def test_plugin_install_provenance_fields(lrr_client: LRRClient, environme assert plugin.installed_sha256 == expected_sha, ( f"Expected installed_sha256 {expected_sha}, got {plugin.installed_sha256!r}" ) - assert plugin.installed_channel == "latest", ( - f"Expected installed_channel 'latest', got {plugin.installed_channel!r}" - ) + environment.restart() response, error = await lrr_client.misc_api.get_available_plugins( @@ -214,36 +205,31 @@ async def test_plugin_install_provenance_fields(lrr_client: LRRClient, environme 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.installed_channel == "latest", ( - f"Expected installed_channel 'latest' after restart, got {plugin.installed_channel!r}" + assert plugin.installed_registry == reg_id, ( + f"Expected provenance {reg_id} after restart, got {plugin.installed_registry!r}" + ) + assert plugin.installed_version == version_key, ( + f"Expected installed_version {version_key!r} after restart, got {plugin.installed_version!r}" ) assert plugin.installed_sha256 == expected_sha, ( f"Expected installed_sha256 {expected_sha} after restart, got {plugin.installed_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 lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=version_key) ) - assert not error, f"Failed to install explicit-version plugin (status {error.status}): {error.error}" - assert response.installed_channel is None, ( - f"Expected no installed_channel for explicit install, got {response.installed_channel!r}" + assert not error, f"Failed to reinstall plugin (status {error.status}): {error.error}" + assert response.installed_registry == reg_id, ( + f"Expected provenance {reg_id} after reinstall, got {response.installed_registry!r}" ) assert response.installed_sha256 == expected_sha, ( - f"Expected install sha256 {expected_sha}, got {response.installed_sha256}" - ) - response, error = await lrr_client.misc_api.get_available_plugins( - GetAvailablePluginsRequest(type="download") + f"Expected installed_sha256 {expected_sha} after reinstall, got {response.installed_sha256}" ) - assert not error, f"Failed to list plugins after explicit install (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 after explicit reinstall" - assert plugin.installed_channel is None, ( - f"Expected installed_channel to clear after explicit install, got {plugin.installed_channel!r}" - ) - assert plugin.installed_sha256 == expected_sha, ( - f"Expected installed_sha256 {expected_sha}, got {plugin.installed_sha256!r}" + 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) @@ -351,7 +337,7 @@ async def test_plugin_install_failed_require_rolls_back( 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/{broken_pm_name}" + plugin_rel_path = f"artifacts/{broken_ns}/1.0.0/{broken_pm_name}" registry_data = { "version": 1, @@ -360,10 +346,9 @@ async def test_plugin_install_failed_require_rolls_back( broken_ns: { "namespace": broken_ns, "type": "metadata", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "sample-broken-tx-1", "author": "test", "description": "broken require test plugin", @@ -400,7 +385,7 @@ async def test_plugin_install_failed_require_rolls_back( # >>>>> INSTALL BROKEN PLUGIN >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0") + InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0.0") ) assert error is not None, "Expected error for broken plugin install" assert error.status >= 400, f"Expected non-2xx status for broken plugin install, got {error.status}" @@ -495,8 +480,8 @@ async def test_install_failure_preserves_other_plugins( 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/{good_pm_name}" - broken_rel_path = f"artifacts/{broken_ns}/1.0/{broken_pm_name}" + 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}" good_file = environment.local_registry_dir / good_rel_path good_file.parent.mkdir(parents=True, exist_ok=True) @@ -513,10 +498,9 @@ async def test_install_failure_preserves_other_plugins( good_ns: { "namespace": good_ns, "type": "metadata", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "sample-good-tx-1", "author": "test", "description": "good metadata test plugin", @@ -529,10 +513,9 @@ async def test_install_failure_preserves_other_plugins( broken_ns: { "namespace": broken_ns, "type": "metadata", - "channels": {"latest": "1.0"}, "versions": { - "1.0": { - "version": "1.0", + "1.0.0": { + "version": "1.0.0", "name": "sample-broken-tx-2", "author": "test", "description": "broken metadata test plugin", @@ -564,7 +547,7 @@ async def test_install_failure_preserves_other_plugins( # >>>>> INSTALL GOOD PLUGIN AND CAPTURE STATE >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace=good_ns, registry=reg_id, version="1.0") + 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 @@ -585,7 +568,7 @@ async def test_install_failure_preserves_other_plugins( # >>>>> INSTALL BROKEN PLUGIN >>>>> response, error = await lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0") + InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0.0") ) assert error is not None, "Expected error for broken plugin install" assert error.status >= 400, f"Expected non-2xx status for broken plugin install, got {error.status}" @@ -671,7 +654,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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 = refresh_response.index["plugins"]["title-suffix-1"]["channels"]["latest"] + title_suffix_1_version = max(refresh_response.index["plugins"]["title-suffix-1"]["versions"].keys()) # <<<<< SETUP REGISTRY <<<<< # >>>>> INSTALL >>>>> @@ -838,8 +821,8 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr 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 = refresh_response.index["plugins"]["sample-metadata"]["channels"]["latest"] - sample_downloader_version = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + 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 >>>>> @@ -911,7 +894,7 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A 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 = refresh_response.index["plugins"]["sample-login"]["channels"]["latest"] + sample_login_version = max(refresh_response.index["plugins"]["sample-login"]["versions"].keys()) # <<<<< SETUP REGISTRY <<<<< for i in range(5): @@ -968,7 +951,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: 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 = refresh_a_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + sample_downloader_version = max(refresh_a_response.index["plugins"]["sample-downloader"]["versions"].keys()) # <<<<< SETUP REG A <<<<< # >>>>> INSTALL FROM REG A >>>>> @@ -1011,7 +994,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: 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 = refresh_b_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + 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 >>>>> @@ -1085,7 +1068,7 @@ async def test_managed_plugin_upgrade_reloads_class( 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 = main_refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] + main_version = max(main_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) _, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-script", registry=reg_id, version=main_version) @@ -1111,7 +1094,7 @@ async def test_managed_plugin_upgrade_reloads_class( 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 = v11_refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] + v11_version = max(v11_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) _, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-script", registry=reg_id, version=v11_version, force=True) @@ -1175,7 +1158,7 @@ async def test_managed_plugin_upgrade_reloads_across_workers( 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 = main_refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] + main_version = max(main_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) _, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-script", registry=reg_id, version=main_version) @@ -1204,7 +1187,7 @@ async def test_managed_plugin_upgrade_reloads_across_workers( 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 = v11_refresh_response.index["plugins"]["sample-script"]["channels"]["latest"] + v11_version = max(v11_refresh_response.index["plugins"]["sample-script"]["versions"].keys()) _, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-script", registry=reg_id, version=v11_version, force=True) @@ -1269,7 +1252,7 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen 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 = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=sample_downloader_version) @@ -1303,9 +1286,6 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen assert plugin.installed_sha256 == installed_sha256, ( f"Expected installed_sha256 {installed_sha256!r} after restart, got: {plugin.installed_sha256!r}" ) - assert plugin.installed_channel is None, ( - f"Expected no installed_channel after explicit install, got: {plugin.installed_channel!r}" - ) break else: pytest.fail("sample-downloader not found in download plugin list after restart") @@ -1343,7 +1323,7 @@ async def test_plugin_file_deleted_under_lrr(lrr_client: LRRClient, environment: 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 = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version=sample_downloader_version) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 27bbee93..be626421 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -295,7 +295,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra 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 = refresh_response.index["plugins"]["sample-downloader"]["channels"]["latest"] + sample_downloader_version = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) # <<<<< CREATE AND REFRESH <<<<< # >>>>> INSTALL PLUGIN BEFORE SOURCE CHANGE >>>>> diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 6f992208..583dcdbc 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -266,9 +266,9 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo POST /api/plugins/install """ url = self.api_context.build_url("/api/plugins/install") - body: dict[str, Any] = {"namespace": request.namespace, "registry": request.registry, "version": request.version} - if request.installed_channel is not None: - body["installed_channel"] = request.installed_channel + 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( @@ -282,7 +282,6 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo version=response_j["version"], installed_registry=response_j["installed_registry"], installed_sha256=response_j["installed_sha256"], - installed_channel=response_j.get("installed_channel"), ), None) return (None, _build_err_response(content, status)) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 037c35ff..714ceecd 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -54,7 +54,6 @@ class GetAvailablePluginsResponsePlugin(BaseModel): installed_registry: str | None = Field(None) installed_version: str | None = Field(None) installed_sha256: str | None = Field(None) - installed_channel: str | None = Field(None) class GetAvailablePluginsResponse(LanraragiResponse): plugins: list[GetAvailablePluginsResponsePlugin] = Field(...) @@ -152,8 +151,7 @@ class UpdatePluginConfigRequest(LanraragiRequest): class InstallPluginRequest(LanraragiRequest): namespace: str = Field(...) registry: str = Field(...) - version: str = Field(...) - installed_channel: str | None = Field(None) + version: str | None = Field(None) force: bool | None = Field(None) class InstallPluginResponse(LanraragiResponse): @@ -162,7 +160,6 @@ class InstallPluginResponse(LanraragiResponse): version: str = Field(...) installed_registry: str = Field(...) installed_sha256: str = Field(...) - installed_channel: str | None = Field(None) __all__ = [ "GetServerInfoResponse", From 6b43088b7b8e38be7c4e1eee9151a1907df3952d Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 6 May 2026 11:27:39 -0700 Subject: [PATCH 37/72] extend registry installation tests --- .../tests/registry/test_plugin_lifecycle.py | 156 +++++++++++++++++- 1 file changed, 147 insertions(+), 9 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 0dee2b20..54dc9610 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -303,14 +303,19 @@ async def test_plugin_install_failed_require_rolls_back( environment: AbstractLRRDeploymentContext, ): """ - Test that a managed install rolls back when the plugin file fails to require. + 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. Refresh registry. - 3. Install the broken plugin; expect a non-2xx error response. - 4. Assert plugin file is absent on the host. - 5. Assert Redis hash for the namespace is empty. - 6. Assert namespace is absent from GET /api/plugins/metadata. + 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) @@ -339,6 +344,50 @@ async def test_plugin_install_failed_require_rolls_back( 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, @@ -358,6 +407,30 @@ async def test_plugin_install_failed_require_rolls_back( }, }, }, + 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, + }, + }, + }, }, } @@ -365,6 +438,13 @@ async def test_plugin_install_failed_require_rolls_back( plugin_file.parent.mkdir(parents=True, exist_ok=True) plugin_file.write_bytes(broken_pm_bytes) + upgrade_v1_file = environment.local_registry_dir / 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 = environment.local_registry_dir / upgrade_v2_rel_path + upgrade_v2_file.parent.mkdir(parents=True, exist_ok=True) + upgrade_v2_file.write_bytes(upgrade_v2_bytes) + registry_json = environment.local_registry_dir / "registry.json" registry_json.write_text(json.dumps(registry_data), encoding="utf-8") @@ -409,12 +489,70 @@ async def test_plugin_install_failed_require_rolls_back( 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 lrr_client.misc_api.install_plugin( + 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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace=upgrade_ns, registry=reg_id, version="1.1.0") + ) + assert error is not None, "Expected error for broken upgrade install" + assert error.status >= 400, f"Expected non-2xx status for broken upgrade install, got {error.status}" + 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 attempt against - # a deliberately broken plugin causes LRR to log a server-side error - # describing the failed require/rollback. That log is expected, not a defect. + # 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 From da639e2964d7343d954e422ef5d4131a3766a7ef Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 6 May 2026 14:39:06 -0700 Subject: [PATCH 38/72] add default registry integration test --- .../tests/registry/test_default_registry.py | 115 ++++++++++++++++++ src/lanraragi/clients/api_clients/misc.py | 36 ++++++ src/lanraragi/models/misc.py | 12 ++ 3 files changed, 163 insertions(+) create mode 100644 integration_tests/tests/registry/test_default_registry.py diff --git a/integration_tests/tests/registry/test_default_registry.py b/integration_tests/tests/registry/test_default_registry.py new file mode 100644 index 00000000..8ca50ea7 --- /dev/null +++ b/integration_tests/tests/registry/test_default_registry.py @@ -0,0 +1,115 @@ +""" +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_default_registry_lifecycle( + environment: AbstractLRRDeploymentContext, + lrr_client: LRRClient, +): + """ + Test the default-registry designation across set/get/clear and auto-clear on registry delete. + + 1. Get default when unset, expect empty string. + 2. DELETE when unset, expect empty string returned. + 3. Set default to wrong-length id, expect 400 (OpenAPI path-length validation). + 4. Set default to right-length but non-REG_ id, expect 400 (model regex validation). + 5. Set default to well-formed but nonexistent id, expect 404. + 6. Create a local registry, set as default, get reflects it. + 7. Explicit DELETE returns the previous id and clears the designation. + 8. Re-set the default, then DELETE the underlying registry; default auto-clears. + """ + environment.setup(with_api_key=True) + + # >>>>> GET WHEN UNSET >>>>> + response, error = await lrr_client.misc_api.get_default_registry() + assert not error, f"Failed to get default registry (status {error.status}): {error.error}" + assert response.registry_id == "", f"Expected empty string when unset, got: {response.registry_id!r}" + # <<<<< GET WHEN UNSET <<<<< + + # >>>>> DELETE WHEN UNSET >>>>> + response, error = await lrr_client.misc_api.remove_default_registry() + assert not error, f"Failed to clear unset default registry (status {error.status}): {error.error}" + assert response.registry_id == "", f"Expected empty string when no default was set, got: {response.registry_id!r}" + # <<<<< DELETE WHEN UNSET <<<<< + + # >>>>> SET WRONG-LENGTH ID >>>>> + response, error = await lrr_client.misc_api.update_default_registry("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_default_registry("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_default_registry("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_default_registry() + assert not error, f"Failed to get default registry (status {error.status}): {error.error}" + assert response.registry_id == "", f"Default must remain unset after failed PUT, got: {response.registry_id!r}" + # <<<<< SET NONEXISTENT ID <<<<< + + # >>>>> SET VALID ID >>>>> + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="default-test", type="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_default_registry(reg_id) + assert not error, f"Failed to set default registry (status {error.status}): {error.error}" + assert response.registry_id == reg_id, f"Expected default {reg_id}, got: {response.registry_id}" + + response, error = await lrr_client.misc_api.get_default_registry() + assert not error, f"Failed to get default registry (status {error.status}): {error.error}" + assert response.registry_id == reg_id, f"Expected default {reg_id}, got: {response.registry_id}" + # <<<<< SET VALID ID <<<<< + + # >>>>> EXPLICIT DELETE >>>>> + response, error = await lrr_client.misc_api.remove_default_registry() + assert not error, f"Failed to clear default registry (status {error.status}): {error.error}" + assert response.registry_id == reg_id, f"Expected previous id {reg_id}, got: {response.registry_id}" + + response, error = await lrr_client.misc_api.get_default_registry() + assert not error, f"Failed to get default registry (status {error.status}): {error.error}" + assert response.registry_id == "", f"Expected empty string after clear, got: {response.registry_id!r}" + # <<<<< EXPLICIT DELETE <<<<< + + # >>>>> AUTO-CLEAR ON REGISTRY DELETE >>>>> + response, error = await lrr_client.misc_api.update_default_registry(reg_id) + assert not error, f"Failed to re-set default registry (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_default_registry() + assert not error, f"Failed to get default registry (status {error.status}): {error.error}" + assert response.registry_id == "", ( + f"Default must auto-clear when its registry is deleted, got: {response.registry_id!r}" + ) + # <<<<< AUTO-CLEAR ON REGISTRY DELETE <<<<< + + expect_no_error_logs(environment, LOGGER) diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 583dcdbc..386a21b5 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -18,6 +18,7 @@ CreateRegistryResponse, GetAvailablePluginsRequest, GetAvailablePluginsResponse, + GetDefaultRegistryResponse, GetOpdsCatalogRequest, GetOpdsCatalogResponse, GetRegistryResponse, @@ -31,6 +32,8 @@ RegenerateThumbnailRequest, RegenerateThumbnailResponse, RegistryConfig, + RemoveDefaultRegistryResponse, + UpdateDefaultRegistryResponse, UpdatePluginConfigRequest, UpdateRegistryRequest, UpdateRegistryResponse, @@ -250,6 +253,39 @@ async def delete_registry(self, registry_id: str) -> _LRRClientResponse[Lanrarag return (LanraragiResponse(), None) return (None, _build_err_response(content, status)) + async def get_default_registry(self) -> _LRRClientResponse[GetDefaultRegistryResponse]: + """ + GET /api/registries/default + """ + url = self.api_context.build_url("/api/registries/default") + status, content = await self.api_context.handle_request(http.HTTPMethod.GET, url, self.headers) + if status == 200: + response_j = json.loads(content) + return (GetDefaultRegistryResponse(registry_id=response_j["registry_id"]), None) + return (None, _build_err_response(content, status)) + + async def update_default_registry(self, registry_id: str) -> _LRRClientResponse[UpdateDefaultRegistryResponse]: + """ + PUT /api/registries/default/{id} + """ + url = self.api_context.build_url(f"/api/registries/default/{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 (UpdateDefaultRegistryResponse(registry_id=response_j["registry_id"]), None) + return (None, _build_err_response(content, status)) + + async def remove_default_registry(self) -> _LRRClientResponse[RemoveDefaultRegistryResponse]: + """ + DELETE /api/registries/default + """ + url = self.api_context.build_url("/api/registries/default") + status, content = await self.api_context.handle_request(http.HTTPMethod.DELETE, url, self.headers) + if status == 200: + response_j = json.loads(content) + return (RemoveDefaultRegistryResponse(registry_id=response_j["registry_id"]), None) + return (None, _build_err_response(content, status)) + async def refresh_registry(self, registry_id: str) -> _LRRClientResponse[RefreshRegistryResponse]: """ POST /api/registries/{id}/refresh diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 714ceecd..642fa3c4 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -143,6 +143,15 @@ class ListRegistriesResponse(LanraragiResponse): class RefreshRegistryResponse(LanraragiResponse): index: dict[str, Any] | None = Field(None) +class GetDefaultRegistryResponse(LanraragiResponse): + registry_id: str = Field(...) + +class UpdateDefaultRegistryResponse(LanraragiResponse): + registry_id: str = Field(...) + +class RemoveDefaultRegistryResponse(LanraragiResponse): + registry_id: str = Field(...) + class UpdatePluginConfigRequest(LanraragiRequest): enabled: bool | None = Field(None) hidden: bool | None = Field(None) @@ -186,6 +195,9 @@ class InstallPluginResponse(LanraragiResponse): "GetRegistryResponse", "ListRegistriesResponse", "RefreshRegistryResponse", + "GetDefaultRegistryResponse", + "UpdateDefaultRegistryResponse", + "RemoveDefaultRegistryResponse", "UpdatePluginConfigRequest", "InstallPluginRequest", "InstallPluginResponse", From 39c4549b7032a66502b950dfa10d4c97e89ec2c2 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 6 May 2026 15:45:57 -0700 Subject: [PATCH 39/72] add test_composite_registry --- .../tests/registry/test_local_registry.py | 341 +++++++++++++++++- 1 file changed, 322 insertions(+), 19 deletions(-) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 2effa358..2a6645cb 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -2,10 +2,13 @@ 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 @@ -13,12 +16,14 @@ 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, upload_archive LOGGER = logging.getLogger(__name__) @@ -483,7 +488,7 @@ async def test_composite_registry( ): """ Test composite registry/plugin functionality. Use 3 local registries with metadata plugins. - Also tests duplicates and cross-registry/plugin. + Tests cross-registry provenance handoff via force-install, max-version SemVer resolution, and version/registry-orphan transitions. Registry 1: - shared-metadata-1 @@ -500,25 +505,323 @@ async def test_composite_registry( - v1.1.0 (appends " from registry 3 v1.1.0" to title) - v2.0.0 (appends " from registry 3 v2.0.0" to title) + Default-registry designation is covered separately in test_default_registry.py. + Steps: - 1. Add registry 1 (default), registry 2, registry 3. - 2. Set default to nonexistent registry, expect 404. - 3. Install shared-metadata-1 from registry 1 (max-version resolution selects v2.0.0). - 4. Expect shared-metadata-1 version is v2.0.0. - 5. Upload archive and invoke plugin, expect processed title. - 6. Reinstall same plugin/version with force, expect idempotent (provenance unchanged). - 7. Uninstall shared-metadata-1. - 8. Set registry 2 as default, install shared-metadata-1:v1.0.0 (explicit version). - 9. Expect version: v1.0.0. - 10. Invoke plugin, expect processed title. - 11. Remove registry 2 (plugin now registry-orphaned), assert 2 registries total. - 12. Install v1.0.0 from registry 3 without force, expect provenance mismatch error. - 13. Set registry 3 as default, install v1.0.0 with force; verify provenance updated. - 14. Test plugin run. - 15. Regenerate registry 3 without version 1.0.0 (plugin becomes version orphan). - 16. Install v2.0.0 and run plugin. + 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. - Also reset the title after every plugin call. + `use_plugin` does not persist the returned title to the archive, so the stored title remains unchanged across runs. """ - pass + 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", + type="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", + type="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", + type="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 lrr_client.misc_api.install_plugin( + 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.installed_registry == reg1_id, ( + f"Expected installed_registry {reg1_id}, got {response.installed_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.installed_version + pre_reinstall_sha256 = plugin.installed_sha256 + pre_reinstall_registry = plugin.installed_registry + break + else: + pytest.fail("shared-metadata-1 not found before force reinstall") + + response, error = await lrr_client.misc_api.install_plugin( + 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.installed_version == pre_reinstall_version, "installed_version changed after force reinstall" + assert plugin.installed_sha256 == pre_reinstall_sha256, "installed_sha256 changed after force reinstall" + assert plugin.installed_registry == pre_reinstall_registry, "installed_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 lrr_client.misc_api.install_plugin( + 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.installed_registry == reg2_id, ( + f"Expected installed_registry {reg2_id}, got {response.installed_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 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.installed_registry == reg2_id, ( + f"Expected orphaned provenance {reg2_id}, got {plugin.installed_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 lrr_client.misc_api.install_plugin( + 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 error.status == 400, f"Expected 400 for cross-registry conflict, got {error.status}" + # <<<<< CROSS-REGISTRY WITHOUT FORCE (400) <<<<< + + # >>>>> CROSS-REGISTRY WITH FORCE AND INVOKE >>>>> + response, error = await lrr_client.misc_api.install_plugin( + 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.installed_registry == reg3_id, ( + f"Expected installed_registry {reg3_id}, got {response.installed_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 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 lrr_client.misc_api.install_plugin( + 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.installed_registry == reg3_id, ( + f"Expected installed_registry {reg3_id}, got {response.installed_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 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) From 0766fa34f039245ee3b6d938ca2702bbc8133cc3 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 6 May 2026 17:19:34 -0700 Subject: [PATCH 40/72] remove single-registry limit --- .../tests/registry/test_registry_crud.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index be626421..6c61ef2a 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -119,7 +119,6 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab 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 a valid registry, then create a second, expect single-registry limit error. """ environment.setup(with_api_key=True) @@ -155,23 +154,6 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab assert error.status == 400, f"Expected 400 for missing registry name, got {error.status}" # <<<<< MISSING NAME <<<<< - # >>>>> SINGLE-REGISTRY LIMIT >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="first", type="local", path="/tmp/plugins") - ) - assert not error, f"Failed to create first registry (status {error.status}): {error.error}" - first_id = response.id - - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="second", type="local", path="/tmp/other") - ) - assert error is not None, "Expected error for single-registry limit" - assert error.status == 400, f"Expected 400 for single-registry limit, got {error.status}" - - response, error = await lrr_client.misc_api.delete_registry(first_id) - assert not error, f"Failed to delete registry (status {error.status}): {error.error}" - # <<<<< SINGLE-REGISTRY LIMIT <<<<< - expect_no_error_logs(environment, LOGGER) From cfd524c41f66279bae1a014e0731012b35235387 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 6 May 2026 20:45:58 -0700 Subject: [PATCH 41/72] add mutation survivor tests --- .../tests/registry/test_local_registry.py | 451 +++++++++++++++++- .../tests/registry/test_plugin_lifecycle.py | 112 +++++ .../tests/registry/test_registry_crud.py | 21 +- 3 files changed, 577 insertions(+), 7 deletions(-) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 2a6645cb..f3847330 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -44,11 +44,12 @@ async def test_local_registry_install_errors( 3. Unknown plugin field: refresh 400. 4. Invalid published_at: refresh 400. 5. Invalid sha256 format: refresh 400. - 6. Traversal path: refresh 400. - 7. Absolute path: refresh 400. - 8. Symlink escape path: install 400. - 9. Wrong sha256: install 422, target path absent. - 10. Correct sha256: install 200, file present on host. + 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) @@ -168,6 +169,88 @@ async def test_local_registry_install_errors( 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, @@ -480,6 +563,364 @@ async def test_install_blocked_against_default_namespace( 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", type="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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="filename-test", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail for invalid filename" + assert error.status == 422, f"Expected 422 for invalid filename, got {error.status}: {error.error}" + assert "Invalid plugin filename" in (error.error or ""), ( + 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", type="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 lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="package-mismatch", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail for package mismatch" + assert error.status == 422, f"Expected 422 for package mismatch, got {error.status}: {error.error}" + assert "Package mismatch" in (error.error or ""), ( + 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" + 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", + type="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 lrr_client.misc_api.install_plugin( + 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 error.status == 400, ( + f"Expected 400 for sideloaded conflict, got {error.status}: {error.error}" + ) + # <<<<< INSTALL BLOCKED AGAINST SIDELOADED <<<<< + + # >>>>> FORCE INSTALL ALSO BLOCKED >>>>> + response, error = await lrr_client.misc_api.install_plugin( + 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 error.status == 400, ( + f"Expected 400 for sideloaded conflict (force), got {error.status}: {error.error}" + ) + # <<<<< FORCE INSTALL ALSO BLOCKED <<<<< + + # >>>>> SIDELOADED PROVENANCE UNTOUCHED, MANAGED ARTIFACT NOT WRITTEN >>>>> + 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( diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 54dc9610..dc169144 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -1359,6 +1359,118 @@ async def test_managed_plugin_upgrade_reloads_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", + type="git", + 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 lrr_client.misc_api.install_plugin( + 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 lrr_client.misc_api.install_plugin( + 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 <<<<< + + # >>>>> VERIFY v1.1 IN LISTING ACROSS WORKERS >>>>> + 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 " + f"version after upgrade: {stale_listings[:5]}. The listing path is " + f"serving cached plugin_info() from %INC without checking for upgrades." + ) + # <<<<< VERIFY v1.1 IN LISTING ACROSS WORKERS <<<<< + + expect_no_error_logs(environment, LOGGER) + + @pytest.mark.asyncio @pytest.mark.dev("registry") @pytest.mark.ratelimit diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 6c61ef2a..3c83995d 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -168,7 +168,8 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract 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 ref field, verify index_cleared. + 6. Update with fields invalid for the registry's type, expect error. + 7. Update ref field, verify index_cleared. """ environment.setup(with_api_key=True) @@ -209,13 +210,29 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract # <<<<< 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. + # The provider field is supplied so the missing-provider guard does not mask + # the URL pattern enforcement we are exercising here. response, error = await lrr_client.misc_api.update_registry( - reg_id, UpdateRegistryRequest(type="git", url="http://example.com/repo.git") + reg_id, UpdateRegistryRequest(type="git", url="http://example.com/repo.git", provider="github") ) 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 <<<<< + # >>>>> UPDATE WITH FIELDS INVALID FOR LOCAL TYPE >>>>> + # User expectation: an update that supplies fields meaningless for the + # registry's stored type fails loudly. A 200 OK must mean LRR changed + # something the caller asked for; silently dropping `provider` on a local + # registry would mislead the operator. + response, error = await lrr_client.misc_api.update_registry( + reg_id, UpdateRegistryRequest(provider="github") + ) + assert error is not None, "Expected error for type-invalid field on update" + assert error.status == 400, f"Expected 400 for type-invalid field on update, got {error.status}" + # <<<<< UPDATE WITH FIELDS INVALID FOR LOCAL TYPE <<<<< + # >>>>> UPDATE REF 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}" From 2d1df85cac4bf4fcd1db67b0e1743d25f8811d1a Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 7 May 2026 00:05:37 -0700 Subject: [PATCH 42/72] read from the right database --- integration_tests/tests/registry/test_local_registry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index f3847330..68e64020 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -814,6 +814,7 @@ async def test_plugin_install_blocked_against_sideloaded( ) 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}" @@ -906,6 +907,7 @@ async def test_plugin_install_blocked_against_sideloaded( # <<<<< 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" ) From ffdb87216fcc7ad7a1e83ff70042690d67058cfd Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 7 May 2026 21:43:16 -0700 Subject: [PATCH 43/72] undisable transactional registry tests --- integration_tests/tests/registry/test_plugin_lifecycle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index dc169144..ad8541be 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -297,7 +297,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab @pytest.mark.asyncio -@pytest.mark.dev("registry-tx") # transactional (tx) +@pytest.mark.dev("registry") async def test_plugin_install_failed_require_rolls_back( lrr_client: LRRClient, environment: AbstractLRRDeploymentContext, @@ -556,7 +556,7 @@ async def test_plugin_install_failed_require_rolls_back( @pytest.mark.asyncio -@pytest.mark.dev("registry-tx") # transactional (tx) +@pytest.mark.dev("registry") async def test_install_failure_preserves_other_plugins( lrr_client: LRRClient, environment: AbstractLRRDeploymentContext, From 8a3755149a831e959a97077ec173c9aed0e2d945 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Fri, 8 May 2026 16:23:29 -0700 Subject: [PATCH 44/72] UpdateMetadataPluginConfig --- .../tests/registry/test_plugin_config.py | 135 +++++++++++------- .../tests/registry/test_plugin_lifecycle.py | 9 +- src/lanraragi/clients/api_clients/misc.py | 8 +- src/lanraragi/models/misc.py | 4 +- 4 files changed, 97 insertions(+), 59 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index b882839c..5c1db467 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -14,7 +14,7 @@ CreateRegistryRequest, GetAvailablePluginsRequest, InstallPluginRequest, - UpdatePluginConfigRequest, + UpdateMetadataPluginConfigRequest, ) from aio_lanraragi_tests.deployment.base import ( @@ -89,8 +89,8 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR # <<<<< DEFAULT FIELD VALUES <<<<< # >>>>> HIDE PLUGIN >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "sample-metadata", UpdatePluginConfigRequest(hidden=True) + 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}" @@ -107,8 +107,8 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR # <<<<< HIDE PLUGIN <<<<< # >>>>> UNHIDE PLUGIN >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "sample-metadata", UpdatePluginConfigRequest(hidden=False) + 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}" @@ -125,8 +125,8 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR # <<<<< UNHIDE PLUGIN <<<<< # >>>>> CONFIG SURVIVES UNINSTALL/REINSTALL >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "sample-metadata", UpdatePluginConfigRequest(hidden=True, priority=7) + 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}" @@ -152,8 +152,8 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR # <<<<< CONFIG SURVIVES UNINSTALL/REINSTALL <<<<< # >>>>> HIDE BUILT-IN PLUGIN >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "copytags", UpdatePluginConfigRequest(hidden=True) + 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}" @@ -168,8 +168,8 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR else: pytest.fail("Built-in plugin copytags not found in list after hide") - response, error = await lrr_client.misc_api.update_plugin_config( - "copytags", UpdatePluginConfigRequest(hidden=False) + 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 <<<<< @@ -183,8 +183,8 @@ async def test_plugin_config_nonexistent_namespace(lrr_client: LRRClient, enviro """Updating config for a never-installed namespace returns 404.""" environment.setup(with_api_key=True) - response, error = await lrr_client.misc_api.update_plugin_config( - "definitely-not-real", UpdatePluginConfigRequest(hidden=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}" @@ -197,13 +197,12 @@ async def test_plugin_config_nonexistent_namespace(lrr_client: LRRClient, enviro @pytest.mark.ratelimit async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test plugin priority via update_plugin_config. + 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. - 5. Set priority on a non-metadata plugin, verify it is stored. """ environment.setup(with_api_key=True) @@ -223,7 +222,6 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe 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()) response, error = await lrr_client.misc_api.install_plugin( InstallPluginRequest(namespace="sample-metadata", registry=reg_id, version=sample_metadata_version) @@ -245,8 +243,8 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe # <<<<< VERIFY DEFAULT PRIORITY <<<<< # >>>>> SET PRIORITY >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "sample-metadata", UpdatePluginConfigRequest(priority=5) + 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}" @@ -263,8 +261,8 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe # <<<<< SET PRIORITY <<<<< # >>>>> DISTINCT PRIORITIES ON TWO METADATA PLUGINS >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "copytags", UpdatePluginConfigRequest(priority=3) + 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}" @@ -280,30 +278,69 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe assert priorities["copytags"] == 3, f"Expected copytags priority 3, got {priorities.get('copytags')}" # <<<<< DISTINCT PRIORITIES ON TWO METADATA PLUGINS <<<<< - # >>>>> PRIORITY ON NON-METADATA PLUGIN >>>>> + expect_no_error_logs(environment, LOGGER) + + +@pytest.mark.asyncio +@pytest.mark.dev("registry") +@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", + type="git", + 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 lrr_client.misc_api.install_plugin( 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_plugin_config( - "sample-downloader", UpdatePluginConfigRequest(priority=2) + 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'}" ) - assert not error, f"Failed to set sample-downloader priority (status {error.status}): {error.error}" - response, error = await lrr_client.misc_api.get_available_plugins( - GetAvailablePluginsRequest(type="download") + 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'}" ) - 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.priority == 2, f"Expected sample-downloader priority 2, got {plugin.priority}" - break - else: - pytest.fail("sample-downloader not found in download plugin list") - # <<<<< PRIORITY ON NON-METADATA PLUGIN <<<<< - expect_no_error_logs(environment, LOGGER) + 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'}" + ) @pytest.mark.asyncio @@ -349,26 +386,26 @@ async def test_plugin_priority_execution_order(lrr_client: LRRClient, environmen # <<<<< INSTALL ALL THREE <<<<< # >>>>> SET PRIORITIES: 2, 1, 3 >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "title-suffix-2", UpdatePluginConfigRequest(priority=1) + 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_plugin_config( - "title-suffix-1", UpdatePluginConfigRequest(priority=2) + 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_plugin_config( - "title-suffix-3", UpdatePluginConfigRequest(priority=3) + 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_plugin_config( - ns, UpdatePluginConfigRequest(enabled=True) + 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 <<<<< @@ -389,18 +426,18 @@ async def test_plugin_priority_execution_order(lrr_client: LRRClient, environmen # <<<<< UPLOAD AND VERIFY ORDER 2-1-3 <<<<< # >>>>> CHANGE PRIORITIES: 3, 2, 1 >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "title-suffix-3", UpdatePluginConfigRequest(priority=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_plugin_config( - "title-suffix-2", UpdatePluginConfigRequest(priority=2) + 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_plugin_config( - "title-suffix-1", UpdatePluginConfigRequest(priority=3) + 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 <<<<< diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index ad8541be..067edd7a 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -17,7 +17,7 @@ CreateRegistryRequest, GetAvailablePluginsRequest, InstallPluginRequest, - UpdatePluginConfigRequest, + UpdateMetadataPluginConfigRequest, UpdateRegistryRequest, UsePluginRequest, ) @@ -852,8 +852,8 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab # <<<<< VERIFY REINSTALLED <<<<< # >>>>> ENABLE AND VERIFY EXECUTION >>>>> - response, error = await lrr_client.misc_api.update_plugin_config( - "title-suffix-1", UpdatePluginConfigRequest(enabled=True) + 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}" @@ -936,7 +936,8 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr 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" ); }\n' + 'sub plugin_info { return ( name => "Conflict", namespace => "sample-metadata", type => "metadata" ); }\n' + 'sub get_tags { return (); }\n' '1;\n' ) environment.setup( diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 386a21b5..2e54a3e1 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -34,7 +34,7 @@ RegistryConfig, RemoveDefaultRegistryResponse, UpdateDefaultRegistryResponse, - UpdatePluginConfigRequest, + UpdateMetadataPluginConfigRequest, UpdateRegistryRequest, UpdateRegistryResponse, UsePluginAsyncRequest, @@ -331,11 +331,11 @@ async def uninstall_plugin(self, namespace: str) -> _LRRClientResponse[Lanraragi return (LanraragiResponse(), None) return (None, _build_err_response(content, status)) - async def update_plugin_config(self, namespace: str, request: UpdatePluginConfigRequest) -> _LRRClientResponse[LanraragiResponse]: + async def update_metadata_plugin_config(self, namespace: str, request: UpdateMetadataPluginConfigRequest) -> _LRRClientResponse[LanraragiResponse]: """ - PUT /api/plugins/installed/{namespace}/config + PUT /api/plugins/installed/{namespace}/metadata-config """ - url = self.api_context.build_url(f"/api/plugins/installed/{namespace}/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 diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 642fa3c4..7ccfb3d1 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -152,7 +152,7 @@ class UpdateDefaultRegistryResponse(LanraragiResponse): class RemoveDefaultRegistryResponse(LanraragiResponse): registry_id: str = Field(...) -class UpdatePluginConfigRequest(LanraragiRequest): +class UpdateMetadataPluginConfigRequest(LanraragiRequest): enabled: bool | None = Field(None) hidden: bool | None = Field(None) priority: int | None = Field(None) @@ -198,7 +198,7 @@ class InstallPluginResponse(LanraragiResponse): "GetDefaultRegistryResponse", "UpdateDefaultRegistryResponse", "RemoveDefaultRegistryResponse", - "UpdatePluginConfigRequest", + "UpdateMetadataPluginConfigRequest", "InstallPluginRequest", "InstallPluginResponse", ] From 1d1b04735c4ad21bebec26d5b1ea66fe99d143df Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Fri, 8 May 2026 17:13:09 -0700 Subject: [PATCH 45/72] cleanup more gracefully --- integration_tests/tests/registry/conftest.py | 6 +- .../tests/registry/test_local_registry.py | 56 ++++++++++--------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/integration_tests/tests/registry/conftest.py b/integration_tests/tests/registry/conftest.py index af0472e1..f7f9bd00 100644 --- a/integration_tests/tests/registry/conftest.py +++ b/integration_tests/tests/registry/conftest.py @@ -26,8 +26,10 @@ def port_offset(request: pytest.FixtureRequest) -> Generator[int, None, None]: 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} - yield env - env.teardown(remove_data=True) + try: + yield env + finally: + env.teardown(remove_data=True) @pytest_asyncio.fixture diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 68e64020..1a2250f1 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -318,37 +318,41 @@ async def test_local_registry_install_errors( symlink_path.unlink() symlink_path.symlink_to(escape_target) - 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, + 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 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 lrr_client.misc_api.install_plugin( - InstallPluginRequest(namespace="symlink-plugin", registry=reg_id, version="1.0.0") - ) - assert error is not None, "Expected install to fail for symlink escape" - assert error.status == 400, f"Expected 400 for symlink escape install, got {error.status}" - assert not list(environment.plugin_managed_dir.rglob("*.pm")), "No .pm files should be written for symlink escape" + response, error = await lrr_client.misc_api.install_plugin( + InstallPluginRequest(namespace="symlink-plugin", registry=reg_id, version="1.0.0") + ) + assert error is not None, "Expected install to fail for symlink escape" + assert error.status == 400, f"Expected 400 for symlink escape install, got {error.status}" + 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" From ab3dc1f61400ba50344fd399ed5c8d35b88cf194 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Fri, 8 May 2026 17:23:30 -0700 Subject: [PATCH 46/72] strengthen the test and dont do lottery --- .../tests/registry/test_plugin_lifecycle.py | 80 ++++++++++--------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 067edd7a..5cf4849a 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -1176,21 +1176,19 @@ async def test_managed_plugin_upgrade_reloads_class( environment: AbstractLRRDeploymentContext, ): """ - Test that managed plugin upgrade reloads the class in the installing worker. + Test that managed plugin upgrade reloads the class metadata in every prefork worker. - The uploading worker has the plugin's source file cached in %INC. Without an - explicit delete, require short-circuits and the new file contents are not - loaded into the worker interpreter until server restart. + 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 from the main ref (version 1.0). - 2. Verify plugin_info returns version "1.0". - 3. Update the registry to the v1.1 ref (same namespace, version "1.1"). - 4. Refresh and force-install sample-script. - 5. Verify plugin_info returns version "1.1" across multiple requests. + 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. """ - # Single worker deterministically routes the verification request to the - # same process that handled the install/upgrade, where %INC is populated. - environment.setup(with_api_key=True, environment={"MOJO_WORKERS": "1"}) + environment.setup(with_api_key=True) # >>>>> INSTALL v1.0 FROM main >>>>> response, error = await lrr_client.misc_api.create_registry( @@ -1213,17 +1211,21 @@ async def test_managed_plugin_upgrade_reloads_class( 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 <<<<< - # >>>>> VERIFY v1.0 IN LOADED CLASS >>>>> - response, error = await lrr_client.misc_api.get_available_plugins( - GetAvailablePluginsRequest(type="script") - ) - assert not error, f"Failed to list scripts (status {error.status}): {error.error}" - sample = next((p for p in response.plugins if p.namespace == "sample-script"), None) - assert sample is not None, "sample-script not listed after install" - assert sample.version == main_version, f"Expected v{main_version} after initial install, got {sample.version!r}" - # <<<<< VERIFY v1.0 IN LOADED CLASS <<<<< + # 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( @@ -1241,22 +1243,26 @@ async def test_managed_plugin_upgrade_reloads_class( assert not error, f"Failed to upgrade sample-script to v1.1 (status {error.status}): {error.error}" # <<<<< SWITCH REGISTRY TO v1.1 AND UPGRADE <<<<< - # >>>>> VERIFY v1.1 IN LOADED CLASS >>>>> - # Fire several reads to cover any transient scheduling; every one must see v1.1 - # because plugin_info() returns data from the in-memory class, which should have - # been re-required against the new file contents. - for attempt in range(5): - response, error = await lrr_client.misc_api.get_available_plugins( - GetAvailablePluginsRequest(type="script") - ) - assert not error, f"Failed to list scripts (status {error.status}): {error.error}" + # >>>>> VERIFY v1.1 IN LOADED CLASS ACROSS WORKERS >>>>> + # Fan out concurrent reads to spread across workers. Every response must report v1.1; + # any stale v1.0 indicates a worker whose %INC short-circuited require after upgrade. + 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"sample-script not listed on attempt {attempt}" - assert sample.version == v11_version, ( - f"Attempt {attempt}: loaded class still reports version {sample.version!r} " - f"after upgrade; %INC short-circuited require so the new file was not re-read" - ) - # <<<<< VERIFY v1.1 IN LOADED CLASS <<<<< + 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 from stale workers still report v{main_version} " + f"plugin_info after upgrade: {stale_responses[:5]}. %INC reload not converging." + ) + # <<<<< VERIFY v1.1 IN LOADED CLASS ACROSS WORKERS <<<<< expect_no_error_logs(environment, LOGGER) From 0e325c1382c051af55e324d5bc24445584276ca2 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 9 May 2026 11:21:11 -0700 Subject: [PATCH 47/72] strengthen registry tests --- .../tests/registry/test_plugin_config.py | 29 +++++++++++++++++++ .../tests/registry/test_plugin_lifecycle.py | 21 ++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 5c1db467..1ad75acb 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -342,6 +342,35 @@ async def test_plugin_config_rejects_non_metadata_fields( 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("registry") +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("registry") diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 5cf4849a..d280ceeb 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -1485,12 +1485,16 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen """ 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. Restart LRR. - 5. Assert host path still exists after restart. - 6. GET download plugins -> sample-downloader present, registry provenance unchanged. + 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) @@ -1519,6 +1523,13 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen installed_sha256 = response.installed_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 <<<<< @@ -1527,6 +1538,10 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen 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") ) From 8268068ca9efd51343223303e526efbb247cb976 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 9 May 2026 15:26:59 -0700 Subject: [PATCH 48/72] separate getters from setters --- .../tests/registry/test_registry_crud.py | 16 +++++++++++----- src/lanraragi/clients/api_clients/misc.py | 5 +---- src/lanraragi/models/misc.py | 2 -- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 3c83995d..820bfd2f 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -57,9 +57,6 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl 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}" - assert response.registry.name == "demo plugins" - assert response.registry.type == "git" - assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" # <<<<< CREATE GIT REGISTRY <<<<< # >>>>> GET BY ID >>>>> @@ -76,8 +73,11 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl reg_id, UpdateRegistryRequest(name="renamed plugins") ) assert not error, f"Failed to update registry (status {error.status}): {error.error}" - assert response.registry.name == "renamed plugins" assert response.index_cleared is False, "Name-only update should not clear index" + + 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 >>>>> @@ -94,9 +94,12 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl CreateRegistryRequest(name="local plugins", type="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.type == "local" assert response.registry.path == "/home/koyomi/plugins" - local_reg_id = response.id 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}" @@ -338,6 +341,9 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra ) assert not error, f"Failed to switch type (status {error.status}): {error.error}" assert response.index_cleared is True, "Type change should clear index" + + 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.type == "local", "Type 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" diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 2e54a3e1..5039d453 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -196,8 +196,7 @@ async def create_registry(self, request: CreateRegistryRequest) -> _LRRClientRes ) if status == 200: response_j = json.loads(content) - registry = RegistryConfig.model_validate(response_j.get("registry")) - return (CreateRegistryResponse(id=response_j["id"], registry=registry), None) + return (CreateRegistryResponse(id=response_j["id"]), None) return (None, _build_err_response(content, status)) async def get_registry(self, registry_id: str) -> _LRRClientResponse[GetRegistryResponse]: @@ -235,10 +234,8 @@ async def update_registry(self, registry_id: str, request: UpdateRegistryRequest ) if status == 200: response_j = json.loads(content) - registry = RegistryConfig.model_validate(response_j.get("registry")) return (UpdateRegistryResponse( id=response_j["id"], - registry=registry, index_cleared=response_j.get("index_cleared", False), ), None) return (None, _build_err_response(content, status)) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 7ccfb3d1..87780a4d 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -118,7 +118,6 @@ class CreateRegistryRequest(LanraragiRequest): class CreateRegistryResponse(LanraragiResponse): id: str = Field(...) - registry: RegistryConfig = Field(...) class UpdateRegistryRequest(LanraragiRequest): name: str | None = Field(None) @@ -130,7 +129,6 @@ class UpdateRegistryRequest(LanraragiRequest): class UpdateRegistryResponse(LanraragiResponse): id: str = Field(...) - registry: RegistryConfig = Field(...) index_cleared: bool = Field(...) class GetRegistryResponse(LanraragiResponse): From 76338efbd56acdbe2d08b8c6cce3dff223132fc6 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 9 May 2026 16:38:25 -0700 Subject: [PATCH 49/72] add created and updated --- src/lanraragi/models/misc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 87780a4d..74dd269e 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -107,6 +107,8 @@ class RegistryConfig(BaseModel): 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(...) From 11632b31fd992676e9f3a7228a33d5c7053d0e45 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 13 May 2026 15:38:27 -0700 Subject: [PATCH 50/72] add test case for https://github.com/Difegue/LANraragi/pull/1511\#discussion_r3237813117 --- .../tests/registry/test_registry_crud.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 820bfd2f..e998f58c 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -2,6 +2,7 @@ Plugin registry CRUD integration tests. """ +import http import logging import pytest @@ -122,6 +123,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab 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. """ environment.setup(with_api_key=True) @@ -157,6 +159,23 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab assert error.status == 400, f"Expected 400 for missing registry name, got {error.status}" # <<<<< MISSING NAME <<<<< + # >>>>> INVALID TYPE ENUM >>>>> + # Pydantic Literal["git", "local"] blocks case typos at the client; send raw + # to confirm OpenAPI rejects before the controller derefs $TYPE_FIELDS{$type}. + status, _ = await lrr_client.handle_request( + http.HTTPMethod.POST, + lrr_client.build_url("/api/registries"), + lrr_client.headers, + json_data={ + "name": "bad type", + "type": "Git", + "provider": "github", + "url": "https://github.com/owner/repo.git", + }, + ) + assert status == 400, f"Expected 400 for invalid type enum, got {status}" + # <<<<< INVALID TYPE ENUM <<<<< + expect_no_error_logs(environment, LOGGER) From de93e065e5f689a593ede7c2cafbd6da2bf16caa Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 13 May 2026 15:42:38 -0700 Subject: [PATCH 51/72] tighten the test cases --- integration_tests/tests/registry/test_registry_crud.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index e998f58c..5bf5a128 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -3,6 +3,7 @@ """ import http +import json import logging import pytest @@ -162,7 +163,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab # >>>>> INVALID TYPE ENUM >>>>> # Pydantic Literal["git", "local"] blocks case typos at the client; send raw # to confirm OpenAPI rejects before the controller derefs $TYPE_FIELDS{$type}. - status, _ = await lrr_client.handle_request( + status, content = await lrr_client.handle_request( http.HTTPMethod.POST, lrr_client.build_url("/api/registries"), lrr_client.headers, @@ -173,7 +174,11 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab "url": "https://github.com/owner/repo.git", }, ) - assert status == 400, f"Expected 400 for invalid type enum, got {status}" + body = json.loads(content) + assert status == 400, f"Expected 400 for invalid type enum, got {status}: {body}" + type_error = next((e for e in body.get("errors", []) if e.get("path") == "/body/type"), None) + assert type_error is not None, f"Expected enum violation on /body/type, got: {body}" + assert "enum" in type_error.get("message", "").lower(), f"Expected enum-list message, got: {type_error}" # <<<<< INVALID TYPE ENUM <<<<< expect_no_error_logs(environment, LOGGER) From 1cc6a297cf2d291ca041932003bb1f501b777010 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 13 May 2026 16:33:00 -0700 Subject: [PATCH 52/72] align registry tests to PR --- .../tests/registry/test_default_registry.py | 18 +++++++-------- .../tests/registry/test_plugin_lifecycle.py | 21 ++++++++++++++++++ .../tests/registry/test_registry_crud.py | 22 +++++++++++++++++++ src/lanraragi/clients/api_clients/misc.py | 6 ++--- src/lanraragi/models/misc.py | 6 ++--- 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/integration_tests/tests/registry/test_default_registry.py b/integration_tests/tests/registry/test_default_registry.py index 8ca50ea7..4f550f02 100644 --- a/integration_tests/tests/registry/test_default_registry.py +++ b/integration_tests/tests/registry/test_default_registry.py @@ -41,13 +41,13 @@ async def test_default_registry_lifecycle( # >>>>> GET WHEN UNSET >>>>> response, error = await lrr_client.misc_api.get_default_registry() assert not error, f"Failed to get default registry (status {error.status}): {error.error}" - assert response.registry_id == "", f"Expected empty string when unset, got: {response.registry_id!r}" + 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_default_registry() assert not error, f"Failed to clear unset default registry (status {error.status}): {error.error}" - assert response.registry_id == "", f"Expected empty string when no default was set, got: {response.registry_id!r}" + assert response.id == "", f"Expected empty string when no default was set, got: {response.id!r}" # <<<<< DELETE WHEN UNSET <<<<< # >>>>> SET WRONG-LENGTH ID >>>>> @@ -69,7 +69,7 @@ async def test_default_registry_lifecycle( response, error = await lrr_client.misc_api.get_default_registry() assert not error, f"Failed to get default registry (status {error.status}): {error.error}" - assert response.registry_id == "", f"Default must remain unset after failed PUT, got: {response.registry_id!r}" + assert response.id == "", f"Default must remain unset after failed PUT, got: {response.id!r}" # <<<<< SET NONEXISTENT ID <<<<< # >>>>> SET VALID ID >>>>> @@ -81,21 +81,21 @@ async def test_default_registry_lifecycle( response, error = await lrr_client.misc_api.update_default_registry(reg_id) assert not error, f"Failed to set default registry (status {error.status}): {error.error}" - assert response.registry_id == reg_id, f"Expected default {reg_id}, got: {response.registry_id}" + assert response.id == reg_id, f"Expected default {reg_id}, got: {response.id}" response, error = await lrr_client.misc_api.get_default_registry() assert not error, f"Failed to get default registry (status {error.status}): {error.error}" - assert response.registry_id == reg_id, f"Expected default {reg_id}, got: {response.registry_id}" + assert response.id == reg_id, f"Expected default {reg_id}, got: {response.id}" # <<<<< SET VALID ID <<<<< # >>>>> EXPLICIT DELETE >>>>> response, error = await lrr_client.misc_api.remove_default_registry() assert not error, f"Failed to clear default registry (status {error.status}): {error.error}" - assert response.registry_id == reg_id, f"Expected previous id {reg_id}, got: {response.registry_id}" + assert response.id == reg_id, f"Expected previous id {reg_id}, got: {response.id}" response, error = await lrr_client.misc_api.get_default_registry() assert not error, f"Failed to get default registry (status {error.status}): {error.error}" - assert response.registry_id == "", f"Expected empty string after clear, got: {response.registry_id!r}" + assert response.id == "", f"Expected empty string after clear, got: {response.id!r}" # <<<<< EXPLICIT DELETE <<<<< # >>>>> AUTO-CLEAR ON REGISTRY DELETE >>>>> @@ -107,8 +107,8 @@ async def test_default_registry_lifecycle( response, error = await lrr_client.misc_api.get_default_registry() assert not error, f"Failed to get default registry (status {error.status}): {error.error}" - assert response.registry_id == "", ( - f"Default must auto-clear when its registry is deleted, got: {response.registry_id!r}" + assert response.id == "", ( + f"Default must auto-clear when its registry is deleted, got: {response.id!r}" ) # <<<<< AUTO-CLEAR ON REGISTRY DELETE <<<<< diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index d280ceeb..cf9cfc9b 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -4,6 +4,7 @@ import asyncio import hashlib +import http import json import logging import tempfile @@ -248,6 +249,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab 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) @@ -290,6 +292,25 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab assert error.status == 404, f"Expected 404 for unknown namespace, got {error.status}" # <<<<< 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}" diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 5bf5a128..f1c91369 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -125,6 +125,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab 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) @@ -181,6 +182,27 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab assert "enum" in type_error.get("message", "").lower(), f"Expected enum-list message, got: {type_error}" # <<<<< INVALID TYPE 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", + "type": "git", + "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) diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 5039d453..eb63e86e 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -258,7 +258,7 @@ async def get_default_registry(self) -> _LRRClientResponse[GetDefaultRegistryRes status, content = await self.api_context.handle_request(http.HTTPMethod.GET, url, self.headers) if status == 200: response_j = json.loads(content) - return (GetDefaultRegistryResponse(registry_id=response_j["registry_id"]), None) + return (GetDefaultRegistryResponse(id=response_j["id"]), None) return (None, _build_err_response(content, status)) async def update_default_registry(self, registry_id: str) -> _LRRClientResponse[UpdateDefaultRegistryResponse]: @@ -269,7 +269,7 @@ async def update_default_registry(self, registry_id: str) -> _LRRClientResponse[ status, content = await self.api_context.handle_request(http.HTTPMethod.PUT, url, self.headers) if status == 200: response_j = json.loads(content) - return (UpdateDefaultRegistryResponse(registry_id=response_j["registry_id"]), None) + return (UpdateDefaultRegistryResponse(id=response_j["id"]), None) return (None, _build_err_response(content, status)) async def remove_default_registry(self) -> _LRRClientResponse[RemoveDefaultRegistryResponse]: @@ -280,7 +280,7 @@ async def remove_default_registry(self) -> _LRRClientResponse[RemoveDefaultRegis status, content = await self.api_context.handle_request(http.HTTPMethod.DELETE, url, self.headers) if status == 200: response_j = json.loads(content) - return (RemoveDefaultRegistryResponse(registry_id=response_j["registry_id"]), None) + return (RemoveDefaultRegistryResponse(id=response_j["id"]), None) return (None, _build_err_response(content, status)) async def refresh_registry(self, registry_id: str) -> _LRRClientResponse[RefreshRegistryResponse]: diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 74dd269e..2522cd00 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -144,13 +144,13 @@ class RefreshRegistryResponse(LanraragiResponse): index: dict[str, Any] | None = Field(None) class GetDefaultRegistryResponse(LanraragiResponse): - registry_id: str = Field(...) + id: str = Field(...) class UpdateDefaultRegistryResponse(LanraragiResponse): - registry_id: str = Field(...) + id: str = Field(...) class RemoveDefaultRegistryResponse(LanraragiResponse): - registry_id: str = Field(...) + id: str = Field(...) class UpdateMetadataPluginConfigRequest(LanraragiRequest): enabled: bool | None = Field(None) From 036a4c9b120fe70c87b95dfbfa16bb9ff0767774 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 13 May 2026 22:27:22 -0700 Subject: [PATCH 53/72] remove metadata plugin features --- .../tests/registry/test_plugin_config.py | 12 +++--- .../tests/registry/test_plugin_lifecycle.py | 39 +++++++++--------- .../tests/registry/test_registry_crud.py | 40 ++++++++++++++++++- src/lanraragi/clients/api_clients/misc.py | 2 +- src/lanraragi/models/misc.py | 1 - 5 files changed, 67 insertions(+), 27 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 1ad75acb..01c29177 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -27,7 +27,7 @@ @pytest.mark.asyncio -@pytest.mark.dev("registry") +@pytest.mark.dev("metadata-plugin") @pytest.mark.ratelimit async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ @@ -178,7 +178,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR @pytest.mark.asyncio -@pytest.mark.dev("registry") +@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) @@ -193,7 +193,7 @@ async def test_plugin_config_nonexistent_namespace(lrr_client: LRRClient, enviro @pytest.mark.asyncio -@pytest.mark.dev("registry") +@pytest.mark.dev("metadata-plugin") @pytest.mark.ratelimit async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ @@ -282,7 +282,7 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe @pytest.mark.asyncio -@pytest.mark.dev("registry") +@pytest.mark.dev("metadata-plugin") @pytest.mark.ratelimit async def test_plugin_config_rejects_non_metadata_fields( lrr_client: LRRClient, environment: AbstractLRRDeploymentContext @@ -346,7 +346,7 @@ async def test_plugin_config_rejects_non_metadata_fields( @pytest.mark.asyncio -@pytest.mark.dev("registry") +@pytest.mark.dev("metadata-plugin") async def test_plugin_config_returns_500_on_corrupt_type( lrr_client: LRRClient, environment: AbstractLRRDeploymentContext ): @@ -373,7 +373,7 @@ async def test_plugin_config_returns_500_on_corrupt_type( @pytest.mark.asyncio -@pytest.mark.dev("registry") +@pytest.mark.dev("metadata-plugin") @pytest.mark.ratelimit async def test_plugin_priority_execution_order(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index cf9cfc9b..e1850556 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -18,7 +18,7 @@ CreateRegistryRequest, GetAvailablePluginsRequest, InstallPluginRequest, - UpdateMetadataPluginConfigRequest, + # UpdateMetadataPluginConfigRequest, # metadata-plugin feature removed; see test_plugin_config.py UpdateRegistryRequest, UsePluginRequest, ) @@ -873,23 +873,26 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab # <<<<< VERIFY REINSTALLED <<<<< # >>>>> ENABLE AND VERIFY EXECUTION >>>>> - 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}" + # 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 >>>>> diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index f1c91369..55c26901 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -64,10 +64,20 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl # >>>>> 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.type == "git" 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 >>>>> @@ -218,7 +228,9 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract 4. Create registry, update with empty body, expect error. 5. Update with non-HTTPS url, expect error. 6. Update with fields invalid for the registry's type, expect error. - 7. Update ref field, verify index_cleared. + 7. Update with mixed valid + type-invalid fields, expect error. + 8. Update with empty name, expect error. + 9. Update ref field, verify index_cleared. """ environment.setup(with_api_key=True) @@ -282,6 +294,32 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract assert error.status == 400, f"Expected 400 for type-invalid field on update, got {error.status}" # <<<<< UPDATE WITH FIELDS INVALID FOR LOCAL TYPE <<<<< + # >>>>> UPDATE WITH MIXED VALID AND TYPE-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 and + # the cached index must not be invalidated by the meaningless field. + 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 + type-invalid update" + assert error.status == 400, f"Expected 400 for mixed valid + type-invalid update, got {error.status}" + # <<<<< UPDATE WITH MIXED VALID AND TYPE-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 <<<<< + # >>>>> UPDATE REF 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}" diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index eb63e86e..41c979e1 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -208,7 +208,7 @@ async def get_registry(self, registry_id: str) -> _LRRClientResponse[GetRegistry if status == 200: response_j = json.loads(content) registry = RegistryConfig.model_validate(response_j.get("registry")) - return (GetRegistryResponse(id=response_j["id"], registry=registry), None) + return (GetRegistryResponse(registry=registry), None) return (None, _build_err_response(content, status)) async def update_registry(self, registry_id: str, request: UpdateRegistryRequest) -> _LRRClientResponse[UpdateRegistryResponse]: diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 2522cd00..cc22cec7 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -134,7 +134,6 @@ class UpdateRegistryResponse(LanraragiResponse): index_cleared: bool = Field(...) class GetRegistryResponse(LanraragiResponse): - id: str = Field(...) registry: RegistryConfig = Field(...) class ListRegistriesResponse(LanraragiResponse): From 5d16503d4c4636228e73755d57aa8ef2bd855418 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Wed, 13 May 2026 14:57:19 -0700 Subject: [PATCH 54/72] cdn-related tests --- .../tests/registry/test_registry_crud.py | 49 +++++++++++++++++++ src/lanraragi/models/misc.py | 6 +-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 55c26901..0a5b65e4 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -121,6 +121,38 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl 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", type="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.type == "cdn" + assert response.registry.url == "https://cdn.example.com/plugins" + assert response.registry.provider is None, "CDN registry should not carry a provider" + 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", type="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) @@ -163,6 +195,23 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab 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", type="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", type="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="", type="local", path="/tmp/plugins") diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index cc22cec7..a47fc00a 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -102,7 +102,7 @@ class RegenerateThumbnailResponse(LanraragiResponse): class RegistryConfig(BaseModel): id: str = Field(...) name: str = Field(...) - type: Literal["git", "local"] = Field(...) + type: Literal["git", "cdn", "local"] = Field(...) provider: Literal["github", "gitlab", "gitea"] | None = Field(None) url: str | None = Field(None) ref: str | None = Field(None) @@ -112,7 +112,7 @@ class RegistryConfig(BaseModel): class CreateRegistryRequest(LanraragiRequest): name: str = Field(...) - type: Literal["git", "local"] = Field(...) + type: Literal["git", "cdn", "local"] = Field(...) provider: Literal["github", "gitlab", "gitea"] | None = Field(None) url: str | None = Field(None) ref: str | None = Field(None) @@ -123,7 +123,7 @@ class CreateRegistryResponse(LanraragiResponse): class UpdateRegistryRequest(LanraragiRequest): name: str | None = Field(None) - type: Literal["git", "local"] | None = Field(None) + type: Literal["git", "cdn", "local"] | None = Field(None) provider: Literal["github", "gitlab", "gitea"] | None = Field(None) url: str | None = Field(None) ref: str | None = Field(None) From 808d57773169322a7b21fd5d0bca8717e00df4c4 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Thu, 14 May 2026 01:41:11 -0700 Subject: [PATCH 55/72] hibernate metadata plugin tests --- .../tests/registry/test_plugin_lifecycle.py | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index e1850556..a5717752 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -13,7 +13,8 @@ import pytest from lanraragi.clients.client import LRRClient -from lanraragi.models.archive import GetArchiveMetadataRequest + +# from lanraragi.models.archive import GetArchiveMetadataRequest from lanraragi.models.misc import ( CreateRegistryRequest, GetAvailablePluginsRequest, @@ -27,10 +28,11 @@ AbstractLRRDeploymentContext, expect_no_error_logs, ) -from aio_lanraragi_tests.utils.api_wrappers import ( - create_archive_file, - upload_archive, -) + +# from aio_lanraragi_tests.utils.api_wrappers import ( +# create_archive_file, +# upload_archive, +# ) LOGGER = logging.getLogger(__name__) @@ -912,18 +914,18 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab # <<<<< 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}" + # 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 >>>>> From d72782324b3e2369d1a36920eb96ca686f7f6255 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 16 May 2026 00:01:35 -0700 Subject: [PATCH 56/72] xfail test_plugin_not_available --- integration_tests/tests/test_plugins.py | 1 + 1 file changed, 1 insertion(+) 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. From becba2ae8ed9d8c5a15045cc4721326619313194 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 16 May 2026 00:08:07 -0700 Subject: [PATCH 57/72] remove all traces of installed --- .../tests/registry/test_local_registry.py | 36 ++++----- .../tests/registry/test_plugin_config.py | 8 +- .../tests/registry/test_plugin_lifecycle.py | 75 +++++++++---------- .../tests/registry/test_registry_crud.py | 4 +- src/lanraragi/clients/api_clients/misc.py | 4 +- src/lanraragi/models/misc.py | 9 +-- 6 files changed, 66 insertions(+), 70 deletions(-) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 1a2250f1..800c14dc 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -460,8 +460,8 @@ async def test_local_registry_install_errors( 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.installed_registry == reg_id, ( - f"Expected provenance {reg_id}, got {response.installed_registry}" + 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}" @@ -1104,8 +1104,8 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: ) 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.installed_registry == reg1_id, ( - f"Expected installed_registry {reg1_id}, got {response.installed_registry}" + assert response.registry == reg1_id, ( + f"Expected registry {reg1_id}, got {response.registry}" ) response, error = await lrr_client.misc_api.use_plugin( @@ -1128,9 +1128,9 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: pre_reinstall_registry = None for plugin in response.plugins: if plugin.namespace == "shared-metadata-1": - pre_reinstall_version = plugin.installed_version - pre_reinstall_sha256 = plugin.installed_sha256 - pre_reinstall_registry = plugin.installed_registry + 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") @@ -1146,9 +1146,9 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: 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.installed_version == pre_reinstall_version, "installed_version changed after force reinstall" - assert plugin.installed_sha256 == pre_reinstall_sha256, "installed_sha256 changed after force reinstall" - assert plugin.installed_registry == pre_reinstall_registry, "installed_registry changed after force reinstall" + 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") @@ -1174,8 +1174,8 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: ) 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.installed_registry == reg2_id, ( - f"Expected installed_registry {reg2_id}, got {response.installed_registry}" + assert response.registry == reg2_id, ( + f"Expected registry {reg2_id}, got {response.registry}" ) response, error = await lrr_client.misc_api.use_plugin( @@ -1206,8 +1206,8 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: 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.installed_registry == reg2_id, ( - f"Expected orphaned provenance {reg2_id}, got {plugin.installed_registry}" + assert plugin.registry == reg2_id, ( + f"Expected orphaned provenance {reg2_id}, got {plugin.registry}" ) break else: @@ -1227,8 +1227,8 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: 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.installed_registry == reg3_id, ( - f"Expected installed_registry {reg3_id}, got {response.installed_registry}" + assert response.registry == reg3_id, ( + f"Expected registry {reg3_id}, got {response.registry}" ) response, error = await lrr_client.misc_api.use_plugin( @@ -1256,8 +1256,8 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: ) 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.installed_registry == reg3_id, ( - f"Expected installed_registry {reg3_id}, got {response.installed_registry}" + assert response.registry == reg3_id, ( + f"Expected registry {reg3_id}, got {response.registry}" ) response, error = await lrr_client.misc_api.use_plugin( diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 01c29177..1f53b5e9 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -76,13 +76,13 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR if plugin.namespace == "sample-metadata": found_managed = True assert plugin.hidden is False, f"Fresh install expected hidden=False, got {plugin.hidden}" - assert plugin.installed_registry == reg_id, ( - f"Managed plugin expected installed_registry={reg_id}, got {plugin.installed_registry}" + assert plugin.registry == reg_id, ( + f"Managed plugin expected registry={reg_id}, got {plugin.registry}" ) if plugin.namespace == "copytags": found_default = True - assert plugin.installed_registry is None, ( - f"Default plugin expected installed_registry=None, got {plugin.installed_registry}" + 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" diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index a5717752..5875b2f7 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -79,7 +79,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: 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.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" + assert response.registry == reg_id, f"Expected provenance {reg_id}, got: {response.registry}" # <<<<< INSTALL PLUGIN <<<<< # >>>>> VERIFY INSTALLED >>>>> @@ -89,8 +89,8 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: 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.installed_registry == reg_id, ( - f"Expected managed provenance {reg_id}, got: {sample.installed_registry}" + assert sample.registry == reg_id, ( + f"Expected managed provenance {reg_id}, got: {sample.registry}" ) # <<<<< VERIFY INSTALLED <<<<< @@ -149,7 +149,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: @pytest.mark.ratelimit async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test that installed_registry, installed_version, and installed_sha256 survive restart and explicit reinstall. + 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. @@ -181,9 +181,9 @@ async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, enviro 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.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" - assert response.installed_sha256 == expected_sha, ( - f"Expected install sha256 {expected_sha}, got {response.installed_sha256}" + 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( @@ -192,12 +192,12 @@ async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, enviro 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.installed_registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.installed_registry}" - assert plugin.installed_version == version_key, ( - f"Expected installed_version {version_key!r}, got {plugin.installed_version!r}" + 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.installed_sha256 == expected_sha, ( - f"Expected installed_sha256 {expected_sha}, got {plugin.installed_sha256!r}" + assert plugin.sha256 == expected_sha, ( + f"Expected sha256 {expected_sha}, got {plugin.sha256!r}" ) environment.restart() @@ -208,14 +208,14 @@ async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, enviro 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.installed_registry == reg_id, ( - f"Expected provenance {reg_id} after restart, got {plugin.installed_registry!r}" + assert plugin.registry == reg_id, ( + f"Expected provenance {reg_id} after restart, got {plugin.registry!r}" ) - assert plugin.installed_version == version_key, ( - f"Expected installed_version {version_key!r} after restart, got {plugin.installed_version!r}" + assert plugin.version == version_key, ( + f"Expected version {version_key!r} after restart, got {plugin.version!r}" ) - assert plugin.installed_sha256 == expected_sha, ( - f"Expected installed_sha256 {expected_sha} after restart, got {plugin.installed_sha256!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") @@ -225,11 +225,11 @@ async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, enviro 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.installed_registry == reg_id, ( - f"Expected provenance {reg_id} after reinstall, got {response.installed_registry!r}" + assert response.registry == reg_id, ( + f"Expected provenance {reg_id} after reinstall, got {response.registry!r}" ) - assert response.installed_sha256 == expected_sha, ( - f"Expected installed_sha256 {expected_sha} after reinstall, got {response.installed_sha256}" + 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}" @@ -823,7 +823,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" + assert response.registry == reg_id, f"Expected provenance {reg_id}, got: {response.registry}" # <<<<< INSTALL <<<<< # >>>>> VERIFY INSTALLED >>>>> @@ -833,7 +833,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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.installed_registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.installed_registry}" + 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") @@ -858,7 +858,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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.installed_registry == reg_id, f"Expected provenance on reinstall {reg_id}, got: {response.installed_registry}" + assert response.registry == reg_id, f"Expected provenance on reinstall {reg_id}, got: {response.registry}" # <<<<< REINSTALL <<<<< # >>>>> VERIFY REINSTALLED >>>>> @@ -868,7 +868,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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.installed_registry == reg_id, f"Expected managed provenance {reg_id}, got: {plugin.installed_registry}" + 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") @@ -907,7 +907,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab 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.installed_registry == reg_id, f"Expected orphaned provenance {reg_id}, got: {plugin.installed_registry}" + 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") @@ -1014,7 +1014,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr ) assert not error, f"Failed to install non-conflicting plugin (status {error.status}): {error.error}" assert response.namespace == "sample-downloader" - assert response.installed_registry == reg_id, f"Expected provenance {reg_id}, got: {response.installed_registry}" + assert response.registry == reg_id, f"Expected provenance {reg_id}, got: {response.registry}" # <<<<< INSTALL WITHOUT CONFLICT <<<<< # >>>>> UPGRADE (REINSTALL) >>>>> @@ -1124,7 +1124,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: 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.installed_registry == reg_a_id, f"Expected provenance {reg_a_id}, got: {response.installed_registry}" + assert response.registry == reg_a_id, f"Expected provenance {reg_a_id}, got: {response.registry}" # <<<<< INSTALL FROM REG A <<<<< # >>>>> DELETE REG A -> ORPHAN >>>>> @@ -1175,7 +1175,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: 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.installed_registry == reg_b_id, f"Expected provenance {reg_b_id} after force install, got: {response.installed_registry}" + 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 >>>>> @@ -1185,7 +1185,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: 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.installed_registry == reg_b_id, f"Expected provenance {reg_b_id}, got: {plugin.installed_registry}" + 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") @@ -1515,7 +1515,7 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen 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. + 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. @@ -1546,7 +1546,7 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen ) assert not error, f"Failed to install sample-downloader (status {error.status}): {error.error}" installed_version = response.version - installed_sha256 = response.installed_sha256 + installed_sha256 = response.sha256 # <<<<< SETUP AND INSTALL <<<<< # >>>>> SIMULATE PRE-PR STATE: TYPE FIELD ABSENT >>>>> @@ -1574,15 +1574,12 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen 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.installed_registry == reg_id, f"Expected provenance {reg_id} after restart, got: {plugin.installed_registry}" + 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.installed_version == installed_version, ( - f"Expected installed_version {installed_version!r} after restart, got: {plugin.installed_version!r}" - ) - assert plugin.installed_sha256 == installed_sha256, ( - f"Expected installed_sha256 {installed_sha256!r} after restart, got: {plugin.installed_sha256!r}" + assert plugin.sha256 == installed_sha256, ( + f"Expected sha256 {installed_sha256!r} after restart, got: {plugin.sha256!r}" ) break else: diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 0a5b65e4..09f75d02 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -438,7 +438,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra 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.installed_registry == reg_id + assert response.registry == reg_id # <<<<< INSTALL PLUGIN BEFORE SOURCE CHANGE <<<<< # >>>>> UPDATE URL (SOURCE CHANGE) >>>>> @@ -454,7 +454,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra 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.installed_registry == reg_id, f"Expected provenance {reg_id} after source change, got: {plugin.installed_registry}" + 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") diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 41c979e1..38c46e78 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -313,8 +313,8 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo name=response_j["name"], namespace=response_j["namespace"], version=response_j["version"], - installed_registry=response_j["installed_registry"], - installed_sha256=response_j["installed_sha256"], + registry=response_j["registry"], + sha256=response_j["sha256"], ), None) return (None, _build_err_response(content, status)) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index a47fc00a..4811ffd2 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -51,9 +51,8 @@ class GetAvailablePluginsResponsePlugin(BaseModel): version: str = Field(...) hidden: bool = Field(False) priority: int = Field(0) - installed_registry: str | None = Field(None) - installed_version: str | None = Field(None) - installed_sha256: str | None = Field(None) + registry: str | None = Field(None) + sha256: str | None = Field(None) class GetAvailablePluginsResponse(LanraragiResponse): plugins: list[GetAvailablePluginsResponsePlugin] = Field(...) @@ -166,8 +165,8 @@ class InstallPluginResponse(LanraragiResponse): name: str = Field(...) namespace: str = Field(...) version: str = Field(...) - installed_registry: str = Field(...) - installed_sha256: str = Field(...) + registry: str = Field(...) + sha256: str = Field(...) __all__ = [ "GetServerInfoResponse", From 611f774bd57b51974108f6aa8cccf82eee1dd6f6 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 16 May 2026 18:28:25 -0700 Subject: [PATCH 58/72] consolidate all to provider --- .../tests/registry/test_default_registry.py | 2 +- .../tests/registry/test_local_registry.py | 16 +-- .../tests/registry/test_plugin_config.py | 4 - .../tests/registry/test_plugin_lifecycle.py | 17 +--- .../tests/registry/test_plugin_ui.py | 1 - .../tests/registry/test_registry_crud.py | 99 +++++++++---------- src/lanraragi/clients/api_clients/misc.py | 6 +- src/lanraragi/models/misc.py | 9 +- 8 files changed, 60 insertions(+), 94 deletions(-) diff --git a/integration_tests/tests/registry/test_default_registry.py b/integration_tests/tests/registry/test_default_registry.py index 4f550f02..73c6ad2a 100644 --- a/integration_tests/tests/registry/test_default_registry.py +++ b/integration_tests/tests/registry/test_default_registry.py @@ -74,7 +74,7 @@ async def test_default_registry_lifecycle( # >>>>> SET VALID ID >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="default-test", type="local", path=environment.local_registry_path) + CreateRegistryRequest(name="default-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 diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 800c14dc..5538acd3 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -58,7 +58,7 @@ async def test_local_registry_install_errors( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="local-test", - type="local", + provider="local", path=environment.local_registry_path, ) ) @@ -539,7 +539,7 @@ async def test_install_blocked_against_default_namespace( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="default-conflict", - type="local", + provider="local", path=environment.local_registry_path, ) ) @@ -640,7 +640,7 @@ async def test_install_blocked_against_invalid_filename( })) response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="filename-test", type="local", path=environment.local_registry_path) + 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 @@ -738,7 +738,7 @@ async def test_install_blocked_against_package_mismatch( })) response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="package-mismatch", type="local", path=environment.local_registry_path) + 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 @@ -880,7 +880,7 @@ async def test_plugin_install_blocked_against_sideloaded( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="sideloaded-conflict", - type="local", + provider="local", path=environment.local_registry_path, ) ) @@ -1050,7 +1050,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="registry-1", - type="local", + provider="local", path=f"{environment.local_registry_path}/registry-1", ) ) @@ -1063,7 +1063,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="registry-2", - type="local", + provider="local", path=f"{environment.local_registry_path}/registry-2", ) ) @@ -1076,7 +1076,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="registry-3", - type="local", + provider="local", path=f"{environment.local_registry_path}/registry-3", ) ) diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 1f53b5e9..7a596f90 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -46,7 +46,6 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -210,7 +209,6 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -303,7 +301,6 @@ async def test_plugin_config_rejects_non_metadata_fields( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -392,7 +389,6 @@ async def test_plugin_priority_execution_order(lrr_client: LRRClient, environmen response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 5875b2f7..37dff82c 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -57,7 +57,6 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -161,7 +160,6 @@ async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, enviro response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -267,7 +265,6 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -475,7 +472,7 @@ async def test_plugin_install_failed_require_rolls_back( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="local-broken", - type="local", + provider="local", path=environment.local_registry_path, ) ) @@ -695,7 +692,7 @@ async def test_install_failure_preserves_other_plugins( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="local-two-plugins", - type="local", + provider="local", path=environment.local_registry_path, ) ) @@ -804,7 +801,6 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -975,7 +971,6 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -1048,7 +1043,6 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -1105,7 +1099,6 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo-A", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -1147,7 +1140,6 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo-B", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -1220,7 +1212,6 @@ async def test_managed_plugin_upgrade_reloads_class( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -1318,7 +1309,6 @@ async def test_managed_plugin_upgrade_reloads_across_workers( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -1425,7 +1415,6 @@ async def test_managed_plugin_upgrade_reloads_across_workers_via_list_plugins( response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -1528,7 +1517,6 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -1607,7 +1595,6 @@ async def test_plugin_file_deleted_under_lrr(lrr_client: LRRClient, environment: response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", diff --git a/integration_tests/tests/registry/test_plugin_ui.py b/integration_tests/tests/registry/test_plugin_ui.py index bff476bb..f49aa218 100644 --- a/integration_tests/tests/registry/test_plugin_ui.py +++ b/integration_tests/tests/registry/test_plugin_ui.py @@ -48,7 +48,6 @@ async def test_plugin_uninstall_ui(lrr_client: LRRClient, environment: AbstractL response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 09f75d02..242529bb 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -49,7 +49,6 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo plugins", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -66,7 +65,7 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl 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.type == "git" + assert response.registry.provider == "github" assert response.registry.url == "https://github.com/psilabs-dev/lrr-plugins-demo.git" assert response.registry.ref == "main" @@ -103,14 +102,14 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl # >>>>> CREATE LOCAL REGISTRY >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="local plugins", type="local", path="/home/koyomi/plugins") + 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.type == "local" + assert response.registry.provider == "local" assert response.registry.path == "/home/koyomi/plugins" response, error = await lrr_client.misc_api.delete_registry(local_reg_id) @@ -123,7 +122,7 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl # >>>>> CREATE CDN REGISTRY (https) >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="cdn plugins", type="cdn", url="https://cdn.example.com/plugins") + 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 @@ -131,9 +130,8 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl 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.type == "cdn" + assert response.registry.provider == "cdn" assert response.registry.url == "https://cdn.example.com/plugins" - assert response.registry.provider is None, "CDN registry should not carry a provider" 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" @@ -144,7 +142,7 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl # >>>>> 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", type="cdn", url="http://cdn.example.com/plugins") + 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 @@ -173,7 +171,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab # >>>>> MISSING URL FOR GIT >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="bad git", type="git") + 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}" @@ -181,7 +179,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab # >>>>> MISSING PATH FOR LOCAL >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="bad local", type="local") + 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}" @@ -189,7 +187,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab # >>>>> NON-HTTPS URL >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="http git", type="git", provider="github", url="http://github.com/owner/repo.git") + 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}" @@ -197,7 +195,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab # >>>>> MISSING URL FOR CDN >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="bad cdn", type="cdn") + 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}" @@ -206,7 +204,7 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab # >>>>> 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", type="cdn", url="ftp://cdn.example.com/plugins") + 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}" @@ -214,32 +212,32 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab # >>>>> MISSING NAME >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="", type="local", path="/tmp/plugins") + 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 TYPE ENUM >>>>> - # Pydantic Literal["git", "local"] blocks case typos at the client; send raw - # to confirm OpenAPI rejects before the controller derefs $TYPE_FIELDS{$type}. + # >>>>> 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 type", - "type": "Git", - "provider": "github", + "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 type enum, got {status}: {body}" - type_error = next((e for e in body.get("errors", []) if e.get("path") == "/body/type"), None) - assert type_error is not None, f"Expected enum violation on /body/type, got: {body}" - assert "enum" in type_error.get("message", "").lower(), f"Expected enum-list message, got: {type_error}" - # <<<<< INVALID TYPE ENUM <<<<< + 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 @@ -250,7 +248,6 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab lrr_client.headers, json_data={ "name": "empty ref", - "type": "git", "provider": "github", "url": "https://github.com/owner/repo.git", "ref": "", @@ -276,7 +273,7 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract 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 with fields invalid for the registry's type, 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. 9. Update ref field, verify index_cleared. @@ -307,7 +304,7 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract # >>>>> EMPTY UPDATE >>>>> response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="test", type="local", path="/tmp/plugins") + 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 @@ -322,37 +319,33 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract # >>>>> 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. - # The provider field is supplied so the missing-provider guard does not mask - # the URL pattern enforcement we are exercising here. response, error = await lrr_client.misc_api.update_registry( - reg_id, UpdateRegistryRequest(type="git", url="http://example.com/repo.git", provider="github") + 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 <<<<< - # >>>>> UPDATE WITH FIELDS INVALID FOR LOCAL TYPE >>>>> - # User expectation: an update that supplies fields meaningless for the - # registry's stored type fails loudly. A 200 OK must mean LRR changed - # something the caller asked for; silently dropping `provider` on a local - # registry would mislead the operator. + # >>>>> 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 type-invalid field on update" - assert error.status == 400, f"Expected 400 for type-invalid field on update, got {error.status}" - # <<<<< UPDATE WITH FIELDS INVALID FOR LOCAL TYPE <<<<< + 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 TYPE-INVALID FIELDS >>>>> + # >>>>> 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 and # the cached index must not be invalidated by the meaningless field. 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 + type-invalid update" - assert error.status == 400, f"Expected 400 for mixed valid + type-invalid update, got {error.status}" - # <<<<< UPDATE WITH MIXED VALID AND TYPE-INVALID FIELDS <<<<< + 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 @@ -376,7 +369,6 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -410,7 +402,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra 3. Update the URL, verify index_cleared is true. 4. Verify installed plugin retains provenance despite index clear. 5. Update name only, verify index_cleared is false. - 6. Switch type from git to local, verify stale git fields are absent. + 6. Switch type from github to local, verify stale git fields are absent. """ environment.setup(with_api_key=True) @@ -418,7 +410,6 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -468,21 +459,20 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra assert response.index_cleared is False, "Name change should not clear index" # <<<<< UPDATE NAME ONLY <<<<< - # >>>>> TYPE SWITCH: GIT -> LOCAL >>>>> + # >>>>> KIND SWITCH: GITHUB -> LOCAL >>>>> response, error = await lrr_client.misc_api.update_registry( - reg_id, UpdateRegistryRequest(type="local", path="/tmp/plugins") + reg_id, UpdateRegistryRequest(provider="local", path="/tmp/plugins") ) - assert not error, f"Failed to switch type (status {error.status}): {error.error}" - assert response.index_cleared is True, "Type change should clear index" + assert not error, f"Failed to switch provider (status {error.status}): {error.error}" + assert response.index_cleared is True, "Provider change should clear index" 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.type == "local", "Type should be local" + 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.provider is None, "Stale git field 'provider' should be absent" assert response.registry.ref is None, "Stale git field 'ref' should be absent" - # <<<<< TYPE SWITCH: GIT -> LOCAL <<<<< + # <<<<< KIND SWITCH: GITHUB -> LOCAL <<<<< expect_no_error_logs(environment, LOGGER) @@ -510,7 +500,6 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD response, error = await lrr_client.misc_api.create_registry( CreateRegistryRequest( name="demo", - type="git", provider="github", url="https://github.com/psilabs-dev/lrr-plugins-demo.git", ref="main", @@ -536,3 +525,5 @@ async def test_registry_refresh(lrr_client: LRRClient, environment: AbstractLRRD 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/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 38c46e78..227f23b4 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -182,9 +182,7 @@ async def create_registry(self, request: CreateRegistryRequest) -> _LRRClientRes POST /api/registries """ url = self.api_context.build_url("/api/registries") - body: dict[str, str] = {"name": request.name, "type": request.type} - if request.provider: - body["provider"] = request.provider + body: dict[str, str] = {"name": request.name, "provider": request.provider} if request.url: body["url"] = request.url if request.ref: @@ -219,8 +217,6 @@ async def update_registry(self, registry_id: str, request: UpdateRegistryRequest body: dict[str, str] = {} if request.name is not None: body["name"] = request.name - if request.type is not None: - body["type"] = request.type if request.provider is not None: body["provider"] = request.provider if request.url is not None: diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 4811ffd2..ff7f80d2 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -101,8 +101,7 @@ class RegenerateThumbnailResponse(LanraragiResponse): class RegistryConfig(BaseModel): id: str = Field(...) name: str = Field(...) - type: Literal["git", "cdn", "local"] = Field(...) - provider: Literal["github", "gitlab", "gitea"] | None = Field(None) + provider: Literal["github", "gitlab", "gitea", "cdn", "local"] = Field(...) url: str | None = Field(None) ref: str | None = Field(None) path: str | None = Field(None) @@ -111,8 +110,7 @@ class RegistryConfig(BaseModel): class CreateRegistryRequest(LanraragiRequest): name: str = Field(...) - type: Literal["git", "cdn", "local"] = Field(...) - provider: Literal["github", "gitlab", "gitea"] | None = Field(None) + provider: Literal["github", "gitlab", "gitea", "cdn", "local"] = Field(...) url: str | None = Field(None) ref: str | None = Field(None) path: str | None = Field(None) @@ -122,8 +120,7 @@ class CreateRegistryResponse(LanraragiResponse): class UpdateRegistryRequest(LanraragiRequest): name: str | None = Field(None) - type: Literal["git", "cdn", "local"] | None = Field(None) - provider: Literal["github", "gitlab", "gitea"] | 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) From 3bb3405408c2b000150d49b537ba6e2dc7e9501e Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 17 May 2026 20:02:07 -0700 Subject: [PATCH 59/72] remove cleared index assumption --- .../tests/registry/test_registry_crud.py | 42 +++---------------- src/lanraragi/clients/api_clients/misc.py | 1 - src/lanraragi/models/misc.py | 1 - 3 files changed, 5 insertions(+), 39 deletions(-) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 242529bb..4c6f4532 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -84,7 +84,6 @@ async def test_registry_crud(lrr_client: LRRClient, environment: AbstractLRRDepl reg_id, UpdateRegistryRequest(name="renamed plugins") ) assert not error, f"Failed to update registry (status {error.status}): {error.error}" - assert response.index_cleared is False, "Name-only update should not clear index" response, error = await lrr_client.misc_api.get_registry(reg_id) assert not error, f"Failed to get registry (status {error.status}): {error.error}" @@ -276,7 +275,6 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract 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. - 9. Update ref field, verify index_cleared. """ environment.setup(with_api_key=True) @@ -338,8 +336,7 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract # >>>>> 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 and - # the cached index must not be invalidated by the meaningless field. + # 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") ) @@ -362,31 +359,6 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract assert name_error is not None, f"Expected length violation on /body/name, got: {body}" # <<<<< UPDATE WITH EMPTY NAME <<<<< - # >>>>> UPDATE REF 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.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 git registry (status {error.status}): {error.error}" - reg_id = response.id - - response, error = await lrr_client.misc_api.update_registry( - reg_id, UpdateRegistryRequest(ref="dev") - ) - assert not error, f"Failed to update ref (status {error.status}): {error.error}" - assert response.index_cleared is True, "Ref change should clear 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}" - # <<<<< UPDATE REF CLEARS INDEX <<<<< - expect_no_error_logs(environment, LOGGER) @@ -395,14 +367,13 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract @pytest.mark.ratelimit async def test_registry_update_relink(lrr_client: LRRClient, environment: AbstractLRRDeploymentContext): """ - Test that updating source fields clears the cached index. + 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 index_cleared is true. - 4. Verify installed plugin retains provenance despite index clear. - 5. Update name only, verify index_cleared is false. - 6. Switch type from github to local, verify stale git fields are absent. + 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) @@ -437,7 +408,6 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra reg_id, UpdateRegistryRequest(url="https://github.com/example/other-repo.git") ) assert not error, f"Failed to update registry (status {error.status}): {error.error}" - assert response.index_cleared is True, "URL change should clear index" response, error = await lrr_client.misc_api.get_available_plugins( GetAvailablePluginsRequest(type="download") @@ -456,7 +426,6 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra reg_id, UpdateRegistryRequest(name="renamed") ) assert not error, f"Failed to update registry name (status {error.status}): {error.error}" - assert response.index_cleared is False, "Name change should not clear index" # <<<<< UPDATE NAME ONLY <<<<< # >>>>> KIND SWITCH: GITHUB -> LOCAL >>>>> @@ -464,7 +433,6 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra reg_id, UpdateRegistryRequest(provider="local", path="/tmp/plugins") ) assert not error, f"Failed to switch provider (status {error.status}): {error.error}" - assert response.index_cleared is True, "Provider change should clear index" response, error = await lrr_client.misc_api.get_registry(reg_id) assert not error, f"Failed to get registry (status {error.status}): {error.error}" diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 227f23b4..d5e7866a 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -232,7 +232,6 @@ async def update_registry(self, registry_id: str, request: UpdateRegistryRequest response_j = json.loads(content) return (UpdateRegistryResponse( id=response_j["id"], - index_cleared=response_j.get("index_cleared", False), ), None) return (None, _build_err_response(content, status)) diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index ff7f80d2..7f231d1a 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -127,7 +127,6 @@ class UpdateRegistryRequest(LanraragiRequest): class UpdateRegistryResponse(LanraragiResponse): id: str = Field(...) - index_cleared: bool = Field(...) class GetRegistryResponse(LanraragiResponse): registry: RegistryConfig = Field(...) From 03ff215c536d8f26bd72f486becdee607fb1e7a2 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 24 May 2026 01:16:28 -0700 Subject: [PATCH 60/72] switch target lrr to dev-registry/backend --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f371eeb..c5652c87 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,11 +23,11 @@ on: lrr_ref: description: 'LANraragi ref (branch, tag, or commit SHA)' required: true - default: 'dev-registry/main' + default: 'dev-registry/backend' env: LRR_REPOSITORY: ${{ github.event.inputs.lrr_repository || 'psilabs-dev/LANraragi' }} - LRR_REF: ${{ github.event.inputs.lrr_ref || 'dev-registry/main' }} + LRR_REF: ${{ github.event.inputs.lrr_ref || 'dev-registry/backend' }} jobs: From 0c225ee90d60eef60c1a75b9a94dc04fb46a402b Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 24 May 2026 23:22:55 -0700 Subject: [PATCH 61/72] enforce absolute path for local registry --- .../tests/registry/test_registry_crud.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 4c6f4532..321db7fb 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -184,6 +184,14 @@ async def test_registry_create_validation(lrr_client: LRRClient, environment: Ab 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") @@ -324,6 +332,14 @@ async def test_registry_error_paths(lrr_client: LRRClient, environment: Abstract 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. From 4baa86d686952e5f9fdc2a2c9fac74e179408bf7 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 25 May 2026 23:54:43 -0700 Subject: [PATCH 62/72] add plugin test that captures provenance wipe --- .../tests/registry/test_plugin_config.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 7a596f90..3a6824ae 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -3,6 +3,7 @@ """ import asyncio +import http import logging import tempfile from pathlib import Path @@ -26,6 +27,74 @@ 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 lrr_client.misc_api.install_plugin( + 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 From 087b48516d0f4beed6fb24f425b5baca6412d259 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:01:21 -0700 Subject: [PATCH 63/72] add server status api method and tests --- .../tests/registry/test_plugin_lifecycle.py | 124 ++++++++++++++++++ src/lanraragi/clients/api_clients/misc.py | 12 ++ src/lanraragi/clients/res_processors/misc.py | 5 + src/lanraragi/models/misc.py | 3 + 4 files changed, 144 insertions(+) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 37dff82c..c20ab58f 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -779,6 +779,130 @@ async def test_install_failure_preserves_other_plugins( # 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, + }, + }, + }, + }, + } + plugin_file = environment.local_registry_dir / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_bytes(plugin_bytes) + registry_json = environment.local_registry_dir / "registry.json" + registry_json.write_text(json.dumps(registry_data), encoding="utf-8") + + response, error = await lrr_client.misc_api.create_registry( + CreateRegistryRequest(name="local-restart", 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}" + + # >>>>> FRESH SERVER >>>>> + response, error = await lrr_client.misc_api.get_server_status() + assert not error, f"Failed to get server status (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 lrr_client.misc_api.install_plugin( + 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_status() + assert not error, f"Failed to get server status (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 lrr_client.misc_api.install_plugin( + 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_status() + assert not error, f"Failed to get server status (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_status() + assert not error, f"Failed to get server status (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_status() + assert not error, f"Failed to get server status (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 diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index d5e7866a..97a0cd5b 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -8,6 +8,7 @@ from lanraragi.clients.res_processors.misc import ( _handle_get_available_plugins_response, _process_get_server_info_response, + _process_get_server_status_response, ) from lanraragi.clients.utils import _build_err_response from lanraragi.models.base import LanraragiErrorResponse, LanraragiResponse @@ -23,6 +24,7 @@ GetOpdsCatalogResponse, GetRegistryResponse, GetServerInfoResponse, + GetServerStatusResponse, InstallPluginRequest, InstallPluginResponse, ListRegistriesResponse, @@ -57,6 +59,16 @@ async def get_server_info(self) -> _LRRClientResponse[GetServerInfoResponse]: return (_process_get_server_info_response(content), None) return (None, _build_err_response(content, status)) + async def get_server_status(self) -> _LRRClientResponse[GetServerStatusResponse]: + """ + GET /api/server/status + """ + url = self.api_context.build_url("/api/server/status") + status, content = await self.api_context.handle_request(http.HTTPMethod.GET, url, self.headers) + if status == 200: + return (_process_get_server_status_response(content), None) + return (None, _build_err_response(content, status)) + async def get_opds_catalog(self, request: GetOpdsCatalogRequest) -> _LRRClientResponse[GetOpdsCatalogResponse]: """ - GET /api/opds diff --git a/src/lanraragi/clients/res_processors/misc.py b/src/lanraragi/clients/res_processors/misc.py index d72d81a3..98a258fc 100644 --- a/src/lanraragi/clients/res_processors/misc.py +++ b/src/lanraragi/clients/res_processors/misc.py @@ -4,6 +4,7 @@ GetAvailablePluginsResponse, GetAvailablePluginsResponsePlugin, GetServerInfoResponse, + GetServerStatusResponse, ) _available_plugins_adapter = TypeAdapter(list[GetAvailablePluginsResponsePlugin]) @@ -11,10 +12,14 @@ def _process_get_server_info_response(content: str) -> GetServerInfoResponse: return GetServerInfoResponse.model_validate_json(content) +def _process_get_server_status_response(content: str) -> GetServerStatusResponse: + return GetServerStatusResponse.model_validate_json(content) + def _handle_get_available_plugins_response(content: str) -> GetAvailablePluginsResponse: return GetAvailablePluginsResponse(plugins=_available_plugins_adapter.validate_json(content)) __all__ = [ "_process_get_server_info_response", + "_process_get_server_status_response", "_handle_get_available_plugins_response", ] diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 7f231d1a..a78c7808 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -23,6 +23,9 @@ class GetServerInfoResponse(LanraragiResponse): version_name: str = Field(...) excluded_namespaces: list[str] = Field(default_factory=list) +class GetServerStatusResponse(LanraragiResponse): + restart_required: bool = Field(...) + class GetOpdsCatalogRequest(LanraragiRequest): arcid: str | None = Field(None, min_length=40, max_length=40) category: str | None = Field(None) From f1503d0de4578096f0fa00c70f59035773297035 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:02:58 -0700 Subject: [PATCH 64/72] add restart and close connections for registry --- .../tests/registry/test_local_registry.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 5538acd3..1b57024d 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -1178,6 +1178,11 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: 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) ) @@ -1231,6 +1236,11 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: 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) ) @@ -1260,6 +1270,11 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: 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) ) From ec07cd9a9887a8c05ee70dbfba16d2b91f4bb9b0 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:59:12 -0700 Subject: [PATCH 65/72] server restart flag is in server info --- .../tests/registry/test_plugin_lifecycle.py | 20 +++++++++---------- src/lanraragi/clients/api_clients/misc.py | 12 ----------- src/lanraragi/clients/res_processors/misc.py | 5 ----- src/lanraragi/models/misc.py | 2 -- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index c20ab58f..06f68a84 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -854,8 +854,8 @@ async def test_server_restart_status(lrr_client: LRRClient, environment: Abstrac assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" # >>>>> FRESH SERVER >>>>> - response, error = await lrr_client.misc_api.get_server_status() - assert not error, f"Failed to get server status (status {getattr(error, 'status', None)})" + 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 <<<<< @@ -865,8 +865,8 @@ async def test_server_restart_status(lrr_client: LRRClient, environment: Abstrac ) assert not error, f"Failed to install plugin (status {error.status}): {error.error}" - response, error = await lrr_client.misc_api.get_server_status() - assert not error, f"Failed to get server status (status {getattr(error, 'status', None)})" + 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 <<<<< @@ -876,15 +876,15 @@ async def test_server_restart_status(lrr_client: LRRClient, environment: Abstrac ) assert not error, f"Failed to reinstall plugin (status {error.status}): {error.error}" - response, error = await lrr_client.misc_api.get_server_status() - assert not error, f"Failed to get server status (status {getattr(error, 'status', None)})" + 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_status() - assert not error, f"Failed to get server status (status {getattr(error, 'status', None)})" + 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 <<<<< @@ -892,8 +892,8 @@ async def test_server_restart_status(lrr_client: LRRClient, environment: Abstrac 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_status() - assert not error, f"Failed to get server status (status {getattr(error, 'status', None)})" + 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 <<<<< diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 97a0cd5b..d5e7866a 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -8,7 +8,6 @@ from lanraragi.clients.res_processors.misc import ( _handle_get_available_plugins_response, _process_get_server_info_response, - _process_get_server_status_response, ) from lanraragi.clients.utils import _build_err_response from lanraragi.models.base import LanraragiErrorResponse, LanraragiResponse @@ -24,7 +23,6 @@ GetOpdsCatalogResponse, GetRegistryResponse, GetServerInfoResponse, - GetServerStatusResponse, InstallPluginRequest, InstallPluginResponse, ListRegistriesResponse, @@ -59,16 +57,6 @@ async def get_server_info(self) -> _LRRClientResponse[GetServerInfoResponse]: return (_process_get_server_info_response(content), None) return (None, _build_err_response(content, status)) - async def get_server_status(self) -> _LRRClientResponse[GetServerStatusResponse]: - """ - GET /api/server/status - """ - url = self.api_context.build_url("/api/server/status") - status, content = await self.api_context.handle_request(http.HTTPMethod.GET, url, self.headers) - if status == 200: - return (_process_get_server_status_response(content), None) - return (None, _build_err_response(content, status)) - async def get_opds_catalog(self, request: GetOpdsCatalogRequest) -> _LRRClientResponse[GetOpdsCatalogResponse]: """ - GET /api/opds diff --git a/src/lanraragi/clients/res_processors/misc.py b/src/lanraragi/clients/res_processors/misc.py index 98a258fc..d72d81a3 100644 --- a/src/lanraragi/clients/res_processors/misc.py +++ b/src/lanraragi/clients/res_processors/misc.py @@ -4,7 +4,6 @@ GetAvailablePluginsResponse, GetAvailablePluginsResponsePlugin, GetServerInfoResponse, - GetServerStatusResponse, ) _available_plugins_adapter = TypeAdapter(list[GetAvailablePluginsResponsePlugin]) @@ -12,14 +11,10 @@ def _process_get_server_info_response(content: str) -> GetServerInfoResponse: return GetServerInfoResponse.model_validate_json(content) -def _process_get_server_status_response(content: str) -> GetServerStatusResponse: - return GetServerStatusResponse.model_validate_json(content) - def _handle_get_available_plugins_response(content: str) -> GetAvailablePluginsResponse: return GetAvailablePluginsResponse(plugins=_available_plugins_adapter.validate_json(content)) __all__ = [ "_process_get_server_info_response", - "_process_get_server_status_response", "_handle_get_available_plugins_response", ] diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index a78c7808..92296cd6 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -22,8 +22,6 @@ class GetServerInfoResponse(LanraragiResponse): version_desc: str = Field(...) version_name: str = Field(...) excluded_namespaces: list[str] = Field(default_factory=list) - -class GetServerStatusResponse(LanraragiResponse): restart_required: bool = Field(...) class GetOpdsCatalogRequest(LanraragiRequest): From 85133b0827c5c906e7bdcabd22590a9735fb9dfe Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:51:18 -0700 Subject: [PATCH 66/72] add local registry test --- .../tests/registry/test_local_registry.py | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 1b57024d..4f2f8851 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -1287,3 +1287,152 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: # <<<<< 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 lrr_client.misc_api.install_plugin( + 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 error.status == 422, f"Expected 422 for compile failure, got {error.status}: {error.error}" + assert "failed to load" in (error.error or ""), ( + 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 lrr_client.misc_api.install_plugin( + 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 error.status == 500, f"Expected 500 for load-check timeout, got {error.status}: {error.error}" + assert "load check failed" in (error.error or ""), ( + 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" + ) From 040af3fafde90827ad7eddaa5019dfddb151b89c Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:06:28 -0700 Subject: [PATCH 67/72] move install plugin to minion --- .../aio_lanraragi_tests/utils/api_wrappers.py | 39 ++++++++++- .../tests/registry/test_local_registry.py | 40 ++++++----- .../tests/registry/test_plugin_config.py | 18 +++-- .../tests/registry/test_plugin_lifecycle.py | 70 +++++++++---------- .../tests/registry/test_registry_crud.py | 3 +- src/lanraragi/clients/api_clients/misc.py | 12 +--- 6 files changed, 108 insertions(+), 74 deletions(-) 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..e84e4e62 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,8 @@ GetCategoryResponse, RemoveArchiveFromCategoryRequest, ) -from lanraragi.models.minion import GetMinionJobStatusRequest +from lanraragi.models.minion import GetMinionJobDetailRequest, GetMinionJobStatusRequest +from lanraragi.models.misc import InstallPluginRequest, InstallPluginResponse from aio_lanraragi_tests.archive_generation.archive import write_archives_to_disk from aio_lanraragi_tests.archive_generation.enums import ArchivalStrategyEnum @@ -413,3 +414,39 @@ 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) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 4f2f8851..1abc474f 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -23,7 +23,11 @@ AbstractLRRDeploymentContext, expect_no_error_logs, ) -from aio_lanraragi_tests.utils.api_wrappers import create_archive_file, upload_archive +from aio_lanraragi_tests.utils.api_wrappers import ( + create_archive_file, + install_plugin_and_wait, + upload_archive, +) LOGGER = logging.getLogger(__name__) @@ -344,7 +348,7 @@ async def test_local_registry_install_errors( 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 lrr_client.misc_api.install_plugin( + 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" @@ -409,7 +413,7 @@ async def test_local_registry_install_errors( 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 lrr_client.misc_api.install_plugin( + 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" @@ -454,7 +458,7 @@ async def test_local_registry_install_errors( 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 lrr_client.misc_api.install_plugin( + 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}" @@ -549,13 +553,13 @@ async def test_install_blocked_against_default_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 lrr_client.misc_api.install_plugin( + 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 error.status == 400, f"Expected 400 for default-namespace conflict, got {error.status}" - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -648,7 +652,7 @@ async def test_install_blocked_against_invalid_filename( 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 lrr_client.misc_api.install_plugin( + 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" @@ -746,7 +750,7 @@ async def test_install_blocked_against_package_mismatch( 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 lrr_client.misc_api.install_plugin( + 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" @@ -891,7 +895,7 @@ async def test_plugin_install_blocked_against_sideloaded( assert not error, f"Failed to refresh registry (status {error.status}): {error.error}" # >>>>> INSTALL BLOCKED AGAINST SIDELOADED >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -901,7 +905,7 @@ async def test_plugin_install_blocked_against_sideloaded( # <<<<< INSTALL BLOCKED AGAINST SIDELOADED <<<<< # >>>>> FORCE INSTALL ALSO BLOCKED >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -1099,7 +1103,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: # <<<<< UPLOAD ARCHIVE <<<<< # >>>>> MAX-VERSION INSTALL AND INVOKE FROM REGISTRY 1 >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1135,7 +1139,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: else: pytest.fail("shared-metadata-1 not found before force reinstall") - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1169,7 +1173,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: # <<<<< UNINSTALL <<<<< # >>>>> EXPLICIT VERSION INSTALL AND INVOKE FROM REGISTRY 2 >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1220,7 +1224,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: # <<<<< REGISTRY-ORPHAN (DELETE REGISTRY 2) <<<<< # >>>>> CROSS-REGISTRY WITHOUT FORCE (400) >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -1228,7 +1232,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: # <<<<< CROSS-REGISTRY WITHOUT FORCE (400) <<<<< # >>>>> CROSS-REGISTRY WITH FORCE AND INVOKE >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1261,7 +1265,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: # <<<<< VERSION-ORPHAN (DROP v1.0.0 FROM REGISTRY 3) <<<<< # >>>>> UPGRADE FROM VERSION-ORPHAN STATE AND INVOKE >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1359,7 +1363,7 @@ async def test_install_validation_classification( 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 lrr_client.misc_api.install_plugin( + 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" @@ -1423,7 +1427,7 @@ async def test_install_validation_classification( 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 lrr_client.misc_api.install_plugin( + 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" diff --git a/integration_tests/tests/registry/test_plugin_config.py b/integration_tests/tests/registry/test_plugin_config.py index 3a6824ae..56545887 100644 --- a/integration_tests/tests/registry/test_plugin_config.py +++ b/integration_tests/tests/registry/test_plugin_config.py @@ -22,7 +22,11 @@ AbstractLRRDeploymentContext, expect_no_error_logs, ) -from aio_lanraragi_tests.utils.api_wrappers import create_archive_file, upload_archive +from aio_lanraragi_tests.utils.api_wrappers import ( + create_archive_file, + install_plugin_and_wait, + upload_archive, +) LOGGER = logging.getLogger(__name__) @@ -62,7 +66,7 @@ async def test_save_config_preserves_managed_plugin_provenance( 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 lrr_client.misc_api.install_plugin( + 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}" @@ -127,7 +131,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR 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 lrr_client.misc_api.install_plugin( + 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}" @@ -201,7 +205,7 @@ async def test_plugin_hide_unhide(lrr_client: LRRClient, environment: AbstractLR 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 lrr_client.misc_api.install_plugin( + 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}" @@ -290,7 +294,7 @@ async def test_plugin_priority(lrr_client: LRRClient, environment: AbstractLRRDe 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 lrr_client.misc_api.install_plugin( + 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}" @@ -382,7 +386,7 @@ async def test_plugin_config_rejects_non_metadata_fields( 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 lrr_client.misc_api.install_plugin( + 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}" @@ -473,7 +477,7 @@ async def test_plugin_priority_execution_order(lrr_client: LRRClient, environmen # >>>>> 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 lrr_client.misc_api.install_plugin( + 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}" diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 06f68a84..40d3e271 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -28,11 +28,7 @@ AbstractLRRDeploymentContext, expect_no_error_logs, ) - -# from aio_lanraragi_tests.utils.api_wrappers import ( -# create_archive_file, -# upload_archive, -# ) +from aio_lanraragi_tests.utils.api_wrappers import install_plugin_and_wait LOGGER = logging.getLogger(__name__) @@ -72,7 +68,7 @@ async def test_plugin_install_and_uninstall(lrr_client: LRRClient, environment: # >>>>> INSTALL PLUGIN >>>>> refresh_response = response version_key = max(refresh_response.index["plugins"]["sample-downloader"]["versions"].keys()) - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -175,7 +171,7 @@ async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, enviro version_record = refresh_response.index["plugins"]["sample-downloader"]["versions"][version_key] expected_sha = version_record["sha256"] - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -219,7 +215,7 @@ async def test_plugin_install_provenance_roundtrip(lrr_client: LRRClient, enviro 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.install_plugin( + 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}" @@ -254,7 +250,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab environment.setup(with_api_key=True) # >>>>> INSTALL FROM NONEXISTENT REGISTRY >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -273,7 +269,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab assert not error, f"Failed to create registry (status {error.status}): {error.error}" reg_id = response.id - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -284,7 +280,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab 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 lrr_client.misc_api.install_plugin( + 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" @@ -484,7 +480,7 @@ async def test_plugin_install_failed_require_rolls_back( # <<<<< SETUP REGISTRY <<<<< # >>>>> INSTALL BROKEN PLUGIN >>>>> - response, error = await lrr_client.misc_api.install_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" @@ -510,7 +506,7 @@ async def test_plugin_install_failed_require_rolls_back( # <<<<< ROLLBACK ASSERTIONS <<<<< # >>>>> INSTALL UPGRADE BASELINE v1.0.0 AND CAPTURE STATE >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -534,7 +530,7 @@ async def test_plugin_install_failed_require_rolls_back( # <<<<< INSTALL UPGRADE BASELINE v1.0.0 AND CAPTURE STATE <<<<< # >>>>> ATTEMPT UPGRADE TO BROKEN v1.1.0 >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -704,7 +700,7 @@ async def test_install_failure_preserves_other_plugins( # <<<<< SETUP REGISTRY <<<<< # >>>>> INSTALL GOOD PLUGIN AND CAPTURE STATE >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -725,7 +721,7 @@ async def test_install_failure_preserves_other_plugins( # <<<<< INSTALL GOOD PLUGIN AND CAPTURE STATE <<<<< # >>>>> INSTALL BROKEN PLUGIN >>>>> - response, error = await lrr_client.misc_api.install_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" @@ -860,7 +856,7 @@ async def test_server_restart_status(lrr_client: LRRClient, environment: Abstrac # <<<<< FRESH SERVER <<<<< # >>>>> FIRST INSTALL DOES NOT REQUIRE RESTART >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -871,7 +867,7 @@ async def test_server_restart_status(lrr_client: LRRClient, environment: Abstrac # <<<<< FIRST INSTALL DOES NOT REQUIRE RESTART <<<<< # >>>>> REINSTALL REQUIRES RESTART >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -939,7 +935,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab # <<<<< SETUP REGISTRY <<<<< # >>>>> INSTALL >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -974,7 +970,7 @@ async def test_plugin_uninstall_reinstall(lrr_client: LRRClient, environment: Ab # <<<<< VERIFY REMOVED <<<<< # >>>>> REINSTALL >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1110,7 +1106,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # <<<<< SETUP REGISTRY <<<<< # >>>>> INSTALL WITH NON-MANAGED CONFLICT >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -1119,7 +1115,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # <<<<< INSTALL WITH NON-MANAGED CONFLICT <<<<< # >>>>> FORCE INSTALL STILL BLOCKED OVER NON-MANAGED >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -1128,7 +1124,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # <<<<< FORCE INSTALL STILL BLOCKED OVER NON-MANAGED <<<<< # >>>>> INSTALL WITHOUT CONFLICT >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1137,7 +1133,7 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr # <<<<< INSTALL WITHOUT CONFLICT <<<<< # >>>>> UPGRADE (REINSTALL) >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1182,7 +1178,7 @@ async def test_plugin_uninstall_not_listed(lrr_client: LRRClient, environment: A for i in range(5): LOGGER.debug(f"Cycle {i}: installing sample-login") - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1237,7 +1233,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: # <<<<< SETUP REG A <<<<< # >>>>> INSTALL FROM REG A >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1253,7 +1249,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: # <<<<< DELETE REG A -> ORPHAN <<<<< # >>>>> UPGRADE WITH ORPHAN REGISTRY -> 404 >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -1279,7 +1275,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: # <<<<< CREATE REG B (SAME SOURCE) <<<<< # >>>>> INSTALL FROM REG B WITHOUT FORCE -> PROVENANCE MISMATCH >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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" @@ -1287,7 +1283,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: # <<<<< INSTALL FROM REG B WITHOUT FORCE -> PROVENANCE MISMATCH <<<<< # >>>>> INSTALL FROM REG B WITH FORCE -> 200 >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" @@ -1348,7 +1344,7 @@ async def test_managed_plugin_upgrade_reloads_class( 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 lrr_client.misc_api.install_plugin( + _, 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}" @@ -1378,7 +1374,7 @@ async def test_managed_plugin_upgrade_reloads_class( 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 lrr_client.misc_api.install_plugin( + _, 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}" @@ -1445,7 +1441,7 @@ async def test_managed_plugin_upgrade_reloads_across_workers( 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 lrr_client.misc_api.install_plugin( + _, 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}" @@ -1474,7 +1470,7 @@ async def test_managed_plugin_upgrade_reloads_across_workers( 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 lrr_client.misc_api.install_plugin( + _, 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}" @@ -1551,7 +1547,7 @@ async def test_managed_plugin_upgrade_reloads_across_workers_via_list_plugins( 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 lrr_client.misc_api.install_plugin( + _, 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}" @@ -1588,7 +1584,7 @@ async def test_managed_plugin_upgrade_reloads_across_workers_via_list_plugins( f"Demo registry must publish a different version on the v1.1 ref; got {v11_version!r}" ) - _, error = await lrr_client.misc_api.install_plugin( + _, 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}" @@ -1653,7 +1649,7 @@ async def test_managed_plugin_survives_restart(lrr_client: LRRClient, environmen 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 lrr_client.misc_api.install_plugin( + 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}" @@ -1731,7 +1727,7 @@ async def test_plugin_file_deleted_under_lrr(lrr_client: LRRClient, environment: 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 lrr_client.misc_api.install_plugin( + 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}" diff --git a/integration_tests/tests/registry/test_registry_crud.py b/integration_tests/tests/registry/test_registry_crud.py index 321db7fb..652ab3ee 100644 --- a/integration_tests/tests/registry/test_registry_crud.py +++ b/integration_tests/tests/registry/test_registry_crud.py @@ -19,6 +19,7 @@ AbstractLRRDeploymentContext, expect_no_error_logs, ) +from aio_lanraragi_tests.utils.api_wrappers import install_plugin_and_wait LOGGER = logging.getLogger(__name__) @@ -412,7 +413,7 @@ async def test_registry_update_relink(lrr_client: LRRClient, environment: Abstra # <<<<< CREATE AND REFRESH <<<<< # >>>>> INSTALL PLUGIN BEFORE SOURCE CHANGE >>>>> - response, error = await lrr_client.misc_api.install_plugin( + 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}" diff --git a/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index d5e7866a..8cd18891 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -24,7 +24,6 @@ GetRegistryResponse, GetServerInfoResponse, InstallPluginRequest, - InstallPluginResponse, ListRegistriesResponse, QueueUrlDownloadRequest, QueueUrlDownloadResponse, @@ -289,7 +288,7 @@ async def refresh_registry(self, registry_id: str) -> _LRRClientResponse[Refresh return (RefreshRegistryResponse(index=response_j.get("index")), None) return (None, _build_err_response(content, status)) - async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientResponse[InstallPluginResponse]: + async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientResponse[int]: """ POST /api/plugins/install """ @@ -303,14 +302,7 @@ async def install_plugin(self, request: InstallPluginRequest) -> _LRRClientRespo http.HTTPMethod.POST, url, self.headers, json_data=body ) if status == 200: - response_j = json.loads(content) - return (InstallPluginResponse( - name=response_j["name"], - namespace=response_j["namespace"], - version=response_j["version"], - registry=response_j["registry"], - sha256=response_j["sha256"], - ), None) + return (int(json.loads(content)["job"]), None) return (None, _build_err_response(content, status)) async def uninstall_plugin(self, namespace: str) -> _LRRClientResponse[LanraragiResponse]: From 4b1e56bfb12c8bfbe314afe44809774d10cbc84f Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:35:28 -0700 Subject: [PATCH 68/72] worker can be null --- src/lanraragi/models/minion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From edd6604478e00ccb41bd655ed6aeda8de4392387 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:35:55 -0700 Subject: [PATCH 69/72] missing migrations to minion --- .../tests/registry/test_local_registry.py | 30 +++---- .../tests/registry/test_plugin_lifecycle.py | 82 +++++++++++++------ 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 1abc474f..944ca11f 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -352,7 +352,7 @@ async def test_local_registry_install_errors( InstallPluginRequest(namespace="symlink-plugin", registry=reg_id, version="1.0.0") ) assert error is not None, "Expected install to fail for symlink escape" - assert error.status == 400, f"Expected 400 for symlink escape install, got {error.status}" + 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) @@ -417,7 +417,7 @@ async def test_local_registry_install_errors( 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 error.status == 422, f"Expected 422 for wrong sha256 install, got {error.status}" + 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}" @@ -557,13 +557,13 @@ async def test_install_blocked_against_default_namespace( 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 error.status == 400, f"Expected 400 for default-namespace conflict, got {error.status}" + 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 error.status == 400, f"Expected 400 for default-namespace conflict (force), got {error.status}" + 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}" @@ -656,8 +656,7 @@ async def test_install_blocked_against_invalid_filename( InstallPluginRequest(namespace="filename-test", registry=reg_id, version="1.0.0") ) assert error is not None, "Expected install to fail for invalid filename" - assert error.status == 422, f"Expected 422 for invalid filename, got {error.status}: {error.error}" - assert "Invalid plugin filename" in (error.error or ""), ( + 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." ) @@ -754,8 +753,7 @@ async def test_install_blocked_against_package_mismatch( InstallPluginRequest(namespace="package-mismatch", registry=reg_id, version="1.0.0") ) assert error is not None, "Expected install to fail for package mismatch" - assert error.status == 422, f"Expected 422 for package mismatch, got {error.status}: {error.error}" - assert "Package mismatch" in (error.error or ""), ( + 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." ) @@ -899,8 +897,8 @@ async def test_plugin_install_blocked_against_sideloaded( 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 error.status == 400, ( - f"Expected 400 for sideloaded conflict, got {error.status}: {error.error}" + assert "already exists as a sideloaded plugin" in error.error, ( + f"Expected sideloaded-conflict rejection, got: {error.error!r}" ) # <<<<< INSTALL BLOCKED AGAINST SIDELOADED <<<<< @@ -909,8 +907,8 @@ async def test_plugin_install_blocked_against_sideloaded( 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 error.status == 400, ( - f"Expected 400 for sideloaded conflict (force), got {error.status}: {error.error}" + assert "already exists as a sideloaded plugin" in error.error, ( + f"Expected sideloaded-conflict rejection (force), got: {error.error!r}" ) # <<<<< FORCE INSTALL ALSO BLOCKED <<<<< @@ -1228,7 +1226,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: 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 error.status == 400, f"Expected 400 for cross-registry conflict, got {error.status}" + 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 >>>>> @@ -1367,8 +1365,7 @@ async def test_install_validation_classification( 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 error.status == 422, f"Expected 422 for compile failure, got {error.status}: {error.error}" - assert "failed to load" in (error.error or ""), ( + assert "failed to load" in error.error, ( f"Expected a bad-plugin 'failed to load' message, got: {error.error!r}" ) # <<<<< COMPILE FAILURE -> 422 <<<<< @@ -1431,8 +1428,7 @@ async def test_install_validation_classification( 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 error.status == 500, f"Expected 500 for load-check timeout, got {error.status}: {error.error}" - assert "load check failed" in (error.error or ""), ( + assert "load check failed" in error.error, ( f"Expected an operational 'load check failed' message, got: {error.error!r}" ) # <<<<< LOAD TIMEOUT -> 500 <<<<< diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index 40d3e271..b4f19891 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -254,7 +254,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab InstallPluginRequest(namespace="sample-downloader", registry="REG_0000000001", version="1.0") ) assert error is not None, "Expected error for nonexistent registry" - assert error.status == 404, f"Expected 404 for nonexistent registry, got {error.status}" + assert "doesn't exist" in error.error, f"Expected nonexistent-registry rejection, got: {error.error!r}" # <<<<< INSTALL FROM NONEXISTENT REGISTRY <<<<< # >>>>> INSTALL WITHOUT REFRESH >>>>> @@ -273,7 +273,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab InstallPluginRequest(namespace="sample-downloader", registry=reg_id, version="1.0") ) assert error is not None, "Expected error when installing without refresh" - assert error.status == 409, f"Expected 409 for no cached index, got {error.status}" + assert "No registry index cached" in error.error, f"Expected no-cached-index rejection, got: {error.error!r}" # <<<<< INSTALL WITHOUT REFRESH <<<<< # >>>>> INSTALL NONEXISTENT NAMESPACE >>>>> @@ -284,7 +284,7 @@ async def test_plugin_install_error_paths(lrr_client: LRRClient, environment: Ab InstallPluginRequest(namespace="does-not-exist-xyz", registry=reg_id, version="1.0") ) assert error is not None, "Expected error for nonexistent namespace" - assert error.status == 404, f"Expected 404 for unknown namespace, got {error.status}" + assert "not found in registry" in error.error, f"Expected unknown-namespace rejection, got: {error.error!r}" # <<<<< INSTALL NONEXISTENT NAMESPACE <<<<< # >>>>> INSTALL EMPTY VERSION >>>>> @@ -484,7 +484,7 @@ async def test_plugin_install_failed_require_rolls_back( InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0.0") ) assert error is not None, "Expected error for broken plugin install" - assert error.status >= 400, f"Expected non-2xx status for broken plugin install, got {error.status}" + 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 <<<<< @@ -534,7 +534,7 @@ async def test_plugin_install_failed_require_rolls_back( InstallPluginRequest(namespace=upgrade_ns, registry=reg_id, version="1.1.0") ) assert error is not None, "Expected error for broken upgrade install" - assert error.status >= 400, f"Expected non-2xx status for broken upgrade install, got {error.status}" + 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 <<<<< @@ -725,7 +725,7 @@ async def test_install_failure_preserves_other_plugins( InstallPluginRequest(namespace=broken_ns, registry=reg_id, version="1.0.0") ) assert error is not None, "Expected error for broken plugin install" - assert error.status >= 400, f"Expected non-2xx status for broken plugin install, got {error.status}" + 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 <<<<< @@ -1110,7 +1110,6 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr 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 error.status == 400, f"Expected 400 for non-managed conflict, got {error.status}" assert "Remove it first" in error.error, f"Expected 'Remove it first' in error, got: {error.error}" # <<<<< INSTALL WITH NON-MANAGED CONFLICT <<<<< @@ -1119,7 +1118,6 @@ async def test_plugin_install_conflict(lrr_client: LRRClient, environment: Abstr 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 error.status == 400, f"Expected 400 for non-managed conflict (force), got {error.status}" assert "Remove it first" in error.error, f"Expected 'Remove it first' in error, got: {error.error}" # <<<<< FORCE INSTALL STILL BLOCKED OVER NON-MANAGED <<<<< @@ -1253,7 +1251,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: 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 error.status == 404, f"Expected 404 for deleted registry, got {error.status}" + 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) >>>>> @@ -1279,7 +1277,7 @@ async def test_plugin_cross_provenance_force(lrr_client: LRRClient, environment: 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 error.status == 400, f"Expected 400 for cross-registry provenance mismatch, got {error.status}" + 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 >>>>> @@ -1380,9 +1378,20 @@ async def test_managed_plugin_upgrade_reloads_class( assert not error, f"Failed to upgrade sample-script to v1.1 (status {error.status}): {error.error}" # <<<<< SWITCH REGISTRY TO v1.1 AND UPGRADE <<<<< - # >>>>> VERIFY v1.1 IN LOADED CLASS ACROSS WORKERS >>>>> - # Fan out concurrent reads to spread across workers. Every response must report v1.1; - # any stale v1.0 indicates a worker whose %INC short-circuited require after 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) @@ -1396,10 +1405,10 @@ async def test_managed_plugin_upgrade_reloads_class( stale_responses.append((i, sample.version)) assert not stale_responses, ( - f"{len(stale_responses)} of 40 responses from stale workers still report v{main_version} " - f"plugin_info after upgrade: {stale_responses[:5]}. %INC reload not converging." + 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." ) - # <<<<< VERIFY v1.1 IN LOADED CLASS ACROSS WORKERS <<<<< + # <<<<< RESTART, THEN VERIFY v1.1 IN LOADED CLASS ACROSS WORKERS <<<<< expect_no_error_logs(environment, LOGGER) @@ -1476,9 +1485,20 @@ async def test_managed_plugin_upgrade_reloads_across_workers( assert not error, f"Failed to upgrade sample-script to v1.1 (status {error.status}): {error.error}" # <<<<< UPGRADE TO v1.1 <<<<< - # >>>>> VERIFY v1.1 ACROSS WORKERS >>>>> + # >>>>> 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. - # Concurrent requests spread across workers via the connection pool. + 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}") @@ -1494,10 +1514,10 @@ async def test_managed_plugin_upgrade_reloads_across_workers( v10_responses.append((i, result)) assert not v10_responses, ( - f"{len(v10_responses)} of 40 responses from stale workers still running v1.0 symbols: " - f"{v10_responses[:5]}. Cross-worker coherence not converging after upgrade." + 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." ) - # <<<<< VERIFY v1.1 ACROSS WORKERS <<<<< + # <<<<< RESTART, THEN VERIFY v1.1 ACROSS WORKERS <<<<< expect_no_error_logs(environment, LOGGER) @@ -1590,7 +1610,18 @@ async def test_managed_plugin_upgrade_reloads_across_workers_via_list_plugins( assert not error, f"Failed to upgrade sample-script to v1.1 (status {error.status}): {error.error}" # <<<<< UPGRADE TO v1.1 <<<<< - # >>>>> VERIFY v1.1 IN LISTING ACROSS WORKERS >>>>> + # >>>>> 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) @@ -1604,11 +1635,10 @@ async def test_managed_plugin_upgrade_reloads_across_workers_via_list_plugins( stale_listings.append((i, sample.version)) assert not stale_listings, ( - f"{len(stale_listings)} of 40 list_plugins responses still report the old " - f"version after upgrade: {stale_listings[:5]}. The listing path is " - f"serving cached plugin_info() from %INC without checking for upgrades." + 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." ) - # <<<<< VERIFY v1.1 IN LISTING ACROSS WORKERS <<<<< + # <<<<< RESTART, THEN VERIFY v1.1 IN LISTING ACROSS WORKERS <<<<< expect_no_error_logs(environment, LOGGER) From d19d171224936adf053372263540b99d7dad189d Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:41:48 -0700 Subject: [PATCH 70/72] rename default registry to ougi --- .../tests/registry/test_default_registry.py | 115 ------------------ .../tests/registry/test_local_registry.py | 2 +- integration_tests/tests/registry/test_ougi.py | 115 ++++++++++++++++++ src/lanraragi/clients/api_clients/misc.py | 30 ++--- src/lanraragi/models/misc.py | 12 +- 5 files changed, 137 insertions(+), 137 deletions(-) delete mode 100644 integration_tests/tests/registry/test_default_registry.py create mode 100644 integration_tests/tests/registry/test_ougi.py diff --git a/integration_tests/tests/registry/test_default_registry.py b/integration_tests/tests/registry/test_default_registry.py deleted file mode 100644 index 73c6ad2a..00000000 --- a/integration_tests/tests/registry/test_default_registry.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -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_default_registry_lifecycle( - environment: AbstractLRRDeploymentContext, - lrr_client: LRRClient, -): - """ - Test the default-registry designation across set/get/clear and auto-clear on registry delete. - - 1. Get default when unset, expect empty string. - 2. DELETE when unset, expect empty string returned. - 3. Set default to wrong-length id, expect 400 (OpenAPI path-length validation). - 4. Set default to right-length but non-REG_ id, expect 400 (model regex validation). - 5. Set default to well-formed but nonexistent id, expect 404. - 6. Create a local registry, set as default, get reflects it. - 7. Explicit DELETE returns the previous id and clears the designation. - 8. Re-set the default, then DELETE the underlying registry; default auto-clears. - """ - environment.setup(with_api_key=True) - - # >>>>> GET WHEN UNSET >>>>> - response, error = await lrr_client.misc_api.get_default_registry() - assert not error, f"Failed to get default registry (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_default_registry() - assert not error, f"Failed to clear unset default registry (status {error.status}): {error.error}" - assert response.id == "", f"Expected empty string when no default was set, got: {response.id!r}" - # <<<<< DELETE WHEN UNSET <<<<< - - # >>>>> SET WRONG-LENGTH ID >>>>> - response, error = await lrr_client.misc_api.update_default_registry("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_default_registry("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_default_registry("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_default_registry() - assert not error, f"Failed to get default registry (status {error.status}): {error.error}" - assert response.id == "", f"Default 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="default-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_default_registry(reg_id) - assert not error, f"Failed to set default registry (status {error.status}): {error.error}" - assert response.id == reg_id, f"Expected default {reg_id}, got: {response.id}" - - response, error = await lrr_client.misc_api.get_default_registry() - assert not error, f"Failed to get default registry (status {error.status}): {error.error}" - assert response.id == reg_id, f"Expected default {reg_id}, got: {response.id}" - # <<<<< SET VALID ID <<<<< - - # >>>>> EXPLICIT DELETE >>>>> - response, error = await lrr_client.misc_api.remove_default_registry() - assert not error, f"Failed to clear default registry (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_default_registry() - assert not error, f"Failed to get default registry (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_default_registry(reg_id) - assert not error, f"Failed to re-set default registry (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_default_registry() - assert not error, f"Failed to get default registry (status {error.status}): {error.error}" - assert response.id == "", ( - f"Default 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_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 944ca11f..3017acca 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -954,7 +954,7 @@ async def test_composite_registry( - v1.1.0 (appends " from registry 3 v1.1.0" to title) - v2.0.0 (appends " from registry 3 v2.0.0" to title) - Default-registry designation is covered separately in test_default_registry.py. + Ougi (default registry) designation is covered separately in test_ougi.py. Steps: 1. Add registry 1, registry 2, registry 3. 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/src/lanraragi/clients/api_clients/misc.py b/src/lanraragi/clients/api_clients/misc.py index 8cd18891..fb7dc4ea 100644 --- a/src/lanraragi/clients/api_clients/misc.py +++ b/src/lanraragi/clients/api_clients/misc.py @@ -18,9 +18,9 @@ CreateRegistryResponse, GetAvailablePluginsRequest, GetAvailablePluginsResponse, - GetDefaultRegistryResponse, GetOpdsCatalogRequest, GetOpdsCatalogResponse, + GetOugiResponse, GetRegistryResponse, GetServerInfoResponse, InstallPluginRequest, @@ -31,9 +31,9 @@ RegenerateThumbnailRequest, RegenerateThumbnailResponse, RegistryConfig, - RemoveDefaultRegistryResponse, - UpdateDefaultRegistryResponse, + RemoveOugiResponse, UpdateMetadataPluginConfigRequest, + UpdateOugiResponse, UpdateRegistryRequest, UpdateRegistryResponse, UsePluginAsyncRequest, @@ -244,37 +244,37 @@ async def delete_registry(self, registry_id: str) -> _LRRClientResponse[Lanrarag return (LanraragiResponse(), None) return (None, _build_err_response(content, status)) - async def get_default_registry(self) -> _LRRClientResponse[GetDefaultRegistryResponse]: + async def get_ougi(self) -> _LRRClientResponse[GetOugiResponse]: """ - GET /api/registries/default + GET /api/registries/ougi """ - url = self.api_context.build_url("/api/registries/default") + 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 (GetDefaultRegistryResponse(id=response_j["id"]), None) + return (GetOugiResponse(id=response_j["id"]), None) return (None, _build_err_response(content, status)) - async def update_default_registry(self, registry_id: str) -> _LRRClientResponse[UpdateDefaultRegistryResponse]: + async def update_ougi(self, registry_id: str) -> _LRRClientResponse[UpdateOugiResponse]: """ - PUT /api/registries/default/{id} + PUT /api/registries/ougi/{id} """ - url = self.api_context.build_url(f"/api/registries/default/{registry_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 (UpdateDefaultRegistryResponse(id=response_j["id"]), None) + return (UpdateOugiResponse(id=response_j["id"]), None) return (None, _build_err_response(content, status)) - async def remove_default_registry(self) -> _LRRClientResponse[RemoveDefaultRegistryResponse]: + async def remove_ougi(self) -> _LRRClientResponse[RemoveOugiResponse]: """ - DELETE /api/registries/default + DELETE /api/registries/ougi """ - url = self.api_context.build_url("/api/registries/default") + 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 (RemoveDefaultRegistryResponse(id=response_j["id"]), None) + return (RemoveOugiResponse(id=response_j["id"]), None) return (None, _build_err_response(content, status)) async def refresh_registry(self, registry_id: str) -> _LRRClientResponse[RefreshRegistryResponse]: diff --git a/src/lanraragi/models/misc.py b/src/lanraragi/models/misc.py index 92296cd6..ff9a00a7 100644 --- a/src/lanraragi/models/misc.py +++ b/src/lanraragi/models/misc.py @@ -138,13 +138,13 @@ class ListRegistriesResponse(LanraragiResponse): class RefreshRegistryResponse(LanraragiResponse): index: dict[str, Any] | None = Field(None) -class GetDefaultRegistryResponse(LanraragiResponse): +class GetOugiResponse(LanraragiResponse): id: str = Field(...) -class UpdateDefaultRegistryResponse(LanraragiResponse): +class UpdateOugiResponse(LanraragiResponse): id: str = Field(...) -class RemoveDefaultRegistryResponse(LanraragiResponse): +class RemoveOugiResponse(LanraragiResponse): id: str = Field(...) class UpdateMetadataPluginConfigRequest(LanraragiRequest): @@ -190,9 +190,9 @@ class InstallPluginResponse(LanraragiResponse): "GetRegistryResponse", "ListRegistriesResponse", "RefreshRegistryResponse", - "GetDefaultRegistryResponse", - "UpdateDefaultRegistryResponse", - "RemoveDefaultRegistryResponse", + "GetOugiResponse", + "UpdateOugiResponse", + "RemoveOugiResponse", "UpdateMetadataPluginConfigRequest", "InstallPluginRequest", "InstallPluginResponse", From a5a97217c9400998e2c68b0aa0a1a16d384b94e5 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:24:00 -0700 Subject: [PATCH 71/72] divorce registry from deployment --- .../aio_lanraragi_tests/deployment/base.py | 16 ++ .../deployment/container.py | 37 ++- .../aio_lanraragi_tests/deployment/windows.py | 30 +-- .../registries/__init__.py | 3 + .../aio_lanraragi_tests/registries/base.py | 80 +++++++ .../registries/local_registry.py | 30 +++ .../aio_lanraragi_tests/utils/api_wrappers.py | 39 +++- .../tests/registry/test_local_registry.py | 214 +++++++----------- integration_tests/tests/registry/test_ougi.py | 13 +- .../tests/registry/test_plugin_lifecycle.py | 56 ++--- 10 files changed, 302 insertions(+), 216 deletions(-) create mode 100644 integration_tests/src/aio_lanraragi_tests/registries/__init__.py create mode 100644 integration_tests/src/aio_lanraragi_tests/registries/base.py create mode 100644 integration_tests/src/aio_lanraragi_tests/registries/local_registry.py 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 fc65e819..988b688b 100644 --- a/integration_tests/src/aio_lanraragi_tests/deployment/container.py +++ b/integration_tests/src/aio_lanraragi_tests/deployment/container.py @@ -35,7 +35,7 @@ from aio_lanraragi_tests.utils.docker import set_pdeathsig DEFAULT_LANRARAGI_DOCKER_TAG = "difegue/lanraragi" -LOCAL_REGISTRY_CONTAINER_PATH = "/srv/test-registry" +LRR_SHARED_CONTAINER_PATH = "/srv/shared" LOGGER = logging.getLogger(__name__) @@ -233,20 +233,9 @@ def plugin_sideloaded_dir(self) -> Path: dirname = self.resource_prefix + "plugin_sideloaded" return self.staging_dir / dirname - @property - def local_registry_dir(self) -> Path: - """ - Host path bind-mounted at ``local_registry_path`` for local-registry tests. - """ - dirname = self.resource_prefix + "local_registry" - return self.staging_dir / dirname - - @property - def local_registry_path(self) -> str: - """ - Path at which LRR reads the local registry. Pass this to ``CreateRegistryRequest(path=...)``. - """ - return LOCAL_REGISTRY_CONTAINER_PATH + 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: @@ -543,7 +532,7 @@ def setup( redis_dir = self.redis_dir plugin_managed_dir = self.plugin_managed_dir plugin_sideloaded_dir = self.plugin_sideloaded_dir - local_registry_dir = self.local_registry_dir + shared_dir = self.shared_dir if contents_dir.exists(): self.logger.debug(f"Contents directory exists: {contents_dir}") else: @@ -583,11 +572,11 @@ def setup( plugin_sideloaded_dir.mkdir(parents=True, exist_ok=False) if sys.platform == "darwin": time.sleep(1) - if local_registry_dir.exists(): - self.logger.debug(f"Local registry directory exists: {local_registry_dir}") + if shared_dir.exists(): + self.logger.debug(f"Shared directory exists: {shared_dir}") else: - self.logger.debug(f"Creating local registry dir: {local_registry_dir}") - local_registry_dir.mkdir(parents=True, exist_ok=False) + self.logger.debug(f"Creating shared dir: {shared_dir}") + shared_dir.mkdir(parents=True, exist_ok=False) if sys.platform == "darwin": time.sleep(1) @@ -767,7 +756,7 @@ def setup( 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(local_registry_dir): {"bind": LOCAL_REGISTRY_CONTAINER_PATH, "mode": "ro"}, + str(self.shared_dir): {"bind": LRR_SHARED_CONTAINER_PATH, "mode": "ro"}, } lrr_volumes.update(plugin_volumes) self.lrr_container = self.docker_client.containers.create( @@ -1008,9 +997,9 @@ def _reset_test_env(self, remove_data: bool=False): 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.local_registry_dir.exists(): - shutil.rmtree(self.local_registry_dir) - self.logger.debug(f"Removed local registry directory: {self.local_registry_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 9845fa79..5e4d83d8 100644 --- a/integration_tests/src/aio_lanraragi_tests/deployment/windows.py +++ b/integration_tests/src/aio_lanraragi_tests/deployment/windows.py @@ -176,14 +176,8 @@ def plugin_managed_dir(self) -> Path: def plugin_sideloaded_dir(self) -> Path: return self.lrr_plugin_dir / "Sideloaded" - @property - def local_registry_dir(self) -> Path: - dirname = self.resource_prefix + "local_registry" - return self.staging_dir / dirname - - @property - def local_registry_path(self) -> str: - return str(self.local_registry_dir) + 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, @@ -261,7 +255,7 @@ def setup( log_dir = self.logs_dir pid_dir = self.pid_dir redis_dir = self.redis_dir - local_registry_dir = self.local_registry_dir + shared_dir = self.shared_dir if contents_dir.exists(): self.logger.debug(f"Contents directory exists: {contents_dir}") else: @@ -292,11 +286,11 @@ def setup( else: self.logger.debug(f"Creating Redis directory: {redis_dir}") redis_dir.mkdir(parents=True, exist_ok=False) - if local_registry_dir.exists(): - self.logger.debug(f"Local registry directory exists: {local_registry_dir}") + if shared_dir.exists(): + self.logger.debug(f"Shared directory exists: {shared_dir}") else: - self.logger.debug(f"Creating local registry directory: {local_registry_dir}") - local_registry_dir.mkdir(parents=True, exist_ok=False) + 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 @@ -414,7 +408,7 @@ def teardown(self, remove_data: bool=False): windist_dir = self.windist_dir redis_dir = self.redis_dir temp_dir = self.temp_dir - local_registry_dir = self.local_registry_dir + shared_dir = self.shared_dir self.stop() if hasattr(self, "_redis_client") and self._redis_client is not None: self._redis_client.close() @@ -443,10 +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 local_registry_dir.exists(): - self._remove_ro(local_registry_dir) - shutil.rmtree(local_registry_dir) - self.logger.debug(f"Removed local registry directory: {local_registry_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 e84e4e62..b884a43d 100644 --- a/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py +++ b/integration_tests/src/aio_lanraragi_tests/utils/api_wrappers.py @@ -29,7 +29,11 @@ RemoveArchiveFromCategoryRequest, ) from lanraragi.models.minion import GetMinionJobDetailRequest, GetMinionJobStatusRequest -from lanraragi.models.misc import InstallPluginRequest, InstallPluginResponse +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 @@ -42,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__) @@ -450,3 +457,33 @@ async def install_plugin_and_wait( 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/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 3017acca..02bea126 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -13,7 +13,6 @@ import pytest from lanraragi.clients.client import LRRClient from lanraragi.models.misc import ( - CreateRegistryRequest, GetAvailablePluginsRequest, InstallPluginRequest, UsePluginRequest, @@ -23,7 +22,9 @@ AbstractLRRDeploymentContext, expect_no_error_logs, ) +from aio_lanraragi_tests.registries.local_registry import LocalRegistry from aio_lanraragi_tests.utils.api_wrappers import ( + add_registry, create_archive_file, install_plugin_and_wait, upload_archive, @@ -57,17 +58,10 @@ async def test_local_registry_install_errors( """ 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 + registry = LocalRegistry(name="local-test", root=environment.shared_dir / "local-test") + registry.generate_manifest() + reg_id = await add_registry(lrr_client, environment, registry) + registry_json = registry.registry_json_path generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) dummy_sha = "00" * 32 @@ -314,13 +308,24 @@ async def test_local_registry_install_errors( # <<<<< 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) + # Escape target lives inside the shared mount but OUTSIDE this registry's + # root, so LRR resolves the symlink and rejects it via root-confinement on + # every deployment -- not merely because the target is invisible, which is + # all a bind-mount boundary would prove. + escape_target = environment.shared_dir / "outside-plugin.pm" + symlink_path = registry.root / "artifacts" / "escape-link.pm" + # Relative target so the link resolves identically on the host and inside the + # container's mount namespace. + relative_target = escape_target.relative_to(symlink_path.parent, walk_up=True) + + def _plant_escape_symlink(): + escape_target.write_text("outside", encoding="utf-8") + 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(relative_target) + + await asyncio.to_thread(_plant_escape_symlink) try: registry_json.write_text(json.dumps({ @@ -352,17 +357,17 @@ async def test_local_registry_install_errors( 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 "escapes registry root" in error.error, ( + f"Expected symlink-escape install rejected via root confinement, 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) + await asyncio.to_thread(symlink_path.unlink, missing_ok=True) + await asyncio.to_thread(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("""\ + local_sample_pm = """\ package LANraragi::Plugin::Managed::Download::LocalSample; use strict; @@ -384,8 +389,10 @@ async def test_local_registry_install_errors( } 1; -""", encoding="utf-8") - real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() +""" + plugin_file = registry.root / plugin_rel_path + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_text(local_sample_pm, encoding="utf-8") # >>>>> SHA256 MISMATCH REJECTED >>>>> registry_json.write_text(json.dumps({ @@ -433,27 +440,13 @@ async def test_local_registry_install_errors( # <<<<< 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, - }, - }, - }, - }, - })) + registry.add_plugin( + "local-sample-downloader", "download", "1.0.0", + name="Local Sample", author="test", description="local sample downloader", + artifact_content=local_sample_pm, artifact_relpath=plugin_rel_path, + published_at=generated_at, + ) + registry.generate_manifest() response, error = await lrr_client.misc_api.refresh_registry(reg_id) assert not error, f"Expected refresh to succeed (status {error.status}): {error.error}" @@ -491,7 +484,9 @@ async def test_install_blocked_against_default_namespace( 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 + registry = LocalRegistry(name="default-conflict", root=environment.shared_dir / "default-conflict") + 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_text("""\ package LANraragi::Plugin::Managed::Metadata::CopyTagsImpostor; @@ -517,7 +512,7 @@ async def test_install_blocked_against_default_namespace( 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 = registry.registry_json_path registry_json.write_text(json.dumps({ "version": 1, "generated_at": generated_at, @@ -540,15 +535,7 @@ async def test_install_blocked_against_default_namespace( }, })) - 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 + 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}" @@ -594,7 +581,9 @@ async def test_install_blocked_against_invalid_filename( 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 + registry = LocalRegistry(name="filename-test", root=environment.shared_dir / "filename-test") + 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_text("""\ package LANraragi::Plugin::Managed::Download::SafePackage; @@ -620,7 +609,7 @@ async def test_install_blocked_against_invalid_filename( 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 = registry.registry_json_path registry_json.write_text(json.dumps({ "version": 1, "generated_at": generated_at, @@ -643,11 +632,7 @@ async def test_install_blocked_against_invalid_filename( }, })) - 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 + reg_id = await add_registry(lrr_client, environment, registry) 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}" @@ -691,7 +676,9 @@ async def test_install_blocked_against_package_mismatch( 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 + registry = LocalRegistry(name="package-mismatch", root=environment.shared_dir / "package-mismatch") + 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_text("""\ package LANraragi::Plugin::Managed::Download::Bar; @@ -717,7 +704,7 @@ async def test_install_blocked_against_package_mismatch( 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 = registry.registry_json_path registry_json.write_text(json.dumps({ "version": 1, "generated_at": generated_at, @@ -740,11 +727,7 @@ async def test_install_blocked_against_package_mismatch( }, })) - 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 + reg_id = await add_registry(lrr_client, environment, registry) 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}" @@ -830,7 +813,9 @@ async def test_plugin_install_blocked_against_sideloaded( ) plugin_rel_path = "artifacts/sample-downloader/1.0.0/SampleDownload.pm" - plugin_file = environment.local_registry_dir / plugin_rel_path + registry = LocalRegistry(name="sideloaded-conflict", root=environment.shared_dir / "sideloaded-conflict") + 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_text("""\ package LANraragi::Plugin::Managed::Download::SampleDownload; @@ -856,7 +841,7 @@ async def test_plugin_install_blocked_against_sideloaded( 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 = registry.registry_json_path registry_json.write_text(json.dumps({ "version": 1, "generated_at": generated_at, @@ -879,15 +864,7 @@ async def test_plugin_install_blocked_against_sideloaded( }, })) - 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 + 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}" @@ -1036,55 +1013,28 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], 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 = LocalRegistry(name="registry-1", root=environment.shared_dir / "registry-1") + reg2 = LocalRegistry(name="registry-2", root=environment.shared_dir / "registry-2") + reg3 = LocalRegistry(name="registry-3", root=environment.shared_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) + reg1.root.mkdir(parents=True, exist_ok=True) + reg2.root.mkdir(parents=True, exist_ok=True) + reg3.root.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) + write_registry(reg1.root, [("1.0.0", 1), ("2.0.0", 1)], generated_at) + write_registry(reg2.root, [("1.0.0", 2), ("1.1.0", 2), ("2.0.0", 2)], generated_at) + write_registry(reg3.root, [("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 - + reg1_id = await add_registry(lrr_client, environment, reg1) 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 - + reg2_id = await add_registry(lrr_client, environment, reg2) 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 - + reg3_id = await add_registry(lrr_client, environment, reg3) 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 <<<<< @@ -1256,7 +1206,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: # >>>>> 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) + write_registry(reg3.root, [("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}" @@ -1312,18 +1262,18 @@ async def test_install_validation_classification( """ environment.setup(with_api_key=True) - registry_json = environment.local_registry_dir / "registry.json" + registry = LocalRegistry( + name="load-check-classification", root=environment.shared_dir / "load-check-classification" + ) + registry.root.mkdir(parents=True, exist_ok=True) + registry_json = registry.registry_json_path 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 + reg_id = await add_registry(lrr_client, environment, registry) # >>>>> 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 = registry.root / broken_rel broken_file.parent.mkdir(parents=True, exist_ok=True) broken_file.write_text("""\ package LANraragi::Plugin::Managed::Metadata::BrokenLoader; @@ -1372,7 +1322,7 @@ async def test_install_validation_classification( # >>>>> 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 = registry.root / slow_rel slow_file.parent.mkdir(parents=True, exist_ok=True) slow_file.write_text("""\ package LANraragi::Plugin::Managed::Metadata::SlowLoader; diff --git a/integration_tests/tests/registry/test_ougi.py b/integration_tests/tests/registry/test_ougi.py index be187a9b..47da1675 100644 --- a/integration_tests/tests/registry/test_ougi.py +++ b/integration_tests/tests/registry/test_ougi.py @@ -6,14 +6,13 @@ 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, ) +from aio_lanraragi_tests.registries.local_registry import LocalRegistry +from aio_lanraragi_tests.utils.api_wrappers import add_registry LOGGER = logging.getLogger(__name__) @@ -73,11 +72,9 @@ async def test_ougi_lifecycle( # <<<<< 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 + registry = LocalRegistry(name="ougi-test", root=environment.shared_dir / "ougi-test") + registry.generate_manifest() + reg_id = await add_registry(lrr_client, environment, registry) response, error = await lrr_client.misc_api.update_ougi(reg_id) assert not error, f"Failed to set Ougi (status {error.status}): {error.error}" diff --git a/integration_tests/tests/registry/test_plugin_lifecycle.py b/integration_tests/tests/registry/test_plugin_lifecycle.py index b4f19891..3be97282 100644 --- a/integration_tests/tests/registry/test_plugin_lifecycle.py +++ b/integration_tests/tests/registry/test_plugin_lifecycle.py @@ -28,7 +28,8 @@ AbstractLRRDeploymentContext, expect_no_error_logs, ) -from aio_lanraragi_tests.utils.api_wrappers import install_plugin_and_wait +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__) @@ -450,30 +451,25 @@ async def test_plugin_install_failed_require_rolls_back( }, } - plugin_file = environment.local_registry_dir / plugin_rel_path + 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 = environment.local_registry_dir / upgrade_v1_rel_path + 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 = environment.local_registry_dir / upgrade_v2_rel_path + 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 = environment.local_registry_dir / "registry.json" + registry_json = registry.registry_json_path registry_json.write_text(json.dumps(registry_data), encoding="utf-8") # >>>>> SETUP REGISTRY >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest( - name="local-broken", - provider="local", - path=environment.local_registry_path, - ) - ) - assert not error, f"Failed to create registry (status {error.status}): {error.error}" - reg_id = response.id + 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}" @@ -637,11 +633,14 @@ async def test_install_failure_preserves_other_plugins( 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}" - good_file = environment.local_registry_dir / good_rel_path + 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 = environment.local_registry_dir / broken_rel_path + broken_file = registry.root / broken_rel_path broken_file.parent.mkdir(parents=True, exist_ok=True) broken_file.write_bytes(broken_pm_bytes) @@ -681,19 +680,11 @@ async def test_install_failure_preserves_other_plugins( }, }, } - registry_json = environment.local_registry_dir / "registry.json" + registry_json = registry.registry_json_path registry_json.write_text(json.dumps(registry_data), encoding="utf-8") # >>>>> SETUP REGISTRY >>>>> - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest( - name="local-two-plugins", - provider="local", - path=environment.local_registry_path, - ) - ) - assert not error, f"Failed to create registry (status {error.status}): {error.error}" - reg_id = response.id + 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}" @@ -834,17 +825,16 @@ async def test_server_restart_status(lrr_client: LRRClient, environment: Abstrac }, }, } - plugin_file = environment.local_registry_dir / plugin_rel_path + 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 = environment.local_registry_dir / "registry.json" + registry_json = registry.registry_json_path registry_json.write_text(json.dumps(registry_data), encoding="utf-8") - response, error = await lrr_client.misc_api.create_registry( - CreateRegistryRequest(name="local-restart", provider="local", path=environment.local_registry_path) - ) - assert not error, f"Failed to create registry (status {error.status}): {error.error}" - reg_id = response.id + 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}" From 72f182fe533afb7ff48fa8dbc88bd11f7ccb2cd5 Mon Sep 17 00:00:00 2001 From: psilabs-dev <113860476+psilabs-dev@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:56:23 -0700 Subject: [PATCH 72/72] WIP: registry integration tests (archived, unfinished) Archived as part of registry feature retirement. Co-Authored-By: Claude Opus 5 --- .../tests/registry/test_local_registry.py | 214 +++++++++++------- integration_tests/tests/registry/test_ougi.py | 13 +- 2 files changed, 140 insertions(+), 87 deletions(-) diff --git a/integration_tests/tests/registry/test_local_registry.py b/integration_tests/tests/registry/test_local_registry.py index 02bea126..3017acca 100644 --- a/integration_tests/tests/registry/test_local_registry.py +++ b/integration_tests/tests/registry/test_local_registry.py @@ -13,6 +13,7 @@ import pytest from lanraragi.clients.client import LRRClient from lanraragi.models.misc import ( + CreateRegistryRequest, GetAvailablePluginsRequest, InstallPluginRequest, UsePluginRequest, @@ -22,9 +23,7 @@ AbstractLRRDeploymentContext, expect_no_error_logs, ) -from aio_lanraragi_tests.registries.local_registry import LocalRegistry from aio_lanraragi_tests.utils.api_wrappers import ( - add_registry, create_archive_file, install_plugin_and_wait, upload_archive, @@ -58,10 +57,17 @@ async def test_local_registry_install_errors( """ environment.setup(with_api_key=True) - registry = LocalRegistry(name="local-test", root=environment.shared_dir / "local-test") - registry.generate_manifest() - reg_id = await add_registry(lrr_client, environment, registry) - registry_json = registry.registry_json_path + 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 @@ -308,24 +314,13 @@ async def test_local_registry_install_errors( # <<<<< ABSOLUTE PATH REJECTED <<<<< # >>>>> SYMLINK ESCAPE REJECTED >>>>> - # Escape target lives inside the shared mount but OUTSIDE this registry's - # root, so LRR resolves the symlink and rejects it via root-confinement on - # every deployment -- not merely because the target is invisible, which is - # all a bind-mount boundary would prove. - escape_target = environment.shared_dir / "outside-plugin.pm" - symlink_path = registry.root / "artifacts" / "escape-link.pm" - # Relative target so the link resolves identically on the host and inside the - # container's mount namespace. - relative_target = escape_target.relative_to(symlink_path.parent, walk_up=True) - - def _plant_escape_symlink(): - escape_target.write_text("outside", encoding="utf-8") - 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(relative_target) - - await asyncio.to_thread(_plant_escape_symlink) + 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({ @@ -357,17 +352,17 @@ def _plant_escape_symlink(): InstallPluginRequest(namespace="symlink-plugin", registry=reg_id, version="1.0.0") ) assert error is not None, "Expected install to fail for symlink escape" - assert "escapes registry root" in error.error, ( - f"Expected symlink-escape install rejected via root confinement, got: {error.error!r}" - ) + 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: - await asyncio.to_thread(symlink_path.unlink, missing_ok=True) - await asyncio.to_thread(escape_target.unlink, missing_ok=True) + 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" - local_sample_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; @@ -389,10 +384,8 @@ def _plant_escape_symlink(): } 1; -""" - plugin_file = registry.root / plugin_rel_path - plugin_file.parent.mkdir(parents=True, exist_ok=True) - plugin_file.write_text(local_sample_pm, encoding="utf-8") +""", encoding="utf-8") + real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() # >>>>> SHA256 MISMATCH REJECTED >>>>> registry_json.write_text(json.dumps({ @@ -440,13 +433,27 @@ def _plant_escape_symlink(): # <<<<< SHA256 MISMATCH REJECTED <<<<< # >>>>> SHA256 MATCH INSTALLS >>>>> - registry.add_plugin( - "local-sample-downloader", "download", "1.0.0", - name="Local Sample", author="test", description="local sample downloader", - artifact_content=local_sample_pm, artifact_relpath=plugin_rel_path, - published_at=generated_at, - ) - registry.generate_manifest() + 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}" @@ -484,9 +491,7 @@ async def test_install_blocked_against_default_namespace( environment.setup(with_api_key=True) plugin_rel_path = "artifacts/copytags-impostor/1.0.0/CopyTagsImpostor.pm" - registry = LocalRegistry(name="default-conflict", root=environment.shared_dir / "default-conflict") - registry.root.mkdir(parents=True, exist_ok=True) - plugin_file = registry.root / plugin_rel_path + 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; @@ -512,7 +517,7 @@ async def test_install_blocked_against_default_namespace( real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - registry_json = registry.registry_json_path + registry_json = environment.local_registry_dir / "registry.json" registry_json.write_text(json.dumps({ "version": 1, "generated_at": generated_at, @@ -535,7 +540,15 @@ async def test_install_blocked_against_default_namespace( }, })) - reg_id = await add_registry(lrr_client, environment, registry) + 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}" @@ -581,9 +594,7 @@ async def test_install_blocked_against_invalid_filename( environment.setup(with_api_key=True) plugin_rel_path = "artifacts/filename-test/1.0.0/My Plugin.pm" - registry = LocalRegistry(name="filename-test", root=environment.shared_dir / "filename-test") - registry.root.mkdir(parents=True, exist_ok=True) - plugin_file = registry.root / plugin_rel_path + 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; @@ -609,7 +620,7 @@ async def test_install_blocked_against_invalid_filename( real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - registry_json = registry.registry_json_path + registry_json = environment.local_registry_dir / "registry.json" registry_json.write_text(json.dumps({ "version": 1, "generated_at": generated_at, @@ -632,7 +643,11 @@ async def test_install_blocked_against_invalid_filename( }, })) - reg_id = await add_registry(lrr_client, environment, registry) + 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}" @@ -676,9 +691,7 @@ async def test_install_blocked_against_package_mismatch( environment.setup(with_api_key=True) plugin_rel_path = "artifacts/package-mismatch/1.0.0/Foo.pm" - registry = LocalRegistry(name="package-mismatch", root=environment.shared_dir / "package-mismatch") - registry.root.mkdir(parents=True, exist_ok=True) - plugin_file = registry.root / plugin_rel_path + 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; @@ -704,7 +717,7 @@ async def test_install_blocked_against_package_mismatch( real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - registry_json = registry.registry_json_path + registry_json = environment.local_registry_dir / "registry.json" registry_json.write_text(json.dumps({ "version": 1, "generated_at": generated_at, @@ -727,7 +740,11 @@ async def test_install_blocked_against_package_mismatch( }, })) - reg_id = await add_registry(lrr_client, environment, registry) + 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}" @@ -813,9 +830,7 @@ async def test_plugin_install_blocked_against_sideloaded( ) plugin_rel_path = "artifacts/sample-downloader/1.0.0/SampleDownload.pm" - registry = LocalRegistry(name="sideloaded-conflict", root=environment.shared_dir / "sideloaded-conflict") - registry.root.mkdir(parents=True, exist_ok=True) - plugin_file = registry.root / plugin_rel_path + 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; @@ -841,7 +856,7 @@ async def test_plugin_install_blocked_against_sideloaded( real_sha = hashlib.sha256(plugin_file.read_bytes()).hexdigest() generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - registry_json = registry.registry_json_path + registry_json = environment.local_registry_dir / "registry.json" registry_json.write_text(json.dumps({ "version": 1, "generated_at": generated_at, @@ -864,7 +879,15 @@ async def test_plugin_install_blocked_against_sideloaded( }, })) - reg_id = await add_registry(lrr_client, environment, registry) + 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}" @@ -1013,28 +1036,55 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: "plugins": plugins, }), encoding="utf-8") - reg1 = LocalRegistry(name="registry-1", root=environment.shared_dir / "registry-1") - reg2 = LocalRegistry(name="registry-2", root=environment.shared_dir / "registry-2") - reg3 = LocalRegistry(name="registry-3", root=environment.shared_dir / "registry-3") + 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.root.mkdir(parents=True, exist_ok=True) - reg2.root.mkdir(parents=True, exist_ok=True) - reg3.root.mkdir(parents=True, exist_ok=True) + 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.root, [("1.0.0", 1), ("2.0.0", 1)], generated_at) - write_registry(reg2.root, [("1.0.0", 2), ("1.1.0", 2), ("2.0.0", 2)], generated_at) - write_registry(reg3.root, [("1.0.0", 3), ("1.1.0", 3), ("2.0.0", 3)], generated_at) + 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 >>>>> - reg1_id = await add_registry(lrr_client, environment, reg1) + 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}" - reg2_id = await add_registry(lrr_client, environment, reg2) + 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}" - reg3_id = await add_registry(lrr_client, environment, reg3) + 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 <<<<< @@ -1206,7 +1256,7 @@ def write_registry(reg_dir: Path, versions: list[tuple[str, int]], generated_at: # >>>>> 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.root, [("1.1.0", 3), ("2.0.0", 3)], generated_at) + 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}" @@ -1262,18 +1312,18 @@ async def test_install_validation_classification( """ environment.setup(with_api_key=True) - registry = LocalRegistry( - name="load-check-classification", root=environment.shared_dir / "load-check-classification" - ) - registry.root.mkdir(parents=True, exist_ok=True) - registry_json = registry.registry_json_path + registry_json = environment.local_registry_dir / "registry.json" generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - reg_id = await add_registry(lrr_client, environment, registry) + 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 = registry.root / broken_rel + 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; @@ -1322,7 +1372,7 @@ async def test_install_validation_classification( # >>>>> LOAD TIMEOUT -> 500 (operational fault) >>>>> slow_rel = "artifacts/slow-loader/1.0.0/SlowLoader.pm" - slow_file = registry.root / slow_rel + 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; diff --git a/integration_tests/tests/registry/test_ougi.py b/integration_tests/tests/registry/test_ougi.py index 47da1675..be187a9b 100644 --- a/integration_tests/tests/registry/test_ougi.py +++ b/integration_tests/tests/registry/test_ougi.py @@ -6,13 +6,14 @@ 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, ) -from aio_lanraragi_tests.registries.local_registry import LocalRegistry -from aio_lanraragi_tests.utils.api_wrappers import add_registry LOGGER = logging.getLogger(__name__) @@ -72,9 +73,11 @@ async def test_ougi_lifecycle( # <<<<< SET NONEXISTENT ID <<<<< # >>>>> SET VALID ID >>>>> - registry = LocalRegistry(name="ougi-test", root=environment.shared_dir / "ougi-test") - registry.generate_manifest() - reg_id = await add_registry(lrr_client, environment, registry) + 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}"