Compare commits

..
Author SHA1 Message Date
Vlad Stan 005f384c82 test: clicks 2026-07-16 10:40:28 +03:00
Vlad Stan 912c0cb1fa refactor: split 2026-07-16 07:13:39 +03:00
Vlad Stan ad90e95b10 feat: nicer 2026-07-15 22:54:52 +03:00
Vlad Stan 9961c76caa feat: first migration 2026-07-15 21:49:42 +03:00
42 changed files with 3331 additions and 1487 deletions
+3 -3
View File
@@ -85,15 +85,15 @@ jobs:
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
jmeter:
integration:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.12"]
uses: ./.github/workflows/jmeter.yml
uses: ./.github/workflows/integration.yml
with:
python-version: ${{ matrix.python-version }}
bundle:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, integration ]
uses: ./.github/workflows/bundle.yml
+45
View File
@@ -0,0 +1,45 @@
name: Extension Integration Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
type: string
jobs:
integration:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
npm: "true"
- name: run Playwright integration tests
env:
EXTENSIONS_MANIFEST_URL: "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly,satspay,tipjar,tpos,lnurlp,withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
AUTH_HTTPS_ONLY: false
run: npm run test:integration
- name: print lnbits log
if: ${{ always() }}
run: |
sleep 1
cat tests/data/integration/logs/debug.log || true
- name: upload integration test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: extension-integration-test-results
path: |
playwright-report/integration/
test-results/
tests/data/integration/logs/
-65
View File
@@ -1,65 +0,0 @@
name: JMeter Extension Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
type: string
jobs:
jmeter:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: run LNbits
env:
LNBITS_ADMIN_UI: true
AUTH_HTTPS_ONLY: false
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
uv run lnbits &
sleep 10
- name: setup java version
run: |
update-java-alternatives --list
sudo update-java-alternatives --set /usr/lib/jvm/temurin-8-jdk-amd64
java -version
- name: clone lnbits-extensions, install jmeter and run tests
env:
JAVA_HOME: /usr/lib/jvm/temurin-8-jdk-amd64
EXTENSIONS_MANIFEST_PATH: "/lnbits/lnbits-extensions/refs/heads/main/extensions.json"
run: |
git clone https://github.com/lnbits/lnbits-extensions
cd lnbits-extensions
mkdir logs
mkdir reports
make install-jmeter
make start-mirror-server
make test
- name: print lnbits log
if: ${{ always() }}
run: |
# catch up time for lnbits
sleep 1
cat data/logs/debug.log
- name: upload jmeter test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: jmeter-extension-test-results
path: |
lnbits-extensions/reports/
lnbits-extensions/logs/
+2
View File
@@ -18,6 +18,8 @@ __pycache__
.webassets-cache
htmlcov
test-reports
playwright-report
test-results
tests/data/*.sqlite3
*.swo
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: test
.PHONY: test test-integration
all: format check
@@ -69,6 +69,9 @@ test-regtest:
rm -rf ./tests/data \
uv run pytest tests/regtest
test-integration:
npm run test:integration
test-migration:
LNBITS_ADMIN_UI=True \
make test-api
+3 -260
View File
@@ -11,15 +11,10 @@ from lnbits.helpers import sha256s
from ..client.extensions import send_extension_api_request
from ..storage.crud import (
OWNER_ID_FIELD,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
storage_set_row,
)
from .background_payments import (
@@ -44,28 +39,21 @@ from .models import (
PayLnurlRequest,
RandomIdRequest,
RandomIdResponse,
StorageAppendPublicRequest,
StorageAppendPublicResponse,
StorageDeleteRequest,
StorageDeleteResponse,
StorageGetRequest,
StorageGetResponse,
StoragePaginatedRequest,
StoragePaginatedResponse,
StoragePublicPaginatedRequest,
StorageSetRequest,
StorageSetResponse,
UserWalletSummary,
WalletBalanceRequest,
WalletBalanceResponse,
WebsocketPublishRequest,
WebsocketPublishResponse,
)
from .registry import extension_api_method
from .websockets import scoped_websocket_item_id
logger = logging.getLogger("lnbits.extensions")
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
class ExtensionHostAPI:
@@ -129,7 +117,7 @@ class ExtensionHostAPI:
async def storage_get_public(
self, request: StorageGetRequest
) -> StorageGetResponse:
public_fields = self._public_storage_policy(request.table)["public_fields"]
public_fields = self._public_storage_fields(request.table)
row = await storage_get_public_row(self.extension_id, request.table, request.id)
if not row:
return StorageGetResponse()
@@ -141,45 +129,6 @@ class ExtensionHostAPI:
# todo: check public fields filtering
return StorageGetResponse(data_json=json.dumps(public_row))
@extension_api_method(
method_id="storage.append_public",
namespace="storage",
name="Append public storage row",
host_name="storage_append_public",
sdk_name="appendPublic",
description="Append one public row to an extension storage table.",
required_permission="ext.storage.append_public",
require_auth=False,
)
async def storage_append_public(
self, request: StorageAppendPublicRequest
) -> StorageAppendPublicResponse:
policy, owner_id = await self._public_storage_append_policy(
request.table, request.source_id
)
data = dict(request.data)
self._validate_public_append_data(policy, data)
source_id_field = policy["source_id_field"]
current_rows = await storage_count_rows(
self.extension_id,
request.table,
{source_id_field: request.source_id},
owner_id=owner_id,
)
if current_rows >= policy["max_rows_per_source"]:
raise PermissionError(
f"Public storage append limit reached for '{request.table}'."
)
row_id = await storage_append_public_row(
self.extension_id,
request.table,
{**data, source_id_field: request.source_id},
owner_id,
)
return StorageAppendPublicResponse(id=row_id)
@extension_api_method(
method_id="storage.set",
namespace="storage",
@@ -229,55 +178,6 @@ class ExtensionHostAPI:
total=page["total"],
)
@extension_api_method(
method_id="storage.get_public_paginated",
namespace="storage",
name="Get paginated public storage rows",
host_name="storage_get_public_paginated",
sdk_name="getPublicPaginated",
description="Get filtered, searched, sorted, paginated public storage rows.",
required_permission="ext.storage.read_public",
require_auth=False,
)
async def storage_get_public_paginated(
self, request: StoragePublicPaginatedRequest
) -> StoragePaginatedResponse:
policy = self._public_storage_policy(request.table)
public_fields = policy["public_fields"]
source_id_field = policy["source_id_field"]
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public paginated storage reads require a source ID field policy."
)
filters = self._public_storage_paginated_filters(
request, public_fields, source_id_field
)
page = await storage_get_public_paginated_rows(
self.extension_id,
request.table,
filters,
search=request.search,
search_fields=request.search_fields,
sort_by=request.sort_by,
descending=request.descending,
limit=request.limit,
offset=request.offset,
)
return StoragePaginatedResponse(
rows_json=json.dumps(
[
{
field_name: value
for field_name, value in row.items()
if field_name in public_fields
}
for row in page["data"]
]
),
total=page["total"],
)
@extension_api_method(
method_id="storage.delete",
namespace="storage",
@@ -299,25 +199,6 @@ class ExtensionHostAPI:
)
return StorageDeleteResponse()
@extension_api_method(
method_id="websocket.publish",
namespace="websocket",
name="Publish websocket message",
host_name="websocket_publish",
sdk_name="publish",
description="Publish a JSON message on an extension-local websocket channel.",
required_permission="websocket.publish",
require_auth=False,
)
async def websocket_publish(
self, request: WebsocketPublishRequest
) -> WebsocketPublishResponse:
from lnbits.core.services import websocket_manager
item_id = scoped_websocket_item_id(self.extension_id, request.item_id)
await websocket_manager.send(item_id, request.data_json)
return WebsocketPublishResponse()
@extension_api_method(
method_id="wallet.create_invoice",
namespace="wallet",
@@ -747,7 +628,7 @@ class ExtensionHostAPI:
return permission_ids, policies
def _public_storage_policy(self, table: str) -> dict[str, Any]:
def _public_storage_fields(self, table: str) -> set[str]:
tables = self.permission_policies.get("ext.storage.read_public")
if not isinstance(tables, list) or not tables:
raise PermissionError(
@@ -767,148 +648,10 @@ class ExtensionHostAPI:
raise PermissionError(
f"Public storage table '{table}' has no valid public fields."
)
source_id_field = table_policy.get("source_id_field")
if source_id_field is not None and (
not isinstance(source_id_field, str) or not source_id_field
):
raise PermissionError(
f"Public storage table '{table}' has no valid source ID field."
)
return {
"public_fields": set(public_fields),
"source_id_field": source_id_field,
}
return set(public_fields)
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _validate_public_storage_query_fields(
self,
request: StoragePaginatedRequest,
public_fields: set[str],
allowed_private_fields: set[str] | None = None,
) -> None:
allowed_private_fields = allowed_private_fields or set()
query_fields = set(request.filters)
query_fields.update(request.search_fields)
if request.sort_by:
query_fields.add(request.sort_by)
private_fields = sorted(query_fields - public_fields - allowed_private_fields)
if private_fields:
raise PermissionError(
"Public storage query uses non-public fields: "
+ ", ".join(private_fields)
)
def _public_storage_paginated_filters(
self,
request: StoragePublicPaginatedRequest,
public_fields: set[str],
source_id_field: str,
) -> dict[str, Any]:
self._validate_public_storage_query_fields(
request, public_fields, {source_id_field}
)
filters = dict(request.filters)
requested_source_id = filters.get(source_id_field)
if requested_source_id is not None and requested_source_id != request.source_id:
raise PermissionError(
"Public storage source filter does not match source_id."
)
filters[source_id_field] = request.source_id
return filters
async def _public_storage_append_policy(
self, table: str, source_id: str
) -> tuple[dict[str, Any], str]:
policies = self.permission_policies.get("ext.storage.append_public")
if not isinstance(policies, list) or not policies:
raise PermissionError(
"Public storage appends require policies for "
"'ext.storage.append_public'."
)
source_not_found = False
for raw_policy in policies:
policy = self._normalize_public_storage_append_policy(raw_policy)
if policy["table"] != table:
continue
owner_id = await storage_get_row_owner_id(
self.extension_id,
policy["source_table"],
source_id,
)
if not owner_id:
source_not_found = True
continue
return policy, owner_id
if source_not_found:
raise PermissionError("Public storage append source was not found.")
raise PermissionError(f"Storage table '{table}' is not publicly appendable.")
def _normalize_public_storage_append_policy(self, policy: Any) -> dict[str, Any]:
if not isinstance(policy, dict):
raise PermissionError("Public storage append policies must be objects.")
table = policy.get("table")
source_table = policy.get("source_table")
source_id_field = policy.get("source_id_field")
allowed_fields = policy.get("allowed_fields")
max_rows_per_source = policy.get(
"max_rows_per_source", PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
)
if not isinstance(table, str) or not table:
raise PermissionError("Public storage append requires a table policy.")
if not isinstance(source_table, str) or not source_table:
raise PermissionError(
"Public storage append requires a source table policy."
)
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public storage append requires a source ID field policy."
)
if source_id_field == "id":
raise PermissionError("Public storage append source field cannot be 'id'.")
if (
not isinstance(allowed_fields, list)
or not all(isinstance(field, str) and field for field in allowed_fields)
or "id" in allowed_fields
or OWNER_ID_FIELD in allowed_fields
or source_id_field in allowed_fields
):
raise PermissionError(
"Public storage append requires valid allowed fields."
)
if (
isinstance(max_rows_per_source, bool)
or not isinstance(max_rows_per_source, int)
or max_rows_per_source <= 0
):
raise PermissionError(
"Public storage append requires a positive row limit."
)
return {
"table": table,
"source_table": source_table,
"source_id_field": source_id_field,
"allowed_fields": set(allowed_fields),
"max_rows_per_source": max_rows_per_source,
}
def _validate_public_append_data(
self, policy: dict[str, Any], data: dict[str, Any]
) -> None:
if not isinstance(data, dict):
raise PermissionError("Public storage append data must be an object.")
unknown_fields = sorted(set(data) - policy["allowed_fields"])
if unknown_fields:
raise PermissionError(
"Public storage append contains disallowed fields: "
+ ", ".join(unknown_fields)
)
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
policies = self.permission_policies.get("wallet.create_invoice_public")
if not isinstance(policies, list) or not policies:
-53
View File
@@ -67,23 +67,6 @@ class StorageSetResponse(BaseModel):
ok: bool = True
class StorageAppendPublicRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
source_id: str = Field(..., min_length=1, max_length=512)
data: dict[str, Any] = Field(default_factory=dict)
@root_validator(pre=True)
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
data_json = values.get("data_json")
if data_json is not None and "data" not in values:
values["data"] = json.loads(data_json)
return values
class StorageAppendPublicResponse(BaseModel):
id: str
class StoragePaginatedRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
filters: dict[str, Any] = Field(default_factory=dict)
@@ -109,47 +92,11 @@ class StoragePaginatedRequest(BaseModel):
return values
class StoragePublicPaginatedRequest(StoragePaginatedRequest):
source_id: str = Field(..., min_length=1, max_length=512)
class StoragePaginatedResponse(BaseModel):
rows_json: str = "[]"
total: int = 0
class WebsocketPublishRequest(BaseModel):
item_id: str = Field(..., min_length=1, max_length=128)
data: Any = Field(default_factory=dict)
@root_validator(pre=True)
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
data_json = values.get("data_json")
if data_json is not None and "data" not in values:
values["data"] = json.loads(data_json)
return values
@root_validator
def validate_data_size(cls, values: dict[str, Any]) -> dict[str, Any]:
data = values.get("data")
try:
encoded = json.dumps(data, separators=(",", ":"))
except TypeError as exc:
raise ValueError("websocket data must be JSON serializable.") from exc
if len(encoded.encode()) > 65536:
raise ValueError("websocket data must not exceed 65536 bytes.")
values["data"] = data
return values
@property
def data_json(self) -> str:
return json.dumps(self.data, separators=(",", ":"))
class WebsocketPublishResponse(BaseModel):
sent: bool = True
class StorageDeleteRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
id: str = Field(..., min_length=1, max_length=512)
+6 -89
View File
@@ -10,14 +10,11 @@ from lnbits.core.wasm_ext.wasm.config import (
)
_POLICY_AWARE_PERMISSION_IDS = {
"ext.storage.append_public",
"ext.storage.read_public",
"extension.api.request",
"http.request",
"wallet.create_invoice_public",
}
_OWNER_ID_FIELD = "__lnbits_owner_id__"
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
def validate_extension_permissions(
@@ -130,10 +127,6 @@ def _permission_grant_is_subset(
return _http_request_grant_is_subset(requested.policies, granted.policies)
if requested.id == "extension.api.request":
return _extension_api_grant_is_subset(requested.policies, granted.policies)
if requested.id == "ext.storage.append_public":
return _public_storage_append_grant_is_subset(
requested.policies, granted.policies
)
if requested.id == "ext.storage.read_public":
return _public_storage_grant_is_subset(requested.policies, granted.policies)
if requested.id == "wallet.create_invoice_public":
@@ -210,108 +203,32 @@ def _public_storage_grant_is_subset(
) -> bool:
requested_tables = _public_storage_tables(requested_policies)
granted_tables = _public_storage_tables(granted_policies)
for table_name, granted_policy in granted_tables.items():
requested_policy = requested_tables.get(table_name)
if requested_policy is None:
return False
if not granted_policy["public_fields"].issubset(
requested_policy["public_fields"]
):
return False
requested_source_id_field = requested_policy["source_id_field"]
if (
requested_source_id_field
and granted_policy["source_id_field"] != requested_source_id_field
):
for table_name, granted_fields in granted_tables.items():
requested_fields = requested_tables.get(table_name)
if requested_fields is None or not granted_fields.issubset(requested_fields):
return False
return True
def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
tables: dict[str, dict[str, Any]] = {}
def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
tables: dict[str, set[str]] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table_name = policy.get("table_name")
public_fields = policy.get("public_fields")
source_id_field = policy.get("source_id_field")
if (
not isinstance(table_name, str)
or table_name in tables
or not isinstance(public_fields, list)
or (
source_id_field is not None
and (not isinstance(source_id_field, str) or not source_id_field)
)
):
continue
fields = {field for field in public_fields if isinstance(field, str) and field}
if fields:
tables[table_name] = {
"public_fields": fields,
"source_id_field": source_id_field,
}
tables[table_name] = fields
return tables
def _public_storage_append_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
requested_targets = _public_storage_append_targets(requested_policies)
granted_targets = _public_storage_append_targets(granted_policies)
for target, granted_policy in granted_targets.items():
requested_policy = requested_targets.get(target)
if requested_policy is None:
return False
if not granted_policy["allowed_fields"].issubset(
requested_policy["allowed_fields"]
):
return False
if (
granted_policy["max_rows_per_source"]
> requested_policy["max_rows_per_source"]
):
return False
return True
def _public_storage_append_targets(policies: list[Any] | None) -> dict[Any, dict]:
targets: dict[Any, dict] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table = policy.get("table")
source_table = policy.get("source_table")
source_id_field = policy.get("source_id_field")
allowed_fields = policy.get("allowed_fields")
max_rows_per_source = policy.get(
"max_rows_per_source", _PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
)
if (
not isinstance(table, str)
or not table
or not isinstance(source_table, str)
or not source_table
or not isinstance(source_id_field, str)
or not source_id_field
or source_id_field == "id"
or not isinstance(allowed_fields, list)
or isinstance(max_rows_per_source, bool)
or not isinstance(max_rows_per_source, int)
or max_rows_per_source <= 0
):
continue
fields = {field for field in allowed_fields if isinstance(field, str) and field}
if "id" in fields or _OWNER_ID_FIELD in fields or source_id_field in fields:
continue
targets[(table, source_table, source_id_field)] = {
"allowed_fields": fields,
"max_rows_per_source": max_rows_per_source,
}
return targets
def _public_invoice_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
-1
View File
@@ -15,7 +15,6 @@ _EXTENSION_RUNTIME_PERMISSION_IDS = {
"wallet.pay_invoice",
"wallet.pay_invoice_background",
"wallet.payments.watch",
"websocket.subscribe",
}
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
-17
View File
@@ -1,17 +0,0 @@
from __future__ import annotations
import re
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_LOCAL_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$")
def scoped_websocket_item_id(extension_id: str, item_id: str) -> str:
if not _EXTENSION_ID_RE.fullmatch(extension_id):
raise ValueError("Extension websocket namespace is invalid.")
if not _LOCAL_ITEM_ID_RE.fullmatch(item_id):
raise ValueError(
"Extension websocket item ID must be 1-128 characters and contain "
"only letters, numbers, colon, underscore, or dash."
)
return f"ext:{extension_id}:{item_id}"
-6
View File
@@ -1,10 +1,7 @@
from .crud import (
migrate_wasm_extension_database,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
@@ -13,11 +10,8 @@ from .crud import (
__all__ = [
"migrate_wasm_extension_database",
"storage_append_public_row",
"storage_count_rows",
"storage_delete_row",
"storage_get_paginated_rows",
"storage_get_public_paginated_rows",
"storage_get_public_row",
"storage_get_row",
"storage_get_row_owner_id",
-78
View File
@@ -5,7 +5,6 @@ import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from loguru import logger
@@ -111,39 +110,6 @@ async def storage_set_row(
await conn.execute(query, clean_data)
async def storage_append_public_row(
ext_id: str,
table: str,
data: dict[str, Any],
owner_id: str,
) -> str:
row_id = uuid4().hex
await storage_set_row(ext_id, table, {**data, "id": row_id}, owner_id)
return row_id
async def storage_count_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
owner_id: str,
) -> int:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(database, table_schema, filters, None, [])
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
query = f"""
SELECT COUNT(*) AS count FROM {_table_ref_for_schema(ext_id, table)}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
row = await conn.fetchone(query, values)
return int(row["count"]) if row else 0
async def storage_get_paginated_rows(
ext_id: str,
table: str,
@@ -191,50 +157,6 @@ async def storage_get_paginated_rows(
}
async def storage_get_public_paginated_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
search: str | None,
search_fields: list[str],
sort_by: str | None,
descending: bool,
limit: int,
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
order_sql = _order_sql(table_schema, sort_by, descending)
count_values = dict(values)
values.update({"limit": min(limit, 1000), "offset": offset})
table_ref = _table_ref_for_schema(ext_id, table)
rows_query = f"""
SELECT * FROM {table_ref}
{where_sql}
{order_sql}
LIMIT :limit
OFFSET :offset
""" # noqa: S608
count_query = f"""
SELECT COUNT(*) AS count FROM {table_ref}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
return {
"data": [_row_from_db(table_schema, row) for row in rows],
"total": int(count_row["count"]) if count_row else 0,
}
async def storage_delete_row(
ext_id: str,
table: str,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
-10
View File
@@ -116,7 +116,6 @@ window.localisation.en = {
read: 'Read',
write: 'Write',
pay: 'Pay',
sending: 'Sending',
memo: 'Memo',
date: 'Date',
path: 'Path',
@@ -544,13 +543,7 @@ window.localisation.en = {
extension_permission_warning_extension_api_request_write:
'Can write data or trigger actions in approved extensions.',
extension_permission_ext_storage_read: 'Read extension storage',
extension_permission_ext_storage_append_public:
'Append public extension storage',
extension_permission_ext_storage_append_public_sources:
'Allowed append targets',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_read_public_source_required:
'required to read',
extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
extension_permission_extension_api_request: 'Use other extensions',
@@ -561,9 +554,6 @@ window.localisation.en = {
extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
extension_permission_websocket: 'Use extension websockets',
extension_permission_websocket_publish: 'Publish websocket messages',
extension_permission_websocket_subscribe: 'Subscribe to websocket messages',
extension_permission_wallet_payments_watch: 'Watch wallet payments',
extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public:
@@ -110,15 +110,11 @@
'extension_permission_warning_wallet_payments_watch'
)
}
if (['websocket.publish', 'websocket.subscribe'].includes(permission.id)) {
return mediumRisk(translateFn)
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.append_public',
'ext.storage.read_public'
].includes(permission.id)
) {
@@ -146,13 +142,9 @@
'extension.api.request',
'http.request',
'ui.camera.scan_qr',
'websocket',
'websocket.publish',
'websocket.subscribe',
'ext.storage.read',
'ext.storage.write',
'ext.storage.read_public',
'ext.storage.append_public',
'wallet.create_invoice_public',
'wallet.create_invoice',
'utils.basic'
@@ -174,12 +166,7 @@
: table.public_fields.filter(
field => typeof field === 'string' && field
)
const sourceIdField =
typeof table === 'string' ||
typeof table?.source_id_field !== 'string'
? ''
: table.source_id_field
return tableName ? {table: tableName, fields, sourceIdField} : null
return tableName ? {table: tableName, fields} : null
})
.filter(Boolean)
}
@@ -207,68 +194,24 @@
.filter(Boolean)
}
function publicAppendPolicies(permission) {
const policies = permission.policies
if (!Array.isArray(policies)) return []
return policies
.map(policy => {
if (!policy || typeof policy !== 'object') return null
const table = policy.table
const sourceTable = policy.source_table
const sourceIdField = policy.source_id_field
const allowedFields = Array.isArray(policy.allowed_fields)
? policy.allowed_fields.filter(
field => typeof field === 'string' && field
)
: []
const maxRowsPerSource = Number.isInteger(policy.max_rows_per_source)
? policy.max_rows_per_source
: 10000
if (typeof table !== 'string' || !table) return null
if (typeof sourceTable !== 'string' || !sourceTable) return null
if (typeof sourceIdField !== 'string' || !sourceIdField) return null
return {
table,
sourceTable,
sourceIdField,
allowedFields,
maxRowsPerSource
}
})
.filter(Boolean)
}
function permissionDisplayItem(permissions, extensions, translateFn) {
const permission = permissions[0]
const isReadWriteStorage =
permissions.length === 2 &&
permissions.some(permission => permission.id === 'ext.storage.read') &&
permissions.some(permission => permission.id === 'ext.storage.write')
const isWebsocket =
permissions.every(permission =>
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) &&
permissions.some(permission => permission.id === 'websocket.publish') &&
permissions.some(permission => permission.id === 'websocket.subscribe')
const descriptions = permissions
.map(permission => permissionManifestDescription(permission))
.filter(Boolean)
const item = {
id: isReadWriteStorage
? 'ext.storage.read_write'
: isWebsocket
? 'websocket'
: permission.id,
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
label: isReadWriteStorage
? translate(translateFn, 'extension_permission_ext_storage_read_write')
: isWebsocket
? translate(translateFn, 'extension_permission_websocket')
: permissionLabel(permission, translateFn),
: permissionLabel(permission, translateFn),
risk: permissionRisk(permissions, extensions, translateFn),
badges: [],
descriptions,
fieldGroups: [],
appendPolicies: [],
invoicePolicies: [],
extensionAccess: [],
httpHosts: []
@@ -301,15 +244,6 @@
item.invoicePolicies = publicInvoicePolicies(permission)
}
if (permission.id === 'ext.storage.append_public') {
item.appendPolicies = publicAppendPolicies(permission)
item.badges = item.appendPolicies.map(policy => ({
key:
policy.table + ':' + policy.sourceTable + ':' + policy.sourceIdField,
label: policy.table
}))
}
return item
}
@@ -321,11 +255,7 @@
const hasReadWriteStorage =
permissionsById.has('ext.storage.read') &&
permissionsById.has('ext.storage.write')
const hasWebsocket =
permissionsById.has('websocket.publish') &&
permissionsById.has('websocket.subscribe')
let addedReadWriteStorage = false
let addedWebsocket = false
return permissionList
.map((permission, index) => {
@@ -344,21 +274,6 @@
]
}
}
if (
hasWebsocket &&
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) {
if (addedWebsocket) return null
addedWebsocket = true
return {
index,
orderId: 'websocket',
permissions: [
permissionsById.get('websocket.publish'),
permissionsById.get('websocket.subscribe')
]
}
}
return {
index,
orderId: permission.id,
@@ -412,9 +327,6 @@
publicInvoicePolicySentence(policy) {
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
},
publicAppendPolicySentence(policy) {
return `${policy.table} rows can be appended for ${policy.sourceTable} using ${policy.sourceIdField}. Limit: ${policy.maxRowsPerSource} rows per source.`
},
permissionAccessLabel(access) {
const key = `extension_permission_access_${access}`
const label = this.$t(key)
+2 -15
View File
@@ -7,7 +7,6 @@ window.PageWallet = {
invoice: null,
lnurlpay: null,
lnurlauth: null,
sending: false,
data: {
request: '',
amount: 0,
@@ -132,7 +131,6 @@ window.PageWallet = {
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.internalMemo = null
this.parse.sending = false
this.parse.data.paymentChecker = null
this.parse.camera.show = false
},
@@ -364,9 +362,6 @@ window.PageWallet = {
this.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice() {
if (this.parse.sending) return
this.parse.sending = true
const dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: this.$t('payment_processing')
@@ -379,7 +374,6 @@ window.PageWallet = {
this.parse.data.internalMemo
)
.then(response => {
this.parse.sending = false
dismissPaymentMsg()
this.g.updatePayments = !this.g.updatePayments
this.parse.show = false
@@ -397,16 +391,13 @@ window.PageWallet = {
}
})
.catch(err => {
this.parse.sending = false
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
this.g.updatePayments = !this.g.updatePayments
this.parse.show = false
})
},
payLnurl() {
if (this.parse.sending) return
this.parse.sending = true
LNbits.api
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
res: this.parse.lnurlpay,
@@ -417,7 +408,6 @@ window.PageWallet = {
internalMemo: this.parse.data.internalMemo
})
.then(response => {
this.parse.sending = false
this.parse.show = false
if (response.data.extra.success_action) {
const action = JSON.parse(response.data.extra.success_action)
@@ -474,10 +464,7 @@ window.PageWallet = {
}
}
})
.catch(err => {
this.parse.sending = false
LNbits.utils.notifyApiError(err)
})
.catch(LNbits.utils.notifyApiError)
},
authLnurl() {
const dismissAuthMsg = Quasar.Notify.create({
+1 -129
View File
@@ -183,8 +183,7 @@ window.WasmExtensionComponent = {
handleWindowMessage: null,
loading: false,
loadId: 0,
paymentSubscriptions: new Map(),
websocketSubscriptions: new Map()
paymentSubscriptions: new Map()
}
},
created() {
@@ -358,29 +357,6 @@ window.WasmExtensionComponent = {
)
})
},
extensionRoute(path) {
let url
try {
url = new URL(String(path || ''), window.location.origin)
} catch (_error) {
throw new Error('Invalid extension route.')
}
if (url.origin !== window.location.origin) {
throw new Error('Extension route must stay on this server.')
}
const basePath = `/ext/${encodeURIComponent(this.bridge.extensionId)}`
if (
url.pathname !== basePath &&
!url.pathname.startsWith(`${basePath}/`)
) {
throw new Error('Extension route must stay inside this extension.')
}
return `${url.pathname}${url.search}${url.hash}`
},
replaceExtensionRoute(message) {
return this.$router.replace(this.extensionRoute(message.path))
},
async callApi(message) {
const method = String(message.method || 'GET').toUpperCase()
const path = String(message.path || '')
@@ -868,18 +844,6 @@ window.WasmExtensionComponent = {
isPaymentHash(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
},
isWebsocketItemId(value) {
return (
typeof value === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$/.test(value)
)
},
scopedWebsocketItemId(itemId) {
if (!this.isWebsocketItemId(itemId)) {
throw new Error('Invalid websocket item ID.')
}
return `ext:${this.bridge.extensionId}:${itemId}`
},
websocketUrl(path) {
const url = new URL(window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -910,24 +874,8 @@ window.WasmExtensionComponent = {
this.closePaymentSubscription(subscriptionId)
}
},
closeWebsocketSubscription(subscriptionId) {
const subscription = this.websocketSubscriptions.get(subscriptionId)
if (!subscription) return
this.websocketSubscriptions.delete(subscriptionId)
try {
subscription.socket.close()
} catch (_error) {}
},
closeWebsocketSubscriptions() {
for (const subscriptionId of Array.from(
this.websocketSubscriptions.keys()
)) {
this.closeWebsocketSubscription(subscriptionId)
}
},
closeBridgePort() {
this.closePaymentSubscriptions()
this.closeWebsocketSubscriptions()
this.bridgePort?.close()
this.bridgePort = null
},
@@ -989,55 +937,6 @@ window.WasmExtensionComponent = {
this.paymentSubscriptions.delete(subscriptionId)
})
},
subscribeWebsocket(message) {
if (!this.hasBridgePermission('websocket.subscribe')) {
throw new Error('Extension is missing websocket subscribe permission.')
}
const subscriptionId = String(message.subscriptionId || '')
const itemId = String(message.itemId || '')
if (
!subscriptionId ||
subscriptionId.length > 128 ||
!this.isWebsocketItemId(itemId)
) {
throw new Error('Invalid websocket subscription.')
}
this.closeWebsocketSubscription(subscriptionId)
const scopedItemId = this.scopedWebsocketItemId(itemId)
const socket = new WebSocket(
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(scopedItemId)}`)
)
this.websocketSubscriptions.set(subscriptionId, {itemId, socket})
socket.addEventListener('message', event => {
let data = event.data
try {
data = JSON.parse(event.data)
} catch (_error) {}
this.sendBridgeEvent({
event: 'websocket.message',
subscriptionId,
itemId,
data
})
})
socket.addEventListener('error', () => {
this.sendBridgeEvent({
event: 'websocket.error',
subscriptionId,
itemId
})
this.closeWebsocketSubscription(subscriptionId)
})
socket.addEventListener('close', () => {
this.websocketSubscriptions.delete(subscriptionId)
})
},
async handleBridgeRequest(message, reply) {
if (!message || message.type !== 'lnbits-extension:request') return
@@ -1067,15 +966,6 @@ window.WasmExtensionComponent = {
return
}
if (message.action === 'navigation.replace') {
await this.replaceExtensionRoute(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'ui.scan_qr') {
this.sendResponse(reply, message.id, {
ok: true,
@@ -1126,24 +1016,6 @@ window.WasmExtensionComponent = {
return
}
if (message.action === 'websocket.subscribe') {
this.subscribeWebsocket(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'websocket.unsubscribe') {
this.closeWebsocketSubscription(String(message.subscriptionId || ''))
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
throw new Error('Unknown extension bridge action.')
} catch (error) {
this.sendResponse(reply, message.id, {
@@ -9,31 +9,28 @@
>
<template v-slot:header>
<q-item-section>
<div class="row items-center q-col-gutter-x-md q-row-gutter-sm">
<q-item-label
class="text-weight-medium col-auto"
style="max-width: 100%"
>
<span v-text="permission.label"></span>
</q-item-label>
<div
v-if="permission.risk.level !== 'low' || permission.badges.length"
class="row items-center q-gutter-xs col-auto q-mb-xs"
style="max-width: 100%"
>
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
<q-item-label class="text-weight-medium">
<span v-text="permission.label"></span>
</q-item-label>
</q-item-section>
<q-item-section
v-if="permission.risk.level !== 'low' || permission.badges.length"
side
top
>
<div class="row items-center justify-end q-gutter-xs">
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
</q-item-section>
</template>
@@ -60,24 +57,7 @@
></p>
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
<li v-for="group of permission.fieldGroups" :key="group.table">
<div class="row items-center q-gutter-xs">
<span v-text="group.table"></span>
<template v-if="group.sourceIdField">
<q-badge
color="warning"
text-color="dark"
v-text="group.sourceIdField"
></q-badge>
<span
class="text-caption text-grey"
v-text="
$t(
'extension_permission_ext_storage_read_public_source_required'
)
"
></span>
</template>
</div>
<span v-text="group.table"></span>
<ul v-if="group.fields.length" class="q-pl-md">
<li
v-for="field of group.fields"
@@ -87,35 +67,6 @@
</ul>
</li>
</ul>
<div v-if="permission.appendPolicies.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="
$t('extension_permission_ext_storage_append_public_sources')
"
></div>
<ul class="q-my-sm q-pl-md">
<li
v-for="policy of permission.appendPolicies"
:key="
policy.table +
':' +
policy.sourceTable +
':' +
policy.sourceIdField
"
>
<span v-text="publicAppendPolicySentence(policy)"></span>
<ul v-if="policy.allowedFields.length" class="q-pl-md">
<li
v-for="field of policy.allowedFields"
:key="policy.table + ':' + field"
v-text="field"
></li>
</ul>
</li>
</ul>
</div>
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
<div
class="text-caption text-grey"
+2 -9
View File
@@ -664,8 +664,7 @@
unelevated
color="primary"
@click="payInvoice"
:disable="parse.sending"
:label="parse.sending ? $t('sending') + '...' : $t('pay')"
:label="$t('pay')"
></q-btn>
<q-btn
v-close-popup
@@ -837,13 +836,7 @@
</div>
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:disable="parse.sending"
:label="parse.sending ? $t('sending') + '...' : $t('send')"
></q-btn>
<q-btn unelevated color="primary" type="submit">Send</q-btn>
<q-btn
:label="$t('cancel')"
v-close-popup
+5
View File
@@ -1,3 +1,5 @@
from collections.abc import AsyncGenerator
from loguru import logger
from .base import (
@@ -38,3 +40,6 @@ class VoidWallet(Wallet):
async def get_payment_status(self, *_, **__) -> PaymentStatus:
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
yield ""
+64
View File
@@ -21,6 +21,7 @@
"vuex": "4.1.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
@@ -576,6 +577,22 @@
"node": ">=0.10"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
@@ -1754,6 +1771,53 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+2
View File
@@ -1,6 +1,7 @@
{
"name": "lnbits",
"scripts": {
"test:integration": "playwright test --config=playwright.integration.config.js",
"sass": "./node_modules/.bin/sass ./lnbits/static/scss/base.scss > ./lnbits/static/css/base.css",
"vendor_copy": "node -e \"require('./package.json').vendor.forEach((file) => require('fs').copyFileSync(file, './lnbits/static/vendor/'+file.split('/').pop()))\"",
"vendor_json": "node -e \"require('fs').writeFileSync('./lnbits/static/vendor.json', JSON.stringify(require('./package.json').bundle))\"",
@@ -13,6 +14,7 @@
"bundle": "npm run sass && npm run vendor_copy && npm run vendor_json && npm run vendor_bundle_css && npm run vendor_bundle_js && npm run vendor_bundle_components && npm run vendor_minify_css && npm run vendor_minify_js && npm run vendor_minify_components"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
+31
View File
@@ -0,0 +1,31 @@
const {defineConfig} = require('@playwright/test')
const port = process.env.PORT || '5000'
const baseURL = process.env.LNBITS_BASE_URL || `http://127.0.0.1:${port}`
module.exports = defineConfig({
testDir: './tests/integration',
testMatch: '**/*.spec.js',
timeout: 120_000,
globalTimeout: 60 * 60 * 1000,
fullyParallel: false,
workers: 1,
reporter: [
['list'],
['html', {outputFolder: 'playwright-report/integration', open: 'never'}]
],
use: {
baseURL,
extraHTTPHeaders: {
Accept: 'application/json, text/plain, */*'
}
},
webServer: process.env.LNBITS_SKIP_WEB_SERVER
? undefined
: {
command: 'node tests/integration/server.cjs',
url: baseURL,
timeout: 180_000,
reuseExistingServer: !process.env.CI
}
})
@@ -0,0 +1,60 @@
const {
test,
expect,
setupIntegration,
state,
pageStatus,
uninstallExtension,
getAdminSession,
installLatestExtensionViaUi,
enableExtensionViaUi,
disableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('001 extensions can be installed, enabled, disabled, and re-enabled', async ({
browser
}) => {
test.setTimeout(45 * 60 * 1000)
const admin = await getAdminSession(browser)
const excluded = new Set([
'discordbot',
'usermanager',
'lnurldevice',
'scheduler',
'deezy',
'nostrrelay',
'tpos',
'webpages'
])
const extensionIds = [...new Set([...state.extensionById.keys()])]
.filter(extension => !excluded.has(extension))
.sort()
expect(extensionIds.length).toBeGreaterThan(0)
for (const extension of extensionIds) {
await test.step(`uninstall ${extension}`, async () => {
await uninstallExtension(state.adminContext, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
})
}
for (const extension of extensionIds) {
await test.step(`install and toggle ${extension}`, async () => {
const installed = await installLatestExtensionViaUi(admin, extension)
if (!installed) {
return
}
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await enableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await disableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
await enableExtensionViaUi(admin, extension)
await pageStatus(state.adminContext, `/${extension}`, [200, 403, 404])
})
}
})
+150
View File
@@ -0,0 +1,150 @@
const {
test,
expect,
setupIntegration,
apiKeyHeaders,
clearMirror,
createWallet,
getPayment,
jsonRequest,
lnurlScan,
pageStatus,
pollPayment,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payLnurlViaUi,
withdrawLnurlViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createPayLink,
createWithdrawLink
} = require('./scenario-helpers')
setupIntegration()
test('002 lnurlp and withdraw scenarios', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
await clearMirror()
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lnurlp', 'withdraw'])
const user = await newUserWithUi(browser, 'lnurl')
await enableExtensionViaUi(user, 'lnurlp')
await pageStatus(user.context, '/lnurlp/')
const initialPayLinks = await jsonRequest(
user.context,
'get',
'/lnurlp/api/v1/links',
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(initialPayLinks).toEqual([])
let userBalanceSats = 0
for (const index of [1, 2, 3]) {
const payLink = await createPayLink(user.context, user, index)
await pageStatus(user.context, `/lnurlp/link/${payLink.id}`)
const fetched = await jsonRequest(
user.context,
'get',
`/lnurlp/api/v1/links/${payLink.id}`,
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(fetched.description).toBe(`receive payments ${index}`)
const lnurlResponse = await lnurlScan(
user.context,
fetched.lnurl,
user.adminkey
)
expect(lnurlResponse.tag).toBe('payRequest')
expect(lnurlResponse.commentAllowed).toBe(128)
for (let paymentIndex = 0; paymentIndex < 2; paymentIndex++) {
const payment = await payLnurlViaUi(
admin,
fetched.lnurl,
(10 + index) * 1000,
`receive payments ${index}`
)
expect(payment.payment_hash).toBeTruthy()
userBalanceSats += 10 + index
await pollWalletBalance(user.context, user.inkey, userBalanceSats * 1000)
const stored = await pollPayment(
user.context,
user.inkey,
payment.payment_hash,
result => Boolean(result?.details?.extra?.wh_response)
)
expect(stored.details.extra.wh_response).toContain('"b2":2')
expect(stored.details.extra.wh_response).toContain(payment.payment_hash)
}
}
await enableExtensionViaUi(user, 'withdraw')
await pageStatus(user.context, '/withdraw/')
const initialWithdrawLinks = await jsonRequest(
user.context,
'get',
'/withdraw/api/v1/links',
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(initialWithdrawLinks.data ?? initialWithdrawLinks).toEqual([])
const receiveWallet = await createWallet(user.context, 'receive wallet')
for (const index of [1, 2, 3]) {
const withdrawLink = await createWithdrawLink(user.context, user, index)
await pageStatus(user.context, `/withdraw/${withdrawLink.id}`)
const fetched = await jsonRequest(
user.context,
'get',
`/withdraw/api/v1/links/${withdrawLink.id}`,
{
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
}
)
expect(fetched.title).toBe(`withdraw ${index}`)
const lnurlResponse = await lnurlScan(
user.context,
fetched.lnurl,
user.adminkey
)
expect(lnurlResponse.tag).toBe('withdrawRequest')
await user.page.waitForTimeout(2100)
for (let withdrawIndex = 0; withdrawIndex < 2; withdrawIndex++) {
const withdrawal = await withdrawLnurlViaUi(
user,
fetched.lnurl,
10,
`withdraw ${index}`,
receiveWallet.walletId
)
expect(withdrawal.payment_hash).toBeTruthy()
userBalanceSats -= 10
await pollWalletBalance(user.context, user.inkey, userBalanceSats * 1000)
await getPayment(
user.context,
receiveWallet.inkey,
withdrawal.payment_hash
)
if (withdrawIndex === 0) {
await user.page.waitForTimeout(1100)
}
}
}
await pollWalletBalance(user.context, receiveWallet.inkey, 60_000)
})
+172
View File
@@ -0,0 +1,172 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
createWallet,
getWallet,
jsonRequest,
pageStatus,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
withdrawLnurl
} = require('./scenario-helpers')
setupIntegration()
test('003 tpos payments, tips, and ATM withdraw', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['tpos'])
const user = await newUserWithUi(browser, 'tpos')
await enableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos/')
const tposs = await jsonRequest(user.context, 'get', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.inkey),
params: {all_wallets: true}
})
expect(tposs).toEqual([])
const tipsWallet = await createWallet(user.context, 'tips wallet')
expect(tipsWallet.userId).toBe(user.userId)
const tpos = await jsonRequest(user.context, 'post', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.adminkey),
data: {name: 'Test', wallet: user.walletId, currency: 'USD'},
expected: 201
})
expect(tpos.wallet).toBe(user.walletId)
const updated = await jsonRequest(
user.context,
'put',
`/tpos/api/v1/tposs/${tpos.id}`,
{
headers: apiKeyHeaders(user.adminkey),
data: {
name: 'Test',
wallet: user.walletId,
currency: 'USD',
tip_options: '[2, 5]',
tip_wallet: tipsWallet.walletId
}
}
)
expect(updated.tip_wallet).toBe(tipsWallet.walletId)
const atm = await jsonRequest(user.context, 'post', '/tpos/api/v1/tposs', {
headers: apiKeyHeaders(user.adminkey),
data: {
withdraw_between: 1,
name: 'Test ATM',
wallet: user.walletId,
currency: 'EUR',
withdraw_limit: 100000
},
expected: 201
})
expect(atm.wallet).toBe(user.walletId)
await pageStatus(user.context, `/tpos/${tpos.id}`)
await jsonRequest(user.context, 'get', '/api/v1/rate/EUR')
let expectedMerchantBalance = 0
for (const amount of [21, 42]) {
const invoice = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/tposs/${tpos.id}/invoices`,
{
headers: apiKeyHeaders(user.inkey),
data: {
amount,
memo: 'TPoS test',
...(amount === 42 ? {tipAmount: 3} : {})
},
expected: 201
}
)
expect(invoice.bolt11).toBeTruthy()
await payInvoiceViaUi(admin, invoice.bolt11)
await jsonRequest(
user.context,
'get',
`/tpos/api/v1/tposs/${tpos.id}/invoices/${invoice.payment_hash}`,
{headers: apiKeyHeaders(user.inkey)}
)
expectedMerchantBalance += amount
await pollWalletBalance(
user.context,
user.inkey,
expectedMerchantBalance * 1000
)
}
await pageStatus(user.context, `/tpos/${atm.id}`)
await jsonRequest(user.context, 'get', '/api/v1/rate/EUR')
const atmInvoice = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/tposs/${atm.id}/invoices`,
{
headers: apiKeyHeaders(user.inkey),
data: {amount: 11000, memo: 'TPoS test'},
expected: 201
}
)
await payInvoiceViaUi(admin, atmInvoice.bolt11)
const beforeAdmin = await getWallet(
state.adminContext,
state.adminWallet.inkey
)
const beforeUser = await getWallet(user.context, user.inkey)
const atmPin = await jsonRequest(
user.context,
'post',
`/tpos/api/v1/atm/${atm.id}/create`,
{
headers: apiKeyHeaders(user.adminkey)
}
)
expect(atmPin.id).toBeTruthy()
const withdraw = await jsonRequest(
user.context,
'get',
`/tpos/api/v1/atm/withdraw/${atmPin.id}/100`,
{
headers: apiKeyHeaders(state.adminWallet.adminkey)
}
)
const withdrawResponse = await jsonRequest(
user.context,
'get',
`/tpos/api/v1/lnurl/${withdraw.id}/100`,
{headers: apiKeyHeaders(state.adminWallet.adminkey)}
)
const payment = await withdrawLnurl(
user.context,
state.adminWallet,
withdrawResponse,
100,
'TPoS withdraw'
)
expect(payment.payment_hash).toBeTruthy()
await pollWalletBalance(
state.adminContext,
state.adminWallet.inkey,
beforeAdmin.balance + 100_000
)
await pollWalletBalance(
user.context,
user.inkey,
beforeUser.balance - 100_000
)
})
@@ -0,0 +1,58 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
latestRelease,
pageStatus,
topUpWallet,
newUserWithUi,
getAdminSession,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
disableExtensionViaUi,
payToEnableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('004 tpos pay-to-enable flow', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['tpos'])
const user = await newUserWithUi(browser, 'pay-to-enable')
await topUpWallet(state.adminContext, user.walletId, 1000)
const release = await latestRelease(state.adminContext, 'tpos')
expect(release.version).toBeTruthy()
await enableExtensionViaUi(admin, 'tpos')
await jsonRequest(state.adminContext, 'put', '/api/v1/extension/tpos/sell', {
data: {required: true, amount: 21, wallet: state.adminWallet.walletId}
})
await pageStatus(state.adminContext, '/tpos')
await pageStatus(user.context, '/tpos', [200, 402, 403])
const lowInvoice = await jsonRequest(
user.context,
'put',
'/api/v1/extension/tpos/invoice/enable',
{data: {amount: 1}, expected: 400}
)
expect(JSON.stringify(lowInvoice)).toContain('21')
await jsonRequest(user.context, 'put', '/api/v1/extension/tpos/enable', {
expected: 402
})
await payToEnableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos')
await disableExtensionViaUi(user, 'tpos')
await enableExtensionViaUi(user, 'tpos')
await pageStatus(user.context, '/tpos')
await jsonRequest(state.adminContext, 'put', '/api/v1/extension/tpos/sell', {
data: {required: false, amount: 0, wallet: state.adminWallet.walletId}
})
})
+78
View File
@@ -0,0 +1,78 @@
const {
test,
expect,
setupIntegration,
createWallet,
getPayments,
jsonRequest,
newUserWithUi,
getAdminSession,
payLnurlViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createPayLink,
createWithdrawLink,
withdrawLnurl
} = require('./scenario-helpers')
setupIntegration()
test('005 lnurlw race limits successful withdrawals', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lnurlp', 'withdraw'])
const user = await newUserWithUi(browser, 'lnurl-race')
await enableExtensionViaUi(user, 'lnurlp')
await enableExtensionViaUi(user, 'withdraw')
const payLink = await createPayLink(user.context, user, 1, {
description: 'receive payments',
min: 1,
max: 100_000_000,
webhook_url: undefined,
webhook_headers: undefined,
webhook_body: undefined
})
const payResponse = await jsonRequest(
user.context,
'get',
`/lnurlp/${payLink.id}`
)
expect(payResponse.tag).toBe('payRequest')
await payLnurlViaUi(admin, payLink.lnurl, 10_000_000, 'receive payments')
const receiveWallet = await createWallet(user.context, 'race receive wallet')
const withdrawLink = await createWithdrawLink(user.context, user, 1, {
uses: 2,
max_withdrawable: 10
})
const withdrawResponse = await jsonRequest(
user.context,
'get',
`/withdraw/api/v1/lnurl/${withdrawLink.unique_hash}`
)
// The race assertion is intentionally API-driven: it needs concurrent calls,
// not serialized browser clicks.
const attempts = Array.from({length: 100}, () =>
withdrawLnurl(
user.context,
receiveWallet,
withdrawResponse,
10,
'withdraw 1'
)
)
await Promise.all(attempts)
const payments = await getPayments(user.context, receiveWallet.inkey, {
limit: 100
})
expect(payments).toHaveLength(100)
const successCount = payments.filter(
payment => payment.status === 'success'
).length
expect(successCount).toBeGreaterThan(0)
expect(successCount).toBeLessThanOrEqual(2)
})
@@ -0,0 +1,233 @@
const {
test,
expect,
setupIntegration,
apiKeyHeaders,
clearMirror,
getPayments,
jsonRequest,
mirrorUrl,
pageStatus,
pollWalletBalance,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('006 watchonly, satspay, and tipjar scenario', async ({browser}) => {
test.setTimeout(7 * 60 * 1000)
await clearMirror()
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, [
'watchonly',
'satspay',
'tipjar'
])
const user = await newUserWithUi(browser, 'watchonly-satspay-tipjar')
await enableExtensionViaUi(user, 'watchonly')
await pageStatus(user.context, `/watchonly/?usr=${user.userId}`)
await jsonRequest(user.context, 'get', '/watchonly/api/v1/config', {
headers: apiKeyHeaders(user.inkey)
})
await jsonRequest(user.context, 'get', '/watchonly/api/v1/wallet', {
headers: apiKeyHeaders(user.inkey),
params: {network: 'Mainnet'}
})
const onchain = await jsonRequest(
user.context,
'post',
'/watchonly/api/v1/wallet',
{
headers: apiKeyHeaders(user.adminkey),
data: {
title: 'segwit',
masterpub:
'zpub6rsRjqj6BTbD9DjqrY4p14tUx5kdA8ZGCTJD99wZTxD5wfvCkyXKrK3s7M3B1eFN6NbRhmbDDRDC8LF3Bn5gmxxN9rF8mDpZsGC6isGrK1g',
network: 'Mainnet',
meta: '{"accountPath":"m/84\'/0\'/0\'"}'
},
expected: [200, 201]
}
)
expect(onchain.id).toBeTruthy()
await jsonRequest(
user.context,
'get',
`/watchonly/api/v1/addresses/${onchain.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await jsonRequest(
user.context,
'get',
`/watchonly/api/v1/address/${onchain.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await enableExtensionViaUi(user, 'satspay')
await pageStatus(user.context, '/satspay/')
await jsonRequest(user.context, 'get', '/satspay/api/v1/charges', {
headers: apiKeyHeaders(user.adminkey)
})
const onchainCharge = await jsonRequest(
user.context,
'post',
'/satspay/api/v1/charge',
{
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: true,
onchainwallet: onchain.id,
lnbits: false,
description: 'Onchain Charge',
time: 1111,
amount: 1111,
lnbitswallet: null
},
expected: [200, 201]
}
)
expect(onchainCharge.id).toBeTruthy()
await jsonRequest(user.context, 'post', '/satspay/api/v1/charge', {
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: false,
onchainwallet: null,
lnbits: true,
description: 'lightning charge - to expire',
time: 1,
amount: 10,
lnbitswallet: user.walletId,
webhook: 'https://google.com',
completelink: 'https://twitter.com',
completelinktext: 'Have Fun'
},
expected: [200, 201]
})
let expectedBalanceSats = 0
for (let index = 1; index <= 5; index++) {
const amount = 9 + index
const charge = await jsonRequest(
user.context,
'post',
'/satspay/api/v1/charge',
{
headers: apiKeyHeaders(user.adminkey),
data: {
onchain: false,
onchainwallet: null,
lnbits: true,
description: `lightning charge [${index}]`,
time: 1220,
amount,
lnbitswallet: user.walletId,
webhook: mirrorUrl(),
completelink: 'https://twitter.com',
completelinktext: 'Have Fun'
},
expected: [200, 201]
}
)
expect(charge.payment_request).toBeTruthy()
await pageStatus(user.context, `/satspay/${charge.id}`)
await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/balance/${charge.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
await payInvoiceViaUi(admin, charge.payment_request)
await expect
.poll(async () => {
const updatedCharge = await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/${charge.id}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
return Boolean(updatedCharge.paid || updatedCharge.lnbitswallet)
})
.toBeTruthy()
expectedBalanceSats += amount
}
await pollWalletBalance(user.context, user.inkey, expectedBalanceSats * 1000)
const charges = await jsonRequest(
user.context,
'get',
'/satspay/api/v1/charges',
{
headers: apiKeyHeaders(user.adminkey)
}
)
expect(charges.length).toBeGreaterThanOrEqual(7)
const payments = await getPayments(user.context, user.inkey)
expect(payments.length).toBeGreaterThanOrEqual(6)
await enableExtensionViaUi(user, 'tipjar')
await pageStatus(user.context, `/tipjar/?usr=${user.userId}`)
await jsonRequest(user.context, 'get', '/tipjar/api/v1/tipjars', {
headers: apiKeyHeaders(user.inkey)
})
const tipjar = await jsonRequest(
user.context,
'post',
'/tipjar/api/v1/tipjars',
{
headers: apiKeyHeaders(user.adminkey),
data: {wallet: user.walletId, name: 'Nakamoto', webhook: mirrorUrl()},
expected: [200, 201]
}
)
expect(tipjar.id).toBeTruthy()
await pageStatus(user.context, `/tipjar/${tipjar.id}`)
for (let index = 1; index <= 5; index++) {
const tip = await jsonRequest(user.context, 'post', '/tipjar/api/v1/tips', {
headers: apiKeyHeaders(user.adminkey),
data: {
tipjar: tipjar.id,
name: 'Hal',
sats: 21,
message: `Let's go ...${index}!`
},
expected: [200, 201]
})
expect(tip.redirect_url).toBeTruthy()
const tipChargeId = tip.redirect_url.split('/').filter(Boolean).at(-1)
const charge = await jsonRequest(
user.context,
'get',
`/satspay/api/v1/charge/${tipChargeId}`,
{
headers: apiKeyHeaders(user.inkey)
}
)
expect(charge.description).toBe(`Let's go ...${index}!`)
const tips = await jsonRequest(user.context, 'get', '/tipjar/api/v1/tips', {
headers: apiKeyHeaders(user.inkey)
})
expect(tips.length).toBe(index)
await payInvoiceViaUi(admin, tip.payment_request || charge.payment_request)
expectedBalanceSats += 21
await pollWalletBalance(
user.context,
user.inkey,
expectedBalanceSats * 1000
)
}
})
+139
View File
@@ -0,0 +1,139 @@
const {
test,
expect,
setupIntegration,
jsonRequest,
pageStatus,
newUserWithUi,
getAdminSession,
createInvoiceViaUi,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
lndhubHeaders
} = require('./scenario-helpers')
setupIntegration()
test('007 lndhub mobile wallet API scenario', async ({browser}) => {
test.setTimeout(5 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, ['lndhub'])
const user = await newUserWithUi(browser, 'lndhub')
await enableExtensionViaUi(user, 'lndhub')
await pageStatus(user.context, '/lndhub/')
await jsonRequest(user.context, 'post', '/api/v1/auth/logout')
await jsonRequest(user.context, 'get', '/lndhub/ext/getinfo')
for (const index of [1, 2, 3]) {
const userInvoice = await createInvoiceViaUi(
user,
21,
`user invoice ${index}`
)
await payInvoiceViaUi(admin, userInvoice.bolt11)
const adminInvoice = await createInvoiceViaUi(
admin,
1,
`admin invoice ${index}`
)
await payInvoiceViaUi(user, adminInvoice.bolt11)
}
const auth = await jsonRequest(user.context, 'post', '/lndhub/ext/auth', {
data: {login: 'some_user', password: user.adminkey, refresh_token: ''}
})
expect(auth.access_token).toBeTruthy()
const authHeaders = await lndhubHeaders(auth.access_token)
const balance = await jsonRequest(
user.context,
'get',
'/lndhub/ext/balance',
{
headers: authHeaders
}
)
expect(balance.BTC.AvailableBalance).toBe(60)
const txs = await jsonRequest(user.context, 'get', '/lndhub/ext/gettxs', {
headers: authHeaders
})
expect(txs).toHaveLength(3)
expect(txs.every(tx => tx.value === -1)).toBeTruthy()
const invoices = await jsonRequest(
user.context,
'get',
'/lndhub/ext/getuserinvoices',
{
headers: authHeaders
}
)
expect(invoices).toHaveLength(3)
expect(invoices.every(invoice => invoice.amt === 21)).toBeTruthy()
const mobileInvoice = await jsonRequest(
user.context,
'post',
'/lndhub/ext/addinvoice',
{
headers: authHeaders,
data: {amt: 50, memo: '50 sats'}
}
)
await payInvoiceViaUi(admin, mobileInvoice.payment_request)
const invoicesAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/getuserinvoices',
{
headers: authHeaders
}
)
expect(invoicesAfter).toHaveLength(4)
const adminInvoice = await createInvoiceViaUi(admin, 30, '30 sats')
const paid = await jsonRequest(
user.context,
'post',
'/lndhub/ext/payinvoice',
{
headers: authHeaders,
data: {invoice: adminInvoice.bolt11}
}
)
expect(JSON.stringify(paid)).toContain('30 sats')
const txsAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/gettxs',
{
headers: authHeaders
}
)
expect(txsAfter).toHaveLength(4)
const balanceAfter = await jsonRequest(
user.context,
'get',
'/lndhub/ext/balance',
{
headers: authHeaders
}
)
expect(balanceAfter.BTC.AvailableBalance).toBe(80)
const invalidAuthHeaders = await lndhubHeaders('YmFkOnRva2Vu')
for (const [method, path, data] of [
['get', '/lndhub/ext/balance'],
['get', '/lndhub/ext/gettxs'],
['get', '/lndhub/ext/getuserinvoices'],
['post', '/lndhub/ext/addinvoice', {amt: 50, memo: '50 sats'}],
['post', '/lndhub/ext/payinvoice', {invoice: adminInvoice.bolt11}]
]) {
await jsonRequest(user.context, method, path, {
headers: invalidAuthHeaders,
data,
expected: [400, 404]
})
}
})
@@ -0,0 +1,54 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
uninstallExtension,
getAdminSession,
refreshExtensionCatalog,
installLatestExtensionViaUi,
setExtensionActiveViaUi
} = require('./scenario-helpers')
setupIntegration()
test('008 lnaddress and lnurlp redirect conflict handling', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await jsonRequest(state.adminContext, 'patch', '/admin/api/v1/settings', {
data: {
lnbits_extensions_manifests: [
'https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json',
'https://raw.githubusercontent.com/lnbits/lnaddress/refs/heads/main/manifest.json'
]
}
})
await refreshExtensionCatalog(state.adminContext)
await uninstallExtension(state.adminContext, 'lnaddress')
await installLatestExtensionViaUi(admin, 'lnurlp')
await installLatestExtensionViaUi(admin, 'lnaddress')
await setExtensionActiveViaUi(admin, 'lnurlp', false)
const conflict = await setExtensionActiveViaUi(
admin,
'lnurlp',
true,
[200, 400]
)
const conflictText = JSON.stringify(conflict)
if (conflictText.includes('Already mapped')) {
expect(conflictText).toContain('Already mapped')
} else {
expect(conflict?.success).toBeTruthy()
}
await setExtensionActiveViaUi(admin, 'lnaddress', false).catch(error => {
if (!String(error.message).includes('Could not find extension card')) {
throw error
}
})
await setExtensionActiveViaUi(admin, 'lnurlp', true)
})
@@ -0,0 +1,49 @@
const {
test,
expect,
setupIntegration,
state,
jsonRequest,
textRequest,
getAdminSession,
installExtensionVersionViaUi,
enableExtensionViaUi
} = require('./scenario-helpers')
setupIntegration()
test('009 example extension can downgrade and upgrade data shape', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
async function installAndCheck(testVersion, extensionVersion) {
const releases = await jsonRequest(
state.adminContext,
'get',
'/api/v1/extension/example/releases'
)
const release = releases.find(item => item.version === extensionVersion)
expect(release).toBeTruthy()
await installExtensionVersionViaUi(admin, 'example', release.version)
await enableExtensionViaUi(admin, 'example')
const page = await textRequest(state.adminContext, 'get', '/example')
expect(page).toContain(
`Do not remove. Test install extension version: ${testVersion}`
)
const response = await jsonRequest(
state.adminContext,
'get',
'/example/api/v1/test/00000000'
)
expect(response.version ?? response.id).toBe(String(testVersion))
expect(response.test_id ?? response.wallet).toBe('00000000')
}
await installAndCheck(1, '1.0.1')
await installAndCheck(2, '1.0.6')
await installAndCheck(1, '1.0.1')
await installAndCheck(2, '1.0.6')
await installAndCheck(1, '1.0.1')
})
+382
View File
@@ -0,0 +1,382 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
createWallet,
decodePayment,
getWallet,
jsonRequest,
textRequest,
newContext,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
topUpWallet,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createNip5Domain,
createNip5Address,
activateNip5Address,
getNostrJson,
buyNip5Address
} = require('./scenario-helpers')
setupIntegration()
test('010 nostrnip5 domain, search, pricing, and referral flow', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await topUpWallet(state.adminContext, state.adminWallet.walletId, 1_000_000)
await ensureExtensionsInstalledViaUi(admin, ['nostrnip5', 'lnurlp'])
const anonymous = await newContext()
await textRequest(anonymous, 'patch', '/nostrnip5/api/v1/domain/ranking/0', {
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'},
expected: 401
})
await enableExtensionViaUi(admin, 'nostrnip5')
await enableExtensionViaUi(admin, 'lnurlp')
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/0',
{
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'}
}
)
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/200',
{
data: 'rank_200_a\r\nrank_200_b\r\nyyy',
headers: {'content-type': 'text/plain'}
}
)
await textRequest(
state.adminContext,
'patch',
'/nostrnip5/api/v1/domain/ranking/1000',
{
data: 'rank_1000_a\r\nrank_1000_b\r\nrank_1000_c\r\nxxx',
headers: {'content-type': 'text/plain'}
}
)
await jsonRequest(state.adminContext, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(state.adminWallet.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const owner = await newUserWithUi(browser, 'nostrnip5-owner')
await enableExtensionViaUi(owner, 'nostrnip5')
await enableExtensionViaUi(owner, 'lnurlp')
await textRequest(
owner.context,
'patch',
'/nostrnip5/api/v1/domain/ranking/0',
{
data: 'reserved_a\r\nreserved_b',
headers: {'content-type': 'text/plain'},
expected: 403
}
)
const domain = await createNip5Domain(owner.context, owner)
const domainId = domain.id
await jsonRequest(
owner.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}`,
{
headers: apiKeyHeaders(owner.inkey)
}
)
await jsonRequest(owner.context, 'put', '/nostrnip5/api/v1/domain', {
headers: apiKeyHeaders(owner.adminkey),
data: {
cost: 100,
cost_extra: {
char_count_cost: [
{bracket: '1', amount: '1001'},
{bracket: '2', amount: '505'},
{bracket: '3', amount: '202'}
],
max_years: '5',
promotions: [
{
code: 'code_10',
buyer_discount_percent: '10',
referer_bonus_percent: '0'
},
{
code: 'code_20',
buyer_discount_percent: '20',
referer_bonus_percent: '5'
},
{
code: 'code_alan',
buyer_discount_percent: '25',
referer_bonus_percent: '20',
selected_referer: 'alan'
}
],
rank_cost: [
{bracket: 200, amount: '10000'},
{bracket: 1000, amount: '500'}
]
},
currency: 'USD',
domain: domain.domain,
id: domainId,
wallet: owner.walletId
}
})
const identifierOne = `one_${Date.now()}`
const identifierTwo = `two_${Date.now()}`
const identifierThree = `three_${Date.now()}`
const address = await createNip5Address(
owner.context,
owner,
domainId,
identifierOne,
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9'
)
await getNostrJson(owner.context, domainId, identifierOne, 404)
await activateNip5Address(owner.context, owner, domainId, address.id)
let nostrJson = await getNostrJson(owner.context, domainId, identifierOne)
expect(nostrJson.names[identifierOne]).toBeTruthy()
await jsonRequest(
owner.context,
'put',
`/nostrnip5/api/v1/domain/${domainId}/address/${address.id}`,
{
headers: apiKeyHeaders(owner.adminkey),
data: {
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbeeee',
relays: ['wss://relay.nostr.com', 'ws://relay.nostr.org']
}
}
)
nostrJson = await getNostrJson(owner.context, domainId, identifierOne)
expect(nostrJson.relays).toBeTruthy()
const second = await createNip5Address(
owner.context,
owner,
domainId,
identifierTwo,
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa'
)
await activateNip5Address(owner.context, owner, domainId, second.id)
let addresses = await jsonRequest(
owner.context,
'get',
'/nostrnip5/api/v1/addresses/paginated',
{
headers: apiKeyHeaders(owner.inkey),
params: {
all_wallets: true,
limit: 10,
offset: 0,
sortby: 'time',
direction: 'asc'
}
}
)
expect(JSON.stringify(addresses)).toContain(identifierTwo)
await jsonRequest(
owner.context,
'delete',
`/nostrnip5/api/v1/domain/${domainId}/address/${address.id}`,
{
headers: apiKeyHeaders(owner.adminkey)
}
)
await getNostrJson(owner.context, domainId, identifierOne, 404)
const alan = await createNip5Address(
owner.context,
owner,
domainId,
'alan',
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaabb'
)
await activateNip5Address(owner.context, owner, domainId, alan.id)
const alanWallet = await createWallet(owner.context, 'Alan Wallet')
await jsonRequest(
owner.context,
'put',
`/nostrnip5/api/v1/user/domain/${domainId}/address/${alan.id}/lnaddress`,
{
headers: apiKeyHeaders(owner.adminkey),
data: {wallet: alanWallet.walletId, min: 5, max: '100005'}
}
)
for (const [query, expectedAvailable] of [
['a', true],
['aa', true],
['aaa', true],
['aaaa', true],
[identifierTwo, false],
['reserved_a', false],
['rank_200_b', true],
['rank_1000_b', true],
['yyy', true],
['xxx', true]
]) {
const result = await jsonRequest(
owner.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/search`,
{
headers: apiKeyHeaders(owner.inkey),
params: {q: query}
}
)
expect(Boolean(result.available)).toBe(expectedAvailable)
}
const client = await newUserWithUi(browser, 'nostrnip5-client')
await enableExtensionViaUi(client, 'nostrnip5')
await enableExtensionViaUi(client, 'lnurlp')
await buyNip5Address(client.context, client, domainId, 'abc1', {
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years: 3
})
await buyNip5Address(client.context, client, domainId, 'a1', {
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years: 3
})
const cartItems = await jsonRequest(
client.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(client.inkey),
params: {active: false}
}
)
expect(JSON.stringify(cartItems)).toContain('abc1')
expect(JSON.stringify(cartItems)).toContain('a1')
const priceChecks = [
['abc1', 3, null, false, 300.0],
['abc1', 5, null, true, 500.0],
['abc1', 7, null, false, null, 400],
['abc1', 3, 'code_10', true, 270.0],
['abc1', 3, 'code_20', false, 240.0],
['abc1', 3, 'code_alan', true, 225.0],
['xyz', 3, null, false, 606.0],
['xyz', 5, null, true, 1010.0],
['xyz', 7, null, false, null, 400],
['xyz', 3, 'code_10', true, 545.4],
['xyz', 3, 'code_20', false, 484.8],
['xyz', 3, 'code_alan', true, 454.5]
]
for (const [
localPart,
years,
promoCode,
createInvoiceFlag,
expectedPrice,
expectedStatus
] of priceChecks) {
const result = await buyNip5Address(
client.context,
client,
domainId,
localPart,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaaa',
years,
promo_code: promoCode,
create_invoice: createInvoiceFlag,
expected: expectedStatus ?? 201
}
)
if (expectedPrice !== null) {
expect(result.extra.price).toBeCloseTo(expectedPrice)
}
if (createInvoiceFlag && result.payment_request) {
const decoded = await decodePayment(
client.context,
result.payment_request
)
expect(Number(result.extra.price_in_sats) * 1000).toBe(
decoded.amount_msat
)
}
}
const paidOne = await buyNip5Address(
client.context,
client,
domainId,
identifierOne,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaab',
years: 3,
create_invoice: true
}
)
await payInvoiceViaUi(admin, paidOne.payment_request)
await getNostrJson(client.context, domainId, identifierOne)
const quoteThree = await buyNip5Address(
client.context,
client,
domainId,
identifierThree,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaac',
years: 5
}
)
expect(quoteThree.extra.price).toBeCloseTo(500.0)
const paidThree = await buyNip5Address(
client.context,
client,
domainId,
identifierThree,
{
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfaaaac',
years: 5,
promo_code: 'code_alan',
create_invoice: true
}
)
await payInvoiceViaUi(admin, paidThree.payment_request)
await getNostrJson(client.context, domainId, identifierThree)
const refererBonus = Math.floor(quoteThree.extra.price_in_sats / 5)
await expect
.poll(
async () => {
const alanBalance = await getWallet(owner.context, alanWallet.inkey)
return Math.abs(Math.floor(alanBalance.balance / 1000) - refererBonus)
},
{timeout: 15_000}
)
.toBeLessThanOrEqual(100)
})
+267
View File
@@ -0,0 +1,267 @@
const {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
getPayments,
jsonRequest,
newUser,
newUserWithUi,
getAdminSession,
payInvoiceViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
createNip5Domain,
createUserNip5Address,
activateNip5Address,
createAuctionRoom,
updateAuctionRoom,
createAuctionItem,
bidOnItem,
auctionNip5Webhooks
} = require('./scenario-helpers')
setupIntegration()
test('011 auction house transfers NIP5 addresses through bids and fixed-price sale', async ({
browser
}) => {
test.setTimeout(10 * 60 * 1000)
const admin = await getAdminSession(browser)
await ensureExtensionsInstalledViaUi(admin, [
'auction_house',
'nostrnip5',
'lnurlp'
])
await enableExtensionViaUi(admin, 'nostrnip5')
await enableExtensionViaUi(admin, 'lnurlp')
await jsonRequest(state.adminContext, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(state.adminWallet.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const owner = await newUserWithUi(browser, 'auction-owner')
await enableExtensionViaUi(owner, 'auction_house')
await enableExtensionViaUi(owner, 'nostrnip5')
await enableExtensionViaUi(owner, 'lnurlp')
const domain = await createNip5Domain(owner.context, owner, {
cost_extra: {transfer_secret: '1234'}
})
const domainId = domain.id
await jsonRequest(owner.context, 'put', '/nostrnip5/api/v1/settings', {
headers: apiKeyHeaders(owner.adminkey),
data: {
lnaddress_api_endpoint: state.baseURL,
lnaddress_api_admin_key: state.adminWallet.adminkey
}
})
const seller = await newUserWithUi(browser, 'auction-seller')
await enableExtensionViaUi(seller, 'auction_house')
await enableExtensionViaUi(seller, 'nostrnip5')
const createdAddresses = []
for (let index = 1; index <= 10; index++) {
const address = await createUserNip5Address(
seller.context,
seller,
domainId,
`id_${index}__${domainId}`
)
await activateNip5Address(owner.context, owner, domainId, address.id)
createdAddresses.push(address)
}
const sellRoom = await createAuctionRoom(owner.context, owner, {
name: 'nip5 sales',
description: 'Auctions for Nip5',
fee_wallet_id: owner.walletId,
currency: 'USD',
type: 'fixed_price'
})
await updateAuctionRoom(owner.context, owner, {
id: sellRoom.id,
name: 'nip5 sells',
description: 'Sells for Nip5',
currency: 'sat',
type: 'fixed_price',
room_percentage: 20,
fee_wallet_id: owner.walletId,
is_open_room: true,
extra: auctionNip5Webhooks(domainId)
})
const auctionRoom = await createAuctionRoom(owner.context, owner, {
name: 'nip5 auctions',
description: 'Auctions for Nip5',
fee_wallet_id: owner.walletId,
currency: 'USD',
type: 'auction'
})
await updateAuctionRoom(owner.context, owner, {
id: auctionRoom.id,
name: 'nip5 auctions',
description: 'Auctions for Nip5',
currency: 'sat',
type: 'auction',
min_bid_up_percentage: 5,
room_percentage: 10,
fee_wallet_id: owner.walletId,
is_open_room: true,
extra: auctionNip5Webhooks(domainId)
})
const sellerAddresses = await jsonRequest(
seller.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(seller.inkey),
params: {active: true}
}
)
expect(Array.isArray(sellerAddresses)).toBeTruthy()
const addressForAuction = createdAddresses[0]
const transfer = await jsonRequest(
seller.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/address/${addressForAuction.id}/transfer`,
{headers: apiKeyHeaders(seller.adminkey)}
)
expect(transfer.transfer_code).toBeTruthy()
const item = await createAuctionItem(seller.context, seller, auctionRoom.id, {
name: addressForAuction.local_part,
ask_price: 10.1,
transfer_code: transfer.transfer_code
})
let winningBidder
for (let index = 1; index <= 15; index++) {
const bidder = await newUser(`auction-bidder-${index}`)
const bid = await bidOnItem(
bidder.context,
bidder,
item.id,
index * 100,
`bid index: ${index}`
)
await payInvoiceViaUi(admin, bid.payment_request)
winningBidder = bidder
}
await expect
.poll(
async () => {
const bids = await jsonRequest(
owner.context,
'get',
`/auction_house/api/v1/bids/${item.id}/paginated`,
{
headers: apiKeyHeaders(owner.inkey),
params: {limit: 20}
}
)
return bids.total
},
{timeout: 15_000}
)
.toBeGreaterThanOrEqual(15)
await getPayments(owner.context, owner.inkey)
await jsonRequest(
owner.context,
'delete',
`/auction_house/api/v1/items/${item.id}`,
{
headers: apiKeyHeaders(owner.adminkey),
params: {force_close: true}
}
)
const winnerAddresses = await jsonRequest(
winningBidder.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(winningBidder.inkey),
params: {active: true}
}
)
expect(JSON.stringify(winnerAddresses)).toContain(
addressForAuction.local_part
)
const fixedPriceAddress = createdAddresses[1]
const fixedItems = await jsonRequest(
seller.context,
'get',
`/auction_house/api/v1/items/${auctionRoom.id}/paginated`,
{
headers: apiKeyHeaders(seller.inkey),
params: {
sortby: 'ask_price',
direction: 'asc',
name: fixedPriceAddress.local_part
}
}
)
if (fixedItems.data?.[0]?.id) {
await jsonRequest(
seller.context,
'delete',
`/auction_house/api/v1/items/${fixedItems.data[0].id}`,
{
headers: apiKeyHeaders(seller.adminkey),
params: {force_close: true}
}
)
}
const fixedTransfer = await jsonRequest(
seller.context,
'get',
`/nostrnip5/api/v1/domain/${domainId}/address/${fixedPriceAddress.id}/transfer`,
{headers: apiKeyHeaders(seller.adminkey)}
)
const sellItem = await createAuctionItem(
seller.context,
seller,
sellRoom.id,
{
name: fixedPriceAddress.local_part,
ask_price: 5000,
transfer_code: fixedTransfer.transfer_code
}
)
const buyer = await newUser('auction-buyer')
const buy = await bidOnItem(
buyer.context,
buyer,
sellItem.id,
5000,
'fixed price buy'
)
await payInvoiceViaUi(admin, buy.payment_request)
await expect
.poll(
async () => {
const buyerAddresses = await jsonRequest(
buyer.context,
'get',
'/nostrnip5/api/v1/user/addresses',
{
headers: apiKeyHeaders(buyer.inkey),
params: {active: true}
}
)
return JSON.stringify(buyerAddresses)
},
{timeout: 15_000}
)
.toContain(fixedPriceAddress.local_part)
})
+387
View File
@@ -0,0 +1,387 @@
const {expect} = require('@playwright/test')
const DEFAULT_MANIFEST_URL =
'https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json'
function apiKeyHeaders(key) {
return key ? {'X-Api-Key': key} : {}
}
async function expectStatus(response, expected, label) {
const statuses = Array.isArray(expected) ? expected : [expected]
if (!statuses.includes(response.status())) {
const body = await response.text()
throw new Error(
`${label || response.url()} expected status ${statuses.join(
'/'
)}, got ${response.status()}: ${body.slice(0, 800)}`
)
}
}
async function responseJson(response) {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch (error) {
throw new Error(
`Expected JSON from ${response.url()}: ${text.slice(0, 800)}`
)
}
}
async function jsonRequest(context, method, url, options = {}) {
const response = await context[method](url, {
data: options.data,
form: options.form,
headers: options.headers,
params: options.params,
failOnStatusCode: false,
maxRedirects: options.maxRedirects
})
await expectStatus(
response,
options.expected ?? 200,
`${method.toUpperCase()} ${url}`
)
return responseJson(response)
}
async function textRequest(context, method, url, options = {}) {
const response = await context[method](url, {
data: options.data,
headers: options.headers,
params: options.params,
failOnStatusCode: false,
maxRedirects: options.maxRedirects
})
await expectStatus(
response,
options.expected ?? 200,
`${method.toUpperCase()} ${url}`
)
return response.text()
}
async function loginAdmin(context) {
await jsonRequest(context, 'post', '/api/v1/auth', {
data: {username: 'admin', password: 'secret1234'}
})
}
async function initServer(context) {
const home = await context.get('/', {
failOnStatusCode: false,
maxRedirects: 0
})
const location = home.headers().location || ''
if (
[301, 302, 303, 307, 308].includes(home.status()) &&
location.includes('first_install')
) {
await jsonRequest(context, 'put', '/api/v1/auth/first_install', {
data: {
username: 'admin',
password: 'secret1234',
password_repeat: 'secret1234'
}
})
}
await loginAdmin(context)
const wallets = await jsonRequest(context, 'get', '/api/v1/wallets')
expect(wallets.length).toBeGreaterThan(0)
const adminWallet = wallets[0]
await jsonRequest(context, 'put', '/admin/api/v1/settings', {
data: {lnbits_callback_url_rules: []}
})
await topUpWallet(context, adminWallet.id, 1_000_000)
return {
walletId: adminWallet.id,
inkey: adminWallet.inkey,
adminkey: adminWallet.adminkey
}
}
async function logout(context) {
await jsonRequest(context, 'post', '/api/v1/auth/logout')
}
async function createAccount(context, name) {
await textRequest(context, 'get', '/')
const wallet = await jsonRequest(context, 'post', '/api/v1/account', {
data: {name}
})
await jsonRequest(context, 'post', '/api/v1/auth/usr', {
data: {usr: wallet.user}
})
return {
userId: wallet.user,
walletId: wallet.id,
inkey: wallet.inkey,
adminkey: wallet.adminkey,
raw: wallet
}
}
async function createWallet(context, name) {
const wallet = await jsonRequest(context, 'post', '/api/v1/wallet', {
data: {name}
})
return {
userId: wallet.user,
walletId: wallet.id,
inkey: wallet.inkey,
adminkey: wallet.adminkey,
raw: wallet
}
}
async function topUpWallet(adminContext, walletId, amount) {
return jsonRequest(adminContext, 'put', '/users/api/v1/balance', {
data: {amount: String(amount), id: walletId}
})
}
async function enableExtension(context, extension) {
const enabled = await jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/enable`,
{
form: {enable: extension}
}
)
if (enabled && Object.prototype.hasOwnProperty.call(enabled, 'is_enabled')) {
expect(enabled.is_enabled).toBeTruthy()
}
return enabled
}
async function disableExtension(context, extension) {
return jsonRequest(context, 'put', `/api/v1/extension/${extension}/disable`, {
form: {enable: extension}
})
}
async function deactivateExtension(context, extension) {
return jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/deactivate`
)
}
async function activateExtension(context, extension, expected = 200) {
return jsonRequest(
context,
'put',
`/api/v1/extension/${extension}/activate`,
{
expected
}
)
}
async function uninstallExtension(context, extension) {
const response = await context.delete(`/api/v1/extension/${extension}`, {
failOnStatusCode: false
})
await expectStatus(
response,
[200, 204, 404],
`DELETE /api/v1/extension/${extension}`
)
}
async function pageStatus(context, path, expected = 200) {
const response = await context.get(path, {failOnStatusCode: false})
await expectStatus(response, expected, `GET ${path}`)
return response
}
async function getWallet(context, inkey) {
return jsonRequest(context, 'get', '/api/v1/wallet', {
headers: apiKeyHeaders(inkey)
})
}
async function getPayments(context, inkey, params) {
return jsonRequest(context, 'get', '/api/v1/payments', {
headers: apiKeyHeaders(inkey),
params
})
}
async function createInvoice(context, adminkey, amount, memo) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(adminkey),
data: {out: false, amount, memo}
})
}
async function payInvoice(context, adminkey, bolt11, expected = 201) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(adminkey),
data: {out: true, bolt11},
expected
})
}
async function getPayment(context, inkey, paymentHash) {
return jsonRequest(context, 'get', `/api/v1/payments/${paymentHash}`, {
headers: apiKeyHeaders(inkey)
})
}
async function decodePayment(context, data) {
return jsonRequest(context, 'post', '/api/v1/payments/decode', {
data: {data}
})
}
async function lnurlScan(context, lnurl, key) {
return jsonRequest(
context,
'get',
`/api/v1/lnurlscan/${encodeURIComponent(lnurl)}`,
{
headers: apiKeyHeaders(key)
}
)
}
async function clearMirror() {
const response = await fetch(mirrorUrl('/__mirror__/clear'))
if (response.status !== 204) {
throw new Error(`Expected mirror clear status 204, got ${response.status}`)
}
}
async function getMirrorRequests() {
const response = await fetch(mirrorUrl('/__mirror__/requests'))
if (!response.ok) {
throw new Error(`Cannot fetch mirror requests: ${response.status}`)
}
return response.json()
}
async function pollPayment(
context,
inkey,
paymentHash,
predicate,
timeout = 10_000
) {
let last
await expect
.poll(
async () => {
last = await getPayment(context, inkey, paymentHash)
return predicate(last)
},
{timeout}
)
.toBeTruthy()
return last
}
async function pollWalletBalance(
context,
inkey,
expectedBalance,
timeout = 10_000
) {
await expect
.poll(
async () => {
const wallet = await getWallet(context, inkey)
return wallet.balance
},
{timeout}
)
.toBe(expectedBalance)
}
async function fetchManifest() {
const manifestUrl =
process.env.EXTENSIONS_MANIFEST_URL || DEFAULT_MANIFEST_URL
const response = await fetch(manifestUrl)
if (!response.ok) {
throw new Error(
`Cannot fetch extension manifest ${manifestUrl}: ${response.status}`
)
}
return response.json()
}
async function latestRelease(context, extension) {
const releases = await jsonRequest(
context,
'get',
`/api/v1/extension/${extension}/releases`
)
expect(releases.length).toBeGreaterThan(0)
return releases
.slice()
.sort((a, b) =>
a.version.localeCompare(b.version, undefined, {numeric: true})
)
.at(-1)
}
async function installLatestExtension(context, extension) {
const release = await latestRelease(context, extension)
return jsonRequest(context, 'post', '/api/v1/extension', {
data: {
ext_id: extension,
archive: release.archive,
source_repo: release.source_repo,
version: release.version
}
})
}
function mirrorUrl(path = '') {
const port = process.env.MIRROR_PORT || '8500'
return `http://localhost:${port}${path}`
}
module.exports = {
apiKeyHeaders,
activateExtension,
clearMirror,
createAccount,
createInvoice,
createWallet,
deactivateExtension,
decodePayment,
disableExtension,
enableExtension,
expectStatus,
fetchManifest,
getMirrorRequests,
getPayment,
getPayments,
getWallet,
initServer,
installLatestExtension,
jsonRequest,
latestRelease,
lnurlScan,
loginAdmin,
logout,
mirrorUrl,
pageStatus,
payInvoice,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension
}
+990
View File
@@ -0,0 +1,990 @@
const {test, expect} = require('@playwright/test')
const {
apiKeyHeaders,
clearMirror,
createAccount,
createWallet,
decodePayment,
expectStatus,
fetchManifest,
getPayment,
getPayments,
getWallet,
initServer,
jsonRequest,
latestRelease,
loginAdmin,
lnurlScan,
mirrorUrl,
pageStatus,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension
} = require('./helpers')
const state = {
baseURL: null,
adminContext: null,
adminWallet: null,
adminSession: null,
extensionById: null,
contextFactory: null
}
const contexts = []
const browserContexts = []
async function newContext() {
const context = await state.contextFactory.newContext({
baseURL: state.baseURL,
extraHTTPHeaders: {
Accept: 'application/json, text/plain, */*'
}
})
contexts.push(context)
return context
}
async function newUser(name) {
const context = await newContext()
const account = await createAccount(context, `${name}-${Date.now()}`)
return {context, ...account}
}
async function newBrowserSession(browser, account) {
const uiContext = await browser.newContext({baseURL: state.baseURL})
browserContexts.push(uiContext)
if (account?.userId) {
await textRequest(uiContext.request, 'get', '/')
await jsonRequest(uiContext.request, 'post', '/api/v1/auth/usr', {
data: {usr: account.userId}
})
} else {
await loginAdmin(uiContext.request)
}
const page = await uiContext.newPage()
page.setDefaultTimeout(45_000)
return {uiContext, page}
}
async function newUserWithUi(browser, name) {
const user = await newUser(name)
return {...user, ...(await newBrowserSession(browser, user))}
}
async function getAdminSession(browser) {
if (!state.adminSession) {
state.adminSession = {
...state.adminWallet,
...(await newBrowserSession(browser))
}
}
return state.adminSession
}
function extensionName(extensionId) {
return state.extensionById?.get(extensionId)?.name || extensionId
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function extensionSearchTerms(extensionId) {
return [...new Set([extensionName(extensionId), extensionId].filter(Boolean))]
}
async function refreshExtensionCatalog(context = state.adminContext) {
const extensions = await jsonRequest(context, 'get', '/api/v1/extension')
for (const extension of extensions) {
const id = extension.id || extension.code
if (id) {
state.extensionById.set(id, {
id,
name: extension.name || extension.title || id
})
}
}
}
function responsePath(response) {
return new URL(response.url()).pathname
}
function responseMatches(response, method, path) {
return (
response.request().method() === method && responsePath(response) === path
)
}
async function clickAndCheckResponse(
page,
method,
path,
action,
expected = 200,
timeout = 45_000
) {
const responsePromise = page.waitForResponse(
response => responseMatches(response, method, path),
{timeout}
)
await action()
const response = await responsePromise
await expectStatus(response, expected, `${method} ${path}`)
return response
}
async function clickAndGetJsonResponse(
page,
method,
path,
action,
expected = 200
) {
const response = await clickAndCheckResponse(
page,
method,
path,
action,
expected
)
return responseJson(response)
}
async function clickAndMaybeGetJsonResponse(
page,
method,
path,
action,
expected = 200,
timeout = 10_000
) {
const responsePromise = page
.waitForResponse(response => responseMatches(response, method, path), {
timeout
})
.catch(() => null)
await action()
const response = await responsePromise
if (!response) return null
await expectStatus(response, expected, `${method} ${path}`)
return responseJson(response)
}
async function gotoPage(page, path, expected = 200) {
const response = await page.goto(path)
if (response) {
await expectStatus(response, expected, `GET ${path}`)
}
await page.waitForFunction(() => window.LNbits && window.g)
const credentialsButton = page.getByRole('button', {name: /i understand/i})
if (await credentialsButton.isVisible({timeout: 1000}).catch(() => false)) {
await credentialsButton.click()
}
return response
}
async function gotoWallet(session, walletId = session.walletId) {
await gotoPage(session.page, `/wallet/${walletId}`)
await expect(
session.page.getByRole('button', {name: /receive/i})
).toBeVisible()
}
async function fillQuasarField(scope, label, value) {
const field = scope
.locator('.q-field')
.filter({hasText: label})
.locator('input, textarea')
.first()
await field.fill(String(value))
}
async function createInvoiceViaUi(session, amount, memo) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /receive/i}).click()
const dialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Create Invoice'})
.last()
await fillQuasarField(dialog, 'Amount (sat)', amount)
await fillQuasarField(dialog, 'Memo', memo)
const invoice = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => dialog.getByRole('button', {name: /create invoice/i}).click(),
[200, 201]
)
expect(invoice.bolt11).toBeTruthy()
return invoice
}
async function payInvoiceViaUi(session, bolt11, expected = 201) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /send/i}).click()
const dialog = session.page.locator('.q-dialog').last()
await fillQuasarField(dialog, 'Paste an invoice', bolt11)
await dialog.getByRole('button', {name: /read/i}).click()
await expect(dialog.getByRole('button', {name: /^pay$/i})).toBeVisible()
return clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => dialog.getByRole('button', {name: /^pay$/i}).click(),
expected
)
}
async function payLnurlViaUi(session, lnurl, amountMsat, comment) {
await gotoWallet(session)
await session.page.getByRole('button', {name: /send/i}).click()
const dialog = session.page.locator('.q-dialog').last()
await fillQuasarField(dialog, 'Paste an invoice', lnurl)
await clickAndCheckResponse(session.page, 'POST', '/api/v1/lnurlscan', () =>
dialog.getByRole('button', {name: /read/i}).click()
)
await fillQuasarField(dialog, 'Amount (sat)', amountMsat / 1000)
if (comment) {
await fillQuasarField(dialog, 'Comment (optional)', comment)
}
const payment = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments/lnurl',
() => dialog.getByRole('button', {name: /^send$/i}).click()
)
expect(payment.payment_hash).toBeTruthy()
return payment
}
async function withdrawLnurlViaUi(
session,
lnurl,
amount = 10,
memo = 'withdraw 1',
walletId = session.walletId
) {
await gotoWallet(session, walletId)
await session.page.getByRole('button', {name: /send/i}).click()
const scanDialog = session.page.locator('.q-dialog').last()
await fillQuasarField(scanDialog, 'Paste an invoice', lnurl)
await clickAndCheckResponse(session.page, 'POST', '/api/v1/lnurlscan', () =>
scanDialog.getByRole('button', {name: /read/i}).click()
)
const withdrawDialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Withdraw from'})
.last()
await fillQuasarField(withdrawDialog, 'Amount (sat)', amount)
await fillQuasarField(withdrawDialog, 'Memo', memo)
const payment = await clickAndGetJsonResponse(
session.page,
'POST',
'/api/v1/payments',
() => withdrawDialog.getByRole('button', {name: /withdraw from/i}).click(),
[200, 201]
)
expect(payment.payment_hash).toBeTruthy()
return payment
}
async function openExtensionsPage(session, tab = 'installed') {
const extensionsLoaded = session.page
.waitForResponse(response =>
responseMatches(response, 'GET', '/api/v1/extension')
)
.catch(() => null)
await gotoPage(session.page, '/extensions')
await extensionsLoaded
await session.page.waitForLoadState('networkidle').catch(() => null)
await session.page.getByLabel(/search extensions/i).waitFor()
const tabButton = session.page.getByRole('tab', {
name: new RegExp(`^${tab}$`, 'i')
})
await tabButton.click()
await expect(tabButton).toHaveAttribute('aria-selected', 'true')
}
async function findExtensionCard(session, extensionId, tab = 'installed') {
await openExtensionsPage(session, tab)
const search = session.page.getByLabel(/search extensions/i)
const cards = session.page.locator('.q-card').filter({
visible: true,
has: session.page.getByRole('button', {
name: /manage|enable|disable|open|pay to enable/i
})
})
for (const term of extensionSearchTerms(extensionId)) {
await search.fill(term)
const matchingCard = cards
.filter({hasText: new RegExp(escapeRegExp(term), 'i')})
.first()
if (await matchingCard.isVisible({timeout: 1500}).catch(() => false)) {
return matchingCard
}
if ((await cards.count()) === 1) {
return cards.first()
}
}
throw new Error(`Could not find extension card for ${extensionId}`)
}
async function openExtensionManager(session, extensionId, tab = 'installed') {
const card = await findExtensionCard(session, extensionId, tab)
const releasesResponsePromise = session.page
.waitForResponse(
response =>
responseMatches(
response,
'GET',
`/api/v1/extension/${extensionId}/releases`
),
{timeout: 10_000}
)
.catch(() => null)
await card.getByRole('button', {name: /manage/i}).click()
const releasesResponse = await releasesResponsePromise
if (releasesResponse) {
await expectStatus(
releasesResponse,
200,
`GET /api/v1/extension/${extensionId}/releases`
)
}
const dialog = session.page.locator('.q-dialog').filter({hasText: 'Releases'})
await expect(dialog.last()).toBeVisible()
return dialog.last()
}
async function installExtensionVersionViaUi(session, extensionId, version) {
let dialog
try {
dialog = await openExtensionManager(session, extensionId, 'installed')
} catch {
dialog = await openExtensionManager(session, extensionId, 'all')
}
const versionLabels = [
...new Set([version, version.replace(/^v/i, '')].filter(Boolean))
]
let releaseVersion = null
for (const label of versionLabels) {
const candidate = dialog
.getByText(label, {exact: true})
.filter({visible: true})
.first()
if (await candidate.isVisible({timeout: 1000}).catch(() => false)) {
releaseVersion = candidate
break
}
}
if (!releaseVersion) {
const expandButton = dialog.getByRole('button', {name: /expand/i}).first()
if (!(await expandButton.isVisible({timeout: 1000}).catch(() => false))) {
const alreadyInstalled = await dialog
.getByRole('button', {name: /uninstall/i})
.isVisible({timeout: 500})
.catch(() => false)
await dialog.getByRole('button', {name: /close/i}).last().click()
return alreadyInstalled ? {alreadyInstalled: true} : null
}
await expandButton.click()
for (const label of versionLabels) {
const candidate = dialog
.getByText(label, {exact: true})
.filter({visible: true})
.first()
if (await candidate.isVisible({timeout: 1000}).catch(() => false)) {
releaseVersion = candidate
break
}
}
}
if (!releaseVersion) {
releaseVersion = dialog
.getByText(versionLabels[0], {exact: true})
.filter({visible: true})
.first()
}
await expect(releaseVersion).toBeVisible()
await releaseVersion.click()
const installButton = dialog.getByRole('button', {name: /^install$/i}).first()
if (!(await installButton.isVisible({timeout: 1000}).catch(() => false))) {
const alreadyInstalled = await dialog
.getByRole('button', {name: /uninstall/i})
.isVisible({timeout: 500})
.catch(() => false)
await dialog.getByRole('button', {name: /close/i}).last().click()
return alreadyInstalled ? {alreadyInstalled: true} : null
}
const activatePromise = session.page
.waitForResponse(response =>
responseMatches(
response,
'PUT',
`/api/v1/extension/${extensionId}/activate`
)
)
.catch(() => null)
let installResponsePromise = session.page.waitForResponse(response =>
responseMatches(response, 'POST', '/api/v1/extension')
)
await installButton.click()
const grantButton = session.page.getByRole('button', {
name: /grant and install/i
})
if (await grantButton.isVisible({timeout: 1000}).catch(() => false)) {
installResponsePromise.catch(() => null)
installResponsePromise = session.page.waitForResponse(response =>
responseMatches(response, 'POST', '/api/v1/extension')
)
await grantButton.click()
}
const installResponse = await installResponsePromise
await expectStatus(installResponse, [200, 201], 'POST /api/v1/extension')
const activateResponse = await activatePromise
if (activateResponse) {
await expectStatus(
activateResponse,
200,
`PUT /api/v1/extension/${extensionId}/activate`
)
}
return responseJson(installResponse)
}
async function installLatestExtensionViaUi(session, extensionId) {
const release = await latestRelease(session.uiContext.request, extensionId)
return installExtensionVersionViaUi(session, extensionId, release.version)
}
async function ensureExtensionsInstalledViaUi(session, extensionIds) {
for (const extensionId of extensionIds) {
const installed = await findExtensionCard(session, extensionId, 'installed')
.then(() => true)
.catch(() => false)
if (!installed) {
await installLatestExtensionViaUi(session, extensionId)
}
await setExtensionActiveViaUi(session, extensionId, true)
}
}
async function enableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
const alreadyEnabled =
(await card.getByRole('button', {name: /open/i}).isVisible()) ||
(await card.getByRole('button', {name: /^disable$/i}).isVisible())
if (alreadyEnabled) {
return null
}
const payToEnableButton = card.getByRole('button', {name: /pay to enable/i})
if (await payToEnableButton.isVisible()) {
await payToEnableButton.click()
const dialog = session.page
.locator('.q-dialog')
.filter({hasText: 'Recheck'})
await expect(dialog.last()).toBeVisible()
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => session.page.getByText(/recheck/i).click()
)
}
const enableButton = card.getByRole('button', {name: /^enable$/i})
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => enableButton.click()
)
if (result) return result
const cardAfterEnable = await findExtensionCard(session, extensionId)
await expect(
cardAfterEnable.getByRole('button', {name: /^disable$/i})
).toBeVisible()
return {success: true}
}
async function disableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
if (!(await card.getByRole('button', {name: /^disable$/i}).isVisible())) {
return null
}
const disableButton = card.getByRole('button', {name: /^disable$/i})
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/disable`,
() => disableButton.click()
)
if (result) return result
const cardAfterDisable = await findExtensionCard(session, extensionId)
await expect(
cardAfterDisable.getByRole('button', {name: /^enable$/i})
).toBeVisible()
return {success: true}
}
async function setExtensionActiveViaUi(
session,
extensionId,
active,
expected = 200
) {
const card = await findExtensionCard(session, extensionId)
const action = active ? 'activate' : 'deactivate'
const toggle = card.locator('.q-toggle').first()
const alreadySet = await toggle
.innerText()
.then(text =>
text.toLowerCase().includes(active ? 'activated' : 'deactivated')
)
if (alreadySet && expected === 200) return null
const result = await clickAndMaybeGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/${action}`,
() => toggle.click(),
expected
)
if (result) return result
const cardAfterToggle = await findExtensionCard(session, extensionId)
await expect(cardAfterToggle.locator('.q-toggle').first()).toContainText(
active ? 'Activated' : 'Deactivated'
)
return {success: true}
}
async function payToEnableExtensionViaUi(session, extensionId) {
const card = await findExtensionCard(session, extensionId)
await card.getByRole('button', {name: /pay to enable/i}).click()
const dialog = session.page.locator('.q-dialog').filter({hasText: 'Recheck'})
await expect(dialog.last()).toBeVisible()
const invoice = await clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/invoice/enable`,
() =>
dialog
.last()
.getByRole('button', {name: /show qr/i})
.click()
)
await dialog.last().getByRole('button', {name: /close/i}).click()
await payInvoiceViaUi(session, invoice.payment_request || invoice.bolt11)
const cardAfterPayment = await findExtensionCard(session, extensionId)
if (
await cardAfterPayment.getByRole('button', {name: /^enable$/i}).isVisible()
) {
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => cardAfterPayment.getByRole('button', {name: /^enable$/i}).click()
)
}
await cardAfterPayment.getByRole('button', {name: /pay to enable/i}).click()
return clickAndGetJsonResponse(
session.page,
'PUT',
`/api/v1/extension/${extensionId}/enable`,
() => session.page.getByText(/recheck/i).click()
)
}
async function createPayLink(context, account, index, overrides = {}) {
const data = compactObject({
description: `receive payments ${index}`,
min: 11,
comment_chars: 128,
webhook_url: mirrorUrl(),
webhook_headers: '{"h1": "1"}',
webhook_body: '{"b2": 2}',
success_text: 'All goood!',
success_url: 'https://lnbits.com',
max: Number(`${index}1`),
...overrides
})
const link = await jsonRequest(context, 'post', '/lnurlp/api/v1/links', {
headers: apiKeyHeaders(account.adminkey),
data,
expected: 201
})
expect(link.id).toBeTruthy()
expect(link.wallet).toBe(account.walletId)
return link
}
async function createWithdrawLink(context, account, index, overrides = {}) {
const link = await jsonRequest(context, 'post', '/withdraw/api/v1/links', {
headers: apiKeyHeaders(account.adminkey),
data: {
is_unique: false,
use_custom: false,
title: `withdraw ${index}`,
min_withdrawable: 10,
max_withdrawable: index * 10,
uses: 5,
wait_time: 1,
webhook_url: mirrorUrl(),
webhook_headers: '{"h1": "1"}',
webhook_body: '{"b2": 2}',
custom_url: null,
...overrides
},
expected: 201
})
expect(link.id).toBeTruthy()
expect(link.wallet).toBe(account.walletId)
expect(link.title).toBe(`withdraw ${index}`)
return link
}
async function withdrawLnurl(
context,
receiveWallet,
withdrawResponse,
amount = 10,
memo = 'withdraw 1'
) {
return jsonRequest(context, 'post', '/api/v1/payments', {
headers: apiKeyHeaders(receiveWallet.inkey),
data: {
out: false,
amount,
memo,
unit: 'sat',
lnurl_withdraw: withdrawResponse
},
expected: 201
})
}
async function lndhubHeaders(accessToken) {
return {Authorization: `Bearer ${accessToken}`}
}
async function createNip5Domain(context, account, extra = {}) {
const {cost_extra: extraCost = {}, ...extraFields} = extra
return jsonRequest(context, 'post', '/nostrnip5/api/v1/domain', {
headers: apiKeyHeaders(account.adminkey),
data: {
cost_extra: {
max_years: '3',
char_count_cost: [],
rank_cost: [],
...extraCost
},
wallet: account.walletId,
currency: 'USD',
domain: `nostr-${Date.now()}.com`,
cost: '100',
...extraFields
},
expected: 201
})
}
async function createUserNip5Address(context, account, domainId, localPart) {
const address = await jsonRequest(
context,
'post',
`/nostrnip5/api/v1/user/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
config: {relays: ['relay.nostr.com']},
pubkey:
'04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
local_part: localPart,
domain_id: domainId
},
expected: 201
}
)
expect(address.id).toBeTruthy()
return address
}
async function createNip5Address(
context,
account,
domainId,
localPart,
pubkey
) {
const address = await jsonRequest(
context,
'post',
`/nostrnip5/api/v1/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
config: {relays: ['relay.nostr.com']},
pubkey,
local_part: localPart,
domain_id: domainId
},
expected: 201
}
)
expect(address.id).toBeTruthy()
return address
}
async function activateNip5Address(context, account, domainId, addressId) {
return jsonRequest(
context,
'put',
`/nostrnip5/api/v1/domain/${domainId}/address/${addressId}/activate`,
{
headers: apiKeyHeaders(account.adminkey)
}
)
}
async function getNostrJson(context, domainId, name, expected = 200) {
const url = `/nostrnip5/api/v1/domain/${domainId}/nostr.json`
const response = await context.get(url, {
params: {name},
failOnStatusCode: false
})
await expectStatus(
response,
expected === 404 ? [404, 200] : expected,
`GET ${url}`
)
if (response.status() === 404) return null
const data = await responseJson(response)
if (expected === 404) {
expect(data.names ?? {}).toEqual({})
expect(data.relays ?? {}).toEqual({})
}
return data
}
async function buyNip5Address(context, account, domainId, localPart, options) {
return jsonRequest(
context,
'post',
`/nostrnip5/api/v1/user/domain/${domainId}/address`,
{
headers: apiKeyHeaders(account.adminkey),
data: {
domain_id: domainId,
local_part: localPart,
pubkey: options.pubkey,
years: options.years,
promo_code: options.promo_code ?? null,
referer: options.referer ?? null,
create_invoice: options.create_invoice ?? false
},
expected: options.expected ?? 201
}
)
}
async function createAuctionRoom(context, owner, data) {
const room = await jsonRequest(
context,
'post',
'/auction_house/api/v1/auction_room',
{
headers: apiKeyHeaders(owner.adminkey),
data,
expected: 201
}
)
expect(room.id).toBeTruthy()
return room
}
async function updateAuctionRoom(context, owner, data) {
const room = await jsonRequest(
context,
'put',
'/auction_house/api/v1/auction_room',
{
headers: apiKeyHeaders(owner.adminkey),
data
}
)
expect(room.id).toBe(data.id)
return room
}
async function createAuctionItem(context, seller, roomId, data) {
const item = await jsonRequest(
context,
'post',
`/auction_house/api/v1/items/${roomId}`,
{
headers: apiKeyHeaders(seller.adminkey),
data,
expected: 201
}
)
expect(item.id).toBeTruthy()
return item
}
async function bidOnItem(context, bidder, itemId, amount, memo) {
const bid = await jsonRequest(
context,
'put',
`/auction_house/api/v1/bids/${itemId}`,
{
headers: apiKeyHeaders(bidder.adminkey),
data: {amount, ln_address: '', memo},
expected: 201
}
)
expect(bid.payment_request).toBeTruthy()
return bid
}
function setupIntegration() {
test.beforeAll(async ({playwright}, testInfo) => {
state.baseURL = testInfo.project.use.baseURL
state.contextFactory = playwright.request
state.adminSession = null
state.adminContext = await newContext()
state.adminWallet = await initServer(state.adminContext)
const manifest = await fetchManifest()
state.extensionById = new Map(
manifest.extensions.map(extension => [extension.id, extension])
)
})
test.afterAll(async () => {
await Promise.all(contexts.map(context => context.dispose()))
await Promise.all(browserContexts.map(context => context.close()))
contexts.splice(0, contexts.length)
browserContexts.splice(0, browserContexts.length)
state.baseURL = null
state.adminContext = null
state.adminWallet = null
state.adminSession = null
state.extensionById = null
state.contextFactory = null
})
}
function compactObject(value) {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined)
)
}
function auctionNip5Webhooks(domainId) {
return {
duration: {days: 7, hours: 0, minutes: 0},
lock_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/lock`,
headers: '',
data: '{\n "transfer_code": "${transfer_code}"\n}'
},
unlock_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/unlock`,
headers: '',
data: '{\n "lock_code": "${lock_code}"\n}'
},
transfer_webhook: {
method: 'PUT',
url: `${state.baseURL}/nostrnip5/api/v1/domain/${domainId}/address/transfer`,
headers: '',
data: '{\n "lock_code": "${lock_code}",\n "new_owner_id": "${new_owner_id}"\n}'
}
}
}
module.exports = {
test,
expect,
setupIntegration,
state,
apiKeyHeaders,
clearMirror,
createAccount,
createWallet,
decodePayment,
expectStatus,
fetchManifest,
getPayment,
getPayments,
getWallet,
initServer,
jsonRequest,
latestRelease,
loginAdmin,
lnurlScan,
mirrorUrl,
pageStatus,
pollPayment,
pollWalletBalance,
responseJson,
textRequest,
topUpWallet,
uninstallExtension,
newContext,
newUser,
newBrowserSession,
newUserWithUi,
getAdminSession,
extensionName,
refreshExtensionCatalog,
responsePath,
responseMatches,
clickAndCheckResponse,
clickAndGetJsonResponse,
gotoPage,
gotoWallet,
fillQuasarField,
createInvoiceViaUi,
payInvoiceViaUi,
payLnurlViaUi,
withdrawLnurlViaUi,
openExtensionsPage,
findExtensionCard,
openExtensionManager,
installExtensionVersionViaUi,
installLatestExtensionViaUi,
ensureExtensionsInstalledViaUi,
enableExtensionViaUi,
disableExtensionViaUi,
setExtensionActiveViaUi,
payToEnableExtensionViaUi,
createPayLink,
createWithdrawLink,
withdrawLnurl,
lndhubHeaders,
createNip5Domain,
createUserNip5Address,
createNip5Address,
activateNip5Address,
getNostrJson,
buyNip5Address,
createAuctionRoom,
updateAuctionRoom,
createAuctionItem,
bidOnItem,
compactObject,
auctionNip5Webhooks
}
+109
View File
@@ -0,0 +1,109 @@
const fs = require('fs')
const http = require('http')
const path = require('path')
const {spawn} = require('child_process')
const root = path.resolve(__dirname, '..', '..')
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || '5000'
const mirrorPort = process.env.MIRROR_PORT || '8500'
const dataFolder =
process.env.LNBITS_DATA_FOLDER ||
path.join(root, 'tests', 'data', 'integration')
if (process.env.LNBITS_INTEGRATION_RESET_DATA !== 'false') {
fs.rmSync(dataFolder, {recursive: true, force: true})
}
fs.mkdirSync(dataFolder, {recursive: true})
const mirrorRequests = []
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('error', reject)
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
})
}
function parseBody(body) {
if (!body) return null
try {
return JSON.parse(body)
} catch {
return body
}
}
const mirror = http.createServer(async (req, res) => {
const bodyText = await readBody(req)
const body = parseBody(bodyText)
const record = {
method: req.method,
url: req.url,
headers: req.headers,
body,
bodyText,
receivedAt: new Date().toISOString()
}
if (req.url === '/__mirror__/clear') {
mirrorRequests.splice(0, mirrorRequests.length)
res.writeHead(204)
res.end()
return
}
if (req.url === '/__mirror__/requests') {
res.writeHead(200, {'content-type': 'application/json'})
res.end(JSON.stringify(mirrorRequests))
return
}
mirrorRequests.push(record)
res.writeHead(200, {
'content-type': 'application/json',
h1: '1'
})
res.end(JSON.stringify(body ?? record))
})
mirror.listen(Number(mirrorPort), '127.0.0.1', () => {
console.log(`Mirror server listening on http://127.0.0.1:${mirrorPort}`)
})
const env = {
...process.env,
HOST: host,
PORT: port,
LNBITS_DATA_FOLDER: dataFolder,
LNBITS_ADMIN_UI: process.env.LNBITS_ADMIN_UI || 'true',
AUTH_HTTPS_ONLY: process.env.AUTH_HTTPS_ONLY || 'false',
LNBITS_BACKEND_WALLET_CLASS:
process.env.LNBITS_BACKEND_WALLET_CLASS || 'FakeWallet',
LNBITS_EXTENSIONS_DEFAULT_INSTALL:
process.env.LNBITS_EXTENSIONS_DEFAULT_INSTALL ||
'watchonly,satspay,tipjar,tpos,lnurlp,withdraw'
}
const lnbits = spawn('uv', ['run', 'lnbits', '--host', host, '--port', port], {
cwd: root,
env,
stdio: 'inherit'
})
function shutdown(signal) {
mirror.close()
if (!lnbits.killed) {
lnbits.kill(signal)
}
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
lnbits.on('exit', code => {
mirror.close()
process.exit(code ?? 0)
})
+1 -8
View File
@@ -14,7 +14,7 @@ def test_wasm_frontend_assets_are_registered_in_component_bundle():
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
def test_wasm_frontend_bridge_restricts_api_routes_and_realtime_actions():
def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
encoding="utf-8"
)
@@ -22,15 +22,8 @@ def test_wasm_frontend_bridge_restricts_api_routes_and_realtime_actions():
assert "allowedApiRoute(method, path)" in bridge
assert "url.origin !== window.location.origin" in bridge
assert "Extension API route is not allowed." in bridge
assert "extensionRoute(path)" in bridge
assert "Extension route must stay inside this extension." in bridge
assert "message.action === 'payment.subscribe'" in bridge
assert "message.action === 'payment.unsubscribe'" in bridge
assert "message.action === 'websocket.subscribe'" in bridge
assert "message.action === 'websocket.unsubscribe'" in bridge
assert "message.action === 'navigation.replace'" in bridge
assert "hasBridgePermission('websocket.subscribe')" in bridge
assert "ext:${this.bridge.extensionId}:${itemId}" in bridge
assert "message.action === 'ui.scan_qr'" in bridge
-336
View File
@@ -10,11 +10,8 @@ from lnbits.core.wasm_ext.api.models import (
CreateInvoicePublicRequest,
EmptyRequest,
PayInvoiceRequest,
StorageAppendPublicRequest,
StorageGetRequest,
StoragePublicPaginatedRequest,
WalletBalanceRequest,
WebsocketPublishRequest,
)
from lnbits.exceptions import PaymentError
from lnbits.helpers import sha256s
@@ -51,209 +48,6 @@ async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
@pytest.mark.anyio
async def test_host_api_filters_public_paginated_storage_rows(
mocker: MockerFixture,
):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_paginated_rows",
mocker.AsyncMock(
return_value={
"data": [
{
"id": "message-1",
"thread_id": "thread-1",
"message": "Hello",
"admin_note": "secret",
}
],
"total": 1,
}
),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "thread_id", "message"],
}
],
)
],
)
response = await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
assert json.loads(response.rows_json) == [
{"id": "message-1", "thread_id": "thread-1", "message": "Hello"}
]
assert response.total == 1
storage_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_private_query_fields():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="non-public fields"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"admin_note": "secret"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_requires_source_policy():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="source ID field policy"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_conflicting_source_filter():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="does not match source_id"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"thread_id": "thread-2"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_websocket_publish_scopes_item_id(mocker: MockerFixture):
send_mock = mocker.patch(
"lnbits.core.services.websocket_manager.send",
mocker.AsyncMock(),
)
api = ExtensionHostAPI("demoext", ["websocket.publish"])
response = await api.websocket_publish(
WebsocketPublishRequest(
item_id="conversation:abc_123",
data={"message": "Hello"},
)
)
assert response.sent is True
send_mock.assert_awaited_once_with(
"ext:demoext:conversation:abc_123",
'{"message":"Hello"}',
)
@pytest.mark.anyio
async def test_host_api_websocket_publish_rejects_invalid_item_id():
api = ExtensionHostAPI("demoext", ["websocket.publish"])
with pytest.raises(ValueError, match="item ID"):
await api.websocket_publish(
WebsocketPublishRequest(
item_id="../other",
data={"message": "Hello"},
)
)
@pytest.mark.anyio
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
mocker: MockerFixture,
@@ -283,136 +77,6 @@ async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
)
@pytest.mark.anyio
async def test_host_api_public_append_uses_source_owner_and_allowed_fields(
mocker: MockerFixture,
):
owner_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
count_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_count_rows",
mocker.AsyncMock(return_value=0),
)
append_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_append_public_row",
mocker.AsyncMock(return_value="message-1"),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["name", "message"],
}
],
)
],
)
response = await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"name": "Alice", "message": "Hello"},
)
)
assert response.id == "message-1"
owner_mock.assert_awaited_once_with("demoext", "threads", "thread-1")
count_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
owner_id="owner-1",
)
append_mock.assert_awaited_once_with(
"demoext",
"messages",
{"name": "Alice", "message": "Hello", "thread_id": "thread-1"},
"owner-1",
)
@pytest.mark.anyio
async def test_host_api_public_append_rejects_disallowed_fields(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="disallowed fields"):
await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"message": "Hello", "admin": True},
)
)
@pytest.mark.anyio
async def test_host_api_public_append_enforces_row_limit(mocker: MockerFixture):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_count_rows",
mocker.AsyncMock(return_value=1),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 1,
}
],
)
],
)
with pytest.raises(PermissionError, match="limit reached"):
await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"message": "Hello"},
)
)
@pytest.mark.anyio
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
mocker: MockerFixture,
@@ -65,7 +65,6 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
"policies": [
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title", "description"],
}
],
@@ -81,7 +80,6 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
policies=[
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"],
}
],
@@ -97,7 +95,6 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
policies=[
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"],
}
],
@@ -105,140 +102,6 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
]
def test_validate_wasm_permissions_rejects_public_read_source_field_omission():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.read_public",
"policies": [
{
"table_name": "messages",
"source_id_field": "conversation_id",
"public_fields": ["id", "body"],
}
],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "body"],
}
],
)
],
extension_config,
)
def test_validate_wasm_permissions_allows_narrower_public_append_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.append_public",
"description": "Append public messages.",
"policies": [
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["name", "message"],
"max_rows_per_source": 100,
}
],
}
],
)
permissions = validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 50,
}
],
)
],
extension_config,
)
assert permissions == [
ExtensionPermission(
id="ext.storage.append_public",
description="Append public messages.",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 50,
}
],
)
]
def test_validate_wasm_permissions_rejects_broader_public_append_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.append_public",
"policies": [
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 100,
}
],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message", "admin"],
"max_rows_per_source": 101,
}
],
)
],
extension_config,
)
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
@@ -308,29 +171,6 @@ def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
) == [ExtensionPermission(id="wallet.payments.watch")]
def test_validate_wasm_permissions_allows_websocket_permissions():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{"id": "websocket.publish"},
{"id": "websocket.subscribe"},
],
)
assert validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(id="websocket.publish"),
ExtensionPermission(id="websocket.subscribe"),
],
extension_config,
) == [
ExtensionPermission(id="websocket.publish"),
ExtensionPermission(id="websocket.subscribe"),
]
def test_background_payment_grant_lookup_and_policy_coverage():
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
+4 -82
View File
@@ -2,7 +2,7 @@ import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from typing import cast
from uuid import uuid4
import pytest
@@ -22,8 +22,6 @@ from lnbits.core.wasm_ext.storage import crud as storage_crud
from lnbits.core.wasm_ext.storage.crud import (
OWNER_ID_FIELD,
migrate_wasm_extension_database,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
@@ -297,59 +295,6 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
assert deleted is None
@pytest.mark.anyio
async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
tmp_path: Path,
settings: Settings,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
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 / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
_write_storage_extension(settings, ext_id)
await migrate_wasm_extension_database(make_installable_extension(ext_id))
await storage_set_row(
ext_id,
"threads",
{"id": "thread-1", "title": "Support"},
"owner-1",
)
message_id = await storage_append_public_row(
ext_id,
"messages",
{"thread_id": "thread-1", "message": "Hello"},
"owner-1",
)
owner_count = await storage_count_rows(
ext_id,
"messages",
{"thread_id": "thread-1"},
owner_id="owner-1",
)
other_owner_count = await storage_count_rows(
ext_id,
"messages",
{"thread_id": "thread-1"},
owner_id="owner-2",
)
message = await storage_get_row(ext_id, "messages", message_id, "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
assert message_id
assert owner_count == 1
assert other_owner_count == 0
assert message is not None
assert message["id"] == message_id
assert message["thread_id"] == "thread-1"
assert message["message"] == "Hello"
@pytest.mark.anyio
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
tmp_path: Path,
@@ -433,7 +378,7 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
storage_dir = ext_dir / "storage"
migrations_dir = storage_dir / "migrations"
migrations_dir.mkdir(parents=True)
schema: dict[str, Any] = {
schema = {
"tables": {
"notes": {
"fields": [
@@ -444,20 +389,7 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
{"name": "tags", "type": "string", "list": True},
{"name": "created_at", "type": "datetime"},
]
},
"threads": {
"fields": [
{"name": "id", "type": "string"},
{"name": "title", "type": "string"},
]
},
"messages": {
"fields": [
{"name": "id", "type": "string"},
{"name": "thread_id", "type": "string"},
{"name": "message", "type": "string"},
]
},
}
}
}
migration = {
@@ -466,17 +398,7 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
"op": "create_table",
"table": "notes",
"fields": schema["tables"]["notes"]["fields"],
},
{
"op": "create_table",
"table": "threads",
"fields": schema["tables"]["threads"]["fields"],
},
{
"op": "create_table",
"table": "messages",
"fields": schema["tables"]["messages"]["fields"],
},
}
]
}
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")