feat: append public row
This commit is contained in:
@@ -10,6 +10,7 @@ from lnbits.core.wasm_ext.api.models import (
|
||||
CreateInvoicePublicRequest,
|
||||
EmptyRequest,
|
||||
PayInvoiceRequest,
|
||||
StorageAppendPublicRequest,
|
||||
StorageGetRequest,
|
||||
WalletBalanceRequest,
|
||||
)
|
||||
@@ -77,6 +78,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
|
||||
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -102,6 +102,104 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
||||
]
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_allows_narrower_public_append_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "ext.storage.append_public",
|
||||
"description": "Append public messages.",
|
||||
"policies": [
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["name", "message"],
|
||||
"max_rows_per_source": 100,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
permissions = validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message"],
|
||||
"max_rows_per_source": 50,
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
assert permissions == [
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
description="Append public messages.",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message"],
|
||||
"max_rows_per_source": 50,
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_public_append_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "ext.storage.append_public",
|
||||
"policies": [
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message"],
|
||||
"max_rows_per_source": 100,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="broader policies"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message", "admin"],
|
||||
"max_rows_per_source": 101,
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
|
||||
@@ -22,6 +22,8 @@ from lnbits.core.wasm_ext.storage import crud as storage_crud
|
||||
from lnbits.core.wasm_ext.storage.crud import (
|
||||
OWNER_ID_FIELD,
|
||||
migrate_wasm_extension_database,
|
||||
storage_append_public_row,
|
||||
storage_count_rows,
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
storage_get_public_row,
|
||||
@@ -295,6 +297,59 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
|
||||
assert deleted is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
):
|
||||
ext_id = f"wasmstore_{uuid4().hex[:8]}"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True)
|
||||
_write_storage_extension(settings, ext_id)
|
||||
|
||||
await migrate_wasm_extension_database(make_installable_extension(ext_id))
|
||||
await storage_set_row(
|
||||
ext_id,
|
||||
"threads",
|
||||
{"id": "thread-1", "title": "Support"},
|
||||
"owner-1",
|
||||
)
|
||||
message_id = await storage_append_public_row(
|
||||
ext_id,
|
||||
"messages",
|
||||
{"thread_id": "thread-1", "message": "Hello"},
|
||||
"owner-1",
|
||||
)
|
||||
owner_count = await storage_count_rows(
|
||||
ext_id,
|
||||
"messages",
|
||||
{"thread_id": "thread-1"},
|
||||
owner_id="owner-1",
|
||||
)
|
||||
other_owner_count = await storage_count_rows(
|
||||
ext_id,
|
||||
"messages",
|
||||
{"thread_id": "thread-1"},
|
||||
owner_id="owner-2",
|
||||
)
|
||||
message = await storage_get_row(ext_id, "messages", message_id, "owner-1")
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
assert message_id
|
||||
assert owner_count == 1
|
||||
assert other_owner_count == 0
|
||||
assert message is not None
|
||||
assert message["id"] == message_id
|
||||
assert message["thread_id"] == "thread-1"
|
||||
assert message["message"] == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
||||
tmp_path: Path,
|
||||
@@ -389,7 +444,20 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
||||
{"name": "tags", "type": "string", "list": True},
|
||||
{"name": "created_at", "type": "datetime"},
|
||||
]
|
||||
}
|
||||
},
|
||||
"threads": {
|
||||
"fields": [
|
||||
{"name": "id", "type": "string"},
|
||||
{"name": "title", "type": "string"},
|
||||
]
|
||||
},
|
||||
"messages": {
|
||||
"fields": [
|
||||
{"name": "id", "type": "string"},
|
||||
{"name": "thread_id", "type": "string"},
|
||||
{"name": "message", "type": "string"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
migration = {
|
||||
@@ -398,7 +466,17 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
||||
"op": "create_table",
|
||||
"table": "notes",
|
||||
"fields": schema["tables"]["notes"]["fields"],
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "create_table",
|
||||
"table": "threads",
|
||||
"fields": schema["tables"]["threads"]["fields"],
|
||||
},
|
||||
{
|
||||
"op": "create_table",
|
||||
"table": "messages",
|
||||
"fields": schema["tables"]["messages"]["fields"],
|
||||
},
|
||||
]
|
||||
}
|
||||
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user