mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(dify-ui): add shared form primitives (#36334)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -70,6 +70,21 @@ def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, An
|
||||
return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]:
|
||||
"""Serialize a saved extension with the plaintext key used for response masking only.
|
||||
|
||||
APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response
|
||||
contract, however, should match list/detail responses, where api_key is masked from the decrypted token.
|
||||
"""
|
||||
return APIBasedExtensionResponse(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
api_endpoint=extension.api_endpoint,
|
||||
api_key=api_key,
|
||||
created_at=to_timestamp(extension.created_at),
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/code-based-extension")
|
||||
class CodeBasedExtensionAPI(Resource):
|
||||
@console_ns.doc("get_code_based_extension")
|
||||
@@ -125,7 +140,7 @@ class APIBasedExtensionAPI(Resource):
|
||||
api_key=payload.api_key,
|
||||
)
|
||||
|
||||
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data))
|
||||
return _serialize_saved_api_based_extension(APIBasedExtensionService.save(extension_data), payload.api_key), 201
|
||||
|
||||
|
||||
@console_ns.route("/api-based-extension/<uuid:id>")
|
||||
@@ -160,14 +175,19 @@ class APIBasedExtensionDetailAPI(Resource):
|
||||
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id)
|
||||
|
||||
payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
|
||||
api_key_for_response = extension_data_from_db.api_key
|
||||
|
||||
extension_data_from_db.name = payload.name
|
||||
extension_data_from_db.api_endpoint = payload.api_endpoint
|
||||
|
||||
if payload.api_key != HIDDEN_VALUE:
|
||||
extension_data_from_db.api_key = payload.api_key
|
||||
api_key_for_response = payload.api_key
|
||||
|
||||
return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data_from_db))
|
||||
return _serialize_saved_api_based_extension(
|
||||
APIBasedExtensionService.save(extension_data_from_db),
|
||||
api_key_for_response,
|
||||
)
|
||||
|
||||
@console_ns.doc("delete_api_based_extension")
|
||||
@console_ns.doc(description="Delete API-based extension")
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
"""Integration tests for console API-based extension endpoints using testcontainers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from libs.rsa import generate_key_pair
|
||||
from models import Tenant
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
)
|
||||
|
||||
|
||||
def _masked_api_key(api_key: str) -> str:
|
||||
if len(api_key) <= 8:
|
||||
return api_key[0] + "******" + api_key[-1]
|
||||
return api_key[:3] + "******" + api_key[-3:]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_extension_client(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> tuple[FlaskClient, dict[str, str], Tenant]:
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
tenant.encrypt_public_key = generate_key_pair(tenant.id)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
headers = authenticate_console_client(test_client_with_containers, account)
|
||||
return test_client_with_containers, headers, tenant
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_api_based_extension_ping():
|
||||
with patch("services.api_based_extension_service.APIBasedExtensionRequestor") as requestor:
|
||||
requestor.return_value.request.return_value = {"result": "pong"}
|
||||
yield requestor
|
||||
|
||||
|
||||
def test_create_response_masks_plaintext_api_key(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
api_key = "plain-secret-12345"
|
||||
|
||||
response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": api_key,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json is not None
|
||||
assert response.json["api_key"] == _masked_api_key(api_key)
|
||||
|
||||
|
||||
def test_update_response_masks_new_plaintext_api_key(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
new_api_key = "new-secret-67890"
|
||||
create_response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": "old-secret-12345",
|
||||
},
|
||||
)
|
||||
assert create_response.json is not None
|
||||
|
||||
update_response = client.post(
|
||||
f"/console/api/api-based-extension/{create_response.json['id']}",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API Updated",
|
||||
"api_endpoint": "https://docs.example.com/v2",
|
||||
"api_key": new_api_key,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json is not None
|
||||
assert update_response.json["api_key"] == _masked_api_key(new_api_key)
|
||||
|
||||
|
||||
def test_update_response_masks_existing_plaintext_api_key_when_hidden_value_is_submitted(
|
||||
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
|
||||
) -> None:
|
||||
client, headers, _ = api_extension_client
|
||||
existing_api_key = "old-secret-12345"
|
||||
create_response = client.post(
|
||||
"/console/api/api-based-extension",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API",
|
||||
"api_endpoint": "https://docs.example.com/hook",
|
||||
"api_key": existing_api_key,
|
||||
},
|
||||
)
|
||||
assert create_response.json is not None
|
||||
|
||||
update_response = client.post(
|
||||
f"/console/api/api-based-extension/{create_response.json['id']}",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Docs API Updated",
|
||||
"api_endpoint": "https://docs.example.com/v2",
|
||||
"api_key": HIDDEN_VALUE,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json is not None
|
||||
assert update_response.json["api_key"] == _masked_api_key(existing_api_key)
|
||||
@@ -44,6 +44,12 @@ def _make_extension(
|
||||
return extension
|
||||
|
||||
|
||||
def _masked_api_key(api_key: str) -> str:
|
||||
if len(api_key) <= 8:
|
||||
return api_key[0] + "******" + api_key[-1]
|
||||
return api_key[:3] + "******" + api_key[-3:]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
"""Bypass console decorators so handlers can run in isolation."""
|
||||
@@ -114,7 +120,7 @@ def test_api_based_extension_get_returns_tenant_extensions(app: Flask, monkeypat
|
||||
|
||||
|
||||
def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
saved_extension = _make_extension(name="Docs API", api_key="saved-secret")
|
||||
saved_extension = _make_extension(name="Docs API", api_key="encrypted-token-from-save")
|
||||
save_mock = MagicMock(return_value=saved_extension)
|
||||
monkeypatch.setattr("controllers.console.extension.APIBasedExtensionService.save", save_mock)
|
||||
|
||||
@@ -125,7 +131,7 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
|
||||
}
|
||||
|
||||
with app.test_request_context("/console/api/api-based-extension", method="POST", json=payload):
|
||||
response = APIBasedExtensionAPI().post()
|
||||
response, status = APIBasedExtensionAPI().post()
|
||||
|
||||
args, _ = save_mock.call_args
|
||||
created_extension: APIBasedExtension = args[0]
|
||||
@@ -133,7 +139,9 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
|
||||
assert created_extension.name == payload["name"]
|
||||
assert created_extension.api_endpoint == payload["api_endpoint"]
|
||||
assert created_extension.api_key == payload["api_key"]
|
||||
assert status == 201
|
||||
assert response["name"] == saved_extension.name
|
||||
assert response["api_key"] == _masked_api_key(payload["api_key"])
|
||||
save_mock.assert_called_once()
|
||||
|
||||
|
||||
@@ -183,6 +191,7 @@ def test_api_based_extension_detail_post_keeps_hidden_api_key(app: Flask, monkey
|
||||
assert existing_extension.api_key == "keep-me"
|
||||
save_mock.assert_called_once_with(existing_extension)
|
||||
assert response["name"] == payload["name"]
|
||||
assert response["api_key"] == _masked_api_key("keep-me")
|
||||
|
||||
|
||||
def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -212,6 +221,7 @@ def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flas
|
||||
assert existing_extension.api_key == "new-secret"
|
||||
save_mock.assert_called_once_with(existing_extension)
|
||||
assert response["name"] == payload["name"]
|
||||
assert response["api_key"] == _masked_api_key(payload["api_key"])
|
||||
|
||||
|
||||
def test_api_based_extension_detail_delete_removes_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
Reference in New Issue
Block a user