Files
superset2/tests/unit_tests/extensions/storage/test_persistent_dao.py
Michael S. Molina 34ebe3d22a feat(extensions): add a Storage API for extensions (#39171)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Benyovszky Máté <benyovszky.mate.work@outlook.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 16:23:56 -03:00

958 lines
36 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for ExtensionStorageDAO — encryption, scoping, and CRUD behavior."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import MagicMock, Mock, patch
import pytest
from flask import Flask
from flask_babel import Babel
from sqlalchemy.orm.session import Session
from superset.extensions.storage.codecs import get_codec
from superset.extensions.storage.filters import ExtensionStorageFilter
from superset.extensions.storage.persistent_dao import (
_derive_key,
_enc_type,
ExtensionStorageDAO,
ExtensionStorageKeyTooLong,
ExtensionStorageListPayloadTooLarge,
ExtensionStorageQuotaExceeded,
ExtensionStorageValueTooLarge,
MAX_KEY_LENGTH,
SET_LOCK_TTL_SECONDS,
)
from superset.extensions.storage.persistent_model import ExtensionStorage
@pytest.fixture
def app() -> Flask:
"""Create a minimal Flask app for testing."""
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
flask_app.config["SECRET_KEY"] = "test-secret-key" # noqa: S105
# Mirrors the default set in superset/config.py, which is always present
# in a real Superset app.
flask_app.config["EXTENSIONS_PERSISTENT_STORAGE"] = {}
return flask_app
@pytest.fixture
def app_with_quota(app: Flask) -> Flask:
"""App fixture with a configured persistent storage quota (100 bytes).
Initializes Babel since exceeding the quota raises an exception whose
message is built with flask_babel's gettext.
"""
app.config["EXTENSIONS_PERSISTENT_STORAGE"] = {"QUOTA_PER_EXTENSION": 100}
Babel(app)
return app
@pytest.fixture
def app_with_max_value_size(app: Flask) -> Flask:
"""App fixture with a configured MAX_VALUE_SIZE (10 bytes).
Initializes Babel since exceeding the limit raises an exception whose
message is built with flask_babel's gettext.
"""
app.config["EXTENSIONS_PERSISTENT_STORAGE"] = {"MAX_VALUE_SIZE": 10}
Babel(app)
return app
@pytest.fixture
def app_with_max_list_payload_size(app: Flask) -> Flask:
"""App fixture with a configured MAX_LIST_PAYLOAD_SIZE (20 bytes).
Initializes Babel since exceeding the limit raises an exception whose
message is built with flask_babel's gettext.
"""
app.config["EXTENSIONS_PERSISTENT_STORAGE"] = {"MAX_LIST_PAYLOAD_SIZE": 20}
Babel(app)
return app
@pytest.fixture(autouse=True)
def mock_distributed_lock() -> Generator[MagicMock, None, None]:
"""`ExtensionStorageDAO.set` wraps its select-then-write section in a
`DistributedLock`, which needs a configured lock backend (Redis or an
initialized DB session) to acquire. These tests exercise DAO logic
against a mocked `db`, not lock acquisition, so replace it with a no-op.
"""
with patch("superset.extensions.storage.persistent_dao.DistributedLock") as mock:
yield mock
@pytest.fixture
def list_session(session: Session) -> Session:
"""In-memory SQLite session with the extension_storage table created,
used only by `list()` tests below (which exercise real queries rather
than mocking `db.session`, since `list()`'s query chain — count,
order_by, offset, limit, a value_size subquery sum — is too complex to
mock link-by-link without the test just re-asserting the
implementation)."""
ExtensionStorage.metadata.create_all(session.get_bind()) # pylint: disable=no-member
return session
def _create_entry(
session: Session,
extension_id: str,
key: str,
value: bytes = b"{}",
codec: str = "json",
user_fk: int | None = None,
resource_type: str | None = None,
resource_uuid: str | None = None,
is_encrypted: bool = False,
) -> ExtensionStorage:
"""Insert an ExtensionStorage row directly, bypassing DAO.set() (whose
own validation isn't what these `list()` tests are exercising)."""
entry = ExtensionStorage(
extension_id=extension_id,
key=key,
value=value,
value_size=len(value),
codec=codec,
user_fk=user_fk,
resource_type=resource_type,
resource_uuid=resource_uuid,
is_encrypted=is_encrypted,
)
session.add(entry)
session.flush()
return entry
# ── get / get_value ───────────────────────────────────────────────────────────
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_get_returns_none_when_not_found(mock_db: MagicMock, app: Flask) -> None:
"""get() returns None when no entry exists for the given scope."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
result = ExtensionStorageDAO.get("my-ext", "key", user_fk=1)
assert result is None
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_get_value_returns_raw_bytes_for_unencrypted(
mock_db: MagicMock, app: Flask
) -> None:
"""get_value() returns raw bytes unchanged when the entry is not encrypted."""
entry = MagicMock()
entry.is_encrypted = False
entry.value = b'{"foo": "bar"}'
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
with app.app_context():
result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
assert result == b'{"foo": "bar"}'
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_get_value_returns_none_when_not_found(
mock_db: MagicMock, app: Flask
) -> None:
"""get_value() returns None when the key does not exist."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
result = ExtensionStorageDAO.get_value("my-ext", "missing", user_fk=1)
assert result is None
# ── get_decoded_value ─────────────────────────────────────────────────────────
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_get_decoded_value_decodes_with_stored_codec(
mock_db: MagicMock, app: Flask
) -> None:
"""get_decoded_value() decodes the raw bytes using the entry's codec."""
entry = MagicMock()
entry.is_encrypted = False
entry.value = b'{"theme": "dark"}'
entry.codec = "json"
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
with app.app_context():
result = ExtensionStorageDAO.get_decoded_value("my-ext", "key", user_fk=1)
assert result == {"theme": "dark"}
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_get_decoded_value_returns_none_when_not_found(
mock_db: MagicMock, app: Flask
) -> None:
"""get_decoded_value() returns None when no entry exists."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
result = ExtensionStorageDAO.get_decoded_value("my-ext", "missing", user_fk=1)
assert result is None
@patch("superset.extensions.storage.persistent_dao._enc_type")
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_get_value_decrypts_encrypted_entry(
mock_db: MagicMock, mock_enc_type: MagicMock, app: Flask
) -> None:
"""get_value() decrypts the stored value for encrypted entries."""
entry = MagicMock()
entry.is_encrypted = True
entry.user_fk = 1
entry.value = b"encrypted-bytes"
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
mock_enc_type.return_value.process_result_value.return_value = (
b'{"decrypted": true}'
)
with app.app_context():
result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
assert result == b'{"decrypted": true}'
mock_enc_type.assert_called_once_with(1)
mock_enc_type.return_value.process_result_value.assert_called_once_with(
b"encrypted-bytes", mock_db.engine.dialect
)
@patch("superset.extensions.storage.persistent_dao._enc_type")
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_get_value_returns_none_on_decryption_failure(
mock_db: MagicMock, mock_enc_type: MagicMock, app: Flask
) -> None:
"""get_value() returns None and logs an error when decryption fails."""
entry = MagicMock()
entry.is_encrypted = True
entry.user_fk = 1
entry.value = b"corrupted"
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
mock_enc_type.return_value.process_result_value.side_effect = Exception(
"bad decrypt"
)
with app.app_context():
result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
assert result is None
# ── set (upsert) ──────────────────────────────────────────────────────────────
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_creates_new_entry_when_absent(mock_db: MagicMock, app: Flask) -> None:
"""set() adds a new entry when no existing entry is found."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
ExtensionStorageDAO.set("my-ext", "key", b'{"value": 1}', user_fk=1)
mock_db.session.add.assert_called_once()
mock_db.session.flush.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_locks_the_whole_extension_not_just_the_key(
mock_db: MagicMock, mock_distributed_lock: MagicMock, app: Flask
) -> None:
"""set()'s lock is scoped to extension_id alone, not to the specific key:
_check_quota sums usage across every key in the extension, so a lock
scoped to just this key would let concurrent writes to different keys
each pass the quota check before either commits, letting the extension
exceed its quota."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
ExtensionStorageDAO.set("my-ext", "key", b'{"value": 1}', user_fk=1)
mock_distributed_lock.assert_called_once_with(
namespace="extension_storage_set",
ttl_seconds=SET_LOCK_TTL_SECONDS,
extension_id="my-ext",
)
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_updates_existing_entry(mock_db: MagicMock, app: Flask) -> None:
"""set() updates in-place when an entry already exists (no duplicate row)."""
existing = MagicMock()
existing.value_size = 0
mock_db.session.query.return_value.filter.return_value.first.return_value = existing
with app.app_context():
ExtensionStorageDAO.set("my-ext", "key", b'{"new": true}', user_fk=1)
assert existing.value == b'{"new": true}'
assert existing.value_size == len(b'{"new": true}')
mock_db.session.add.assert_not_called()
mock_db.session.flush.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_defaults_codec_to_json(mock_db: MagicMock, app: Flask) -> None:
"""set() stores codec="json" on the entry when not specified."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
ExtensionStorageDAO.set("my-ext", "key", b'{"value": 1}', user_fk=1)
added_entry = mock_db.session.add.call_args[0][0]
assert added_entry.codec == "json"
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_stores_given_codec(mock_db: MagicMock, app: Flask) -> None:
"""set() stores the caller-supplied codec identifier on the entry."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
ExtensionStorageDAO.set(
"my-ext", "key", b"\x80pickled", codec="pickle", user_fk=1
)
added_entry = mock_db.session.add.call_args[0][0]
assert added_entry.codec == "pickle"
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_updates_codec_on_existing_entry(
mock_db: MagicMock, app: Flask
) -> None:
"""set() overwrites the stored codec when re-writing an existing key."""
existing = MagicMock()
existing.value_size = 0
existing.codec = "json"
mock_db.session.query.return_value.filter.return_value.first.return_value = existing
with app.app_context():
ExtensionStorageDAO.set(
"my-ext", "key", b"\x80pickled", codec="pickle", user_fk=1
)
assert existing.codec == "pickle"
@patch("superset.extensions.storage.persistent_dao._enc_type")
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_encrypts_value_when_requested(
mock_db: MagicMock, mock_enc_type: MagicMock, app: Flask
) -> None:
"""set() encrypts value bytes with user-derived key and sets is_encrypted=True."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
mock_enc_type.return_value.process_bind_param.return_value = b"ciphertext"
with app.app_context():
ExtensionStorageDAO.set("my-ext", "key", b"plaintext", user_fk=1, encrypt=True)
mock_enc_type.assert_called_once_with(1)
mock_enc_type.return_value.process_bind_param.assert_called_once_with(
b"plaintext", mock_db.engine.dialect
)
added_entry = mock_db.session.add.call_args[0][0]
assert added_entry.value == b"ciphertext"
assert added_entry.value_size == len(b"ciphertext")
assert added_entry.is_encrypted is True
# ── value size ────────────────────────────────────────────────────────────────
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_allows_key_at_max_length(mock_db: MagicMock, app: Flask) -> None:
"""set() accepts a key exactly at MAX_KEY_LENGTH."""
Babel(app)
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
ExtensionStorageDAO.set("my-ext", "k" * MAX_KEY_LENGTH, b"value", user_fk=1)
mock_db.session.add.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_rejects_key_exceeding_max_length(
mock_db: MagicMock, app: Flask
) -> None:
"""set() raises ExtensionStorageKeyTooLong and does not write when the
key exceeds MAX_KEY_LENGTH."""
Babel(app)
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
with pytest.raises(ExtensionStorageKeyTooLong):
ExtensionStorageDAO.set(
"my-ext", "k" * (MAX_KEY_LENGTH + 1), b"value", user_fk=1
)
mock_db.session.add.assert_not_called()
mock_db.session.flush.assert_not_called()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_checks_key_length_before_value_size(
mock_db: MagicMock, app: Flask
) -> None:
"""set() rejects an over-length key before running the value-size query,
since the key check happens first."""
Babel(app)
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
with pytest.raises(ExtensionStorageKeyTooLong):
ExtensionStorageDAO.set(
"my-ext", "k" * (MAX_KEY_LENGTH + 1), b"value", user_fk=1
)
mock_db.session.query.return_value.filter.return_value.first.assert_not_called()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_allows_write_when_no_max_value_size_configured(
mock_db: MagicMock, app: Flask
) -> None:
"""set() skips the value-size check entirely when MAX_VALUE_SIZE is unset."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
ExtensionStorageDAO.set("my-ext", "key", b"x" * 10000, user_fk=1)
mock_db.session.add.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_allows_value_within_max_value_size(
mock_db: MagicMock, app_with_max_value_size: Flask
) -> None:
"""set() accepts a value at or under MAX_VALUE_SIZE."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app_with_max_value_size.app_context():
ExtensionStorageDAO.set("my-ext", "key", b"x" * 10, user_fk=1)
mock_db.session.add.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_rejects_value_exceeding_max_value_size(
mock_db: MagicMock, app_with_max_value_size: Flask
) -> None:
"""set() raises ExtensionStorageValueTooLarge and does not write when the
value exceeds MAX_VALUE_SIZE."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app_with_max_value_size.app_context():
with pytest.raises(ExtensionStorageValueTooLarge):
ExtensionStorageDAO.set("my-ext", "key", b"x" * 11, user_fk=1)
mock_db.session.add.assert_not_called()
mock_db.session.flush.assert_not_called()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_checks_value_size_before_quota(
mock_db: MagicMock, app_with_max_value_size: Flask
) -> None:
"""set() rejects an oversized value before running the quota usage query."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app_with_max_value_size.app_context():
with pytest.raises(ExtensionStorageValueTooLarge):
ExtensionStorageDAO.set("my-ext", "key", b"x" * 11, user_fk=1)
# scalar() is only called by the quota-usage query; it must not run.
mock_db.session.query.return_value.filter.return_value.scalar.assert_not_called()
# ── quota ─────────────────────────────────────────────────────────────────────
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_allows_write_when_no_quota_configured(
mock_db: MagicMock, app: Flask
) -> None:
"""set() skips the quota check entirely when no quota is configured."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
ExtensionStorageDAO.set("my-ext", "key", b'{"value": 1}', user_fk=1)
# scalar() is only called by the quota-usage query; it must not run.
mock_db.session.query.return_value.filter.return_value.scalar.assert_not_called()
mock_db.session.add.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_allows_write_under_quota(
mock_db: MagicMock, app_with_quota: Flask
) -> None:
"""set() succeeds when current usage plus the new value stays within quota."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
mock_db.session.query.return_value.filter.return_value.scalar.return_value = 50
with app_with_quota.app_context():
ExtensionStorageDAO.set("my-ext", "key", b"x" * 10, user_fk=1)
mock_db.session.add.assert_called_once()
mock_db.session.flush.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_rejects_write_over_quota(
mock_db: MagicMock, app_with_quota: Flask
) -> None:
"""set() raises ExtensionStorageQuotaExceeded and does not write when over quota."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
mock_db.session.query.return_value.filter.return_value.scalar.return_value = 95
with app_with_quota.app_context(), pytest.raises(ExtensionStorageQuotaExceeded):
ExtensionStorageDAO.set("my-ext", "key", b"x" * 10, user_fk=1)
mock_db.session.add.assert_not_called()
mock_db.session.flush.assert_not_called()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_overwrite_does_not_double_count_existing_row(
mock_db: MagicMock, app_with_quota: Flask
) -> None:
"""Overwriting a key nets out its own existing size against quota usage."""
existing = MagicMock()
existing.value = b"x" * 20
existing.value_size = 20
mock_db.session.query.return_value.filter.return_value.first.return_value = existing
# Usage already includes the 20 bytes this row occupies.
mock_db.session.query.return_value.filter.return_value.scalar.return_value = 95
with app_with_quota.app_context():
# Same-size replacement: 95 - 20 + 20 = 95 <= 100, must not raise even
# though total usage (95) is already close to the 100-byte quota.
ExtensionStorageDAO.set("my-ext", "key", b"x" * 20, user_fk=1)
assert existing.value == b"x" * 20
assert existing.value_size == 20
mock_db.session.flush.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_overwrite_still_rejected_when_growth_exceeds_quota(
mock_db: MagicMock, app_with_quota: Flask
) -> None:
"""Overwriting with a larger value is rejected if the *net* growth exceeds quota."""
existing = MagicMock()
existing.value = b"x" * 20
existing.value_size = 20
mock_db.session.query.return_value.filter.return_value.first.return_value = existing
mock_db.session.query.return_value.filter.return_value.scalar.return_value = 95
with app_with_quota.app_context(), pytest.raises(ExtensionStorageQuotaExceeded):
# 95 - 20 + 30 = 105 > 100
ExtensionStorageDAO.set("my-ext", "key", b"x" * 30, user_fk=1)
assert existing.value == b"x" * 20
assert existing.value_size == 20
# ── create (disallowed) ────────────────────────────────────────────────────────
def test_dao_create_raises_not_implemented(app: Flask) -> None:
"""create() is BaseDAO's raw insert, with no upsert dedup, quota check,
or locking against the key set() writes to — it must raise rather than
silently allow constructing a row outside set()'s guarantees."""
with app.app_context(), pytest.raises(NotImplementedError):
ExtensionStorageDAO.create()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_delete_by_key_returns_true_when_entry_exists(
mock_db: MagicMock, app: Flask
) -> None:
"""delete_by_key() returns True and removes the row when the entry is found."""
entry = MagicMock()
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
with app.app_context():
result = ExtensionStorageDAO.delete_by_key("my-ext", "key", user_fk=1)
assert result is True
mock_db.session.delete.assert_called_once_with(entry)
mock_db.session.flush.assert_called_once()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_delete_by_key_returns_false_when_not_found(
mock_db: MagicMock, app: Flask
) -> None:
"""delete_by_key() returns False without touching the session when absent."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
with app.app_context():
result = ExtensionStorageDAO.delete_by_key("my-ext", "key", user_fk=1)
assert result is False
mock_db.session.delete.assert_not_called()
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_set_commits_before_releasing_the_lock(
mock_db: MagicMock, app: Flask, mock_distributed_lock: MagicMock
) -> None:
"""set() must commit its write while still holding the extension-wide
lock. Committing only after the lock is released would let a second
writer's SELECT run — and, under READ COMMITTED isolation, still miss
the first writer's not-yet-committed row — recreating the duplicate-row
race the lock exists to prevent."""
mock_db.session.query.return_value.filter.return_value.first.return_value = None
manager = Mock()
manager.attach_mock(mock_db.session.commit, "commit")
manager.attach_mock(mock_distributed_lock.return_value.__exit__, "lock_exit")
with app.app_context():
ExtensionStorageDAO.set("my-ext", "key", b"value")
call_names = [call[0] for call in manager.mock_calls]
assert call_names.index("commit") < call_names.index("lock_exit")
# ── scoping ───────────────────────────────────────────────────────────────────
@patch("superset.extensions.storage.persistent_dao.db")
def test_dao_user_and_shared_scopes_issue_independent_queries(
mock_db: MagicMock, app: Flask
) -> None:
"""User-scoped (user_fk=N) and shared-scoped (user_fk=None) lookups are separate."""
first_call = MagicMock(return_value=None)
mock_db.session.query.return_value.filter.return_value.first = first_call
with app.app_context():
ExtensionStorageDAO.get("my-ext", "key", user_fk=42)
ExtensionStorageDAO.get("my-ext", "key", user_fk=None)
# Each scope issues its own independent DB query
assert first_call.call_count == 2
def test_dao_is_wired_to_the_extension_storage_model_and_filter() -> None:
"""ExtensionStorageDAO is a BaseDAO[ExtensionStorage] scoped by
ExtensionStorageFilter, so filter_by/find_all/query/create/update/delete
(inherited from BaseDAO) are automatically restricted to the calling
extension's own rows when used via superset_core.extensions.storage.dao.
"""
assert ExtensionStorageDAO.model_cls is ExtensionStorage
assert ExtensionStorageDAO.base_filter is ExtensionStorageFilter
# ── key derivation ────────────────────────────────────────────────────────────
def test_enc_type_user_scoped_uses_derived_key(app: Flask) -> None:
"""User-scoped _enc_type derives the key from SECRET_KEY + user_fk."""
with app.app_context():
secret = app.config["SECRET_KEY"]
enc = _enc_type(user_fk=42)
expected_key = _derive_key(secret, 42)
# The key lambda on the EncryptedType resolves to the derived key.
assert enc.key == expected_key
def test_enc_type_shared_uses_secret_key_directly(app: Flask) -> None:
"""Shared _enc_type (user_fk=None) uses SECRET_KEY without derivation."""
with app.app_context():
secret = app.config["SECRET_KEY"]
enc = _enc_type(user_fk=None)
assert enc.key == secret
def test_enc_type_different_users_produce_different_keys(app: Flask) -> None:
"""Two different user IDs produce different derived keys."""
with app.app_context():
enc_a = _enc_type(user_fk=1)
enc_b = _enc_type(user_fk=2)
assert enc_a.key != enc_b.key
def test_enc_type_user_key_differs_from_shared_key(app: Flask) -> None:
"""User-scoped derived key differs from the plain SECRET_KEY used for shared."""
with app.app_context():
secret = app.config["SECRET_KEY"]
enc_user = _enc_type(user_fk=1)
assert enc_user.key != secret
# ── list ──────────────────────────────────────────────────────────────────────
def test_list_returns_entries_and_total_count(
app: Flask, list_session: Session
) -> None:
"""list() returns decoded entries plus the total count across all pages."""
_create_entry(list_session, "my-ext", "a", value=b'{"n": 1}', user_fk=1)
_create_entry(list_session, "my-ext", "b", value=b'{"n": 2}', user_fk=1)
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
assert total_count == 2
assert {e.key for e in entries} == {"a", "b"}
decoded = {}
for e in entries:
assert e.value is not None
decoded[e.key] = get_codec(e.codec).decode(e.value)
assert decoded == {"a": {"n": 1}, "b": {"n": 2}}
def test_list_scopes_by_extension_id(app: Flask, list_session: Session) -> None:
"""list() only returns entries for the given extension_id."""
_create_entry(list_session, "ext-a", "key", user_fk=1)
_create_entry(list_session, "ext-b", "key", user_fk=1)
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries("ext-a", user_fk=1)
assert total_count == 1
assert entries[0].key == "key"
def test_list_scopes_by_user_fk(app: Flask, list_session: Session) -> None:
"""list() only returns entries matching the given user_fk (or global
scope when user_fk=None)."""
_create_entry(list_session, "my-ext", "mine", user_fk=1)
_create_entry(list_session, "my-ext", "theirs", user_fk=2)
_create_entry(list_session, "my-ext", "shared", user_fk=None)
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
assert total_count == 1
assert entries[0].key == "mine"
def test_list_scopes_by_resource(app: Flask, list_session: Session) -> None:
"""list() only returns entries matching the given resource_type/uuid."""
_create_entry(
list_session,
"my-ext",
"a",
resource_type="dashboard",
resource_uuid="uuid-1",
)
_create_entry(
list_session,
"my-ext",
"b",
resource_type="dashboard",
resource_uuid="uuid-2",
)
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries(
"my-ext", resource_type="dashboard", resource_uuid="uuid-1"
)
assert total_count == 1
assert entries[0].key == "a"
def test_list_paginates_with_page_and_page_size(
app: Flask, list_session: Session
) -> None:
"""list() applies offset/limit based on page and page_size, while
total_count still reflects every matching row, not just the page."""
for i in range(5):
_create_entry(list_session, "my-ext", f"key-{i}", user_fk=1)
with app.app_context():
page_0, total_0 = ExtensionStorageDAO.list_entries(
"my-ext", user_fk=1, page=0, page_size=2
)
page_1, total_1 = ExtensionStorageDAO.list_entries(
"my-ext", user_fk=1, page=1, page_size=2
)
assert total_0 == total_1 == 5
assert len(page_0) == 2
assert len(page_1) == 2
assert {e.key for e in page_0}.isdisjoint({e.key for e in page_1})
def test_list_defaults_to_page_size_ten(app: Flask, list_session: Session) -> None:
"""list() returns at most 10 entries per page when page_size is not given."""
for i in range(15):
_create_entry(list_session, "my-ext", f"key-{i}", user_fk=1)
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
assert total_count == 15
assert len(entries) == 10
def test_list_allows_page_size_larger_than_ten(
app: Flask, list_session: Session
) -> None:
"""list() has no row-count ceiling — a caller-supplied page_size above
the default of 10 is honored, subject only to MAX_LIST_PAYLOAD_SIZE."""
for i in range(15):
_create_entry(list_session, "my-ext", f"key-{i}", user_fk=1)
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries(
"my-ext", user_fk=1, page_size=15
)
assert total_count == 15
assert len(entries) == 15
def test_list_orders_by_changed_on_descending(
app: Flask, list_session: Session
) -> None:
"""list() orders entries most-recently-changed first."""
from datetime import datetime, timedelta, timezone
first = _create_entry(list_session, "my-ext", "first", user_fk=1)
second = _create_entry(list_session, "my-ext", "second", user_fk=1)
now = datetime.now(timezone.utc)
first.changed_on = now - timedelta(hours=1)
second.changed_on = now
list_session.flush()
with app.app_context():
entries, _ = ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
assert [e.key for e in entries] == ["second", "first"]
def test_list_allows_page_within_max_list_payload_size(
app_with_max_list_payload_size: Flask, list_session: Session
) -> None:
"""list() succeeds when the page's combined value_size is within
MAX_LIST_PAYLOAD_SIZE (20 bytes in this fixture)."""
_create_entry(list_session, "my-ext", "a", value=b"x" * 10, user_fk=1)
_create_entry(list_session, "my-ext", "b", value=b"x" * 10, user_fk=1)
with app_with_max_list_payload_size.app_context():
entries, total_count = ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
assert total_count == 2
assert len(entries) == 2
def test_list_rejects_page_exceeding_max_list_payload_size(
app_with_max_list_payload_size: Flask, list_session: Session
) -> None:
"""list() raises ExtensionStorageListPayloadTooLarge when the requested
page's combined value_size exceeds MAX_LIST_PAYLOAD_SIZE, without
fetching the oversized rows' `value` column."""
_create_entry(list_session, "my-ext", "a", value=b"x" * 15, user_fk=1)
_create_entry(list_session, "my-ext", "b", value=b"x" * 15, user_fk=1)
with app_with_max_list_payload_size.app_context():
with pytest.raises(ExtensionStorageListPayloadTooLarge):
ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
def test_list_payload_size_check_considers_only_the_requested_page(
app_with_max_list_payload_size: Flask, list_session: Session
) -> None:
"""A row excluded from the requested page by pagination does not count
against MAX_LIST_PAYLOAD_SIZE for that page."""
# Page 0 (page_size=1) contains only one 15-byte row — within the
# 20-byte budget — even though a second 15-byte row exists overall.
_create_entry(list_session, "my-ext", "a", value=b"x" * 15, user_fk=1)
_create_entry(list_session, "my-ext", "b", value=b"x" * 15, user_fk=1)
with app_with_max_list_payload_size.app_context():
entries, total_count = ExtensionStorageDAO.list_entries(
"my-ext", user_fk=1, page_size=1
)
assert total_count == 2
assert len(entries) == 1
def test_list_allows_unlimited_payload_when_not_configured(
app: Flask, list_session: Session
) -> None:
"""list() skips the payload-size check entirely when
MAX_LIST_PAYLOAD_SIZE is not configured."""
for i in range(3):
_create_entry(list_session, "my-ext", f"key-{i}", value=b"x" * 1000, user_fk=1)
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
assert total_count == 3
assert len(entries) == 3
@patch("superset.extensions.storage.persistent_dao._enc_type")
@patch("superset.extensions.storage.persistent_dao.db")
def test_list_decrypts_encrypted_entries(
mock_db: MagicMock, mock_enc_type: MagicMock, app: Flask
) -> None:
"""list() decrypts entries written with is_encrypted=True before
returning them, same as get_value()."""
entry = MagicMock()
entry.key = "secret"
entry.value = b"encrypted-bytes"
entry.is_encrypted = True
entry.user_fk = 1
entry.codec = "json"
mock_db.session.query.return_value.filter.return_value.count.return_value = 1
page_query = (
mock_db.session.query.return_value.filter.return_value.order_by.return_value
)
page_query.offset.return_value.limit.return_value.all.return_value = [entry]
mock_enc_type.return_value.process_result_value.return_value = b"plaintext"
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
assert total_count == 1
assert len(entries) == 1
assert entries[0].is_encrypted is True
assert entries[0].value == b"plaintext"
def test_list_returns_empty_when_no_entries_match(
app: Flask, list_session: Session
) -> None:
"""list() returns an empty list and total_count=0 when nothing matches."""
with app.app_context():
entries, total_count = ExtensionStorageDAO.list_entries("my-ext", user_fk=1)
assert entries == []
assert total_count == 0