mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
Two App Builder spec changes that share the Builder session contract.
Naming (spec N1-N3): an app created from the homepage prompt is no longer named
by the user. core/dify_builder/naming.py cuts the prompt's opening clause at
create time -- instant and infallible, since it runs inside POST /apps -- and
CreateAppPayload.name becomes optional alongside a new prompt field. The Builder
then refines that name once during goal analysis, where a model and the detected
reply language are already in hand, so the judgement calls that are
English-shaped in pure Python (dropping an imperative lead-in, spotting a
contentless prompt) work in any language. Plan approval freezes the name.
Card options: SessionView gains a Decision carrying the gate's choice as options
inside the card plus a fixed confirm/cancel pair, replacing a button row whose
shape changed at every state. Option ids are the old action ids untouched, so
resolve_action_kind and the per-state legality check are unchanged, and
SessionView.actions keeps being populated -- the deployed web client goes on
working until it reads decision instead.
The regenerated console OpenAPI doc also picks up ErrorCard.diagnostics, missed
when cc9ca8b1dd landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
6.0 KiB
Python
150 lines
6.0 KiB
Python
import dataclasses
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from werkzeug.exceptions import Forbidden
|
|
|
|
from controllers.common.rbac import PlainApp, RBACPermission
|
|
from core.dify_builder.errors import BusyError, ConflictError, ModelUnavailableError, NotFoundError
|
|
from core.dify_builder.models import Action, Actor
|
|
from models import TenantAccountRole
|
|
from services.dify_builder import wiring
|
|
from services.dify_builder.service import AppAccess, SessionView
|
|
|
|
|
|
def test_session_view_to_dict_round_trips_fields():
|
|
view = SessionView(
|
|
session_id="s1",
|
|
app_id="a1",
|
|
version=3,
|
|
state="fix.await_verify",
|
|
# "waiting-input" (hyphen) is a raw passthrough literal for this
|
|
# asdict round-trip test, not real service output -- _run_status
|
|
# never returns this; the real widened value is "waiting_input".
|
|
canvas_read_only=False,
|
|
run_status="waiting-input",
|
|
interrupted=False,
|
|
conversation_last_seq=7,
|
|
)
|
|
d = wiring.session_view_to_dict(view)
|
|
assert d == {
|
|
"session_id": "s1",
|
|
"app_id": "a1",
|
|
"version": 3,
|
|
"state": "fix.await_verify",
|
|
"canvas_read_only": False,
|
|
"run_status": "waiting-input",
|
|
"interrupted": False,
|
|
"conversation_last_seq": 7,
|
|
"entry_mode": "fix",
|
|
"phase": "understand",
|
|
"actions": [],
|
|
"decision": None,
|
|
"active_interaction": None,
|
|
"checkpoint": None,
|
|
"recovery": None,
|
|
"model": None,
|
|
"app_revision": None,
|
|
}
|
|
|
|
|
|
def test_error_map():
|
|
assert wiring.dify_builder_error_response(ModelUnavailableError("private provider detail")) == (
|
|
{"code": "model_unavailable", "message": "Builder model is unavailable", "recoverable": True},
|
|
400,
|
|
)
|
|
assert wiring.dify_builder_error_response(NotFoundError("x"))[1] == 404
|
|
assert wiring.dify_builder_error_response(NotFoundError("x"))[0]["code"] == "not_found"
|
|
assert wiring.dify_builder_error_response(ConflictError("x")) == ({"code": "conflict"}, 409)
|
|
assert wiring.dify_builder_error_response(BusyError("x")) == ({"code": "session_busy"}, 409)
|
|
assert wiring.dify_builder_error_response(ValueError("x")) is None # unknown → caller re-raises
|
|
|
|
|
|
def test_build_service_enqueue_calls_delay_with_dicts():
|
|
with patch("services.dify_builder.wiring.advance_session") as task, patch("services.dify_builder.wiring.db"):
|
|
svc = wiring.build_service()
|
|
action = Action(kind="run_verify", base_version=2)
|
|
actor = Actor(account_id="acc", tenant_id="ten")
|
|
assert svc._authorize_app_fn is wiring._authorize_app
|
|
svc._enqueue_fn("sid-1", action, actor, "tok-9") # the injected enqueue
|
|
task.delay.assert_called_once_with("sid-1", dataclasses.asdict(action), dataclasses.asdict(actor), "tok-9")
|
|
|
|
|
|
def test_authorize_app_legacy_requires_tenant_editor_role(monkeypatch: pytest.MonkeyPatch):
|
|
scalar = MagicMock(side_effect=["app-1", TenantAccountRole.EDITOR])
|
|
monkeypatch.setattr(wiring.db.session, "scalar", scalar)
|
|
monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", False)
|
|
enforce = MagicMock()
|
|
monkeypatch.setattr(wiring, "enforce_rbac_checks", enforce)
|
|
|
|
wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT)
|
|
|
|
enforce.assert_called_once_with(
|
|
tenant_id="ten-1",
|
|
account_id="acc-1",
|
|
checks=enforce.call_args.kwargs["checks"],
|
|
path_args={"app_id": "app-1"},
|
|
)
|
|
check = enforce.call_args.kwargs["checks"][0]
|
|
assert check.scene is RBACPermission.APP_EDIT
|
|
assert isinstance(check.locator, PlainApp)
|
|
|
|
|
|
def test_authorize_app_legacy_rejects_non_editor(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setattr(
|
|
wiring.db.session,
|
|
"scalar",
|
|
MagicMock(side_effect=["app-1", TenantAccountRole.NORMAL]),
|
|
)
|
|
monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", False)
|
|
|
|
with pytest.raises(Forbidden):
|
|
wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("access", "scenes"),
|
|
[
|
|
(AppAccess.EDIT, [RBACPermission.APP_EDIT]),
|
|
(AppAccess.TEST_AND_RUN, [RBACPermission.APP_EDIT, RBACPermission.APP_TEST_AND_RUN]),
|
|
(AppAccess.RELEASE, [RBACPermission.APP_EDIT, RBACPermission.APP_RELEASE_AND_VERSION]),
|
|
],
|
|
)
|
|
def test_authorize_app_rbac_enforces_base_and_operation_permission(
|
|
monkeypatch: pytest.MonkeyPatch, access: AppAccess, scenes: list[RBACPermission]
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
wiring.db.session,
|
|
"scalar",
|
|
MagicMock(side_effect=["app-1", TenantAccountRole.NORMAL]),
|
|
)
|
|
monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True)
|
|
enforce = MagicMock()
|
|
monkeypatch.setattr(wiring, "enforce_rbac_checks", enforce)
|
|
|
|
wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", access)
|
|
|
|
assert [item.kwargs["checks"][0].scene for item in enforce.call_args_list] == scenes
|
|
assert all(isinstance(item.kwargs["checks"][0].locator, PlainApp) for item in enforce.call_args_list)
|
|
assert all(
|
|
item.kwargs["tenant_id"] == "ten-1"
|
|
and item.kwargs["account_id"] == "acc-1"
|
|
and item.kwargs["path_args"] == {"app_id": "app-1"}
|
|
for item in enforce.call_args_list
|
|
)
|
|
|
|
|
|
def test_authorize_app_rejects_missing_or_foreign_app(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setattr(wiring.db.session, "scalar", MagicMock(return_value=None))
|
|
|
|
with pytest.raises(NotFoundError, match="app not found"):
|
|
wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT)
|
|
|
|
|
|
def test_authorize_app_rejects_account_outside_tenant(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setattr(wiring.db.session, "scalar", MagicMock(side_effect=["app-1", None]))
|
|
monkeypatch.setattr(wiring.dify_config, "RBAC_ENABLED", True)
|
|
|
|
with pytest.raises(Forbidden):
|
|
wiring._authorize_app(Actor(account_id="acc-1", tenant_id="ten-1"), "app-1", AppAccess.EDIT)
|