Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e8dbcd8d | ||
|
|
b678773728 | ||
|
|
58ccdb7212 | ||
|
|
44f627ffda | ||
|
|
a25cadb66d | ||
|
|
9f317c5edf | ||
|
|
09c6e7239d | ||
|
|
53a1ae5e53 |
@@ -85,15 +85,15 @@ jobs:
|
|||||||
secrets:
|
secrets:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
integration:
|
jmeter:
|
||||||
needs: [ lint ]
|
needs: [ lint ]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.12"]
|
python-version: ["3.12"]
|
||||||
uses: ./.github/workflows/integration.yml
|
uses: ./.github/workflows/jmeter.yml
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
bundle:
|
bundle:
|
||||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, integration ]
|
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
|
||||||
uses: ./.github/workflows/bundle.yml
|
uses: ./.github/workflows/bundle.yml
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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/
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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/
|
||||||
@@ -18,8 +18,6 @@ __pycache__
|
|||||||
.webassets-cache
|
.webassets-cache
|
||||||
htmlcov
|
htmlcov
|
||||||
test-reports
|
test-reports
|
||||||
playwright-report
|
|
||||||
test-results
|
|
||||||
tests/data/*.sqlite3
|
tests/data/*.sqlite3
|
||||||
|
|
||||||
*.swo
|
*.swo
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: test test-integration
|
.PHONY: test
|
||||||
|
|
||||||
all: format check
|
all: format check
|
||||||
|
|
||||||
@@ -69,9 +69,6 @@ test-regtest:
|
|||||||
rm -rf ./tests/data \
|
rm -rf ./tests/data \
|
||||||
uv run pytest tests/regtest
|
uv run pytest tests/regtest
|
||||||
|
|
||||||
test-integration:
|
|
||||||
npm run test:integration
|
|
||||||
|
|
||||||
test-migration:
|
test-migration:
|
||||||
LNBITS_ADMIN_UI=True \
|
LNBITS_ADMIN_UI=True \
|
||||||
make test-api
|
make test-api
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ from lnbits.helpers import sha256s
|
|||||||
|
|
||||||
from ..client.extensions import send_extension_api_request
|
from ..client.extensions import send_extension_api_request
|
||||||
from ..storage.crud import (
|
from ..storage.crud import (
|
||||||
|
OWNER_ID_FIELD,
|
||||||
|
storage_append_public_row,
|
||||||
|
storage_count_rows,
|
||||||
storage_delete_row,
|
storage_delete_row,
|
||||||
storage_get_paginated_rows,
|
storage_get_paginated_rows,
|
||||||
|
storage_get_public_paginated_rows,
|
||||||
storage_get_public_row,
|
storage_get_public_row,
|
||||||
storage_get_row,
|
storage_get_row,
|
||||||
|
storage_get_row_owner_id,
|
||||||
storage_set_row,
|
storage_set_row,
|
||||||
)
|
)
|
||||||
from .background_payments import (
|
from .background_payments import (
|
||||||
@@ -39,21 +44,28 @@ from .models import (
|
|||||||
PayLnurlRequest,
|
PayLnurlRequest,
|
||||||
RandomIdRequest,
|
RandomIdRequest,
|
||||||
RandomIdResponse,
|
RandomIdResponse,
|
||||||
|
StorageAppendPublicRequest,
|
||||||
|
StorageAppendPublicResponse,
|
||||||
StorageDeleteRequest,
|
StorageDeleteRequest,
|
||||||
StorageDeleteResponse,
|
StorageDeleteResponse,
|
||||||
StorageGetRequest,
|
StorageGetRequest,
|
||||||
StorageGetResponse,
|
StorageGetResponse,
|
||||||
StoragePaginatedRequest,
|
StoragePaginatedRequest,
|
||||||
StoragePaginatedResponse,
|
StoragePaginatedResponse,
|
||||||
|
StoragePublicPaginatedRequest,
|
||||||
StorageSetRequest,
|
StorageSetRequest,
|
||||||
StorageSetResponse,
|
StorageSetResponse,
|
||||||
UserWalletSummary,
|
UserWalletSummary,
|
||||||
WalletBalanceRequest,
|
WalletBalanceRequest,
|
||||||
WalletBalanceResponse,
|
WalletBalanceResponse,
|
||||||
|
WebsocketPublishRequest,
|
||||||
|
WebsocketPublishResponse,
|
||||||
)
|
)
|
||||||
from .registry import extension_api_method
|
from .registry import extension_api_method
|
||||||
|
from .websockets import scoped_websocket_item_id
|
||||||
|
|
||||||
logger = logging.getLogger("lnbits.extensions")
|
logger = logging.getLogger("lnbits.extensions")
|
||||||
|
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
|
||||||
|
|
||||||
|
|
||||||
class ExtensionHostAPI:
|
class ExtensionHostAPI:
|
||||||
@@ -117,7 +129,7 @@ class ExtensionHostAPI:
|
|||||||
async def storage_get_public(
|
async def storage_get_public(
|
||||||
self, request: StorageGetRequest
|
self, request: StorageGetRequest
|
||||||
) -> StorageGetResponse:
|
) -> StorageGetResponse:
|
||||||
public_fields = self._public_storage_fields(request.table)
|
public_fields = self._public_storage_policy(request.table)["public_fields"]
|
||||||
row = await storage_get_public_row(self.extension_id, request.table, request.id)
|
row = await storage_get_public_row(self.extension_id, request.table, request.id)
|
||||||
if not row:
|
if not row:
|
||||||
return StorageGetResponse()
|
return StorageGetResponse()
|
||||||
@@ -129,6 +141,45 @@ class ExtensionHostAPI:
|
|||||||
# todo: check public fields filtering
|
# todo: check public fields filtering
|
||||||
return StorageGetResponse(data_json=json.dumps(public_row))
|
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(
|
@extension_api_method(
|
||||||
method_id="storage.set",
|
method_id="storage.set",
|
||||||
namespace="storage",
|
namespace="storage",
|
||||||
@@ -178,6 +229,55 @@ class ExtensionHostAPI:
|
|||||||
total=page["total"],
|
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(
|
@extension_api_method(
|
||||||
method_id="storage.delete",
|
method_id="storage.delete",
|
||||||
namespace="storage",
|
namespace="storage",
|
||||||
@@ -199,6 +299,25 @@ class ExtensionHostAPI:
|
|||||||
)
|
)
|
||||||
return StorageDeleteResponse()
|
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(
|
@extension_api_method(
|
||||||
method_id="wallet.create_invoice",
|
method_id="wallet.create_invoice",
|
||||||
namespace="wallet",
|
namespace="wallet",
|
||||||
@@ -628,7 +747,7 @@ class ExtensionHostAPI:
|
|||||||
|
|
||||||
return permission_ids, policies
|
return permission_ids, policies
|
||||||
|
|
||||||
def _public_storage_fields(self, table: str) -> set[str]:
|
def _public_storage_policy(self, table: str) -> dict[str, Any]:
|
||||||
tables = self.permission_policies.get("ext.storage.read_public")
|
tables = self.permission_policies.get("ext.storage.read_public")
|
||||||
if not isinstance(tables, list) or not tables:
|
if not isinstance(tables, list) or not tables:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
@@ -648,10 +767,148 @@ class ExtensionHostAPI:
|
|||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
f"Public storage table '{table}' has no valid public fields."
|
f"Public storage table '{table}' has no valid public fields."
|
||||||
)
|
)
|
||||||
return set(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,
|
||||||
|
}
|
||||||
|
|
||||||
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
|
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]]:
|
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
|
||||||
policies = self.permission_policies.get("wallet.create_invoice_public")
|
policies = self.permission_policies.get("wallet.create_invoice_public")
|
||||||
if not isinstance(policies, list) or not policies:
|
if not isinstance(policies, list) or not policies:
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ class StorageSetResponse(BaseModel):
|
|||||||
ok: bool = True
|
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):
|
class StoragePaginatedRequest(BaseModel):
|
||||||
table: str = Field(..., min_length=1, max_length=128)
|
table: str = Field(..., min_length=1, max_length=128)
|
||||||
filters: dict[str, Any] = Field(default_factory=dict)
|
filters: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -92,11 +109,47 @@ class StoragePaginatedRequest(BaseModel):
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
class StoragePublicPaginatedRequest(StoragePaginatedRequest):
|
||||||
|
source_id: str = Field(..., min_length=1, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
class StoragePaginatedResponse(BaseModel):
|
class StoragePaginatedResponse(BaseModel):
|
||||||
rows_json: str = "[]"
|
rows_json: str = "[]"
|
||||||
total: int = 0
|
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):
|
class StorageDeleteRequest(BaseModel):
|
||||||
table: str = Field(..., min_length=1, max_length=128)
|
table: str = Field(..., min_length=1, max_length=128)
|
||||||
id: str = Field(..., min_length=1, max_length=512)
|
id: str = Field(..., min_length=1, max_length=512)
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ from lnbits.core.wasm_ext.wasm.config import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_POLICY_AWARE_PERMISSION_IDS = {
|
_POLICY_AWARE_PERMISSION_IDS = {
|
||||||
|
"ext.storage.append_public",
|
||||||
"ext.storage.read_public",
|
"ext.storage.read_public",
|
||||||
"extension.api.request",
|
"extension.api.request",
|
||||||
"http.request",
|
"http.request",
|
||||||
"wallet.create_invoice_public",
|
"wallet.create_invoice_public",
|
||||||
}
|
}
|
||||||
|
_OWNER_ID_FIELD = "__lnbits_owner_id__"
|
||||||
|
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
|
||||||
|
|
||||||
|
|
||||||
def validate_extension_permissions(
|
def validate_extension_permissions(
|
||||||
@@ -127,6 +130,10 @@ def _permission_grant_is_subset(
|
|||||||
return _http_request_grant_is_subset(requested.policies, granted.policies)
|
return _http_request_grant_is_subset(requested.policies, granted.policies)
|
||||||
if requested.id == "extension.api.request":
|
if requested.id == "extension.api.request":
|
||||||
return _extension_api_grant_is_subset(requested.policies, granted.policies)
|
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":
|
if requested.id == "ext.storage.read_public":
|
||||||
return _public_storage_grant_is_subset(requested.policies, granted.policies)
|
return _public_storage_grant_is_subset(requested.policies, granted.policies)
|
||||||
if requested.id == "wallet.create_invoice_public":
|
if requested.id == "wallet.create_invoice_public":
|
||||||
@@ -203,32 +210,108 @@ def _public_storage_grant_is_subset(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
requested_tables = _public_storage_tables(requested_policies)
|
requested_tables = _public_storage_tables(requested_policies)
|
||||||
granted_tables = _public_storage_tables(granted_policies)
|
granted_tables = _public_storage_tables(granted_policies)
|
||||||
for table_name, granted_fields in granted_tables.items():
|
for table_name, granted_policy in granted_tables.items():
|
||||||
requested_fields = requested_tables.get(table_name)
|
requested_policy = requested_tables.get(table_name)
|
||||||
if requested_fields is None or not granted_fields.issubset(requested_fields):
|
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
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
|
def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
|
||||||
tables: dict[str, set[str]] = {}
|
tables: dict[str, dict[str, Any]] = {}
|
||||||
for policy in _policy_list(policies):
|
for policy in _policy_list(policies):
|
||||||
if not isinstance(policy, dict):
|
if not isinstance(policy, dict):
|
||||||
continue
|
continue
|
||||||
table_name = policy.get("table_name")
|
table_name = policy.get("table_name")
|
||||||
public_fields = policy.get("public_fields")
|
public_fields = policy.get("public_fields")
|
||||||
|
source_id_field = policy.get("source_id_field")
|
||||||
if (
|
if (
|
||||||
not isinstance(table_name, str)
|
not isinstance(table_name, str)
|
||||||
or table_name in tables
|
or table_name in tables
|
||||||
or not isinstance(public_fields, list)
|
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
|
continue
|
||||||
fields = {field for field in public_fields if isinstance(field, str) and field}
|
fields = {field for field in public_fields if isinstance(field, str) and field}
|
||||||
if fields:
|
if fields:
|
||||||
tables[table_name] = fields
|
tables[table_name] = {
|
||||||
|
"public_fields": fields,
|
||||||
|
"source_id_field": source_id_field,
|
||||||
|
}
|
||||||
return tables
|
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(
|
def _public_invoice_grant_is_subset(
|
||||||
requested_policies: list[Any] | None,
|
requested_policies: list[Any] | None,
|
||||||
granted_policies: list[Any] | None,
|
granted_policies: list[Any] | None,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ _EXTENSION_RUNTIME_PERMISSION_IDS = {
|
|||||||
"wallet.pay_invoice",
|
"wallet.pay_invoice",
|
||||||
"wallet.pay_invoice_background",
|
"wallet.pay_invoice_background",
|
||||||
"wallet.payments.watch",
|
"wallet.payments.watch",
|
||||||
|
"websocket.subscribe",
|
||||||
}
|
}
|
||||||
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
||||||
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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}"
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
from .crud import (
|
from .crud import (
|
||||||
migrate_wasm_extension_database,
|
migrate_wasm_extension_database,
|
||||||
|
storage_append_public_row,
|
||||||
|
storage_count_rows,
|
||||||
storage_delete_row,
|
storage_delete_row,
|
||||||
storage_get_paginated_rows,
|
storage_get_paginated_rows,
|
||||||
|
storage_get_public_paginated_rows,
|
||||||
storage_get_public_row,
|
storage_get_public_row,
|
||||||
storage_get_row,
|
storage_get_row,
|
||||||
storage_get_row_owner_id,
|
storage_get_row_owner_id,
|
||||||
@@ -10,8 +13,11 @@ from .crud import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"migrate_wasm_extension_database",
|
"migrate_wasm_extension_database",
|
||||||
|
"storage_append_public_row",
|
||||||
|
"storage_count_rows",
|
||||||
"storage_delete_row",
|
"storage_delete_row",
|
||||||
"storage_get_paginated_rows",
|
"storage_get_paginated_rows",
|
||||||
|
"storage_get_public_paginated_rows",
|
||||||
"storage_get_public_row",
|
"storage_get_public_row",
|
||||||
"storage_get_row",
|
"storage_get_row",
|
||||||
"storage_get_row_owner_id",
|
"storage_get_row_owner_id",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import re
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -110,6 +111,39 @@ async def storage_set_row(
|
|||||||
await conn.execute(query, clean_data)
|
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(
|
async def storage_get_paginated_rows(
|
||||||
ext_id: str,
|
ext_id: str,
|
||||||
table: str,
|
table: str,
|
||||||
@@ -157,6 +191,50 @@ 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(
|
async def storage_delete_row(
|
||||||
ext_id: str,
|
ext_id: str,
|
||||||
table: str,
|
table: str,
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -116,6 +116,7 @@ window.localisation.en = {
|
|||||||
read: 'Read',
|
read: 'Read',
|
||||||
write: 'Write',
|
write: 'Write',
|
||||||
pay: 'Pay',
|
pay: 'Pay',
|
||||||
|
sending: 'Sending',
|
||||||
memo: 'Memo',
|
memo: 'Memo',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
path: 'Path',
|
path: 'Path',
|
||||||
@@ -543,7 +544,13 @@ window.localisation.en = {
|
|||||||
extension_permission_warning_extension_api_request_write:
|
extension_permission_warning_extension_api_request_write:
|
||||||
'Can write data or trigger actions in approved extensions.',
|
'Can write data or trigger actions in approved extensions.',
|
||||||
extension_permission_ext_storage_read: 'Read extension storage',
|
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: '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_write: 'Write extension storage',
|
||||||
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
|
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
|
||||||
extension_permission_extension_api_request: 'Use other extensions',
|
extension_permission_extension_api_request: 'Use other extensions',
|
||||||
@@ -554,6 +561,9 @@ window.localisation.en = {
|
|||||||
extension_permission_http_request_hosts: 'Allowed hosts',
|
extension_permission_http_request_hosts: 'Allowed hosts',
|
||||||
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
||||||
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
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_payments_watch: 'Watch wallet payments',
|
||||||
extension_permission_wallet_create_invoice: 'Create invoices',
|
extension_permission_wallet_create_invoice: 'Create invoices',
|
||||||
extension_permission_wallet_create_invoice_public:
|
extension_permission_wallet_create_invoice_public:
|
||||||
|
|||||||
@@ -110,11 +110,15 @@
|
|||||||
'extension_permission_warning_wallet_payments_watch'
|
'extension_permission_warning_wallet_payments_watch'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (['websocket.publish', 'websocket.subscribe'].includes(permission.id)) {
|
||||||
|
return mediumRisk(translateFn)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
'wallet.list',
|
'wallet.list',
|
||||||
'wallet.balance.read',
|
'wallet.balance.read',
|
||||||
'wallet.create_invoice_public',
|
'wallet.create_invoice_public',
|
||||||
|
'ext.storage.append_public',
|
||||||
'ext.storage.read_public'
|
'ext.storage.read_public'
|
||||||
].includes(permission.id)
|
].includes(permission.id)
|
||||||
) {
|
) {
|
||||||
@@ -142,9 +146,13 @@
|
|||||||
'extension.api.request',
|
'extension.api.request',
|
||||||
'http.request',
|
'http.request',
|
||||||
'ui.camera.scan_qr',
|
'ui.camera.scan_qr',
|
||||||
|
'websocket',
|
||||||
|
'websocket.publish',
|
||||||
|
'websocket.subscribe',
|
||||||
'ext.storage.read',
|
'ext.storage.read',
|
||||||
'ext.storage.write',
|
'ext.storage.write',
|
||||||
'ext.storage.read_public',
|
'ext.storage.read_public',
|
||||||
|
'ext.storage.append_public',
|
||||||
'wallet.create_invoice_public',
|
'wallet.create_invoice_public',
|
||||||
'wallet.create_invoice',
|
'wallet.create_invoice',
|
||||||
'utils.basic'
|
'utils.basic'
|
||||||
@@ -166,7 +174,12 @@
|
|||||||
: table.public_fields.filter(
|
: table.public_fields.filter(
|
||||||
field => typeof field === 'string' && field
|
field => typeof field === 'string' && field
|
||||||
)
|
)
|
||||||
return tableName ? {table: tableName, fields} : null
|
const sourceIdField =
|
||||||
|
typeof table === 'string' ||
|
||||||
|
typeof table?.source_id_field !== 'string'
|
||||||
|
? ''
|
||||||
|
: table.source_id_field
|
||||||
|
return tableName ? {table: tableName, fields, sourceIdField} : null
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
@@ -194,24 +207,68 @@
|
|||||||
.filter(Boolean)
|
.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) {
|
function permissionDisplayItem(permissions, extensions, translateFn) {
|
||||||
const permission = permissions[0]
|
const permission = permissions[0]
|
||||||
const isReadWriteStorage =
|
const isReadWriteStorage =
|
||||||
permissions.length === 2 &&
|
permissions.length === 2 &&
|
||||||
permissions.some(permission => permission.id === 'ext.storage.read') &&
|
permissions.some(permission => permission.id === 'ext.storage.read') &&
|
||||||
permissions.some(permission => permission.id === 'ext.storage.write')
|
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
|
const descriptions = permissions
|
||||||
.map(permission => permissionManifestDescription(permission))
|
.map(permission => permissionManifestDescription(permission))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const item = {
|
const item = {
|
||||||
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
|
id: isReadWriteStorage
|
||||||
|
? 'ext.storage.read_write'
|
||||||
|
: isWebsocket
|
||||||
|
? 'websocket'
|
||||||
|
: permission.id,
|
||||||
label: isReadWriteStorage
|
label: isReadWriteStorage
|
||||||
? translate(translateFn, 'extension_permission_ext_storage_read_write')
|
? translate(translateFn, 'extension_permission_ext_storage_read_write')
|
||||||
: permissionLabel(permission, translateFn),
|
: isWebsocket
|
||||||
|
? translate(translateFn, 'extension_permission_websocket')
|
||||||
|
: permissionLabel(permission, translateFn),
|
||||||
risk: permissionRisk(permissions, extensions, translateFn),
|
risk: permissionRisk(permissions, extensions, translateFn),
|
||||||
badges: [],
|
badges: [],
|
||||||
descriptions,
|
descriptions,
|
||||||
fieldGroups: [],
|
fieldGroups: [],
|
||||||
|
appendPolicies: [],
|
||||||
invoicePolicies: [],
|
invoicePolicies: [],
|
||||||
extensionAccess: [],
|
extensionAccess: [],
|
||||||
httpHosts: []
|
httpHosts: []
|
||||||
@@ -244,6 +301,15 @@
|
|||||||
item.invoicePolicies = publicInvoicePolicies(permission)
|
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
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +321,11 @@
|
|||||||
const hasReadWriteStorage =
|
const hasReadWriteStorage =
|
||||||
permissionsById.has('ext.storage.read') &&
|
permissionsById.has('ext.storage.read') &&
|
||||||
permissionsById.has('ext.storage.write')
|
permissionsById.has('ext.storage.write')
|
||||||
|
const hasWebsocket =
|
||||||
|
permissionsById.has('websocket.publish') &&
|
||||||
|
permissionsById.has('websocket.subscribe')
|
||||||
let addedReadWriteStorage = false
|
let addedReadWriteStorage = false
|
||||||
|
let addedWebsocket = false
|
||||||
|
|
||||||
return permissionList
|
return permissionList
|
||||||
.map((permission, index) => {
|
.map((permission, index) => {
|
||||||
@@ -274,6 +344,21 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
return {
|
||||||
index,
|
index,
|
||||||
orderId: permission.id,
|
orderId: permission.id,
|
||||||
@@ -327,6 +412,9 @@
|
|||||||
publicInvoicePolicySentence(policy) {
|
publicInvoicePolicySentence(policy) {
|
||||||
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
|
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) {
|
permissionAccessLabel(access) {
|
||||||
const key = `extension_permission_access_${access}`
|
const key = `extension_permission_access_${access}`
|
||||||
const label = this.$t(key)
|
const label = this.$t(key)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ window.PageWallet = {
|
|||||||
invoice: null,
|
invoice: null,
|
||||||
lnurlpay: null,
|
lnurlpay: null,
|
||||||
lnurlauth: null,
|
lnurlauth: null,
|
||||||
|
sending: false,
|
||||||
data: {
|
data: {
|
||||||
request: '',
|
request: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
@@ -131,6 +132,7 @@ window.PageWallet = {
|
|||||||
this.parse.data.request = ''
|
this.parse.data.request = ''
|
||||||
this.parse.data.comment = ''
|
this.parse.data.comment = ''
|
||||||
this.parse.data.internalMemo = null
|
this.parse.data.internalMemo = null
|
||||||
|
this.parse.sending = false
|
||||||
this.parse.data.paymentChecker = null
|
this.parse.data.paymentChecker = null
|
||||||
this.parse.camera.show = false
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
@@ -362,6 +364,9 @@ window.PageWallet = {
|
|||||||
this.parse.invoice = Object.freeze(cleanInvoice)
|
this.parse.invoice = Object.freeze(cleanInvoice)
|
||||||
},
|
},
|
||||||
payInvoice() {
|
payInvoice() {
|
||||||
|
if (this.parse.sending) return
|
||||||
|
|
||||||
|
this.parse.sending = true
|
||||||
const dismissPaymentMsg = Quasar.Notify.create({
|
const dismissPaymentMsg = Quasar.Notify.create({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
message: this.$t('payment_processing')
|
message: this.$t('payment_processing')
|
||||||
@@ -374,6 +379,7 @@ window.PageWallet = {
|
|||||||
this.parse.data.internalMemo
|
this.parse.data.internalMemo
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
this.parse.sending = false
|
||||||
dismissPaymentMsg()
|
dismissPaymentMsg()
|
||||||
this.g.updatePayments = !this.g.updatePayments
|
this.g.updatePayments = !this.g.updatePayments
|
||||||
this.parse.show = false
|
this.parse.show = false
|
||||||
@@ -391,13 +397,16 @@ window.PageWallet = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
this.parse.sending = false
|
||||||
dismissPaymentMsg()
|
dismissPaymentMsg()
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
this.g.updatePayments = !this.g.updatePayments
|
this.g.updatePayments = !this.g.updatePayments
|
||||||
this.parse.show = false
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
payLnurl() {
|
payLnurl() {
|
||||||
|
if (this.parse.sending) return
|
||||||
|
|
||||||
|
this.parse.sending = true
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
|
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
|
||||||
res: this.parse.lnurlpay,
|
res: this.parse.lnurlpay,
|
||||||
@@ -408,6 +417,7 @@ window.PageWallet = {
|
|||||||
internalMemo: this.parse.data.internalMemo
|
internalMemo: this.parse.data.internalMemo
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
this.parse.sending = false
|
||||||
this.parse.show = false
|
this.parse.show = false
|
||||||
if (response.data.extra.success_action) {
|
if (response.data.extra.success_action) {
|
||||||
const action = JSON.parse(response.data.extra.success_action)
|
const action = JSON.parse(response.data.extra.success_action)
|
||||||
@@ -464,7 +474,10 @@ window.PageWallet = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(err => {
|
||||||
|
this.parse.sending = false
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
authLnurl() {
|
authLnurl() {
|
||||||
const dismissAuthMsg = Quasar.Notify.create({
|
const dismissAuthMsg = Quasar.Notify.create({
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ window.WasmExtensionComponent = {
|
|||||||
handleWindowMessage: null,
|
handleWindowMessage: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
loadId: 0,
|
loadId: 0,
|
||||||
paymentSubscriptions: new Map()
|
paymentSubscriptions: new Map(),
|
||||||
|
websocketSubscriptions: new Map()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
@@ -357,6 +358,29 @@ 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) {
|
async callApi(message) {
|
||||||
const method = String(message.method || 'GET').toUpperCase()
|
const method = String(message.method || 'GET').toUpperCase()
|
||||||
const path = String(message.path || '')
|
const path = String(message.path || '')
|
||||||
@@ -844,6 +868,18 @@ window.WasmExtensionComponent = {
|
|||||||
isPaymentHash(value) {
|
isPaymentHash(value) {
|
||||||
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(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) {
|
websocketUrl(path) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
@@ -874,8 +910,24 @@ window.WasmExtensionComponent = {
|
|||||||
this.closePaymentSubscription(subscriptionId)
|
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() {
|
closeBridgePort() {
|
||||||
this.closePaymentSubscriptions()
|
this.closePaymentSubscriptions()
|
||||||
|
this.closeWebsocketSubscriptions()
|
||||||
this.bridgePort?.close()
|
this.bridgePort?.close()
|
||||||
this.bridgePort = null
|
this.bridgePort = null
|
||||||
},
|
},
|
||||||
@@ -937,6 +989,55 @@ window.WasmExtensionComponent = {
|
|||||||
this.paymentSubscriptions.delete(subscriptionId)
|
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) {
|
async handleBridgeRequest(message, reply) {
|
||||||
if (!message || message.type !== 'lnbits-extension:request') return
|
if (!message || message.type !== 'lnbits-extension:request') return
|
||||||
|
|
||||||
@@ -966,6 +1067,15 @@ window.WasmExtensionComponent = {
|
|||||||
return
|
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') {
|
if (message.action === 'ui.scan_qr') {
|
||||||
this.sendResponse(reply, message.id, {
|
this.sendResponse(reply, message.id, {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -1016,6 +1126,24 @@ window.WasmExtensionComponent = {
|
|||||||
return
|
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.')
|
throw new Error('Unknown extension bridge action.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sendResponse(reply, message.id, {
|
this.sendResponse(reply, message.id, {
|
||||||
|
|||||||
@@ -9,28 +9,31 @@
|
|||||||
>
|
>
|
||||||
<template v-slot:header>
|
<template v-slot:header>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label class="text-weight-medium">
|
<div class="row items-center q-col-gutter-x-md q-row-gutter-sm">
|
||||||
<span v-text="permission.label"></span>
|
<q-item-label
|
||||||
</q-item-label>
|
class="text-weight-medium col-auto"
|
||||||
</q-item-section>
|
style="max-width: 100%"
|
||||||
<q-item-section
|
>
|
||||||
v-if="permission.risk.level !== 'low' || permission.badges.length"
|
<span v-text="permission.label"></span>
|
||||||
side
|
</q-item-label>
|
||||||
top
|
<div
|
||||||
>
|
v-if="permission.risk.level !== 'low' || permission.badges.length"
|
||||||
<div class="row items-center justify-end q-gutter-xs">
|
class="row items-center q-gutter-xs col-auto q-mb-xs"
|
||||||
<q-badge
|
style="max-width: 100%"
|
||||||
v-for="badge of permission.badges"
|
>
|
||||||
:key="badge.key"
|
<q-badge
|
||||||
outline
|
v-for="badge of permission.badges"
|
||||||
color="primary"
|
:key="badge.key"
|
||||||
v-text="badge.label"
|
outline
|
||||||
></q-badge>
|
color="primary"
|
||||||
<q-badge
|
v-text="badge.label"
|
||||||
v-if="permission.risk.level !== 'low'"
|
></q-badge>
|
||||||
:color="permission.risk.color"
|
<q-badge
|
||||||
v-text="permission.risk.label"
|
v-if="permission.risk.level !== 'low'"
|
||||||
></q-badge>
|
:color="permission.risk.color"
|
||||||
|
v-text="permission.risk.label"
|
||||||
|
></q-badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</template>
|
</template>
|
||||||
@@ -57,7 +60,24 @@
|
|||||||
></p>
|
></p>
|
||||||
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
|
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
|
||||||
<li v-for="group of permission.fieldGroups" :key="group.table">
|
<li v-for="group of permission.fieldGroups" :key="group.table">
|
||||||
<span v-text="group.table"></span>
|
<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>
|
||||||
<ul v-if="group.fields.length" class="q-pl-md">
|
<ul v-if="group.fields.length" class="q-pl-md">
|
||||||
<li
|
<li
|
||||||
v-for="field of group.fields"
|
v-for="field of group.fields"
|
||||||
@@ -67,6 +87,35 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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 v-if="permission.extensionAccess.length" class="q-mt-sm">
|
||||||
<div
|
<div
|
||||||
class="text-caption text-grey"
|
class="text-caption text-grey"
|
||||||
|
|||||||
@@ -664,7 +664,8 @@
|
|||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="payInvoice"
|
@click="payInvoice"
|
||||||
:label="$t('pay')"
|
:disable="parse.sending"
|
||||||
|
:label="parse.sending ? $t('sending') + '...' : $t('pay')"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-close-popup
|
v-close-popup
|
||||||
@@ -836,7 +837,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn unelevated color="primary" type="submit">Send</q-btn>
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
:disable="parse.sending"
|
||||||
|
:label="parse.sending ? $t('sending') + '...' : $t('send')"
|
||||||
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
:label="$t('cancel')"
|
:label="$t('cancel')"
|
||||||
v-close-popup
|
v-close-popup
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from collections.abc import AsyncGenerator
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -40,6 +38,3 @@ class VoidWallet(Wallet):
|
|||||||
|
|
||||||
async def get_payment_status(self, *_, **__) -> PaymentStatus:
|
async def get_payment_status(self, *_, **__) -> PaymentStatus:
|
||||||
return PaymentPendingStatus()
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
|
||||||
yield ""
|
|
||||||
|
|||||||
Generated
-64
@@ -21,7 +21,6 @@
|
|||||||
"vuex": "4.1.0"
|
"vuex": "4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
|
||||||
"clean-css-cli": "^5.6.3",
|
"clean-css-cli": "^5.6.3",
|
||||||
"concat": "^1.0.3",
|
"concat": "^1.0.3",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
@@ -577,22 +576,6 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/@scure/base": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
@@ -1771,53 +1754,6 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lnbits",
|
"name": "lnbits",
|
||||||
"scripts": {
|
"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",
|
"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_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))\"",
|
"vendor_json": "node -e \"require('fs').writeFileSync('./lnbits/static/vendor.json', JSON.stringify(require('./package.json').bundle))\"",
|
||||||
@@ -14,7 +13,6 @@
|
|||||||
"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"
|
"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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
|
||||||
"clean-css-cli": "^5.6.3",
|
"clean-css-cli": "^5.6.3",
|
||||||
"concat": "^1.0.3",
|
"concat": "^1.0.3",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,990 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@@ -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
|
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
|
||||||
|
|
||||||
|
|
||||||
def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
|
def test_wasm_frontend_bridge_restricts_api_routes_and_realtime_actions():
|
||||||
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
|
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
@@ -22,8 +22,15 @@ def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
|
|||||||
assert "allowedApiRoute(method, path)" in bridge
|
assert "allowedApiRoute(method, path)" in bridge
|
||||||
assert "url.origin !== window.location.origin" in bridge
|
assert "url.origin !== window.location.origin" in bridge
|
||||||
assert "Extension API route is not allowed." 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.subscribe'" in bridge
|
||||||
assert "message.action === 'payment.unsubscribe'" 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
|
assert "message.action === 'ui.scan_qr'" in bridge
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ from lnbits.core.wasm_ext.api.models import (
|
|||||||
CreateInvoicePublicRequest,
|
CreateInvoicePublicRequest,
|
||||||
EmptyRequest,
|
EmptyRequest,
|
||||||
PayInvoiceRequest,
|
PayInvoiceRequest,
|
||||||
|
StorageAppendPublicRequest,
|
||||||
StorageGetRequest,
|
StorageGetRequest,
|
||||||
|
StoragePublicPaginatedRequest,
|
||||||
WalletBalanceRequest,
|
WalletBalanceRequest,
|
||||||
|
WebsocketPublishRequest,
|
||||||
)
|
)
|
||||||
from lnbits.exceptions import PaymentError
|
from lnbits.exceptions import PaymentError
|
||||||
from lnbits.helpers import sha256s
|
from lnbits.helpers import sha256s
|
||||||
@@ -48,6 +51,209 @@ async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
|
|||||||
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
|
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
|
@pytest.mark.anyio
|
||||||
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
|
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -77,6 +283,136 @@ 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
|
@pytest.mark.anyio
|
||||||
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
|
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
|||||||
"policies": [
|
"policies": [
|
||||||
{
|
{
|
||||||
"table_name": "tip_jars",
|
"table_name": "tip_jars",
|
||||||
|
"source_id_field": "wallet_id",
|
||||||
"public_fields": ["id", "title", "description"],
|
"public_fields": ["id", "title", "description"],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -80,6 +81,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
|||||||
policies=[
|
policies=[
|
||||||
{
|
{
|
||||||
"table_name": "tip_jars",
|
"table_name": "tip_jars",
|
||||||
|
"source_id_field": "wallet_id",
|
||||||
"public_fields": ["id", "title"],
|
"public_fields": ["id", "title"],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -95,6 +97,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
|||||||
policies=[
|
policies=[
|
||||||
{
|
{
|
||||||
"table_name": "tip_jars",
|
"table_name": "tip_jars",
|
||||||
|
"source_id_field": "wallet_id",
|
||||||
"public_fields": ["id", "title"],
|
"public_fields": ["id", "title"],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -102,6 +105,140 @@ 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():
|
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
|
||||||
ext_info = make_installable_extension("demoext")
|
ext_info = make_installable_extension("demoext")
|
||||||
extension_config = _wasm_config(
|
extension_config = _wasm_config(
|
||||||
@@ -171,6 +308,29 @@ def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
|
|||||||
) == [ExtensionPermission(id="wallet.payments.watch")]
|
) == [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():
|
def test_background_payment_grant_lookup_and_policy_coverage():
|
||||||
permissions = {
|
permissions = {
|
||||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import cast
|
from typing import Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -22,6 +22,8 @@ from lnbits.core.wasm_ext.storage import crud as storage_crud
|
|||||||
from lnbits.core.wasm_ext.storage.crud import (
|
from lnbits.core.wasm_ext.storage.crud import (
|
||||||
OWNER_ID_FIELD,
|
OWNER_ID_FIELD,
|
||||||
migrate_wasm_extension_database,
|
migrate_wasm_extension_database,
|
||||||
|
storage_append_public_row,
|
||||||
|
storage_count_rows,
|
||||||
storage_delete_row,
|
storage_delete_row,
|
||||||
storage_get_paginated_rows,
|
storage_get_paginated_rows,
|
||||||
storage_get_public_row,
|
storage_get_public_row,
|
||||||
@@ -295,6 +297,59 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
|
|||||||
assert deleted is None
|
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
|
@pytest.mark.anyio
|
||||||
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
@@ -378,7 +433,7 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
|||||||
storage_dir = ext_dir / "storage"
|
storage_dir = ext_dir / "storage"
|
||||||
migrations_dir = storage_dir / "migrations"
|
migrations_dir = storage_dir / "migrations"
|
||||||
migrations_dir.mkdir(parents=True)
|
migrations_dir.mkdir(parents=True)
|
||||||
schema = {
|
schema: dict[str, Any] = {
|
||||||
"tables": {
|
"tables": {
|
||||||
"notes": {
|
"notes": {
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -389,7 +444,20 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
|||||||
{"name": "tags", "type": "string", "list": True},
|
{"name": "tags", "type": "string", "list": True},
|
||||||
{"name": "created_at", "type": "datetime"},
|
{"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 = {
|
migration = {
|
||||||
@@ -398,7 +466,17 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
|||||||
"op": "create_table",
|
"op": "create_table",
|
||||||
"table": "notes",
|
"table": "notes",
|
||||||
"fields": schema["tables"]["notes"]["fields"],
|
"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")
|
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user