diff --git a/.env.example b/.env.example index f8c0b3d7d..be3163da5 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,9 @@ FORWARDED_ALLOW_IPS="*" # Inside this directory the `extensions` and `upgrades` sub-directories will be created. # LNBITS_EXTENSIONS_PATH="/path/to/some/dir" +# Path where WASM extensions will be installed (defaults to `LNBITS_DATA_FOLDER/wasm_extensions`). +# LNBITS_WASM_EXTENSIONS_PATH="/path/to/some/dir" + # ID of the super user. The user ID must exist. # SUPER_USER="" diff --git a/lnbits/app.py b/lnbits/app.py index f1f55b833..ec54d586b 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -54,6 +54,7 @@ from lnbits.core.wasm_ext.routes.register import ( ) from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid from lnbits.core.wasm_ext.wasm.loader import ( + is_wasm_extension_dir, is_wasm_extension_id, ) from lnbits.exceptions import register_exception_handlers @@ -312,7 +313,30 @@ async def build_all_installed_extensions_list( # noqa: C901 installed_extensions = await get_installed_extensions() settings.lnbits_installed_extensions_ids = {e.id for e in installed_extensions} - for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir(): + settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True) + for ext_dir in settings.wasm_extensions_dir.iterdir(): + try: + if not ext_dir.is_dir() or not is_wasm_extension_dir(ext_dir): + continue + ext_id = ext_dir.name + if ext_id in settings.lnbits_installed_extensions_ids: + continue + ext_info = InstallableExtension.from_wasm_ext_dir(ext_id) + if not ext_info: + continue + + installed_extensions.append(ext_info) + settings.lnbits_installed_extensions_ids.add(ext_id) + await create_installed_extension(ext_info) + current_version = await get_db_version(ext_id) + await migrate_extension_database(ext_info, current_version) + + except Exception as e: + logger.warning(e) + + ext_dir_path = Path(settings.lnbits_extensions_path, "extensions") + existing_ext_dirs = ext_dir_path.iterdir() if ext_dir_path.is_dir() else [] + for ext_dir in existing_ext_dirs: try: if not ext_dir.is_dir(): continue @@ -367,14 +391,18 @@ async def build_all_installed_extensions_list( # noqa: C901 async def check_installed_extension_files(ext: InstallableExtension) -> bool: - if ext.has_installed_version: + if ext.is_wasm or ext.has_installed_version: return True zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip")) if f"./{ext.zip_path!s}" not in zip_files: await ext.download_archive() - ext.extract_archive() + archive_config = ext.load_archive_config() + if archive_config.get("extension_type") == "wasm": + ext.extract_wasm_archive() + else: + ext.extract_archive() return False @@ -415,6 +443,7 @@ def register_custom_extensions_path(): extensions_dir = Path(settings.lnbits_extensions_path, "extensions") Path(extensions_dir).mkdir(parents=True, exist_ok=True) sys.path.append(str(extensions_dir)) + settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True) def register_new_ext_routes(app: FastAPI) -> Callable: diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index 1b0c6755a..9f143f08f 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -544,6 +544,10 @@ class InstallableExtension(BaseModel): def ext_dir(self) -> Path: return Path(settings.lnbits_extensions_path, "extensions", self.id) + @property + def wasm_ext_dir(self) -> Path: + return Path(settings.wasm_extensions_dir, self.id) + @property def ext_upgrade_dir(self) -> Path: return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}") @@ -577,7 +581,7 @@ class InstallableExtension(BaseModel): @property def is_wasm(self) -> bool: - config_path = Path(self.ext_dir, "config.json") + config_path = Path(self.wasm_ext_dir, "config.json") if not config_path.is_file(): return False try: @@ -673,6 +677,37 @@ class InstallableExtension(BaseModel): shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir)) logger.info(f"Extension {self.name} ({self.installed_version}) extracted.") + def extract_wasm_archive(self): + logger.info(f"Extracting extension {self.name} ({self.installed_version}).") + + tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash) + shutil.rmtree(tmp_dir, True) + with zipfile.ZipFile(self.zip_path, "r") as zip_ref: + zip_ref.extractall(tmp_dir) + generated_dir_name = os.listdir(tmp_dir)[0] + extracted_dir = Path(tmp_dir, generated_dir_name) + + with open(Path(extracted_dir, "config.json"), "r+") as json_file: + config_json = json.load(json_file) + + self.name = config_json.get("name") + self.short_description = config_json.get("short_description") + + if ( + self.meta + and self.meta.installed_release + and self.meta.installed_release.is_github_release + and config_json.get("tile") + ): + self.icon = icon_to_github_url( + self.meta.installed_release.source_repo, config_json.get("tile") + ) + + shutil.rmtree(self.wasm_ext_dir, True) + shutil.copytree(extracted_dir, self.wasm_ext_dir) + shutil.rmtree(tmp_dir, True) + logger.info(f"Extension {self.name} ({self.installed_version}) extracted.") + def clean_extension_files(self): # remove downloaded archive if self.zip_path.is_file(): @@ -683,6 +718,12 @@ class InstallableExtension(BaseModel): shutil.rmtree(self.ext_upgrade_dir, True) + def clean_wasm_extension_files(self): + if self.zip_path.is_file(): + os.remove(self.zip_path) + + shutil.rmtree(self.wasm_ext_dir, True) + def check_release_updates(self, release: ExtensionRelease | None): self._check_latest_version(release) self._check_payment_link(release) @@ -831,6 +872,41 @@ class InstallableExtension(BaseModel): return None + @classmethod + def from_wasm_ext_dir(cls, ext_id: str) -> InstallableExtension | None: + try: + conf_path = Path(settings.wasm_extensions_dir, ext_id, "config.json") + if not conf_path.is_file(): + return None + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + version = config_json.get("version", "0.0") + + return InstallableExtension( + id=ext_id, + name=config_json.get("name", ext_id), + active=True, + version=version, + short_description=config_json.get("short_description"), + icon=config_json.get("tile"), + permissions=ExtensionPermission.list_from_config(config_json), + meta=ExtensionMeta( + installed_release=ExtensionRelease( + name=ext_id, + version=version, + archive=f"{conf_path}", + source_repo=f"{conf_path}", + min_lnbits_version=config_json.get("min_lnbits_version"), + max_lnbits_version=config_json.get("max_lnbits_version"), + ) + ), + ) + + except Exception as e: + logger.warning(e) + + return None + @classmethod async def get_installable_extensions( cls, post_refresh_cache: bool = False diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py index a68fffcc9..3e788f1e7 100644 --- a/lnbits/core/services/extensions.py +++ b/lnbits/core/services/extensions.py @@ -217,7 +217,10 @@ async def install_extension( allow_admin_policy_overrides=allow_admin_policy_overrides, ) - ext_info.extract_archive() + if extension_config.get("extension_type") == "wasm": + ext_info.extract_wasm_archive() + else: + ext_info.extract_archive() db_version = await get_db_version(ext_info.id) await migrate_extension_database(ext_info, db_version) @@ -645,7 +648,10 @@ async def uninstall_extension(ext_id: str): extension = await get_installed_extension(ext_id) if extension: - extension.clean_extension_files() + if extension.is_wasm: + extension.clean_wasm_extension_files() + else: + extension.clean_extension_files() await delete_installed_extension(ext_id=ext_id) diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index c94575c46..36a98cf6a 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -129,7 +129,14 @@ async def api_install_extension(data: CreateExtension): logger.warning(exc) etype, _, tb = sys.exc_info() traceback.print_exception(etype, exc, tb) - ext_info.clean_extension_files() + try: + archive_config = ext_info.load_archive_config() + except ValueError: + archive_config = {} + if archive_config.get("extension_type") == "wasm": + ext_info.clean_wasm_extension_files() + else: + ext_info.clean_extension_files() detail = ( str(exc) if isinstance(exc, (AssertionError, ValueError)) @@ -1054,7 +1061,8 @@ async def create_extension_review( def _load_installed_extension_config(extension: InstallableExtension) -> dict: - config_path = extension.ext_dir / "config.json" + ext_dir = extension.wasm_ext_dir if extension.is_wasm else extension.ext_dir + config_path = ext_dir / "config.json" if not config_path.is_file(): raise ValueError(f"Extension '{extension.id}' config file is missing.") try: diff --git a/lnbits/core/wasm_ext/storage/crud.py b/lnbits/core/wasm_ext/storage/crud.py index 7c6482310..4c17f5c35 100644 --- a/lnbits/core/wasm_ext/storage/crud.py +++ b/lnbits/core/wasm_ext/storage/crud.py @@ -254,7 +254,7 @@ async def migrate_wasm_extension_database( ext: InstallableExtension, current_version: DbVersion | None = None, ) -> None: - migrations_dir = ext.ext_dir / "storage" / "migrations" + migrations_dir = ext.wasm_ext_dir / "storage" / "migrations" migration_files = _migration_files(migrations_dir) if not migration_files: logger.debug(f"No storage migrations for WASM extension '{ext.id}'.") @@ -433,13 +433,7 @@ def _load_table_schema(ext_id: str, table: str) -> dict[str, Any]: def _load_storage_schema(ext_id: str) -> dict[str, Any]: - schema_path = ( - Path(settings.lnbits_extensions_path) - / "extensions" - / ext_id - / "storage" - / "schema.json" - ) + schema_path = settings.wasm_extensions_dir / ext_id / "storage" / "schema.json" if not schema_path.is_file(): raise ValueError(f"WASM extension '{ext_id}' has no storage schema.") return _load_json(schema_path) diff --git a/lnbits/core/wasm_ext/wasm/loader.py b/lnbits/core/wasm_ext/wasm/loader.py index a1f4ea43b..c62f03a06 100644 --- a/lnbits/core/wasm_ext/wasm/loader.py +++ b/lnbits/core/wasm_ext/wasm/loader.py @@ -27,7 +27,7 @@ class WasmExtension: def is_wasm_extension_id(ext_id: str) -> bool: - ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + ext_dir = Path(settings.wasm_extensions_dir, ext_id) config = _load_json(ext_dir / "config.json") return bool(config and config.get("extension_type") == "wasm") @@ -38,7 +38,7 @@ def is_wasm_extension_dir(ext_dir: Path) -> bool: def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None: - ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + ext_dir = Path(settings.wasm_extensions_dir, ext_id) config = _load_json(ext_dir / "config.json") if not config or config.get("extension_type") != "wasm": return None @@ -46,7 +46,7 @@ def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None: def load_wasm_extension(ext_id: str) -> WasmExtension: - ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + ext_dir = Path(settings.wasm_extensions_dir, ext_id) raw_config = _load_json(ext_dir / "config.json") if not raw_config: raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.") diff --git a/lnbits/server.py b/lnbits/server.py index ea7af7a24..6fbf078c7 100644 --- a/lnbits/server.py +++ b/lnbits/server.py @@ -49,6 +49,7 @@ def main( Path(settings.lnbits_extensions_path, "extensions").mkdir( parents=True, exist_ok=True ) + settings.wasm_extensions_dir.mkdir(parents=True, exist_ok=True) set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips) diff --git a/lnbits/settings.py b/lnbits/settings.py index c09f5f7db..a123c5018 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -1161,6 +1161,32 @@ class EnvSettings(LNbitsSettings): class PersistenceSettings(LNbitsSettings): lnbits_data_folder: str = Field(default="./data") lnbits_database_url: str | None = Field(default=None) + lnbits_wasm_extensions_path: str = Field(default="") + + @validator("lnbits_wasm_extensions_path", pre=True, always=True) + @classmethod + def validate_wasm_extensions_path(cls, value, values) -> str: + if value: + return str(value) + return str(Path(values.get("lnbits_data_folder", "./data"), "wasm_extensions")) + + @property + def wasm_extensions_dir(self) -> Path: + wasm_dir = Path(self.lnbits_wasm_extensions_path) + importable_dirs = ( + Path(getattr(self, "lnbits_extensions_path", "lnbits"), "extensions"), + Path(self.lnbits_data_folder, "upgrades"), + ) + resolved_wasm_dir = wasm_dir.resolve() + if any( + resolved_dir == resolved_wasm_dir + or resolved_dir in resolved_wasm_dir.parents + for resolved_dir in (path.resolve() for path in importable_dirs) + ): + raise ValueError( + "WASM extensions path must be outside importable extension directories." + ) + return wasm_dir class SuperUserSettings(LNbitsSettings): diff --git a/tests/api/test_extension_api.py b/tests/api/test_extension_api.py index db0e78331..f27a2cef7 100644 --- a/tests/api/test_extension_api.py +++ b/tests/api/test_extension_api.py @@ -296,6 +296,7 @@ async def test_extension_api_installs_wasm_with_granted_permissions( ) original_data_folder = settings.lnbits_data_folder original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path register_wasm_routes_mock = mocker.patch( "lnbits.core.services.extensions.core_app_extra.register_new_wasm_ext_routes" ) @@ -313,6 +314,7 @@ async def test_extension_api_installs_wasm_with_granted_permissions( try: settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path / "code") + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") _write_wasm_extension_archive(ext_id, release.version, settings) installed = await api_install_extension(create_data) @@ -321,6 +323,7 @@ async def test_extension_api_installs_wasm_with_granted_permissions( await delete_installed_extension(ext_id=ext_id) settings.lnbits_data_folder = original_data_folder settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path assert installed.code == ext_id assert installed.is_wasm is True @@ -351,10 +354,12 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad ) ] original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path try: settings.lnbits_extensions_path = str(tmp_path) - _write_installed_wasm_config(ext_id, tmp_path) + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") + _write_installed_wasm_config(ext_id, settings.wasm_extensions_dir) await create_installed_extension( InstallableExtension( id=ext_id, @@ -396,6 +401,7 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad await delete_installed_extension(ext_id=ext_id) await delete_installed_extension(ext_id=py_ext_id) settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path assert wasm_info.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234} assert py_ext_id not in {info.id for info in runtime_extensions} @@ -421,6 +427,7 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits( ): ext_id = f"wasm_{uuid4().hex[:8]}" original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path manifest_permissions = [ { "id": "ext.storage.append_public", @@ -456,9 +463,10 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits( try: settings.lnbits_extensions_path = str(tmp_path) + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") _write_installed_wasm_config( ext_id, - tmp_path, + settings.wasm_extensions_dir, permissions=manifest_permissions, ) await create_installed_extension( @@ -479,6 +487,7 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits( finally: await delete_installed_extension(ext_id=ext_id) settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path assert response.extension_permissions == [ ExtensionPermission( @@ -784,10 +793,10 @@ def _write_wasm_extension_archive( def _write_installed_wasm_config( ext_id: str, - extensions_path, + wasm_extensions_path, permissions: list[dict] | None = None, ) -> None: - config_dir = extensions_path / "extensions" / ext_id + config_dir = wasm_extensions_path / ext_id config_dir.mkdir(parents=True) (config_dir / "config.json").write_text( json.dumps(_wasm_config(ext_id, permissions=permissions)), diff --git a/tests/conftest.py b/tests/conftest.py index 1f40f0288..0076fafa3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,6 +70,7 @@ def settings(): lnbits_settings.auth_https_only = False lnbits_settings.lnbits_admin_extensions = [] lnbits_settings.lnbits_data_folder = "./tests/data" + lnbits_settings.lnbits_wasm_extensions_path = "./tests/data/wasm_extensions" lnbits_settings.lnbits_admin_ui = True lnbits_settings.lnbits_extensions_default_install = [] lnbits_settings.lnbits_extensions_deactivate_all = True @@ -363,6 +364,7 @@ def _settings_cleanup(settings: Settings): _restore_pure_settings(settings) settings.auth_https_only = False settings.lnbits_data_folder = "./tests/data" + settings.lnbits_wasm_extensions_path = "./tests/data/wasm_extensions" settings.bundle_assets = True settings.lnbits_admin_ui = True settings.lnbits_extensions_default_install = [] diff --git a/tests/unit/test_app_extensions.py b/tests/unit/test_app_extensions.py new file mode 100644 index 000000000..3561a8006 --- /dev/null +++ b/tests/unit/test_app_extensions.py @@ -0,0 +1,71 @@ +import json +from pathlib import Path + +import pytest +from pytest_mock.plugin import MockerFixture + +from lnbits.app import build_all_installed_extensions_list +from lnbits.settings import Settings + + +@pytest.mark.anyio +async def test_wasm_extension_discovery_uses_configured_directory( + tmp_path: Path, + settings: Settings, + mocker: MockerFixture, +): + discovered_id = "discovered_wasm" + original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path + original_installed_ids = set(settings.lnbits_installed_extensions_ids) + + settings.lnbits_extensions_path = str(tmp_path / "code") + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") + Path(settings.lnbits_extensions_path, "extensions").mkdir(parents=True) + _write_wasm_config(settings.wasm_extensions_dir / discovered_id, discovered_id) + + mocker.patch( + "lnbits.app.get_installed_extensions", + mocker.AsyncMock(return_value=[]), + ) + create_mock = mocker.patch( + "lnbits.app.create_installed_extension", + mocker.AsyncMock(), + ) + mocker.patch( + "lnbits.app.get_db_version", + mocker.AsyncMock(return_value=None), + ) + migrate_mock = mocker.patch( + "lnbits.app.migrate_extension_database", + mocker.AsyncMock(), + ) + + try: + installed = await build_all_installed_extensions_list() + finally: + settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path + settings.lnbits_installed_extensions_ids = original_installed_ids + + assert [extension.id for extension in installed] == [discovered_id] + create_mock.assert_awaited_once() + assert create_mock.await_args is not None + assert create_mock.await_args.args[0].id == discovered_id + migrate_mock.assert_awaited_once() + + +def _write_wasm_config(ext_dir: Path, ext_id: str) -> None: + ext_dir.mkdir(parents=True) + (ext_dir / "config.json").write_text( + json.dumps( + { + "id": ext_id, + "name": ext_id, + "version": "1.0.0", + "extension_type": "wasm", + "wasm": {"module": "extension.wasm"}, + } + ), + encoding="utf-8", + ) diff --git a/tests/unit/test_services_extensions.py b/tests/unit/test_services_extensions.py index b74fa428f..96211ee10 100644 --- a/tests/unit/test_services_extensions.py +++ b/tests/unit/test_services_extensions.py @@ -164,6 +164,7 @@ async def test_install_wasm_extension_requires_permissions_and_skips_background_ ext_info = make_installable_extension(ext_id) original_data_folder = settings.lnbits_data_folder original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path start_mock = mocker.patch( "lnbits.core.services.extensions.start_extension_background_work", mocker.AsyncMock(return_value=True), @@ -172,7 +173,11 @@ async def test_install_wasm_extension_requires_permissions_and_skips_background_ try: settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path / "code") + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") _write_wasm_extension_archive(ext_info, _wasm_install_config(ext_id)) + wasm_ext_dir = settings.wasm_extensions_dir / ext_id + py_ext_dir = ext_info.ext_dir + upgrade_dir = ext_info.ext_upgrade_dir with pytest.raises(ValueError, match="requires permission approval"): await install_extension(ext_info, skip_download=True) @@ -193,6 +198,7 @@ async def test_install_wasm_extension_requires_permissions_and_skips_background_ await delete_installed_extension(ext_id=ext_id) settings.lnbits_data_folder = original_data_folder settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path assert extension.code == ext_id assert extension.is_wasm is True @@ -205,6 +211,9 @@ async def test_install_wasm_extension_requires_permissions_and_skips_background_ ) ] start_mock.assert_not_awaited() + assert wasm_ext_dir.is_dir() + assert not py_ext_dir.exists() + assert not upgrade_dir.exists() @pytest.mark.anyio @@ -269,16 +278,18 @@ async def test_uninstall_wasm_extension_unregisters_live_routes( ext_info = make_installable_extension(ext_id) original_data_folder = settings.lnbits_data_folder original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path original_deactivated = set(settings.lnbits_deactivated_extensions) unregister_routes_mock = mocker.patch( "lnbits.core.services.extensions.core_app_extra.unregister_wasm_ext_routes" ) - clean_mock = mocker.patch.object(InstallableExtension, "clean_extension_files") + clean_mock = mocker.patch.object(InstallableExtension, "clean_wasm_extension_files") try: settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path / "code") - ext_dir = tmp_path / "code" / "extensions" / ext_id + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") + ext_dir = settings.wasm_extensions_dir / ext_id ext_dir.mkdir(parents=True, exist_ok=True) (ext_dir / "config.json").write_text( json.dumps(_wasm_install_config(ext_id)), @@ -294,6 +305,7 @@ async def test_uninstall_wasm_extension_unregisters_live_routes( await delete_installed_extension(ext_id=ext_id) settings.lnbits_data_folder = original_data_folder settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path settings.lnbits_deactivated_extensions = original_deactivated unregister_routes_mock.assert_called_once_with(ext_id) @@ -579,9 +591,11 @@ async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides( ): ext_id = "wasm_demo" original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path try: settings.lnbits_extensions_path = str(tmp_path) - config_dir = tmp_path / "extensions" / ext_id + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") + config_dir = settings.wasm_extensions_dir / ext_id config_dir.mkdir(parents=True) (config_dir / "config.json").write_text( '{"extension_type": "wasm"}', @@ -612,6 +626,7 @@ async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides( ) finally: settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path assert saved_limits == { "wasm_runtime_max_execution_ms": 15000, diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index feda45290..b28318a5b 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Any import pytest @@ -214,6 +215,40 @@ def test_settings_keep_wasm_manifests_separate_from_extension_manifests(): ] +def test_wasm_extensions_directory_defaults_to_data_folder_and_is_configurable( + tmp_path: Path, +): + data_folder = tmp_path / "data" + default_settings = Settings( + lnbits_data_folder=str(data_folder), + lnbits_wasm_extensions_path="", + ) + custom_path = tmp_path / "custom-wasm" + custom_settings = Settings( + lnbits_data_folder=str(data_folder), + lnbits_wasm_extensions_path=str(custom_path), + ) + + assert default_settings.wasm_extensions_dir == data_folder / "wasm_extensions" + assert default_settings.lnbits_wasm_extensions_path == str( + data_folder / "wasm_extensions" + ) + assert custom_settings.wasm_extensions_dir == custom_path + + +def test_wasm_extensions_directory_must_not_be_importable(tmp_path: Path): + settings = Settings( + lnbits_data_folder=str(tmp_path / "data"), + lnbits_extensions_path=str(tmp_path / "code"), + lnbits_wasm_extensions_path=str( + tmp_path / "code" / "extensions" / "wasm_extensions" + ), + ) + + with pytest.raises(ValueError, match="outside importable extension directories"): + _ = settings.wasm_extensions_dir + + def test_exchange_rate_provider_convert_ticker(): provider = ExchangeRateProvider( name="Provider", diff --git a/tests/unit/test_wasm_extension_loader.py b/tests/unit/test_wasm_extension_loader.py index ff395c2ce..5ba6bbcef 100644 --- a/tests/unit/test_wasm_extension_loader.py +++ b/tests/unit/test_wasm_extension_loader.py @@ -8,7 +8,10 @@ from lnbits.core.models.extensions import ExtensionPermission from lnbits.core.models.misc import WasmExtensionRegistry from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config -from lnbits.core.wasm_ext.wasm.loader import WasmExtension, load_wasm_extension +from lnbits.core.wasm_ext.wasm.loader import ( + WasmExtension, + load_wasm_extension, +) from lnbits.settings import Settings from tests.helpers import make_installable_extension @@ -185,8 +188,8 @@ def _write_wasm_extension( *, config_id: str | None, ) -> None: - settings.lnbits_extensions_path = str(tmp_path) - ext_dir = tmp_path / "extensions" / ext_id + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") + ext_dir = settings.wasm_extensions_dir / ext_id ext_dir.mkdir(parents=True) (ext_dir / "extension.wasm").write_bytes(b"\0asm") config = { diff --git a/tests/unit/test_wasm_extension_storage.py b/tests/unit/test_wasm_extension_storage.py index eb4dcbc44..572c33149 100644 --- a/tests/unit/test_wasm_extension_storage.py +++ b/tests/unit/test_wasm_extension_storage.py @@ -225,10 +225,12 @@ async def test_wasm_storage_migration_and_owner_scoped_crud( ): ext_id = f"wasmstore_{uuid4().hex[:8]}" original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path original_data_folder = settings.lnbits_data_folder try: settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path / "code") + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") Path(settings.lnbits_data_folder).mkdir(parents=True) ext_dir = _write_storage_extension(settings, ext_id) @@ -281,6 +283,7 @@ async def test_wasm_storage_migration_and_owner_scoped_crud( deleted = await storage_get_row(ext_id, "notes", "note-1", "owner-1") finally: settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path settings.lnbits_data_folder = original_data_folder assert ext_dir.is_dir() @@ -304,10 +307,12 @@ async def test_wasm_storage_public_append_generates_id_and_counts_by_owner( ): ext_id = f"wasmstore_{uuid4().hex[:8]}" original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path original_data_folder = settings.lnbits_data_folder try: settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path / "code") + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") Path(settings.lnbits_data_folder).mkdir(parents=True) _write_storage_extension(settings, ext_id) @@ -339,6 +344,7 @@ async def test_wasm_storage_public_append_generates_id_and_counts_by_owner( message = await storage_get_row(ext_id, "messages", message_id, "owner-1") finally: settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path settings.lnbits_data_folder = original_data_folder assert message_id @@ -357,10 +363,12 @@ async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers( ): ext_id = f"wasmstore_{uuid4().hex[:8]}" original_extensions_path = settings.lnbits_extensions_path + original_wasm_extensions_path = settings.lnbits_wasm_extensions_path original_data_folder = settings.lnbits_data_folder try: settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path / "code") + settings.lnbits_wasm_extensions_path = str(tmp_path / "wasm_extensions") Path(settings.lnbits_data_folder).mkdir(parents=True) _write_storage_extension(settings, ext_id) @@ -375,13 +383,7 @@ async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers( "owner-1", ) - schema_path = ( - Path(settings.lnbits_extensions_path) - / "extensions" - / ext_id - / "storage" - / "schema.json" - ) + schema_path = settings.wasm_extensions_dir / ext_id / "storage" / "schema.json" schema = json.loads(schema_path.read_text(encoding="utf-8")) schema["tables"]["notes"]["fields"].append( {"name": OWNER_ID_FIELD, "type": "string"} @@ -392,6 +394,7 @@ async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers( await storage_get_row(ext_id, "notes", "note-1", "owner-1") finally: settings.lnbits_extensions_path = original_extensions_path + settings.lnbits_wasm_extensions_path = original_wasm_extensions_path settings.lnbits_data_folder = original_data_folder @@ -429,7 +432,7 @@ async def _temporary_core_crud_database( def _write_storage_extension(settings: Settings, ext_id: str) -> Path: - ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id + ext_dir = settings.wasm_extensions_dir / ext_id storage_dir = ext_dir / "storage" migrations_dir = storage_dir / "migrations" migrations_dir.mkdir(parents=True)