feat: add wasm manifest (#4089)
Some checks failed
codeql / analyze (push) Has been cancelled
LNbits CI / nix / nix (push) Has been cancelled

Co-authored-by: Arc <ben@arc.wales>
This commit is contained in:
Vlad Stan 2026-07-24 15:35:30 +03:00 committed by GitHub
parent c0806ca59c
commit 8723d327fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 312 additions and 157 deletions

View file

@ -47,6 +47,7 @@ class ExplicitRelease(BaseModel):
details_link: str | None
paid_features: str | None
pay_link: str | None
extension_type: str | None = None
def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@ -111,6 +112,12 @@ class ExtensionConfig(BaseModel):
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@classmethod
async def fetch_release_config(cls, url: str) -> ExtensionConfig:
error_msg = "Cannot fetch extension release config"
config = await github_api_get(url, error_msg)
return ExtensionConfig.parse_obj(config)
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
@ -118,9 +125,7 @@ class ExtensionConfig(BaseModel):
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
return await cls.fetch_release_config(config_url)
class ReleasePaymentInfo(BaseModel):
@ -350,6 +355,8 @@ class ExtensionRelease(BaseModel):
repo: str | None = None
icon: str | None = None
details_link: str | None = None
extension_type: str | None = None
permissions: list[ExtensionPermission] = []
paid_features: str | None = None
pay_link: str | None = None
@ -357,6 +364,14 @@ class ExtensionRelease(BaseModel):
paid_sats: int | None = 0
payment_hash: str | None = None
def apply_config(self, config: ExtensionConfig) -> None:
self.min_lnbits_version = config.min_lnbits_version
self.max_lnbits_version = config.max_lnbits_version
self.is_version_compatible = config.is_version_compatible()
self.warning = config.warning
self.extension_type = config.extension_type
self.permissions = config.permissions
@property
def archive_url(self) -> str:
if not self.pay_link:
@ -420,6 +435,7 @@ class ExtensionRelease(BaseModel):
warning=e.warning,
html_url=e.html_url,
details_link=e.details_link,
extension_type=e.extension_type,
pay_link=e.pay_link,
paid_features=e.paid_features,
repo=e.repo,
@ -447,10 +463,7 @@ class ExtensionRelease(BaseModel):
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.apply_config(config)
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
@ -858,7 +871,14 @@ class InstallableExtension(BaseModel):
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
for url in settings.lnbits_extensions_manifests:
manifest_urls = dict.fromkeys(
[
*settings.lnbits_extensions_manifests,
*settings.lnbits_wasm_extensions_manifests,
*settings.lnbits_wasm_extensions_manifests,
]
)
for url in manifest_urls:
try:
manifest = await cls.fetch_manifest(url)
@ -913,6 +933,7 @@ class InstallableExtension(BaseModel):
extension_releases: list[ExtensionRelease] = []
all_manifests = [
*settings.lnbits_extensions_manifests,
*settings.lnbits_wasm_extensions_manifests,
settings.lnbits_extensions_builder_manifest_url,
]
for url in all_manifests:
@ -930,6 +951,14 @@ class InstallableExtension(BaseModel):
if e.id != ext_id:
continue
explicit_release = ExtensionRelease.from_explicit_release(url, e)
if (
explicit_release.extension_type == "wasm"
and explicit_release.details_link
):
config = await ExtensionConfig.fetch_release_config(
explicit_release.details_link
)
explicit_release.apply_config(config)
await explicit_release.check_payment_requirements()
extension_releases.append(explicit_release)

View file

@ -713,6 +713,10 @@ async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
for release in extension_releases:
release.permissions = validate_extension_permissions(
ext_id, release.permissions
)
installed_ext = await get_installed_extension(ext_id)
if not installed_ext:
@ -921,6 +925,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
)
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
installable_exts.sort(key=lambda e: e.id)
installed_exts_by_id = {e.id: e for e in installed_exts}
for e in installable_exts:

View file

@ -17,6 +17,10 @@ from uuid import uuid4
from loguru import logger
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
DEFAULT_WASM_MANIFESTS = [
"https://raw.githubusercontent.com/lnbits/lnbits-extensions-wasm/refs/heads/main/extensions.json"
]
def list_parse_fallback(v: str):
v = v.replace(" ", "")
@ -73,6 +77,7 @@ class ExtensionsSettings(LNbitsSettings):
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
]
)
lnbits_wasm_extensions_manifests: list[str] = Field(default=DEFAULT_WASM_MANIFESTS)
lnbits_extensions_builder_manifest_url: str = Field(
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
)
@ -1072,6 +1077,8 @@ class EditableSettings(
"lnbits_allowed_users",
"lnbits_theme_options",
"lnbits_admin_extensions",
"lnbits_extensions_manifests",
"lnbits_wasm_extensions_manifests",
pre=True,
)
@classmethod

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -166,6 +166,10 @@ window.localisation.en = {
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
ext_sources_label:
'Source URL (only use the official LNbits extension source, and sources you can trust)',
wasm_sources_hint:
'Repositories from where WASM extensions can be downloaded',
wasm_sources_label:
'WASM source URL (only use WASM extension sources you can trust)',
warning: 'Warning',
repository: 'Repository',
confirm_continue: 'Are you sure you want to continue?',

View file

@ -3,27 +3,44 @@ window.app.component('lnbits-admin-extensions', {
template: '#lnbits-admin-extensions',
data() {
return {
formAddExtensionsManifest: ''
formAddExtensionsManifest: '',
formAddWasmManifest: ''
}
},
methods: {
addExtensionsManifest() {
const addManifest = this.formAddExtensionsManifest.trim()
const manifests = this.formData.lnbits_extensions_manifests
this.addManifest(
'lnbits_extensions_manifests',
'formAddExtensionsManifest'
)
},
addWasmManifest() {
this.addManifest(
'lnbits_wasm_extensions_manifests',
'formAddWasmManifest'
)
},
addManifest(manifestField, inputField) {
const addManifest = this[inputField].trim()
const manifests = this.formData[manifestField] || []
if (
addManifest &&
addManifest.length &&
!manifests.includes(addManifest)
) {
this.formData.lnbits_extensions_manifests = [...manifests, addManifest]
this.formAddExtensionsManifest = ''
this.formData[manifestField] = [...manifests, addManifest]
this[inputField] = ''
}
},
removeExtensionsManifest(manifest) {
const manifests = this.formData.lnbits_extensions_manifests
this.formData.lnbits_extensions_manifests = manifests.filter(
m => m !== manifest
)
this.removeManifest('lnbits_extensions_manifests', manifest)
},
removeWasmManifest(manifest) {
this.removeManifest('lnbits_wasm_extensions_manifests', manifest)
},
removeManifest(manifestField, manifest) {
const manifests = this.formData[manifestField] || []
this.formData[manifestField] = manifests.filter(m => m !== manifest)
}
}
})

View file

@ -38,6 +38,27 @@
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>Wasm Extension</p>
<q-input
class="q-mb-md"
filled
v-model="formAddWasmManifest"
type="text"
:label="$t('wasm_sources_label')"
:hint="$t('wasm_sources_hint')"
>
<q-btn @click="addWasmManifest" dense flat icon="add"></q-btn>
</q-input>
<div class="q-mb-md">
<q-chip
v-for="manifestUrl in formData.lnbits_wasm_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeWasmManifest(manifestUrl)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="manifestUrl"></span
></q-chip>
</div>
<div class="row q-gutter-sm">
<q-btn
unelevated

View file

@ -5,7 +5,7 @@ from httpx import AsyncClient
from lnbits.core.crud.settings import get_settings_field, set_settings_field
from lnbits.server import server_restart
from lnbits.settings import Settings
from lnbits.settings import DEFAULT_WASM_MANIFESTS, Settings
@pytest.mark.anyio
@ -23,6 +23,7 @@ async def test_admin_get_settings(client: AsyncClient, superuser_token: str):
assert response.status_code == 200
result = response.json()
assert "super_user" not in result
assert result["lnbits_wasm_extensions_manifests"] == DEFAULT_WASM_MANIFESTS
@pytest.mark.anyio

View file

@ -21,12 +21,14 @@ from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
ExplicitRelease,
Extension,
ExtensionConfig,
ExtensionPermission,
ExtensionPermissionsUpdate,
ExtensionRelease,
InstallableExtension,
Manifest,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
@ -173,6 +175,104 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
assert release_info["is_version_compatible"] is True
@pytest.mark.anyio
async def test_explicit_wasm_release_loads_install_permissions(
settings,
mocker,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
non_wasm_ext_id = f"python_{uuid4().hex[:8]}"
manifest_url = "https://extensions.example/manifest.json"
details_link = f"https://extensions.example/{ext_id}/config.json"
explicit_release = ExplicitRelease(
id=ext_id,
name="Explicit WASM Extension",
version="1.0.0",
archive=f"https://extensions.example/{ext_id}.zip",
hash="archive-hash",
repo=f"https://github.com/example/{ext_id}",
icon=None,
short_description="Explicit WASM release",
min_lnbits_version="0.1.0",
max_lnbits_version=None,
html_url=None,
warning=None,
info_notification=None,
critical_notification=None,
details_link=details_link,
paid_features=None,
pay_link=None,
extension_type="wasm",
)
non_wasm_release = explicit_release.copy(
update={
"id": non_wasm_ext_id,
"name": "Explicit Python Extension",
"details_link": f"https://extensions.example/{non_wasm_ext_id}/config.json",
"extension_type": None,
}
)
config_permissions = [ExtensionPermission(id="wallet.list")]
config = ExtensionConfig(
name=ext_id,
short_description="Explicit WASM release",
min_lnbits_version="0.1.0",
max_lnbits_version=None,
extension_type="wasm",
permissions=config_permissions,
)
async def fetch_manifest(url):
if url == manifest_url:
return Manifest(extensions=[explicit_release, non_wasm_release])
return Manifest()
mocker.patch.object(settings, "lnbits_extensions_manifests", [manifest_url])
mocker.patch.object(
settings,
"lnbits_extensions_builder_manifest_url",
"https://extensions.example/builder.json",
)
mocker.patch.object(
InstallableExtension,
"fetch_manifest",
mocker.AsyncMock(side_effect=fetch_manifest),
)
fetch_config_mock = mocker.patch.object(
ExtensionConfig,
"fetch_release_config",
mocker.AsyncMock(return_value=config),
)
releases = await InstallableExtension.get_extension_releases(ext_id)
assert len(releases) == 1
assert releases[0].extension_type == "wasm"
assert releases[0].permissions == config_permissions
fetch_config_mock.assert_awaited_once_with(details_link)
fetch_config_mock.reset_mock()
non_wasm_releases = await InstallableExtension.get_extension_releases(
non_wasm_ext_id
)
assert len(non_wasm_releases) == 1
assert non_wasm_releases[0].extension_type is None
assert non_wasm_releases[0].permissions == []
fetch_config_mock.assert_not_awaited()
mocker.patch.object(
InstallableExtension,
"get_extension_releases",
mocker.AsyncMock(return_value=releases),
)
mocker.patch(
"lnbits.core.views.extension_api.get_installed_extension",
mocker.AsyncMock(return_value=None),
)
api_releases = await get_extension_releases(ext_id)
assert api_releases[0].permissions == config_permissions
@pytest.mark.anyio
async def test_extension_api_installs_wasm_with_granted_permissions(
tmp_path,

View file

@ -10,16 +10,14 @@ import {
login,
waitForWalletBalance
} from './extension-helpers'
import {BIGPAYMENT, LIVE_EXTENSIONS} from './extensions'
import {BIGPAYMENT} from './extensions'
test('install BigPayment and pay large invoice with fake wallet', async ({
page,
lnbitsServer
}) => {
await login(page, lnbitsServer)
await installAndEnableExtension(page, BIGPAYMENT, {
preloadExtensions: LIVE_EXTENSIONS
})
await installAndEnableExtension(page, BIGPAYMENT)
const collector = await createWallet(
page,

View file

@ -13,10 +13,8 @@ const INSTALLABLE_EXTENSION_REFRESH_TASK =
export type ExtensionUnderTest = {
extId: string
manifestUrl: string
name: string
permissionTexts?: string[]
repository: string
}
export type E2EWallet = {
@ -191,52 +189,12 @@ export function invoicePaymentRequest(
export async function installAndEnableExtension(
page: Page,
extension: ExtensionUnderTest,
{preloadExtensions = []}: {preloadExtensions?: ExtensionUnderTest[]} = {}
extension: ExtensionUnderTest
): Promise<void> {
await addExtensionManifests(page, [...preloadExtensions, extension])
await installExtension(page, extension)
await enableExtension(page, extension)
}
export async function addExtensionManifests(
page: Page,
extensions: ExtensionUnderTest[]
): Promise<void> {
const uniqueExtensions = uniqueExtensionsById(extensions)
if (!uniqueExtensions.length) return
await page.goto('/admin#extensions')
await dismissDisclaimer(page)
const savedManifests = await savedManifestsFor(page)
const missingExtensions = uniqueExtensions.filter(
extension => !savedManifests.includes(extension.manifestUrl)
)
if (missingExtensions.length) {
const manifestInput = page
.locator('.q-field')
.filter({hasText: 'Source URL'})
.locator('input')
.first()
for (const extension of missingExtensions) {
await manifestInput.fill(extension.manifestUrl)
await manifestInput.press('Enter')
await expect(page.getByText(extension.manifestUrl)).toBeVisible()
}
const saveButton = page.getByRole('button', {name: /^save$/i})
await expect(saveButton).toBeEnabled()
await saveButton.click()
await expect(page.getByText(/Settings changed/i)).toBeVisible()
}
const savedAfterUpdate = await savedManifestsFor(page)
const missingUrls = uniqueExtensions
.filter(extension => !savedAfterUpdate.includes(extension.manifestUrl))
.map(extension => extension.manifestUrl)
expect(missingUrls).toEqual([])
}
export async function installExtension(
page: Page,
extension: ExtensionUnderTest
@ -250,7 +208,8 @@ export async function installExtension(
return
}
await waitForInstallableExtension(page, extension)
const installable = await waitForInstallableExtension(page, extension)
const release = latestReleaseFor(installable)
await page.goto('/extensions')
await dismissDisclaimer(page)
@ -268,9 +227,9 @@ export async function installExtension(
timeout: 60_000
})
const release = await latestGithubRelease(extension)
const version = String(release.tag_name)
const repositoryLabel = manageDialog.getByText(extension.repository).first()
const version = String(release.version)
const sourceRepo = String(release.source_repo)
const repositoryLabel = manageDialog.getByText(sourceRepo).first()
await expect(repositoryLabel).toBeVisible({timeout: 120_000})
await repositoryLabel.click()
@ -305,7 +264,7 @@ export async function installExtension(
? installed.permissions.filter(isRecord).map(permission => permission.id)
: []
)
const latestConfig = await latestReleaseConfig(extension)
const latestConfig = await latestReleaseConfig(release)
const permissions = Array.isArray(latestConfig.permissions)
? latestConfig.permissions.filter(isRecord)
: []
@ -361,7 +320,7 @@ export async function waitForInstallableExtension(
page: Page,
extension: ExtensionUnderTest,
{timeout = 360_000}: {timeout?: number} = {}
): Promise<void> {
): Promise<Record<string, unknown>> {
let lastResponse: unknown
let lastError: unknown
const deadline = Date.now() + timeout
@ -370,26 +329,13 @@ export async function waitForInstallableExtension(
try {
lastError = undefined
await waitForInstallableExtensionRefresh(page, deadline)
const settings = await browserJson(page, 'GET', '/admin/api/v1/settings')
const extensions = await browserJson(page, 'GET', '/api/v1/extension/all')
lastResponse = {
manifests: isRecord(settings)
? settings.lnbits_extensions_manifests
: undefined,
extensions
}
const manifests =
isRecord(settings) &&
Array.isArray(settings.lnbits_extensions_manifests)
? settings.lnbits_extensions_manifests
: []
lastResponse = extensions
const extensionList = Array.isArray(extensions) ? extensions : []
if (
manifests.includes(extension.manifestUrl) &&
extensionList.filter(isRecord).some(item => item.id === extension.extId)
) {
return
}
const installable = extensionList
.filter(isRecord)
.find(item => item.id === extension.extId)
if (installable) return installable
await waitForInstallableExtensionRefresh(page, deadline)
} catch (error) {
lastError = error
@ -570,28 +516,27 @@ async function waitForInstallableExtensionRefresh(
}
}
async function latestGithubRelease(
extension: ExtensionUnderTest
): Promise<Record<string, unknown>> {
const release = await fetchJson(
`https://api.github.com/repos/${extension.repository}/releases/latest`
)
if (!isRecord(release) || !release.tag_name) {
function latestReleaseFor(
installable: Record<string, unknown>
): Record<string, unknown> {
const release = installable.latestRelease
if (
!isRecord(release) ||
typeof release.version !== 'string' ||
typeof release.source_repo !== 'string' ||
typeof release.details_link !== 'string'
) {
throw new Error(
`Invalid GitHub release response: ${JSON.stringify(release)}`
`Installable extension is missing release metadata: ${JSON.stringify(installable)}`
)
}
return release
}
async function latestReleaseConfig(
extension: ExtensionUnderTest,
tagName?: string
release: Record<string, unknown>
): Promise<Record<string, unknown>> {
const tag = tagName ?? String((await latestGithubRelease(extension)).tag_name)
const config = await fetchJson(
`https://raw.githubusercontent.com/${extension.repository}/${tag}/config.json`
)
const config = await fetchJson(String(release.details_link))
if (!isRecord(config)) {
throw new Error(
`Invalid extension config response: ${JSON.stringify(config)}`
@ -600,18 +545,6 @@ async function latestReleaseConfig(
return config
}
async function savedManifestsFor(page: Page): Promise<string[]> {
const settings = await browserJson(page, 'GET', '/admin/api/v1/settings')
const manifests = isRecord(settings)
? settings.lnbits_extensions_manifests
: undefined
return Array.isArray(manifests)
? manifests.filter(
(manifest): manifest is string => typeof manifest === 'string'
)
: []
}
async function fetchJson(url: string): Promise<unknown> {
const response = await fetch(url, {
headers: {
@ -626,19 +559,6 @@ async function fetchJson(url: string): Promise<unknown> {
return text ? JSON.parse(text) : {}
}
function uniqueExtensionsById(
extensions: ExtensionUnderTest[]
): ExtensionUnderTest[] {
const unique: ExtensionUnderTest[] = []
const seen = new Set<string>()
for (const extension of extensions) {
if (seen.has(extension.extId)) continue
unique.push(extension)
seen.add(extension.extId)
}
return unique
}
function walletFromResponse(wallet: unknown): E2EWallet {
if (!isRecord(wallet)) {
throw new Error(

View file

@ -3,37 +3,23 @@ import type {ExtensionUnderTest} from './extension-helpers'
export const TIPS: ExtensionUnderTest = {
extId: 'tips',
name: 'Tips',
manifestUrl:
'https://raw.githubusercontent.com/lnbits/tips/refs/heads/main/manifest.json',
repository: 'lnbits/tips',
permissionTexts: ['Make background payments']
}
export const BIGPAYMENT: ExtensionUnderTest = {
extId: 'bigpayment',
name: 'BigPayment',
manifestUrl:
'https://raw.githubusercontent.com/lnbits/bigpayment/refs/heads/main/manifest.json',
repository: 'lnbits/bigpayment',
permissionTexts: ['Pay invoices']
}
export const PINGPONG: ExtensionUnderTest = {
extId: 'pingpong',
name: 'Ping Pong',
manifestUrl:
'https://raw.githubusercontent.com/lnbits/pingpong/refs/heads/main/manifest.json',
repository: 'lnbits/pingpong',
permissionTexts: ['Make background payments']
}
export const PAYSPLIT: ExtensionUnderTest = {
extId: 'paysplit',
name: 'PaySplit',
manifestUrl:
'https://raw.githubusercontent.com/lnbits/paysplit/refs/heads/main/manifest.json',
repository: 'lnbits/paysplit',
permissionTexts: ['Watch wallet payments']
}
export const LIVE_EXTENSIONS = [TIPS, BIGPAYMENT, PINGPONG, PAYSPLIT]

View file

@ -14,7 +14,7 @@ import {
waitForWalletBalance
} from './extension-helpers'
import {LNURLPayServer} from './lnurl-helpers'
import {LIVE_EXTENSIONS, PAYSPLIT} from './extensions'
import {PAYSPLIT} from './extensions'
test('install PaySplit and split incoming payment with fake wallet', async ({
page,
@ -26,9 +26,7 @@ test('install PaySplit and split incoming payment with fake wallet', async ({
const payer = await createWallet(page, `PaySplit payer ${randomHex()}`)
await fundWalletWithFakeBalance(page, payer.id, {amountSats: 250})
await installAndEnableExtension(page, PAYSPLIT, {
preloadExtensions: LIVE_EXTENSIONS
})
await installAndEnableExtension(page, PAYSPLIT)
await grantWalletPaymentsWatchPermission(page, PAYSPLIT.extId, source.id)
await grantBackgroundPaymentPermission(page, PAYSPLIT.extId, source.id, {
maxAmountSats: 100

View file

@ -21,7 +21,7 @@ import {
waitForWalletBalance
} from './extension-helpers'
import {LNURLPayServer} from './lnurl-helpers'
import {LIVE_EXTENSIONS, PINGPONG} from './extensions'
import {PINGPONG} from './extensions'
type GameInvoice = {
gameId: string
@ -64,9 +64,7 @@ test('install Ping Pong, play public game, and pay winner with fake wallet', asy
amountSats: 100
})
await installAndEnableExtension(page, PINGPONG, {
preloadExtensions: LIVE_EXTENSIONS
})
await installAndEnableExtension(page, PINGPONG)
await grantBackgroundPaymentPermission(page, PINGPONG.extId, escrow.id, {
maxAmountSats: 100
})

View file

@ -43,7 +43,6 @@ const server = childProcess.spawn(
LNBITS_BACKEND_WALLET_CLASS: 'FakeWallet',
LNBITS_DATA_FOLDER: dataDir,
LNBITS_ENABLE_LOG_TO_FILE: 'false',
LNBITS_EXTENSIONS_MANIFESTS: '[]',
LNBITS_EXTENSIONS_PATH: dataDir,
PORT: port,
PYTHONUNBUFFERED: '1'

View file

@ -8,7 +8,7 @@ import {
login,
superuserWallet
} from './extension-helpers'
import {LIVE_EXTENSIONS, TIPS} from './extensions'
import {TIPS} from './extensions'
test('install Tips extension and pay tip with fake wallet', async ({
page,
@ -17,9 +17,7 @@ test('install Tips extension and pay tip with fake wallet', async ({
await login(page, lnbitsServer)
const wallet = await superuserWallet(page)
await installAndEnableExtension(page, TIPS, {
preloadExtensions: LIVE_EXTENSIONS
})
await installAndEnableExtension(page, TIPS)
await fundWalletWithFakeBalance(page, wallet.id, {amountSats: 10_000})
const jarTitle = `Playwright Tips ${randomHex()}`

View file

@ -0,0 +1,48 @@
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import InstallableExtension, Manifest
from lnbits.settings import Settings
@pytest.mark.anyio
async def test_get_installable_extensions_loads_wasm_manifests(
settings: Settings, mocker: MockerFixture
):
regular_manifest_url = "https://example.com/extensions.json"
wasm_manifest_url = "https://example.com/wasm-extensions.json"
settings.lnbits_extensions_manifests = [regular_manifest_url]
settings.lnbits_wasm_extensions_manifests = [
wasm_manifest_url,
regular_manifest_url,
]
fetch_manifest = mocker.patch.object(
InstallableExtension,
"fetch_manifest",
mocker.AsyncMock(
side_effect=[
Manifest(),
Manifest.parse_obj(
{
"extensions": [
{
"id": "tips",
"name": "Tips",
"version": "0.1.4",
"archive": "https://example.com/tips.zip",
"hash": "tips-hash",
}
]
}
),
]
),
)
extensions = await InstallableExtension._get_installable_extensions()
assert [extension.id for extension in extensions] == ["tips"]
assert [call.args[0] for call in fetch_manifest.await_args_list] == [
regular_manifest_url,
wasm_manifest_url,
]

View file

@ -4,6 +4,7 @@ import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.settings import (
DEFAULT_WASM_MANIFESTS,
AssetSettings,
ExchangeRateProvider,
InstalledExtensionsSettings,
@ -188,6 +189,31 @@ def test_list_parse_fallback():
assert list_parse_fallback("") == []
def test_settings_keep_wasm_manifests_separate_from_extension_manifests():
settings = Settings(
lnbits_extensions_manifests=["https://example.com/extensions.json"],
lnbits_wasm_extensions_manifests=[
*DEFAULT_WASM_MANIFESTS,
"https://example.com/extensions.json",
],
)
assert settings.lnbits_extensions_manifests == [
"https://example.com/extensions.json"
]
assert settings.lnbits_wasm_extensions_manifests == [
*DEFAULT_WASM_MANIFESTS,
"https://example.com/extensions.json",
]
assert settings.dict()["lnbits_extensions_manifests"] == [
"https://example.com/extensions.json"
]
assert settings.dict()["lnbits_wasm_extensions_manifests"] == [
*DEFAULT_WASM_MANIFESTS,
"https://example.com/extensions.json",
]
def test_exchange_rate_provider_convert_ticker():
provider = ExchangeRateProvider(
name="Provider",