Files
superset2/tests/unit_tests/extensions/test_api.py
Enzo Martellucci 7f6f805ffa fix(extensions): sync second-round review fixes to chatbot-local branch
- Validate manifest.id segments in POST endpoint before building dest_file path
- Add hostile manifest.id test (../../tmp/evil → 400)
- Add sqlite-backed round-trip tests for settings.py upsert logic
- Add HTTP tests for GET/PUT /api/v1/extensions/settings endpoints
- Wrap handleDelete in useCallback; already in columns useMemo deps
- Fix MySQL comment drift in _upsert_settings_row (read-then-update, not merge)
- Add intentional no-admin-gate comment to get_settings endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:21:30 +02:00

409 lines
15 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.
"""Unit tests for the extensions REST API (POST and DELETE endpoints)."""
from __future__ import annotations
import io
import zipfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
from superset.extensions.api import _validate_segment
# ---------------------------------------------------------------------------
# _validate_segment helper
# ---------------------------------------------------------------------------
def test_validate_segment_accepts_alphanumeric() -> None:
assert _validate_segment("acme") is True
assert _validate_segment("my-ext") is True
assert _validate_segment("my_ext") is True
assert _validate_segment("Ext123") is True
def test_validate_segment_rejects_traversal() -> None:
assert _validate_segment("..") is False
assert _validate_segment("../etc") is False
assert _validate_segment("acme/bad") is False
assert _validate_segment("acme%2Fbad") is False
assert _validate_segment("") is False
def test_validate_segment_rejects_dots() -> None:
assert _validate_segment("acme.corp") is False
# ---------------------------------------------------------------------------
# Helpers for building fake .supx payloads
# ---------------------------------------------------------------------------
def _make_supx(manifest_id: str = "acme.chatbot") -> bytes:
"""Return minimal valid .supx (zip) bytes with a manifest."""
buf = io.BytesIO()
manifest_json = (
f'{{"id": "{manifest_id}", "name": "Chatbot", "version": "1.0.0",'
f'"publisher": "acme", "description": "test"}}'
)
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("manifest.json", manifest_json)
return buf.getvalue()
def _make_fake_extension(manifest_id: str = "acme.chatbot") -> MagicMock:
ext = MagicMock()
ext.manifest.id = manifest_id
ext.source_base_path = "upload://"
ext.frontend = {}
ext.backend = {}
ext.version = "1.0.0"
ext.name = "Chatbot"
return ext
# ---------------------------------------------------------------------------
# POST /api/v1/extensions/ — upload and install
# ---------------------------------------------------------------------------
class TestPostEndpoint:
def _post(self, client: Any, data: dict[str, Any], full_api_access: None) -> Any:
return client.post(
"/api/v1/extensions/",
data=data,
content_type="multipart/form-data",
)
def test_non_admin_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=False
)
resp = client.post("/api/v1/extensions/", data={})
assert resp.status_code == 403
def test_missing_extensions_path_returns_400(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict("flask.current_app.config", {"EXTENSIONS_PATH": None})
resp = client.post("/api/v1/extensions/", data={})
assert resp.status_code == 400
assert "EXTENSIONS_PATH" in resp.json["message"]
def test_missing_bundle_field_returns_400(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
)
resp = client.post(
"/api/v1/extensions/",
data={},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "bundle" in resp.json["message"]
def test_wrong_extension_rejected(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
)
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(b"data"), "evil.zip")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert ".supx" in resp.json["message"]
def test_oversize_upload_rejected(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config",
{"EXTENSIONS_PATH": str(tmp_path), "EXTENSIONS_MAX_UPLOAD_SIZE": 10},
)
big = io.BytesIO(b"x" * 20)
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (big, "big.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "maximum" in resp.json["message"]
def test_not_a_zip_returns_400(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
)
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(b"not a zip"), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "ZIP" in resp.json["message"]
def test_zip_slip_rejected(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
"""check_is_safe_zip raises on path-traversal entries inside the zip."""
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
)
mocker.patch(
"superset.extensions.api.check_is_safe_zip",
side_effect=Exception("zip-slip detected"),
)
supx = _make_supx()
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(supx), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "zip-slip" in resp.json["message"]
def test_local_extensions_collision_returns_409(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config",
{
"EXTENSIONS_PATH": str(tmp_path),
"LOCAL_EXTENSIONS": ["/opt/superset/ext/acme.chatbot"],
},
)
fake_ext = _make_fake_extension("acme.chatbot")
mocker.patch(
"superset.extensions.api.get_bundle_files_from_zip", return_value=[]
)
mocker.patch(
"superset.extensions.api.get_loaded_extension", return_value=fake_ext
)
supx = _make_supx("acme.chatbot")
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(supx), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 409
assert "local extension" in resp.json["message"]
def test_hostile_manifest_id_rejected(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
"""A crafted manifest.id with path traversal must not escape EXTENSIONS_PATH."""
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config",
{"EXTENSIONS_PATH": str(tmp_path), "LOCAL_EXTENSIONS": []},
)
fake_ext = _make_fake_extension("../../tmp/evil")
mocker.patch(
"superset.extensions.api.get_bundle_files_from_zip", return_value=[]
)
mocker.patch(
"superset.extensions.api.get_loaded_extension", return_value=fake_ext
)
supx = _make_supx("../../tmp/evil")
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(supx), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "Invalid extension id" in resp.json["message"]
def test_happy_path_returns_201(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config",
{"EXTENSIONS_PATH": str(tmp_path), "LOCAL_EXTENSIONS": []},
)
fake_ext = _make_fake_extension("acme.chatbot")
mocker.patch(
"superset.extensions.api.get_bundle_files_from_zip", return_value=[]
)
mocker.patch(
"superset.extensions.api.get_loaded_extension", return_value=fake_ext
)
mocker.patch(
"superset.extensions.api.build_extension_data",
return_value={"id": "acme.chatbot"},
)
supx = _make_supx("acme.chatbot")
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(supx), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 201
assert resp.json["result"]["id"] == "acme.chatbot"
assert (tmp_path / "acme.chatbot.supx").exists()
# ---------------------------------------------------------------------------
# DELETE /api/v1/extensions/<publisher>/<name>
# ---------------------------------------------------------------------------
class TestDeleteEndpoint:
def test_non_admin_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=False
)
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 403
def test_path_traversal_publisher_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
# Use percent-encoded dots so Flask routing passes the segment to the
# handler as the string ".." — literal slashes in the path would be
# intercepted by the router before reaching the view.
resp = client.delete("/api/v1/extensions/%2E%2E/passwd")
assert resp.status_code == 400
assert "Invalid" in resp.json["message"]
def test_invalid_name_returns_400(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
resp = client.delete("/api/v1/extensions/acme/bad.name")
assert resp.status_code == 400
assert "Invalid" in resp.json["message"]
def test_unknown_extension_returns_404(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch("superset.extensions.api.get_extensions", return_value={})
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 404
def test_local_extension_cannot_be_deleted(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
local_base = str(tmp_path / "local-ext" / "dist")
fake_ext = _make_fake_extension("acme.chatbot")
fake_ext.source_base_path = local_base
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.get_extensions",
return_value={"acme.chatbot": fake_ext},
)
mocker.patch.dict(
"flask.current_app.config",
{"LOCAL_EXTENSIONS": [str(tmp_path / "local-ext")]},
)
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 400
assert "LOCAL_EXTENSIONS" in resp.json["message"]
def test_happy_path_deletes_file(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
supx_file = tmp_path / "acme.chatbot.supx"
supx_file.write_bytes(b"fake")
fake_ext = _make_fake_extension("acme.chatbot")
fake_ext.source_base_path = "upload://"
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.get_extensions",
return_value={"acme.chatbot": fake_ext},
)
mocker.patch.dict(
"flask.current_app.config",
{
"LOCAL_EXTENSIONS": [],
"EXTENSIONS_PATH": str(tmp_path),
},
)
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 200
assert not supx_file.exists()
def test_supx_file_missing_returns_404(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
fake_ext = _make_fake_extension("acme.chatbot")
fake_ext.source_base_path = "upload://"
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.get_extensions",
return_value={"acme.chatbot": fake_ext},
)
mocker.patch.dict(
"flask.current_app.config",
{"LOCAL_EXTENSIONS": [], "EXTENSIONS_PATH": str(tmp_path)},
)
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 404