From 74ecf0bd45ac61f44fc8f1fc8fc6f7d1c2316075 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 21 Jul 2026 17:23:58 +0300 Subject: [PATCH] test: better checks --- lnbits/app.py | 15 +- lnbits/core/helpers.py | 3 +- lnbits/core/models/extensions.py | 337 +++++++++++++++++++++- lnbits/core/services/extensions.py | 69 ++++- lnbits/core/views/extension_api.py | 6 +- lnbits/core/wasm_ext/api/permissions.py | 5 + lnbits/core/wasm_ext/storage/crud.py | 8 +- lnbits/core/wasm_ext/wasm/loader.py | 17 +- lnbits/settings.py | 4 + tests/api/test_extension_api.py | 183 +++++++++++- tests/unit/test_services_extensions.py | 52 +++- tests/unit/test_wasm_extension_loader.py | 90 +++++- tests/unit/test_wasm_extension_storage.py | 61 ++-- 13 files changed, 792 insertions(+), 58 deletions(-) diff --git a/lnbits/app.py b/lnbits/app.py index f1f55b833..4a929bd42 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -53,9 +53,6 @@ from lnbits.core.wasm_ext.routes.register import ( unregister_wasm_extension, ) from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid -from lnbits.core.wasm_ext.wasm.loader import ( - is_wasm_extension_id, -) from lnbits.exceptions import register_exception_handlers from lnbits.helpers import version_parse from lnbits.llms_txt import create_llms_txt_route @@ -374,7 +371,15 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool: if f"./{ext.zip_path!s}" not in zip_files: await ext.download_archive() - ext.extract_archive() + extension_config = ext.load_archive_config() + if ext.expects_wasm or extension_config.get("extension_type") == "wasm": + if ext.expects_wasm and extension_config.get("extension_type") != "wasm": + raise ValueError( + f"Extension '{ext.id}' release is WASM but archive is not WASM." + ) + ext.extract_wasm_archive(ext.load_wasm_archive_config()) + else: + ext.extract_archive() return False @@ -494,7 +499,7 @@ async def check_and_register_extensions(app: FastAPI) -> None: await check_installed_extensions(app) for ext in await get_valid_extensions(False): try: - if is_wasm_extension_id(ext.code): + if ext.is_wasm: register_wasm_extension(app, ext.code) continue register_ext_routes(app, ext) diff --git a/lnbits/core/helpers.py b/lnbits/core/helpers.py index 35b92629a..588406408 100644 --- a/lnbits/core/helpers.py +++ b/lnbits/core/helpers.py @@ -16,7 +16,6 @@ from lnbits.core.db import db as core_db from lnbits.core.models import DbVersion from lnbits.core.models.extensions import InstallableExtension from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database -from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection from lnbits.settings import settings @@ -24,7 +23,7 @@ from lnbits.settings import settings async def migrate_extension_database( ext: InstallableExtension, current_version: DbVersion | None = None ): - if is_wasm_extension_id(ext.id): + if ext.is_wasm: await migrate_wasm_extension_database(ext, current_version) return else: diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index 665e0c29f..e1d1753c5 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -4,9 +4,12 @@ import asyncio import hashlib import json import os +import re import shutil +import stat import zipfile from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum from pathlib import Path, PurePosixPath @@ -27,6 +30,21 @@ from lnbits.settings import settings from lnbits.task_manager import task_manager from lnbits.utils.cache import cache +_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_PATH_TOKEN_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +_WASM_NATIVE_ARTIFACT_SUFFIXES = { + ".dll", + ".dylib", + ".exe", + ".py", + ".pyc", + ".pyd", + ".pyo", + ".so", +} +_WASM_NATIVE_ARTIFACT_FILENAMES = {"__init__.py"} +_WASM_NATIVE_ARTIFACT_DIRS = {"__pycache__"} + class ExplicitRelease(BaseModel): id: str @@ -34,6 +52,7 @@ class ExplicitRelease(BaseModel): version: str archive: str hash: str + extension_type: str | None = None dependencies: list[str] = [] repo: str | None icon: str | None @@ -339,6 +358,7 @@ class ExtensionRelease(BaseModel): version: str archive: str source_repo: str + extension_type: str | None = None is_github_release: bool = False hash: str | None = None min_lnbits_version: str | None = None @@ -411,6 +431,7 @@ class ExtensionRelease(BaseModel): name=e.name, version=e.version, archive=e.archive, + extension_type=e.extension_type, hash=e.hash, source_repo=source_repo, description=e.short_description, @@ -449,6 +470,7 @@ class ExtensionRelease(BaseModel): release.min_lnbits_version = config.min_lnbits_version release.max_lnbits_version = config.max_lnbits_version + release.extension_type = config.extension_type release.is_version_compatible = config.is_version_compatible() release.icon = icon_to_github_url(f"{org}/{repo}", config.tile) @@ -523,17 +545,30 @@ class InstallableExtension(BaseModel): @property def zip_path(self) -> Path: + ext_id = _extension_id_for_path(self.id) extensions_data_dir = Path(settings.lnbits_data_folder, "zips") Path(extensions_data_dir).mkdir(parents=True, exist_ok=True) - return Path(extensions_data_dir, f"{self.id}.zip") + return Path(extensions_data_dir, f"{ext_id}.zip") @property def ext_dir(self) -> Path: - return Path(settings.lnbits_extensions_path, "extensions", self.id) + return Path( + settings.lnbits_extensions_path, + "extensions", + _extension_id_for_path(self.id), + ) + + @property + def wasm_ext_dir(self) -> Path: + return Path( + settings.lnbits_wasm_extensions_path, _extension_id_for_path(self.id) + ) @property def ext_upgrade_dir(self) -> Path: - return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}") + ext_id = _extension_id_for_path(self.id) + ext_hash = _path_token(self.hash, "extension archive hash") + return Path(settings.lnbits_extensions_upgrade_path, f"{ext_id}-{ext_hash}") @property def module_name(self) -> str: @@ -546,6 +581,10 @@ class InstallableExtension(BaseModel): @property def has_installed_version(self) -> bool: + if self.expects_wasm: + return Path(self.wasm_ext_dir, "config.json").is_file() + if Path(self.wasm_ext_dir, "config.json").is_file(): + return True if not self.ext_dir.is_dir(): return False return Path(self.ext_dir, "config.json").is_file() @@ -564,6 +603,16 @@ class InstallableExtension(BaseModel): @property def is_wasm(self) -> bool: + if self.expects_wasm: + return True + config_path = Path(self.wasm_ext_dir, "config.json") + if config_path.is_file(): + try: + with open(config_path, encoding="utf-8") as json_file: + config_json = json.load(json_file) + except Exception: + return False + return config_json.get("extension_type") == "wasm" config_path = Path(self.ext_dir, "config.json") if not config_path.is_file(): return False @@ -574,6 +623,14 @@ class InstallableExtension(BaseModel): return False return config_json.get("extension_type") == "wasm" + @property + def expects_wasm(self) -> bool: + return bool( + self.meta + and self.meta.installed_release + and self.meta.installed_release.extension_type == "wasm" + ) + async def download_archive(self): logger.info(f"Downloading extension {self.name} ({self.installed_version}).") ext_zip_file = self.zip_path @@ -622,6 +679,27 @@ class InstallableExtension(BaseModel): return config if isinstance(config, dict) else {} + def load_wasm_archive_config(self) -> dict[str, Any]: + if not self.zip_path.is_file(): + raise ValueError(f"Missing WASM extension archive for '{self.id}'.") + + try: + with zipfile.ZipFile(self.zip_path, "r") as archive: + layout = _wasm_archive_layout(archive.infolist()) + with archive.open(layout.config_name) as config_file: + config = json.load(config_file) + except Exception as exc: + if isinstance(exc, ValueError): + raise + raise ValueError( + f"Cannot read WASM extension config for '{self.id}'." + ) from exc + + if not isinstance(config, dict): + raise ValueError(f"WASM extension '{self.id}' config file is invalid.") + _validate_wasm_archive_config(self.id, config) + return config + def extract_archive(self): logger.info(f"Extracting extension {self.name} ({self.installed_version}).") Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True) @@ -660,6 +738,52 @@ 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, extension_config: dict[str, Any]) -> None: + logger.info( + f"Extracting WASM extension {self.name} ({self.installed_version})." + ) + _validate_wasm_archive_config(self.id, extension_config) + tmp_dir = Path( + settings.lnbits_data_folder, + "wasm-unzip-temp", + _path_token(self.hash, "extension archive hash"), + ) + package_dir = tmp_dir / "package" + shutil.rmtree(tmp_dir, True) + package_dir.mkdir(parents=True, exist_ok=True) + + try: + with zipfile.ZipFile(self.zip_path, "r") as archive: + layout = _wasm_archive_layout(archive.infolist()) + with archive.open(layout.config_name) as config_file: + staged_config = json.load(config_file) + if staged_config != extension_config: + raise ValueError( + f"WASM extension '{self.id}' archive config changed." + ) + _extract_wasm_archive_to_stage(archive, layout, package_dir) + + _validate_wasm_staged_package(self.id, extension_config, package_dir) + _publish_wasm_package(package_dir, self.wasm_ext_dir) + shutil.rmtree(self.ext_dir, True) + finally: + shutil.rmtree(tmp_dir, True) + + self.name = extension_config.get("name") + self.short_description = extension_config.get("short_description") + + if ( + self.meta + and self.meta.installed_release + and self.meta.installed_release.is_github_release + and extension_config.get("tile") + ): + self.icon = icon_to_github_url( + self.meta.installed_release.source_repo, extension_config.get("tile") + ) + + logger.info(f"WASM extension {self.name} ({self.installed_version}) extracted.") + def clean_extension_files(self): # remove downloaded archive if self.zip_path.is_file(): @@ -667,6 +791,7 @@ class InstallableExtension(BaseModel): # remove module from extensions shutil.rmtree(self.ext_dir, True) + shutil.rmtree(self.wasm_ext_dir, True) shutil.rmtree(self.ext_upgrade_dir, True) @@ -749,6 +874,10 @@ class InstallableExtension(BaseModel): github_release.organisation, github_release.repository ) source_repo = f"{github_release.organisation}/{github_release.repository}" + latest_extension_release = ExtensionRelease.from_github_release( + source_repo, latest_release + ) + latest_extension_release.extension_type = config.extension_type return InstallableExtension( id=github_release.id, name=config.name, @@ -760,9 +889,7 @@ class InstallableExtension(BaseModel): config.tile, ), meta=ExtensionMeta( - latest_release=ExtensionRelease.from_github_release( - source_repo, latest_release - ), + latest_release=latest_extension_release, ), ) except Exception as e: @@ -791,6 +918,8 @@ class InstallableExtension(BaseModel): return None with open(conf_path, "r+") as json_file: config_json = json.load(json_file) + if config_json.get("extension_type") == "wasm": + return None version = config_json.get("version", "0.0") return InstallableExtension( @@ -1059,6 +1188,202 @@ def _extension_tile(ext_info: InstallableExtension) -> str | None: return ext_info.icon +@dataclass(frozen=True) +class _WasmArchiveLayout: + root: str + config_name: str + + +def _extension_id_for_path(ext_id: str) -> str: + if not _EXTENSION_ID_RE.fullmatch(ext_id): + raise ValueError(f"Invalid extension id '{ext_id}'.") + return ext_id + + +def _path_token(value: str, label: str) -> str: + if not _PATH_TOKEN_RE.fullmatch(value): + raise ValueError(f"Invalid {label}.") + return value + + +def _wasm_archive_layout(infos: list[zipfile.ZipInfo]) -> _WasmArchiveLayout: + roots: set[str] = set() + config_names: list[str] = [] + seen_names: set[str] = set() + + for info in infos: + if info.is_dir(): + directory_path = PurePosixPath(info.filename) + if len(directory_path.parts) == 1 and directory_path.parts[0] not in { + "", + ".", + "..", + }: + roots.add(directory_path.parts[0]) + continue + path = _safe_wasm_archive_path(info.filename) + normalized_name = path.as_posix() + if normalized_name in seen_names: + raise ValueError(f"WASM extension archive contains duplicate path: {path}") + seen_names.add(normalized_name) + + _reject_wasm_archive_member(info, path) + roots.add(path.parts[0]) + if len(path.parts) == 2 and path.name == "config.json": + config_names.append(info.filename) + elif path.name == "config.json": + raise ValueError( + "WASM extension archive must contain exactly one top-level config.json." + ) + + if len(roots) != 1: + raise ValueError( + "WASM extension archive must contain exactly one top-level directory." + ) + if len(config_names) != 1: + raise ValueError( + "WASM extension archive must contain exactly one top-level config.json." + ) + + return _WasmArchiveLayout(root=next(iter(roots)), config_name=config_names[0]) + + +def _safe_wasm_archive_path(name: str) -> PurePosixPath: + path = PurePosixPath(name) + if ( + not name + or path.is_absolute() + or len(path.parts) < 2 + or any(part in {"", ".", ".."} for part in path.parts) + ): + raise ValueError(f"WASM extension archive contains unsafe path: {name}") + return path + + +def _reject_wasm_archive_member(info: zipfile.ZipInfo, path: PurePosixPath) -> None: + mode = info.external_attr >> 16 + if stat.S_ISLNK(mode): + raise ValueError(f"WASM extension archive contains symlink: {path}") + + lowered_parts = {part.lower() for part in path.parts} + if lowered_parts & _WASM_NATIVE_ARTIFACT_DIRS: + raise ValueError(f"WASM extension archive contains Python cache path: {path}") + + name = path.name.lower() + if name in _WASM_NATIVE_ARTIFACT_FILENAMES: + raise ValueError(f"WASM extension archive contains native Python file: {path}") + if path.suffix.lower() in _WASM_NATIVE_ARTIFACT_SUFFIXES: + raise ValueError(f"WASM extension archive contains native artifact: {path}") + + if _is_wasm_storage_migration_path(path) and path.suffix.lower() != ".json": + raise ValueError( + f"WASM extension storage migration must be a JSON file: {path}" + ) + + +def _is_wasm_storage_migration_path(path: PurePosixPath) -> bool: + return ( + len(path.parts) >= 4 + and path.parts[1] == "storage" + and path.parts[2] == "migrations" + and path.name != "" + ) + + +def _validate_wasm_archive_config(ext_id: str, config: dict[str, Any]) -> None: + _extension_id_for_path(ext_id) + if config.get("extension_type") != "wasm": + raise ValueError(f"Extension '{ext_id}' archive is not a WASM extension.") + config_id = config.get("id") + if not isinstance(config_id, str) or not config_id: + raise ValueError(f"WASM extension '{ext_id}' config must define id.") + if config_id != ext_id: + raise ValueError( + f"WASM extension id mismatch: installed as '{ext_id}' " + f"but config declares '{config_id}'." + ) + + +def _extract_wasm_archive_to_stage( + archive: zipfile.ZipFile, + layout: _WasmArchiveLayout, + package_dir: Path, +) -> None: + for info in archive.infolist(): + if info.is_dir(): + continue + path = _safe_wasm_archive_path(info.filename) + if path.parts[0] != layout.root: + raise ValueError("WASM extension archive changed while extracting package.") + target = package_dir.joinpath(*path.parts[1:]) + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info) as source_file: + with target.open("wb") as target_file: + shutil.copyfileobj(source_file, target_file) + + +def _validate_wasm_staged_package( + ext_id: str, + config: dict[str, Any], + package_dir: Path, +) -> None: + root = package_dir.resolve() + for path in package_dir.rglob("*"): + if path.is_symlink(): + raise ValueError(f"WASM extension '{ext_id}' contains symlink: {path}") + + installed_config_path = package_dir / "config.json" + if not installed_config_path.is_file(): + raise ValueError(f"WASM extension '{ext_id}' staged config is missing.") + with installed_config_path.open(encoding="utf-8") as config_file: + installed_config = json.load(config_file) + if installed_config != config: + raise ValueError(f"WASM extension '{ext_id}' staged config does not match.") + + wasm_config = config.get("wasm") + module = wasm_config.get("module") if isinstance(wasm_config, dict) else None + if not isinstance(module, str) or not module: + raise ValueError(f"WASM extension '{ext_id}' config has no module path.") + module_path = (package_dir / module).resolve() + if root != module_path and root not in module_path.parents: + raise ValueError(f"WASM extension '{ext_id}' module escapes package root.") + if not module_path.is_file(): + raise FileNotFoundError(f"WASM extension module not found: {module_path}") + with module_path.open("rb") as wasm_file: + if wasm_file.read(4) != b"\0asm": + raise ValueError(f"Invalid WASM module file: {module_path}") + + +def _publish_wasm_package(package_dir: Path, target_dir: Path) -> None: + target_dir.parent.mkdir(parents=True, exist_ok=True) + token = uuid4().hex + replacement_dir = target_dir.parent / f".{target_dir.name}-{token}.new" + backup_dir = target_dir.parent / f".{target_dir.name}-{token}.old" + _remove_path(replacement_dir) + _remove_path(backup_dir) + shutil.copytree(package_dir, replacement_dir, symlinks=False) + + try: + if target_dir.exists() or target_dir.is_symlink(): + target_dir.replace(backup_dir) + replacement_dir.replace(target_dir) + except Exception: + _remove_path(target_dir) + if backup_dir.exists() or backup_dir.is_symlink(): + backup_dir.replace(target_dir) + raise + finally: + _remove_path(replacement_dir) + _remove_path(backup_dir) + + +def _remove_path(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink(missing_ok=True) + elif path.is_dir(): + shutil.rmtree(path, True) + + def _archive_config_name(names: list[str]) -> str | None: for name in names: path = PurePosixPath(name) diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py index a68fffcc9..4687f4009 100644 --- a/lnbits/core/services/extensions.py +++ b/lnbits/core/services/extensions.py @@ -209,16 +209,12 @@ async def install_extension( if not skip_download: await ext_info.download_archive() - extension_config = ext_info.load_archive_config() - ext_info.permissions = validate_wasm_extension_permissions( + _extract_installable_extension_archive( ext_info, granted_permissions, - extension_config, allow_admin_policy_overrides=allow_admin_policy_overrides, ) - ext_info.extract_archive() - db_version = await get_db_version(ext_info.id) await migrate_extension_database(ext_info, db_version) @@ -240,6 +236,60 @@ async def install_extension( return extension +def _extract_installable_extension_archive( + ext_info: InstallableExtension, + granted_permissions: list[ExtensionPermission] | None, + *, + allow_admin_policy_overrides: bool, +) -> None: + extension_config = ext_info.load_archive_config() + archive_is_wasm = extension_config.get("extension_type") == "wasm" + if ext_info.expects_wasm and not archive_is_wasm: + raise ValueError( + f"Extension '{ext_info.id}' release is WASM but archive is not WASM." + ) + + if archive_is_wasm: + _extract_wasm_extension_archive( + ext_info, + granted_permissions, + allow_admin_policy_overrides=allow_admin_policy_overrides, + ) + return + + _extract_native_extension_archive(ext_info, granted_permissions) + + +def _extract_wasm_extension_archive( + ext_info: InstallableExtension, + granted_permissions: list[ExtensionPermission] | None, + *, + allow_admin_policy_overrides: bool, +) -> None: + extension_config = ext_info.load_wasm_archive_config() + ext_info.permissions = validate_wasm_extension_permissions( + ext_info, + granted_permissions, + extension_config, + allow_admin_policy_overrides=allow_admin_policy_overrides, + require_wasm=True, + ) + ext_info.extract_wasm_archive(extension_config) + + +def _extract_native_extension_archive( + ext_info: InstallableExtension, + granted_permissions: list[ExtensionPermission] | None, +) -> None: + if granted_permissions: + raise ValueError( + f"Extension '{ext_info.id}' is not a WASM extension and cannot " + "receive WASM permissions." + ) + ext_info.permissions = [] + ext_info.extract_archive() + + async def check_extensions_limit(installed_ext: InstallableExtension | None = None): if settings.lnbits_max_extensions == 0 or installed_ext: return @@ -661,7 +711,8 @@ async def activate_extension(ext: Extension): async def deactivate_extension(ext_id: str): - if is_wasm_extension_id(ext_id): + installed_ext = await get_installed_extension(ext_id) + if (installed_ext and installed_ext.is_wasm) or is_wasm_extension_id(ext_id): await stop_wasm_extension_invocations(ext_id, reason="Extension deactivated.") settings.deactivate_extension_paths(ext_id) await update_installed_extension_state(ext_id=ext_id, active=False) @@ -673,7 +724,8 @@ async def stop_extension_background_work(ext_id: str) -> bool: Stop background work for extension (like asyncio.Tasks, WebSockets, etc). Extension must expose a `myextension_stop()` function if it is starting tasks. """ - if is_wasm_extension_id(ext_id): + installed_ext = await get_installed_extension(ext_id) + if (installed_ext and installed_ext.is_wasm) or is_wasm_extension_id(ext_id): return True upgrade_hash = settings.extension_upgrade_hash(ext_id) @@ -708,7 +760,8 @@ async def start_extension_background_work(ext_id: str) -> bool: Extension CAN expose a `myextension_start()` function if it is starting tasks. Extension MUST expose a `myextension_stop()` in that case. """ - if is_wasm_extension_id(ext_id): + installed_ext = await get_installed_extension(ext_id) + if (installed_ext and installed_ext.is_wasm) or is_wasm_extension_id(ext_id): return False upgrade_hash = settings.extension_upgrade_hash(ext_id) diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index 08c67b0b3..224b30615 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -1049,7 +1049,11 @@ async def create_extension_review( def _load_installed_extension_config(extension: InstallableExtension) -> dict: - config_path = extension.ext_dir / "config.json" + config_path = ( + extension.wasm_ext_dir / "config.json" + if extension.is_wasm + else extension.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/api/permissions.py b/lnbits/core/wasm_ext/api/permissions.py index 837e77954..afc1334ae 100644 --- a/lnbits/core/wasm_ext/api/permissions.py +++ b/lnbits/core/wasm_ext/api/permissions.py @@ -57,10 +57,15 @@ def validate_wasm_extension_permissions( extension_config: dict[str, Any] | WasmExtensionConfig, *, allow_admin_policy_overrides: bool = False, + require_wasm: bool = False, ) -> list[ExtensionPermission]: if isinstance(extension_config, WasmExtensionConfig): config = extension_config elif extension_config.get("extension_type") != "wasm": + if require_wasm: + raise ValueError( + f"Extension '{ext_info.id}' archive is not a WASM extension." + ) return [] else: config = parse_wasm_extension_config(ext_info.id, extension_config) diff --git a/lnbits/core/wasm_ext/storage/crud.py b/lnbits/core/wasm_ext/storage/crud.py index 7c6482310..3b45885ab 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}'.") @@ -434,11 +434,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" + Path(settings.lnbits_wasm_extensions_path) / ext_id / "storage" / "schema.json" ) if not schema_path.is_file(): raise ValueError(f"WASM extension '{ext_id}' has no storage schema.") diff --git a/lnbits/core/wasm_ext/wasm/loader.py b/lnbits/core/wasm_ext/wasm/loader.py index a1f4ea43b..1d8e74076 100644 --- a/lnbits/core/wasm_ext/wasm/loader.py +++ b/lnbits/core/wasm_ext/wasm/loader.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re from dataclasses import dataclass from pathlib import Path from typing import Any @@ -12,6 +13,8 @@ from lnbits.core.wasm_ext.wasm.config import ( ) from lnbits.settings import settings +_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") + @dataclass(frozen=True) class WasmExtension: @@ -27,7 +30,9 @@ class WasmExtension: def is_wasm_extension_id(ext_id: str) -> bool: - ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + if not _EXTENSION_ID_RE.fullmatch(ext_id): + return False + ext_dir = wasm_extension_root_path(ext_id) config = _load_json(ext_dir / "config.json") return bool(config and config.get("extension_type") == "wasm") @@ -38,7 +43,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 = wasm_extension_root_path(ext_id) config = _load_json(ext_dir / "config.json") if not config or config.get("extension_type") != "wasm": return None @@ -46,7 +51,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 = wasm_extension_root_path(ext_id) raw_config = _load_json(ext_dir / "config.json") if not raw_config: raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.") @@ -73,6 +78,12 @@ def load_wasm_extension(ext_id: str) -> WasmExtension: ) +def wasm_extension_root_path(ext_id: str) -> Path: + if not _EXTENSION_ID_RE.fullmatch(ext_id): + raise ValueError(f"Invalid WASM extension id '{ext_id}'.") + return Path(settings.lnbits_wasm_extensions_path, ext_id) + + def _load_json(path: Path) -> dict[str, Any] | None: if not path.is_file(): return None diff --git a/lnbits/settings.py b/lnbits/settings.py index 46167de29..e25be2cd5 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -1232,6 +1232,10 @@ class ReadOnlySettings( def lnbits_extensions_upgrade_path(self) -> str: return str(Path(self.lnbits_data_folder, "upgrades")) + @property + def lnbits_wasm_extensions_path(self) -> str: + return str(Path(self.lnbits_data_folder, "wasm_extensions")) + @validator( "lnbits_allowed_funding_sources", pre=True, diff --git a/tests/api/test_extension_api.py b/tests/api/test_extension_api.py index e257844ce..3aa17f908 100644 --- a/tests/api/test_extension_api.py +++ b/tests/api/test_extension_api.py @@ -8,6 +8,8 @@ import pytest from fastapi import HTTPException from starlette.requests import Request +from lnbits.core.crud import db_versions as db_versions_crud +from lnbits.core.crud import extensions as extension_crud from lnbits.core.crud.db_versions import get_db_version, update_migration_version from lnbits.core.crud.extensions import ( create_installed_extension, @@ -17,6 +19,12 @@ from lnbits.core.crud.extensions import ( ) from lnbits.core.crud.users import get_account from lnbits.core.crud.wallets import create_wallet +from lnbits.core.migrations import ( + m010_create_installed_extensions_table, + m046_add_permissions_to_installed_extensions, + m047_create_wasm_invocations_table, + m048_add_wasm_runtime_limits_to_installed_extensions, +) from lnbits.core.models import Account, CreateInvoice from lnbits.core.models.extensions import ( CreateExtension, @@ -36,6 +44,7 @@ from lnbits.core.models.extensions import ( from lnbits.core.models.users import AccountId from lnbits.core.services.payments import create_wallet_invoice from lnbits.core.services.users import create_user_account +from lnbits.core.views import extension_api as extension_api_module from lnbits.core.views.extension_api import ( api_activate_extension, api_deactivate_extension, @@ -59,6 +68,7 @@ from lnbits.core.views.extension_api import ( get_pay_to_enable_invoice, get_pay_to_install_invoice, ) +from lnbits.db import Database from tests.helpers import make_extension_release, make_installable_extension @@ -181,6 +191,7 @@ async def test_extension_api_installs_wasm_with_granted_permissions( ): ext_id = f"wasm_{uuid4().hex[:8]}" release = make_extension_release(ext_id) + release.extension_type = "wasm" granted_permissions = [ ExtensionPermission( id="http.request", @@ -213,7 +224,12 @@ 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") + await _patch_extension_api_core_db(settings, mocker) _write_wasm_extension_archive(ext_id, release.version, settings) + wasm_config_path = ( + Path(settings.lnbits_wasm_extensions_path) / ext_id / "config.json" + ) + native_ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id installed = await api_install_extension(create_data) stored = await get_installed_extension(ext_id) @@ -232,9 +248,114 @@ async def test_extension_api_installs_wasm_with_granted_permissions( policies=[{"host": "https://api.example.com"}], ) ] + assert wasm_config_path.is_file() + assert not native_ext_dir.exists() register_wasm_routes_mock.assert_called_once_with(ext_id) +@pytest.mark.anyio +async def test_extension_api_rejects_wasm_release_with_native_archive( + tmp_path, + settings, + mocker, +): + ext_id = f"wasm_{uuid4().hex[:8]}" + release = make_extension_release(ext_id) + release.extension_type = "wasm" + create_data = CreateExtension( + ext_id=ext_id, + archive=release.archive, + source_repo=release.source_repo, + version=release.version, + ) + original_data_folder = settings.lnbits_data_folder + original_extensions_path = settings.lnbits_extensions_path + mocker.patch.object( + InstallableExtension, + "get_extension_release", + mocker.AsyncMock(return_value=release), + ) + mocker.patch.object( + InstallableExtension, + "download_archive", + mocker.AsyncMock(), + ) + + try: + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "code") + await _patch_extension_api_core_db(settings, mocker) + _write_native_extension_archive(ext_id, release.version, settings) + wasm_ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id + native_ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id + + with pytest.raises(HTTPException) as exc_info: + await api_install_extension(create_data) + finally: + await delete_installed_extension(ext_id=ext_id) + settings.lnbits_data_folder = original_data_folder + settings.lnbits_extensions_path = original_extensions_path + + assert exc_info.value.status_code == 400 + assert "release is WASM but archive is not WASM" in exc_info.value.detail + assert not wasm_ext_dir.exists() + assert not native_ext_dir.exists() + + +@pytest.mark.anyio +async def test_extension_api_rejects_wasm_archive_with_python_files( + tmp_path, + settings, + mocker, +): + ext_id = f"wasm_{uuid4().hex[:8]}" + release = make_extension_release(ext_id) + release.extension_type = "wasm" + create_data = CreateExtension( + ext_id=ext_id, + archive=release.archive, + source_repo=release.source_repo, + version=release.version, + ) + original_data_folder = settings.lnbits_data_folder + original_extensions_path = settings.lnbits_extensions_path + mocker.patch.object( + InstallableExtension, + "get_extension_release", + mocker.AsyncMock(return_value=release), + ) + mocker.patch.object( + InstallableExtension, + "download_archive", + mocker.AsyncMock(), + ) + + try: + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "code") + await _patch_extension_api_core_db(settings, mocker) + _write_wasm_extension_archive( + ext_id, + release.version, + settings, + extra_files={"__init__.py": "raise RuntimeError('imported')"}, + ) + wasm_ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id + native_ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id + + with pytest.raises(HTTPException) as exc_info: + await api_install_extension(create_data) + finally: + await delete_installed_extension(ext_id=ext_id) + settings.lnbits_data_folder = original_data_folder + settings.lnbits_extensions_path = original_extensions_path + + assert exc_info.value.status_code == 400 + assert "native Python file" in exc_info.value.detail + assert not wasm_ext_dir.exists() + assert not native_ext_dir.exists() + + @pytest.mark.anyio async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metadata( tmp_path, @@ -251,10 +372,13 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad ) ] original_extensions_path = settings.lnbits_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) - _write_installed_wasm_config(ext_id, tmp_path) + await _patch_extension_api_core_db(settings, mocker) + _write_installed_wasm_config(ext_id, settings) await create_installed_extension( InstallableExtension( id=ext_id, @@ -295,6 +419,7 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad finally: await delete_installed_extension(ext_id=ext_id) await delete_installed_extension(ext_id=py_ext_id) + settings.lnbits_data_folder = original_data_folder settings.lnbits_extensions_path = original_extensions_path assert wasm_info.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234} @@ -318,9 +443,11 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad async def test_extension_api_admin_updates_wasm_extension_permission_limits( tmp_path, settings, + mocker, ): ext_id = f"wasm_{uuid4().hex[:8]}" original_extensions_path = settings.lnbits_extensions_path + original_data_folder = settings.lnbits_data_folder manifest_permissions = [ { "id": "ext.storage.append_public", @@ -355,10 +482,12 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits( ] try: + settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path) + await _patch_extension_api_core_db(settings, mocker) _write_installed_wasm_config( ext_id, - tmp_path, + settings, permissions=manifest_permissions, ) await create_installed_extension( @@ -378,6 +507,7 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits( stored = await get_installed_extension(ext_id) finally: await delete_installed_extension(ext_id=ext_id) + settings.lnbits_data_folder = original_data_folder settings.lnbits_extensions_path = original_extensions_path assert response.extension_permissions == [ @@ -672,6 +802,7 @@ def _write_wasm_extension_archive( version: str, settings, permissions: list[dict] | None = None, + extra_files: dict[str, str | bytes] | None = None, ) -> None: zip_path = Path(settings.lnbits_data_folder, "zips", f"{ext_id}.zip") zip_path.parent.mkdir(parents=True, exist_ok=True) @@ -680,14 +811,58 @@ def _write_wasm_extension_archive( with zipfile.ZipFile(zip_path, "w") as archive: archive.writestr(f"{root}/config.json", json.dumps(config)) archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm") + for path, content in (extra_files or {}).items(): + archive.writestr(f"{root}/{path}", content) + + +async def _patch_extension_api_core_db( + settings, + mocker, +) -> None: + Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True) + db = Database(f"core_extapi_{uuid4().hex[:8]}") + async with db.connect() as conn: + await conn.execute(""" + CREATE TABLE dbversions ( + db TEXT PRIMARY KEY, + version INT NOT NULL + ) + """) + await update_migration_version(conn, "core", 48) + await m010_create_installed_extensions_table(conn) + await m046_add_permissions_to_installed_extensions(conn) + await m047_create_wasm_invocations_table(conn) + await m048_add_wasm_runtime_limits_to_installed_extensions(conn) + + mocker.patch.object(extension_crud, "db", db) + mocker.patch.object(db_versions_crud, "db", db) + mocker.patch.object(extension_api_module, "db", db) + + +def _write_native_extension_archive( + ext_id: str, + version: str, + settings, +) -> None: + zip_path = Path(settings.lnbits_data_folder, "zips", f"{ext_id}.zip") + zip_path.parent.mkdir(parents=True, exist_ok=True) + root = f"{ext_id}-{version}" + config = { + "name": "Native Demo", + "short_description": "Native extension", + "version": version, + } + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr(f"{root}/config.json", json.dumps(config)) + archive.writestr(f"{root}/__init__.py", "raise RuntimeError('imported')") def _write_installed_wasm_config( ext_id: str, - extensions_path, + settings, permissions: list[dict] | None = None, ) -> None: - config_dir = extensions_path / "extensions" / ext_id + config_dir = Path(settings.lnbits_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/unit/test_services_extensions.py b/tests/unit/test_services_extensions.py index b74fa428f..1b8415b49 100644 --- a/tests/unit/test_services_extensions.py +++ b/tests/unit/test_services_extensions.py @@ -1,6 +1,7 @@ import json import zipfile from datetime import datetime, timedelta, timezone +from pathlib import Path from types import SimpleNamespace from uuid import uuid4 @@ -278,7 +279,7 @@ async def test_uninstall_wasm_extension_unregisters_live_routes( 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 + ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id ext_dir.mkdir(parents=True, exist_ok=True) (ext_dir / "config.json").write_text( json.dumps(_wasm_install_config(ext_id)), @@ -579,9 +580,11 @@ async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides( ): ext_id = "wasm_demo" original_extensions_path = settings.lnbits_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) - config_dir = tmp_path / "extensions" / ext_id + config_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id config_dir.mkdir(parents=True) (config_dir / "config.json").write_text( '{"extension_type": "wasm"}', @@ -611,6 +614,7 @@ async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides( }, ) finally: + settings.lnbits_data_folder = original_data_folder settings.lnbits_extensions_path = original_extensions_path assert saved_limits == { @@ -783,6 +787,50 @@ async def test_start_extension_background_work_handles_missing_and_sync_starts( assert called["start"] is True +@pytest.mark.anyio +async def test_wasm_installed_extension_never_imports_native_background_module( + tmp_path, + settings: Settings, + mocker: MockerFixture, +): + ext_id = f"wasm_{uuid4().hex[:8]}" + ext_info = make_installable_extension(ext_id) + assert ext_info.meta and ext_info.meta.installed_release + ext_info.meta.installed_release.extension_type = "wasm" + original_data_folder = settings.lnbits_data_folder + original_extensions_path = settings.lnbits_extensions_path + import_module_mock = mocker.patch( + "lnbits.core.services.extensions.importlib.import_module" + ) + mocker.patch.object( + extension_services, + "get_installed_extension", + mocker.AsyncMock(return_value=ext_info), + ) + + try: + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "code") + native_ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id + native_ext_dir.mkdir(parents=True) + (native_ext_dir / "config.json").write_text( + json.dumps({"name": "Native Demo", "version": "1.0.0"}), + encoding="utf-8", + ) + (native_ext_dir / "__init__.py").write_text( + "raise RuntimeError('imported')", + encoding="utf-8", + ) + + assert await start_extension_background_work(ext_id) is False + assert await stop_extension_background_work(ext_id) is True + finally: + settings.lnbits_data_folder = original_data_folder + settings.lnbits_extensions_path = original_extensions_path + + import_module_mock.assert_not_called() + + @pytest.mark.anyio async def test_get_valid_extensions_and_single_extension_respect_settings( tmp_path, settings: Settings diff --git a/tests/unit/test_wasm_extension_loader.py b/tests/unit/test_wasm_extension_loader.py index ff395c2ce..81135f1f2 100644 --- a/tests/unit/test_wasm_extension_loader.py +++ b/tests/unit/test_wasm_extension_loader.py @@ -1,4 +1,6 @@ import json +import stat +import zipfile from pathlib import Path from typing import Any @@ -165,6 +167,76 @@ def test_install_time_permission_validation_rejects_config_id_mismatch(): ) +def test_wasm_archive_config_rejects_multiple_top_level_directories( + tmp_path: Path, + settings: Settings, +): + settings.lnbits_data_folder = str(tmp_path / "data") + ext_info = make_installable_extension("demoext") + with zipfile.ZipFile(ext_info.zip_path, "w") as archive: + archive.writestr("safe/config.json", json.dumps(_wasm_config("demoext"))) + archive.writestr("safe/extension.wasm", b"\0asm") + archive.writestr("other/readme.txt", "extra top-level directory") + + with pytest.raises(ValueError, match="one top-level directory"): + ext_info.load_wasm_archive_config() + + +def test_wasm_archive_rejects_native_python_files( + tmp_path: Path, + settings: Settings, +): + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "native") + ext_info = make_installable_extension("demoext") + _write_wasm_archive( + ext_info, + _wasm_config("demoext"), + extra_files={"demoext-1.0.0/__init__.py": "raise RuntimeError('imported')"}, + ) + + with pytest.raises(ValueError, match="native Python file"): + ext_info.load_wasm_archive_config() + + assert not ext_info.ext_dir.exists() + assert not ext_info.wasm_ext_dir.exists() + + +def test_wasm_archive_rejects_symlinks( + tmp_path: Path, + settings: Settings, +): + settings.lnbits_data_folder = str(tmp_path / "data") + ext_info = make_installable_extension("demoext") + root = "demoext-1.0.0" + symlink_info = zipfile.ZipInfo(f"{root}/linked") + symlink_info.external_attr = (stat.S_IFLNK | 0o777) << 16 + with zipfile.ZipFile(ext_info.zip_path, "w") as archive: + archive.writestr(f"{root}/config.json", json.dumps(_wasm_config("demoext"))) + archive.writestr(f"{root}/extension.wasm", b"\0asm") + archive.writestr(symlink_info, "extension.wasm") + + with pytest.raises(ValueError, match="symlink"): + ext_info.load_wasm_archive_config() + + +def test_extract_wasm_archive_publishes_to_wasm_root_only( + tmp_path: Path, + settings: Settings, +): + settings.lnbits_data_folder = str(tmp_path / "data") + settings.lnbits_extensions_path = str(tmp_path / "native") + ext_info = make_installable_extension("demoext") + config = _wasm_config("demoext") + _write_wasm_archive(ext_info, config) + + ext_info.extract_wasm_archive(ext_info.load_wasm_archive_config()) + + assert (ext_info.wasm_ext_dir / "config.json").is_file() + assert (ext_info.wasm_ext_dir / "extension.wasm").is_file() + assert not ext_info.ext_dir.exists() + + def test_wasm_extension_registry_rejects_same_id_from_different_root(tmp_path: Path): registry = WasmExtensionRegistry() first = _wasm_extension("demoext", tmp_path / "one") @@ -185,8 +257,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_data_folder = str(tmp_path / "data") + ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id ext_dir.mkdir(parents=True) (ext_dir / "extension.wasm").write_bytes(b"\0asm") config = { @@ -225,3 +297,17 @@ def _wasm_config(ext_id: str) -> dict[str, Any]: "extension_type": "wasm", "wasm": {"module": "extension.wasm"}, } + + +def _write_wasm_archive( + ext_info, + config: dict[str, Any], + *, + extra_files: dict[str, str | bytes] | None = None, +) -> None: + root = f"{ext_info.id}-1.0.0" + with zipfile.ZipFile(ext_info.zip_path, "w") as archive: + archive.writestr(f"{root}/config.json", json.dumps(config)) + archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm") + for path, content in (extra_files or {}).items(): + archive.writestr(path, content) diff --git a/tests/unit/test_wasm_extension_storage.py b/tests/unit/test_wasm_extension_storage.py index eb4dcbc44..32847a759 100644 --- a/tests/unit/test_wasm_extension_storage.py +++ b/tests/unit/test_wasm_extension_storage.py @@ -42,22 +42,28 @@ async def test_core_wasm_migrations_create_persistent_columns( if DB_TYPE != SQLITE: pytest.skip("temporary core databases are SQLite-only") - db = _temporary_database(tmp_path, settings, "wasm_core_migrations") + original_data_folder = settings.lnbits_data_folder + try: + db = _temporary_database(tmp_path, settings, "wasm_core_migrations") - async with db.connect() as conn: - await m010_create_installed_extensions_table(conn) - await m046_add_permissions_to_installed_extensions(conn) - await m047_create_wasm_invocations_table(conn) - await m048_add_wasm_runtime_limits_to_installed_extensions(conn) + async with db.connect() as conn: + await m010_create_installed_extensions_table(conn) + await m046_add_permissions_to_installed_extensions(conn) + await m047_create_wasm_invocations_table(conn) + await m048_add_wasm_runtime_limits_to_installed_extensions(conn) - installed_columns = { - row["name"] - for row in await conn.fetchall("PRAGMA table_info(installed_extensions)") - } - invocation_columns = { - row["name"] - for row in await conn.fetchall("PRAGMA table_info(wasm_invocations)") - } + installed_columns = { + row["name"] + for row in await conn.fetchall( + "PRAGMA table_info(installed_extensions)" + ) + } + invocation_columns = { + row["name"] + for row in await conn.fetchall("PRAGMA table_info(wasm_invocations)") + } + finally: + settings.lnbits_data_folder = original_data_folder assert {"permissions", "wasm_runtime_limits"}.issubset(installed_columns) assert { @@ -75,7 +81,6 @@ async def test_core_wasm_migrations_create_persistent_columns( @pytest.mark.anyio async def test_installed_extension_permissions_and_wasm_limits_round_trip( - app, tmp_path: Path, settings: Settings, mocker: MockerFixture, @@ -105,7 +110,6 @@ async def test_installed_extension_permissions_and_wasm_limits_round_trip( @pytest.mark.anyio async def test_wasm_invocation_crud_stats_and_cleanup_are_isolated( - app, tmp_path: Path, settings: Settings, mocker: MockerFixture, @@ -222,6 +226,7 @@ async def test_wasm_datetime_queries_use_postgres_placeholders( async def test_wasm_storage_migration_and_owner_scoped_crud( tmp_path: Path, settings: Settings, + mocker: MockerFixture, ): ext_id = f"wasmstore_{uuid4().hex[:8]}" original_extensions_path = settings.lnbits_extensions_path @@ -230,6 +235,7 @@ async def test_wasm_storage_migration_and_owner_scoped_crud( settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path / "code") Path(settings.lnbits_data_folder).mkdir(parents=True) + await _patch_wasm_storage_core_db(tmp_path, settings, mocker) ext_dir = _write_storage_extension(settings, ext_id) await migrate_wasm_extension_database(make_installable_extension(ext_id)) @@ -301,6 +307,7 @@ async def test_wasm_storage_migration_and_owner_scoped_crud( async def test_wasm_storage_public_append_generates_id_and_counts_by_owner( tmp_path: Path, settings: Settings, + mocker: MockerFixture, ): ext_id = f"wasmstore_{uuid4().hex[:8]}" original_extensions_path = settings.lnbits_extensions_path @@ -309,6 +316,7 @@ async def test_wasm_storage_public_append_generates_id_and_counts_by_owner( settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_extensions_path = str(tmp_path / "code") Path(settings.lnbits_data_folder).mkdir(parents=True) + await _patch_wasm_storage_core_db(tmp_path, settings, mocker) _write_storage_extension(settings, ext_id) await migrate_wasm_extension_database(make_installable_extension(ext_id)) @@ -376,8 +384,7 @@ async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers( ) schema_path = ( - Path(settings.lnbits_extensions_path) - / "extensions" + Path(settings.lnbits_wasm_extensions_path) / ext_id / "storage" / "schema.json" @@ -428,8 +435,24 @@ async def _temporary_core_crud_database( return db +async def _patch_wasm_storage_core_db( + tmp_path: Path, + settings: Settings, + mocker: MockerFixture, +) -> None: + db = _temporary_database(tmp_path, settings, f"core_versions_{uuid4().hex[:8]}") + async with db.connect() as conn: + await conn.execute(""" + CREATE TABLE dbversions ( + db TEXT PRIMARY KEY, + version INT NOT NULL + ) + """) + mocker.patch.object(storage_crud, "core_db", db) + + def _write_storage_extension(settings: Settings, ext_id: str) -> Path: - ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id + ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id storage_dir = ext_dir / "storage" migrations_dir = storage_dir / "migrations" migrations_dir.mkdir(parents=True)