mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
parent
c0806ca59c
commit
8723d327fe
19 changed files with 312 additions and 157 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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?',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()}`
|
||||
|
|
|
|||
48
tests/unit/test_models_extensions.py
Normal file
48
tests/unit/test_models_extensions.py
Normal 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,
|
||||
]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue