diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index a5846e2815..2f309262cb 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -20,10 +20,13 @@ class TenantUserPayload(BaseModel): def get_user(tenant_id: str, user_id: str | None) -> EndUser: """ - Get current user + Get current user. NOTE: user_id is not trusted, it could be maliciously set to any value. - As a result, it could only be considered as an end user id. + As a result, it could only be considered as an end user id. Even when a + concrete end-user ID is supplied, lookups must stay tenant-scoped so one + tenant cannot bind another tenant's user record into the plugin request + context. """ if not user_id: user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID @@ -42,7 +45,14 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: .limit(1) ) else: - user_model = session.get(EndUser, user_id) + user_model = session.scalar( + select(EndUser) + .where( + EndUser.id == user_id, + EndUser.tenant_id == tenant_id, + ) + .limit(1) + ) if not user_model: user_model = EndUser( diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py index 0895fac3a4..d1b09c3a58 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py @@ -41,17 +41,22 @@ class TestTenantUserPayload: class TestGetUser: """Test get_user function""" + @patch("controllers.inner_api.plugin.wraps.select") @patch("controllers.inner_api.plugin.wraps.EndUser") @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") - def test_should_return_existing_user_by_id(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask): + def test_should_return_existing_user_by_id( + self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + ): """Test returning existing user when found by ID""" # Arrange mock_user = MagicMock() mock_user.id = "user123" mock_session = MagicMock() mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.get.return_value = mock_user + mock_session.scalar.return_value = mock_user + mock_query = MagicMock() + mock_select.return_value.where.return_value.limit.return_value = mock_query # Act with app.app_context(): @@ -59,13 +64,45 @@ class TestGetUser: # Assert assert result == mock_user - mock_session.get.assert_called_once() + mock_session.scalar.assert_called_once() + @patch("controllers.inner_api.plugin.wraps.select") + @patch("controllers.inner_api.plugin.wraps.EndUser") + @patch("controllers.inner_api.plugin.wraps.sessionmaker") + @patch("controllers.inner_api.plugin.wraps.db") + def test_should_not_resolve_non_anonymous_users_across_tenants( + self, + mock_db, + mock_sessionmaker, + mock_enduser_class, + mock_select, + app: Flask, + ): + """Test that explicit user IDs remain scoped to the current tenant.""" + # Arrange + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + mock_session.scalar.return_value = None + mock_new_user = MagicMock() + mock_new_user.tenant_id = "tenant-current" + mock_enduser_class.return_value = mock_new_user + + # Act + with app.app_context(): + result = get_user("tenant-current", "foreign-user-id") + + # Assert + assert result == mock_new_user + mock_session.get.assert_not_called() + mock_session.scalar.assert_called_once() + mock_session.add.assert_called_once_with(mock_new_user) + + @patch("controllers.inner_api.plugin.wraps.select") @patch("controllers.inner_api.plugin.wraps.EndUser") @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") def test_should_return_existing_anonymous_user_by_session_id( - self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask + self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask ): """Test returning existing anonymous user by session_id""" # Arrange @@ -73,8 +110,9 @@ class TestGetUser: mock_user.session_id = "anonymous_session" mock_session = MagicMock() mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - # non-anonymous path uses session.get(); anonymous uses session.scalar() - mock_session.get.return_value = mock_user + mock_session.scalar.return_value = mock_user + mock_query = MagicMock() + mock_select.return_value.where.return_value.limit.return_value = mock_query # Act with app.app_context(): @@ -83,17 +121,22 @@ class TestGetUser: # Assert assert result == mock_user + @patch("controllers.inner_api.plugin.wraps.select") @patch("controllers.inner_api.plugin.wraps.EndUser") @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") - def test_should_create_new_user_when_not_found(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask): + def test_should_create_new_user_when_not_found( + self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask + ): """Test creating new user when not found in database""" # Arrange mock_session = MagicMock() mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.get.return_value = None + mock_session.scalar.return_value = None mock_new_user = MagicMock() mock_enduser_class.return_value = mock_new_user + mock_query = MagicMock() + mock_select.return_value.where.return_value.limit.return_value = mock_query # Act with app.app_context(): @@ -134,7 +177,7 @@ class TestGetUser: # Arrange mock_session = MagicMock() mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session - mock_session.get.side_effect = Exception("Database error") + mock_session.scalar.side_effect = Exception("Database error") # Act & Assert with app.app_context(): diff --git a/e2e/features/apps/create-agent-app.feature b/e2e/features/apps/create-agent-app.feature new file mode 100644 index 0000000000..75d8dc3a77 --- /dev/null +++ b/e2e/features/apps/create-agent-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated @core @mode-matrix +Feature: Create Agent app + Scenario: Create a new Agent app and redirect to the configuration page + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I expand the beginner app types + And I select the "Agent" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the app configuration page diff --git a/e2e/features/apps/create-chatflow-app.feature b/e2e/features/apps/create-chatflow-app.feature new file mode 100644 index 0000000000..364cccc494 --- /dev/null +++ b/e2e/features/apps/create-chatflow-app.feature @@ -0,0 +1,10 @@ +@apps @authenticated @core @mode-matrix +Feature: Create Chatflow app + Scenario: Create a new Chatflow app and redirect to the workflow editor + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I select the "Chatflow" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the workflow editor diff --git a/e2e/features/apps/create-text-generator-app.feature b/e2e/features/apps/create-text-generator-app.feature new file mode 100644 index 0000000000..aec0436644 --- /dev/null +++ b/e2e/features/apps/create-text-generator-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated @core @mode-matrix +Feature: Create Text Generator app + Scenario: Create a new Text Generator app and redirect to the configuration page + Given I am signed in as the default E2E admin + When I open the apps console + And I start creating a blank app + And I expand the beginner app types + And I select the "Text Generator" app type + And I enter a unique E2E app name + And I confirm app creation + Then I should land on the app configuration page diff --git a/e2e/features/apps/delete-app.feature b/e2e/features/apps/delete-app.feature new file mode 100644 index 0000000000..49326ba098 --- /dev/null +++ b/e2e/features/apps/delete-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated @core +Feature: Delete app + Scenario: Delete an existing app from the apps console + Given I am signed in as the default E2E admin + And there is an existing E2E app available for testing + When I open the apps console + And I open the options menu for the last created E2E app + And I click "Delete" in the app options menu + And I type the app name in the deletion confirmation + And I confirm the deletion + Then the app should no longer appear in the apps console diff --git a/e2e/features/apps/duplicate-app.feature b/e2e/features/apps/duplicate-app.feature new file mode 100644 index 0000000000..3645a7d172 --- /dev/null +++ b/e2e/features/apps/duplicate-app.feature @@ -0,0 +1,10 @@ +@apps @authenticated @core +Feature: Duplicate app + Scenario: Duplicate an existing app and open the copy in the editor + Given I am signed in as the default E2E admin + And there is an existing E2E app available for testing + When I open the apps console + And I open the options menu for the last created E2E app + And I click "Duplicate" in the app options menu + And I confirm the app duplication + Then I should land on the app editor diff --git a/e2e/features/apps/export-app.feature b/e2e/features/apps/export-app.feature new file mode 100644 index 0000000000..d6d040fb00 --- /dev/null +++ b/e2e/features/apps/export-app.feature @@ -0,0 +1,9 @@ +@apps @authenticated @core +Feature: Export app DSL + Scenario: Export the DSL file for an existing app + Given I am signed in as the default E2E admin + And there is an existing E2E completion app available for testing + When I open the apps console + And I open the options menu for the last created E2E app + And I click "Export DSL" in the app options menu + Then a YAML file named after the app should be downloaded diff --git a/e2e/features/apps/switch-app-mode.feature b/e2e/features/apps/switch-app-mode.feature new file mode 100644 index 0000000000..5cdc6341fb --- /dev/null +++ b/e2e/features/apps/switch-app-mode.feature @@ -0,0 +1,10 @@ +@apps @authenticated @core +Feature: Switch app mode + Scenario: Switch a Completion app to Workflow Orchestrate + Given I am signed in as the default E2E admin + And there is an existing E2E completion app available for testing + When I open the apps console + And I open the options menu for the last created E2E app + And I click "Switch to Workflow Orchestrate" in the app options menu + And I confirm the app switch + Then I should land on the switched app diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index e444b97dc8..931d4662a2 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -11,7 +11,7 @@ When('I start creating a blank app', async function (this: DifyWorld) { When('I enter a unique E2E app name', async function (this: DifyWorld) { const appName = `E2E App ${Date.now()}` - + this.lastCreatedAppName = appName await this.getPage().getByPlaceholder('Give your app a name').fill(appName) }) @@ -26,10 +26,15 @@ When('I confirm app creation', async function (this: DifyWorld) { When('I select the {string} app type', async function (this: DifyWorld, appType: string) { const dialog = this.getPage().getByRole('dialog') - const appTypeTitle = dialog.getByText(appType, { exact: true }) + // The modal defaults to ADVANCED_CHAT, so the preview panel immediately renders + //